# Code Review: Server & Database Layers

**Scope:** `qc_trace/server/` and `qc_trace/db/` plus `docker-compose.yml`
**Date:** 2026-02-07
**Reviewer:** Claude Opus 4.6

---

## Critical

### C1. No authentication or authorization on any endpoint

**Files:** `qc_trace/server/app.py:80-124`, all handler functions in `qc_trace/server/handlers.py`

There is zero authentication on any endpoint -- ingest, read API, health, everything. Any process that can reach the port can write arbitrary data or read all session/message data. Even for an internal/local tool this is risky because:
- The server binds to `localhost` by default, but `CORS: *` means any webpage open in the user's browser can hit it.
- If the bind address is ever changed to `0.0.0.0` (common for Docker), the entire database is exposed.

**Decision (2026-02-07):** Implement auth via explicit user email + manual API key:
1. Add `qc-traced setup` CLI command that prompts for email and API key, stores in `~/.qc-trace/config.json`
2. Daemon sends `X-API-Key` header + `X-User-Email` header on every `/ingest` POST
3. Server validates API key against a hardcoded allowlist or `QC_TRACE_API_KEYS` env var
4. API keys are manually provisioned for now — automate later when onboarding at scale
5. Read API endpoints should also require auth (same key or session-based for dashboard)
6. Replace CORS `*` with the actual dashboard origin

**Why ask for email instead of inferring from git?** Inferring from git config is fragile — users have different emails for work vs personal, git config might not be set on all machines, and you'd be guessing. Just ask once.

### C2. CORS wildcard (`Access-Control-Allow-Origin: *`) combined with no auth

**File:** `qc_trace/server/app.py:63,70`

The wildcard CORS header is set on every JSON response *and* the OPTIONS preflight. Combined with C1, this means any malicious website the user visits can silently exfiltrate all trace data (session metadata, code paths, repo URLs, user emails, message content) via cross-origin fetch requests. This is a data-leak vector.

### C3. Session upsert SQL in `handle_sessions` is missing `raw_file_path` in the `ON CONFLICT` update clause

**File:** `qc_trace/server/handlers.py:166-197`

The INSERT lists 13 columns including `raw_file_path`, but the `ON CONFLICT DO UPDATE SET` clause only updates 11 columns -- it omits `raw_file_path`. This means `raw_file_path` is silently dropped on session re-upserts from the POST /sessions endpoint. The identical SQL in `qc_trace/db/writer.py:37-68` has the same omission. This is at least inconsistent and likely a bug.

---

## High

### H1. `_make_flush_callback` is dead code

**File:** `qc_trace/server/app.py:127-137`

The async function `_make_flush_callback` is defined but never called. The actual flush callback is constructed inline in `run_server()` at line 188. This is dead code that should be removed. It also has a type annotation of `-> callable` (lowercase) which is incorrect -- should be `-> Callable` or a proper type alias.

### H2. Batch accumulator re-queues messages on failure, risking infinite retry loops

**File:** `qc_trace/server/batch.py:93-97`

When `_flush_locked()` fails, it catches the exception, re-queues all messages into `self._buffer`, and then re-raises. The problem:
1. The re-raised exception propagates to the caller of `add()`, which is the HTTP handler. The handler returns a 500 to the client. But the messages are still in the buffer.
2. The next `add()` call or timer flush will attempt to flush the same messages again. If the DB is persistently down, this creates an ever-growing buffer with repeated flush failures.
3. There is no cap on buffer size, no dead-letter mechanism, and no deduplication of the re-queued messages with newly added ones.

### H3. `BatchWriter.write()` returns `len(messages)` even if some were skipped by `ON CONFLICT DO NOTHING`

**File:** `qc_trace/db/writer.py:228-250`

The method always returns `len(messages)` regardless of how many rows actually made it past the `ON CONFLICT DO NOTHING` filter. This inflates the `total_flushed` counter in the accumulator and gives misleading ingest counts to clients. The `INSERT ... SELECT * FROM _stg_messages ON CONFLICT DO NOTHING` does not report how many rows were actually inserted unless you inspect `cur.rowcount`.

### H4. No rollback on `BatchWriter.write()` failure

**File:** `qc_trace/db/writer.py:239-248`

If any step after `_upsert_sessions` fails (e.g., `_copy_messages` succeeds but `_copy_tool_calls` throws), the method logs and re-raises but does **not** roll back the transaction. The connection is returned to the pool in an indeterminate state. psycopg3's pool will roll back an uncommitted transaction when the connection is returned, but only if the context manager is exited normally -- an exception during an active COPY operation can leave the connection in a broken state, making it unusable for subsequent checkouts from the pool.

### H5. Setting `conn.row_factory` mutates shared connection state

**File:** `qc_trace/db/reader.py:13,50,80,108,127`

Every reader function sets `conn.row_factory = dict_row` directly on the connection object. If the connection is reused (from the pool) by another coroutine or if two reader functions run on the same connection, this global mutation is a race condition. The row factory should be set on individual cursors instead:

```python
async with conn.cursor(row_factory=dict_row) as cur:
```

### H6. No input size limits on POST body

**File:** `qc_trace/server/app.py:55-57`

`_read_body()` reads `Content-Length` bytes with no upper bound. A malicious or buggy client can send a multi-GB payload, exhausting server memory. There should be a maximum body size (e.g., 10 MB) with a `413 Payload Too Large` response.

---

## Medium

### M1. Duplicated session upsert SQL in two locations

**Files:** `qc_trace/server/handlers.py:166-197` and `qc_trace/db/writer.py:33-68`

The exact same 13-column INSERT ... ON CONFLICT upsert SQL for sessions is copy-pasted in two places. Any schema change to the sessions table requires updating both, and they can (and already have -- see C3) drift apart. This should be a single shared function.

### M2. Missing index on `messages.ingested_at`

**File:** `qc_trace/db/schema.sql` (absent), `qc_trace/db/reader.py:130-148`

The `/api/feed` endpoint queries `WHERE m.ingested_at > %s ORDER BY m.ingested_at DESC`. There is no index on `messages.ingested_at`, so this will do a sequential scan as the table grows. Given that `ingested_at` is the primary sort/filter column for the feed, an index is important.

### M3. Missing index on `messages.raw_file_path`

**File:** `qc_trace/db/schema.sql` (absent), `qc_trace/db/reader.py:110-116`

The `/api/sync` endpoint queries `WHERE raw_file_path IS NOT NULL GROUP BY raw_file_path, source`. No index exists on `raw_file_path`. This will degrade as the messages table grows.

### M4. Migration system is all-or-nothing with no incremental migrations

**File:** `qc_trace/db/migrations.py:39-50`

`ensure_schema()` checks the version and re-applies the entire `schema.sql` if the version is outdated. This only works because every statement uses `IF NOT EXISTS` / `ON CONFLICT DO NOTHING`. But it means:
- Columns cannot be added, renamed, or removed via this system.
- There is no way to run ALTER TABLE statements.
- The version check (`version < CURRENT_SCHEMA_VERSION`) will re-run the full DDL every startup if any migration is needed, which is wasteful and fragile for actual schema changes.

This is a ticking time bomb for when the schema actually needs to evolve beyond additive table creation.

### M5. `_parse_qs` only takes the first value for multi-valued query params

**File:** `qc_trace/server/handlers.py:226-232`

```python
return {k: v[0] for k, v in qs.items()}
```

This silently discards repeated query params. While acceptable for the current API surface, it means `?source=a&source=b` would silently use only `a`. This is a latent correctness issue if multi-value filtering is ever added.

### M6. `limit` and `offset` query params are not validated

**Files:** `qc_trace/server/handlers.py:270-271,290-291,322`

The raw `int()` cast of user-supplied query parameters has no bounds checking. A user can pass `limit=999999999` and cause the database to return an enormous result set, or `offset=-1` which is a Postgres error. These should be clamped to reasonable ranges.

### M7. Class-level attribute sharing for handler state is not thread-safe

**File:** `qc_trace/server/app.py:48-51,153-155`

`IngestHandler.pool`, `IngestHandler.accumulator`, and `IngestHandler.loop` are set as class-level attributes. While these are set once at startup and are read-only thereafter, this pattern is fragile -- it makes it impossible to run two servers in the same process (e.g., for testing) and is a known anti-pattern with `BaseHTTPRequestHandler`.

### M8. Hardcoded credentials in `DEFAULT_DSN` and `docker-compose.yml`

**Files:** `qc_trace/db/connection.py:13`, `docker-compose.yml:7-8`

```python
DEFAULT_DSN = "postgresql://qc_trace:qc_trace_dev@localhost:5432/qc_trace"
```

The password `qc_trace_dev` is hardcoded in source. For a local dev tool this is low severity, but it sets a bad precedent. The env var `QC_TRACE_DSN` should be the primary path, with the default only used in development, and the docker-compose should use an `.env` file.

### M9. Temp table names in writer can collide in concurrent transactions

**File:** `qc_trace/db/writer.py:77-106`

`DROP TABLE IF EXISTS _stg_messages; CREATE TEMP TABLE _stg_messages ...` is executed on every batch write. Temp tables are connection-scoped in Postgres, so if two batch writes somehow run on the same connection concurrently (which should not happen with proper pool usage but is not guarded against), they would interfere. More practically, repeated `DROP/CREATE` of temp tables causes catalog bloat in long-running connections. Using `CREATE TEMP TABLE ... ON COMMIT DROP` or `TRUNCATE` would be cleaner.

---

## Low

### L1. `_read_schema_sql()` unused import of `resources`

**File:** `qc_trace/db/migrations.py:5`

`from importlib import resources` is imported but never used. The function uses `Path(__file__).parent / "schema.sql"` instead.

### L2. Inconsistent error detail exposure

**Files:** `qc_trace/server/handlers.py:203,257,276,298,309,327`

Database exceptions are passed directly into the JSON response body:
```python
return _error_response(f"Database error: {e}", HTTPStatus.INTERNAL_SERVER_ERROR)
```

This leaks internal information (table names, column names, constraint names, DSN fragments) to the client. Error details should be logged server-side only, with a generic message returned to the client.

### L3. `_serialize_row` does not handle all non-JSON-serializable types

**File:** `qc_trace/server/handlers.py:235-243`

The function only handles objects with `isoformat()` (datetime). Other Postgres types like `Decimal`, `UUID`, `timedelta`, `memoryview` will cause `json.dumps` to raise a `TypeError`. This will manifest as a 500 error on the read endpoints.

### L4. The `get_sessions` query does a `LEFT JOIN messages` with `GROUP BY` for counting

**File:** `qc_trace/db/reader.py:52-70`

This join-then-group pattern for getting message counts is an N+1-adjacent concern. As the messages table grows, this query will get slow because it joins every message row for every session just to count them. A subquery or lateral join would be more efficient:
```sql
(SELECT count(*) FROM messages WHERE session_id = s.id) AS message_count
```

### L5. `do_OPTIONS` does not set `Access-Control-Max-Age`

**File:** `qc_trace/server/app.py:67-73`

Without `Access-Control-Max-Age`, browsers will send a preflight OPTIONS request before every non-simple request. Setting a cache duration (e.g., 3600) reduces latency for the dashboard.

### L6. Docker Compose does not pin Postgres minor version

**File:** `docker-compose.yml:3`

`image: postgres:16` will float to the latest 16.x release. For reproducibility, pin to a specific minor version (e.g., `postgres:16.4`).

### L7. `handle_ingest` validates the entire batch before ingesting any of it

**File:** `qc_trace/server/handlers.py:130-139`

If item 99 out of 100 in a batch has a validation error, the entire batch is rejected, including the 99 valid items. For a batch ingest API, partial acceptance with per-item error reporting is generally more robust.

### L8. `select * from _stg_messages` in the writer is fragile

**File:** `qc_trace/db/writer.py:102-106`

```sql
INSERT INTO messages SELECT * FROM _stg_messages ON CONFLICT (id) DO NOTHING
```

`SELECT *` depends on the temp table having identical column ordering to the real table. The `CREATE TEMP TABLE ... (LIKE messages INCLUDING DEFAULTS)` guarantees this today, but any future ALTER on messages could cause a mismatch. Explicit column lists would be safer.

---

## Summary

| Severity | Count |
|----------|-------|
| Critical | 3     |
| High     | 6     |
| Medium   | 9     |
| Low      | 8     |

**Top priorities:**
1. Add authentication via explicit email + manual API key (C1) and restrict CORS to dashboard origin (C2). Add `qc-traced setup` to collect email + key, daemon sends `X-API-Key` + `X-User-Email` headers, server validates against allowlist/env var.
2. Fix the missing `raw_file_path` in session upsert ON CONFLICT clause (C3).
3. Consolidate the duplicated session upsert SQL (M1) to prevent further drift.
4. Add `ingested_at` index (M2) before the feed endpoint becomes a performance problem.
5. Add body size limits (H6) and input validation on query params (M6).
6. Fix `conn.row_factory` mutation to use cursor-level row factories (H5).
