# Phase 12: Native Tablo Chat - Research

**Researched:** 2026-05-16
**Domain:** Go + HTMX persisted discussion, Postgres, SSE
**Confidence:** HIGH

<user_constraints>
## User Constraints

Locked decisions from `12-CONTEXT.md` and `12-UI-SPEC.md`:

- D-01/D-02/D-03/D-04: Discussion is the fifth tablo detail tab, label `Discussion`, after Events, with direct full-page fallback at `/tablos/{id}/discussion`.
- D-05/D-26: Dashboard tablo cards need persistent per-user/tablo unread badges.
- D-06/D-07/D-08/D-09/D-10: Message rows show author, absolute timestamp, text, oldest first, composer at bottom, day separators, and the specified empty state copy.
- D-11/D-12/D-13/D-14: No edit/delete UI in Phase 12, but nullable edit/delete metadata must exist in the message schema without forcing future delete semantics.
- D-15/D-16: Composer is a plain textarea plus send button; no Enter-to-send behavior.
- D-17/D-18/D-19/D-20/D-21: Transport is SSE receive plus CSRF-protected HTMX POST send; reconnect is silent; POST renders the sender message immediately; SSE updates other open views; no WebSocket unless a hard blocker appears.
- D-22/D-23/D-24/D-25: Owner-only access under the current tablo model; author label uses display name if available and email fallback; current schema has email only; header can show `1 participant`.
- UI-SPEC: Use existing Layout, tab shell, local templ/ui patterns, restrained white/slate/blue styling, no JS framework, no rich chat controls, no presence/typing/status UI.

Deferred/out of scope:

- Typing, presence, read receipts, reactions, replies, rich media, attachments, search, pinned messages, edit UI, delete UI, membership/invitations, and exact future delete behavior.
</user_constraints>

<architectural_responsibility_map>
## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|--------------|----------------|-----------|
| Persisted message history | Database | Backend | Postgres owns message durability; handlers only authorize and render. |
| Discussion tab rendering | Frontend Server | Browser | templ renders the full page and HTMX fragments; browser only swaps/appends HTML. |
| Message send | Backend | Database | A normal POST validates CSRF/body, inserts, updates read state, renders the sender fragment, and broadcasts. |
| Realtime receive | Backend | Browser | Go owns authenticated SSE streams; browser EventSource receives fragments and applies DOM updates with a tiny local script. |
| Unread counts | Database | Backend | Persistent per-user/tablo read state is needed across reloads and future sharing. |
| Authorization | Backend | Database | `loadOwnedTablo` and ownership-filtered queries preserve current 404-for-non-owner behavior. |
</architectural_responsibility_map>

<research_summary>
## Summary

The existing Go backend already has the right architecture for Phase 12: protected chi routes, `loadOwnedTablo` for owner-only access, sqlc/goose for schema and query changes, templ fragments for full-page fallback, HTMX for normal form submission, and local embedded static assets. Phase 12 should add a native `discussion_messages` table, a persistent `discussion_read_states` table, a `DiscussionDeps` dependency, handlers/templates for the fifth detail tab, and a small Go SSE broker for live append/unread updates.

The safest first version is not the historical Cloudflare worker/WebSocket chat architecture. Browser-to-server sends should remain CSRF-protected POST requests. Receive-only realtime should be SSE from protected routes, with a per-process broker and database-backed history/read state as source of truth. This satisfies the phase requirement without managed providers and keeps degraded non-JS sends/history usable.

**Primary recommendation:** Build the feature in three slices: persisted Discussion tab and POST send, unread badge/read-state integration, then SSE realtime with browser/proxy UAT.
</research_summary>

<standard_stack>
## Standard Stack

| Component | Existing Version/Pattern | Purpose | Recommendation |
|-----------|--------------------------|---------|----------------|
| Go HTTP | stdlib + chi v5.2.5 | Routes, streaming responses | Use `http.Flusher` and request context cancellation for SSE. |
| templ | v0.3.1020 | Server-rendered HTML | Add discussion templates; rely on templ escaping for message text. |
| HTMX | v2 local static asset | POST send and fragment swaps | Keep composer as HTMX POST; do not add htmx SSE extension unless vendored locally. |
| Browser EventSource | Native browser API | Receive SSE | Add one small local script under `backend/static/` for EventSource append/OOB behavior. |
| Postgres/sqlc/goose | Existing backend pattern | Durable schema and typed queries | Add `0009_discussion.sql` and `discussion.sql`; generated Go remains gitignored. |

No new managed service, frontend framework, CDN runtime, or WebSocket infrastructure is needed.
</standard_stack>

<architecture_patterns>
## Architecture Patterns

### System Architecture

```
GET /tablos/{id}/discussion
  -> RequireAuth -> loadOwnedTablo -> ListDiscussionMessages + MarkDiscussionRead
  -> templ full page or tab fragment

POST /tablos/{id}/discussion/messages
  -> RequireAuth + CSRF -> loadOwnedTablo -> validate body
  -> InsertDiscussionMessage -> MarkDiscussionRead
  -> render sender fragment immediately
  -> broadcast message + unread badge refresh to SSE broker

GET /tablos/{id}/discussion/stream
  -> RequireAuth -> loadOwnedTablo -> register SSE client
  -> keepalive comments + message/unread events until context cancels

GET /
  -> RequireAuth -> ListTablosWithDiscussionUnreadCounts
  -> dashboard cards render numeric badge when count > 0
```

### Recommended Project Structure

```
backend/
├── migrations/0009_discussion.sql
├── internal/db/queries/discussion.sql
├── internal/web/handlers_discussion.go
├── internal/web/handlers_discussion_test.go
├── internal/web/discussion_broker.go
├── templates/discussion.templ
├── templates/discussion_forms.go
└── static/discussion-sse.js
```

### Pattern 1: Ownership Gate First

Use `loadOwnedTablo(w, r, TablosDeps{Queries: deps.Queries})` before listing, inserting, marking read, or opening SSE. Preserve the established behavior where invalid UUIDs, missing tablos, and non-owner access return 404.

### Pattern 2: POST Response Is Sender Source Of Truth

The POST handler should render the sender's new message immediately, ideally as an appendable message fragment plus any OOB updates needed for composer/errors. SSE should broadcast to other open views; the browser-side receiver must suppress duplicates for the sender if it is also subscribed.

### Pattern 3: Database-Backed Read Cursor

Use a read-state row keyed by `(tablo_id, user_id)` with `last_read_message_id` and `last_read_at`. Unread count can be computed by joining messages for the owned tablo and counting rows newer than the read cursor/after `last_read_at`. This is durable across reloads and future-compatible with sharing.

### Pattern 4: Per-Process SSE Broker, DB Is Source Of Truth

A small in-memory broker is acceptable for the current single-process Go app because persisted history covers missed events on reconnect. The broker should never be the only source of message history or unread state.
</architecture_patterns>

<common_pitfalls>
## Common Pitfalls

### Pitfall 1: Forgetting Full-Page Fallback
**What goes wrong:** `/tablos/{id}/discussion` only works as an HTMX fragment and direct loads lose the detail shell.
**How to avoid:** Match `TabloEventsTabHandler`: return `DiscussionTabFragment` for `HX-Request`, otherwise render `TabloDetailPage(..., activeTab="discussion")`.

### Pitfall 2: Relying On SSE For The Sender's Own Message
**What goes wrong:** The sender sees delay, duplicate messages, or no update if the stream is not connected.
**How to avoid:** POST inserts and returns the sender message fragment immediately; SSE is for other open views and badge refreshes.

### Pitfall 3: In-Memory Read State
**What goes wrong:** Dashboard badges reset or become stale after reload/restart.
**How to avoid:** Store read state in Postgres and compute badge counts from messages/read-state.

### Pitfall 4: Buffered SSE In Reverse Proxies
**What goes wrong:** Events do not arrive until buffers fill or the connection closes.
**How to avoid:** Set `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`, flush after each event/keepalive, and manually verify the local/prod reverse proxy path.

### Pitfall 5: CSRF Expectations On EventSource
**What goes wrong:** Trying to send custom CSRF headers with native EventSource creates unnecessary complexity.
**How to avoid:** Keep SSE as authenticated GET with session cookie and no mutation. Keep mutations as CSRF-protected POST.

### Pitfall 6: Unescaped Message Rendering
**What goes wrong:** Chat messages become an XSS surface.
**How to avoid:** Render body through templ escaped expressions, preserve line breaks with CSS (`whitespace-pre-wrap`) rather than raw HTML, and test with HTML-looking input.

### Pitfall 7: Adding Rich Chat UI Too Early
**What goes wrong:** Phase expands into presence, typing, replies, delete/edit UX, attachments, and client state.
**How to avoid:** Enforce UI-SPEC: rows, day separators, composer, unread badge only.
</common_pitfalls>

## Validation Architecture

Phase 12 needs DB-backed integration tests plus one manual/browser streaming checkpoint.

- Unit/DB integration tests should cover migration/query behavior, owner-only history, POST validation, CSRF, max body length, escaped rendering, full-page fallback, and unread count/read-state behavior.
- Handler tests can verify SSE response headers and that unauthorized/non-owner streams are blocked. Full end-to-end delivery with two browsers and reverse proxy buffering requires manual UAT because `httptest.ResponseRecorder` is not a faithful streaming/proxy environment.
- Final verification should run `cd backend && just generate`, targeted `go test ./internal/web -run 'TestDiscussion|TestTablosListDiscussionUnread' -count=1`, full backend tests with `TEST_DATABASE_URL`, and `git diff --check`.

## Open Questions

Resolved during research:

- **Realtime transport:** Use SSE receive + HTMX POST send. No hard blocker found.
- **Author display name:** Do not add user display-name schema in Phase 12; email fallback satisfies D-23/D-24 and avoids unrelated account profile scope.
- **Unread model:** Use persistent `discussion_read_states`, even owner-only, to satisfy D-05/D-26 and future sharing.
