--- phase: 06-lab-advisor plan: "01" type: execute wave: 1 depends_on: [] files_modified: - internal/store/store.go - internal/store/migrations.go - internal/store/conversations.go - internal/store/store_test.go - go.mod - go.sum autonomous: true requirements: [ADV-03] must_haves: truths: - "PostgreSQL connection is established and verified at startup" - "conversations table exists with id, started_at, model columns" - "messages table exists with id, conversation_id, role, content, created_at columns" - "Store can create a conversation, append messages, and retrieve full thread" - "Store can list all conversations ordered by started_at DESC" artifacts: - path: "internal/store/store.go" provides: "Store struct, NewStore constructor, DB connection via pgx/v5" exports: [Store, NewStore] - path: "internal/store/migrations.go" provides: "RunMigrations — idempotent CREATE TABLE IF NOT EXISTS for conversations + messages" exports: [RunMigrations] - path: "internal/store/conversations.go" provides: "CreateConversation, AddMessage, GetConversation, ListConversations" exports: [CreateConversation, AddMessage, GetConversation, ListConversations] - path: "internal/store/store_test.go" provides: "Integration tests against real PostgreSQL" key_links: - from: "internal/store/conversations.go" to: "PostgreSQL homelabby DB" via: "pgx/v5 pgxpool" pattern: "pgxpool.New" - from: "internal/store/migrations.go" to: "conversations, messages tables" via: "CREATE TABLE IF NOT EXISTS" pattern: "CREATE TABLE IF NOT EXISTS conversations" --- Create the internal/store package that manages chat persistence in PostgreSQL. RunMigrations creates conversations and messages tables idempotently; the Store struct exposes typed methods for the advisor handler to use. Purpose: ADV-03 requires conversation history to persist across browser sessions. SQLite is already used for other local state but PostgreSQL is the designated store here (already running at 10.5.0.109, connection string in HWLAB_DATABASE_URL env). Output: internal/store package with pgx/v5, auto-migrating tables, typed CRUD methods, integration tests. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md # Module path: git.georgsen.dk/hwlab # PostgreSQL DSN is in env var HWLAB_DATABASE_URL (set in .env): # postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby # pgx/v5 driver: github.com/jackc/pgx/v5 # Pattern: use pgxpool for the connection pool (pgxpool.New), not single conn # Pattern: all other packages use database/sql + sqlx — advisor uses pgx directly # (pgx/v5 is preferred for new code, avoids cgo unlike mattn/go-sqlite3) Task 1: Add pgx/v5 dep and create Store with RunMigrations internal/store/store.go, internal/store/migrations.go, go.mod, go.sum - NewStore("invalid-dsn") returns non-nil error - NewStore(validDSN) returns *Store with open pool, no error - Store.Close() does not panic - RunMigrations(ctx, pool) on empty DB creates conversations and messages tables - RunMigrations(ctx, pool) called twice is idempotent (no error, no duplicate tables) - conversations schema: id UUID PRIMARY KEY DEFAULT gen_random_uuid(), started_at TIMESTAMPTZ NOT NULL DEFAULT now(), model TEXT NOT NULL DEFAULT '' - messages schema: id UUID PRIMARY KEY DEFAULT gen_random_uuid(), conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('user','assistant','system')), content TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() Run `go get github.com/jackc/pgx/v5` to add the dependency. Create internal/store/store.go: - Package store - Import pgxpool "github.com/jackc/pgx/v5/pgxpool" - Store struct holds *pgxpool.Pool - NewStore(ctx context.Context, dsn string) (*Store, error) — calls pgxpool.New(ctx, dsn), pings pool, returns error on failure - Close() — calls pool.Close() - Expose Pool() *pgxpool.Pool accessor for use by repository methods Create internal/store/migrations.go: - RunMigrations(ctx context.Context, pool *pgxpool.Pool) error - Execute the two CREATE TABLE IF NOT EXISTS statements in sequence using pool.Exec - conversations first (messages references it) - Return first non-nil error encountered Write tests first (RED), confirm they fail, then implement (GREEN). cd /home/mikkel/homelabby && HWLAB_DATABASE_URL="postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby" go test ./internal/store/... -run TestNewStore -v TestNewStore passes; go build ./... succeeds; pgx/v5 present in go.mod Task 2: Conversation and message CRUD methods internal/store/conversations.go, internal/store/store_test.go - CreateConversation(ctx, model string) returns (conversationID string, err error) - AddMessage(ctx, conversationID, role, content string) returns (messageID string, err error) - AddMessage with invalid role returns error (CHECK constraint violation from DB) - GetConversation(ctx, conversationID string) returns (*Conversation, error) where Conversation has ID, StartedAt, Model, Messages []Message - GetConversation on unknown ID returns nil, ErrNotFound (sentinel) - ListConversations(ctx) returns []ConversationSummary ordered by started_at DESC (no messages, just id + started_at + model + message_count) - Full round-trip: create conv → add 2 messages → GetConversation → Messages has 2 items in order Create internal/store/conversations.go: Types: - Message struct { ID, ConversationID, Role, Content string; CreatedAt time.Time } - Conversation struct { ID, Model string; StartedAt time.Time; Messages []Message } - ConversationSummary struct { ID, Model string; StartedAt time.Time; MessageCount int } - var ErrNotFound = errors.New("store: not found") Methods on *Store: - CreateConversation(ctx, model) — INSERT INTO conversations(model) VALUES($1) RETURNING id - AddMessage(ctx, conversationID, role, content) — INSERT INTO messages(conversation_id, role, content) VALUES($1,$2,$3) RETURNING id - GetConversation(ctx, id) — SELECT from conversations JOIN messages ORDER BY messages.created_at ASC; if 0 rows return nil, ErrNotFound - ListConversations(ctx) — SELECT c.id, c.started_at, c.model, COUNT(m.id) AS message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id GROUP BY c.id ORDER BY c.started_at DESC Write tests in internal/store/store_test.go using build tag `//go:build integration` so they run only with -tags integration. Tests connect to the real PostgreSQL. Each test creates its own conversation and cleans up (DELETE FROM conversations WHERE id = $1). Write tests first (RED), confirm compile error or test failure, then implement (GREEN). cd /home/mikkel/homelabby && HWLAB_DATABASE_URL="postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby" go test ./internal/store/... -tags integration -v All store integration tests pass; GetConversation returns ErrNotFound for unknown ID; ListConversations returns summaries with message counts; go build ./... passes ## Trust Boundaries | Boundary | Description | |----------|-------------| | App → PostgreSQL | DSN from env; pool executes parameterized queries | | Messages table | role column has CHECK constraint; content is free text | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-06-01-01 | Injection | conversations.go — all queries | mitigate | All queries use $1/$2 parameterized args via pgx; no string interpolation in SQL | | T-06-01-02 | Information Disclosure | store.go — DSN in logs | mitigate | NewStore logs only connection error text, not the DSN; DSN sourced from env, not config file | | T-06-01-03 | Tampering | messages.role column | mitigate | DB-level CHECK (role IN ('user','assistant','system')) — invalid role returns pgx constraint error | | T-06-01-04 | Denial of Service | ListConversations with unbounded growth | accept | Single-operator homelab tool; conversation count bounded in practice; add LIMIT 100 as soft guard | - `go build ./...` passes - `go test ./internal/store/...` passes (non-integration tests) - `go test ./internal/store/... -tags integration` passes against live PostgreSQL - `psql $HWLAB_DATABASE_URL -c "\d conversations"` shows the schema - `psql $HWLAB_DATABASE_URL -c "\d messages"` shows the schema with FK to conversations - pgx/v5 added to go.mod - internal/store package compiles with no errors - RunMigrations creates both tables idempotently - Full CRUD round-trip (create conv, add messages, get conv, list convs) passes integration tests - ErrNotFound returned for unknown conversation ID After completion, create .planning/phases/06-lab-advisor/06-01-SUMMARY.md