From d11aae76a5a8f79da0aedfb2b2825ba83f1ba1b3 Mon Sep 17 00:00:00 2001 From: Shaan Satsangi Date: Thu, 28 May 2026 12:56:06 +0530 Subject: [PATCH 1/8] docs(v0.9.4): spec env-tunable DB pool (defaults unchanged) Data gathered 2026-05-28 (Neon max_connections=112, ~1 live app connection; Vercel 0% error rate; Sentry clean) shows no pool-exhaustion symptom, so the slice ships tunability rather than a blind 10/20 bump. --- ...026-05-28-v0.9.4-db-pool-tunable-design.md | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-28-v0.9.4-db-pool-tunable-design.md diff --git a/docs/superpowers/specs/2026-05-28-v0.9.4-db-pool-tunable-design.md b/docs/superpowers/specs/2026-05-28-v0.9.4-db-pool-tunable-design.md new file mode 100644 index 0000000..5869ddd --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-v0.9.4-db-pool-tunable-design.md @@ -0,0 +1,149 @@ +# v0.9.4 — Env-tunable DB connection pool design spec + +**Status:** Designed. Implementation plan to follow under `docs/superpowers/plans/`. +**Date:** 2026-05-28. +**Author:** Claude (Opus 4.7) with Shaan. + +--- + +## 1. Goal + +Make the SQLAlchemy async engine's `pool_size` and `max_overflow` configurable via environment variables, **keeping the current `5` / `5` defaults unchanged**. This converts the deferred "DB pool tune" slice from a blind default bump into a safe, reversible *capability*: the moment production telemetry ever shows pool exhaustion, an operator sets one env var and it takes effect with no code change and no redeploy. + +This is the DB-pool slice of the v0.9.x Beta hardening family. It is shippable in isolation. + +## 2. Why the original "bump to 10/20" was dropped (evidence, 2026-05-28) + +The slice was data-gated from inception: PLAN.md and PROGRESS_LOG both required PostHog/Sentry RUM to *confirm* pool exhaustion before raising the numbers. On 2026-05-28 we gathered the data directly: + +| Source | Finding | +| --- | --- | +| **Neon** `SHOW max_connections` | **112** (Neon's default for the small ~0.25 CU compute). | +| **Neon** `SHOW superuser_reserved_connections` | **7** → **105 usable** application connections. | +| **Neon** `pg_stat_activity` by `usename` | `cloud_admin=9`, `null=4` (Neon platform/monitoring), **`neondb_owner=1` (our app)**. Live app footprint ≈ **1 connection**. | +| **Neon** `pg_stat_activity` by `state` | 14 total (1 active, 6 idle, 7 null) — ~13% of the 105 ceiling, almost all of it Neon's own. | +| **Vercel** runtime logs (7d, prod) | Zero `QueuePool` / pool-timeout signatures; zero serverless error/fatal logs. | +| **Vercel** observability | `/_svc/backend/index`: 79 invocations at **0% error rate**; other routes single-digit invocations. | +| **Sentry** | No `QueuePool` / `TimeoutError` / `OperationalError` issues. | + +**Conclusion:** the pool-exhaustion symptom does not exist at current scale. The originally-planned bump to `pool_size=10, max_overflow=20` (= **30 connections/instance**) is not only unjustified, it is mildly **dangerous** on this compute: Fluid Compute can run several instances concurrently, and `30 × ~4 instances = 120 > 105 usable`. The only thing that would save that math is the PgBouncer pooler's multiplexing (see §3) — not a margin worth spending on a bump nobody needs. + +The honest, evidence-aligned version of this slice is therefore: **make the pool tunable, change no default.** + +## 3. Operating context — pooler vs direct + +The app's runtime connection uses Neon's **pooled (PgBouncer) endpoint**. Evidence: `app/db/engine.py` sets `connect_args={"statement_cache_size": 0}`, which is the standard accommodation for PgBouncer transaction pooling (prepared statements don't survive it). Alembic migrations use the separate `DATABASE_DIRECT_URL` (unpooled) — that path is out of scope here. + +Implications for sizing the env knob (documented, not enforced): +- **On the pooler (current):** each asyncpg pool connection is a PgBouncer *client* connection, multiplexed onto far fewer real Postgres server connections. Neon's pooler tolerates thousands of client connections, so the 112 ceiling is heavily buffered. A future bump is low-risk here. +- **On a direct connection (if ever switched):** each pool connection is a real Postgres connection. Keep `(pool_size + max_overflow) × peak_instances < 105`. + +> Note: the connection host's `-pooler` substring was not visually confirmed from the screenshots, but `statement_cache_size=0` + the Marketplace-integration provisioning path (`DATABASE_URL` pooled, `DATABASE_URL_UNPOOLED` direct) make the pooled endpoint the near-certain runtime path. This does not block the slice — defaults don't change, so neither case introduces new risk. It only informs the documented safe upper bound for the knob. + +## 4. Locked scope decisions (2026-05-28) + +| Decision | Choice | Why | +| --- | --- | --- | +| What changes | **Make `pool_size`/`max_overflow` env-driven; keep defaults `5`/`5`** | Evidence (§2) shows no symptom. Tunability is the useful, safe deliverable; a default bump is not. | +| Config source | **`Settings.db_pool_size: int = 5` (env `DB_POOL_SIZE`), `Settings.db_max_overflow: int = 5` (env `DB_MAX_OVERFLOW`)** | Matches the v0.9.0 `gh_ingest_concurrency` / v0.8.x settings pattern. Tunable without redeploy. | +| Settings read style | **Module reference: `import app.settings as settings_module` → `settings_module.settings.db_pool_size`** | The v0.8.1 / v0.9.0 lesson: `from app.settings import settings` binds the value at import time and defeats test monkeypatching of `app.settings.settings`. | +| Runtime validation/cap | **None** | Pydantic already coerces the env to `int`. A hard cap is a guess at a number nobody is hitting (YAGNI). Safe-ceiling guidance lives in a docstring + DEPLOY.md instead. | +| Other engine kwargs | **`pool_pre_ping`, `pool_recycle`, `connect_args` untouched** | Out of scope; no evidence they need changing. | +| Default values | **5 / 5 (unchanged)** | Zero behavior change on deploy. | + +## 5. Architecture (one paragraph) + +`Settings` gains two optional integer fields with `5` defaults. `_build_engine` in `app/db/engine.py` stops hardcoding `pool_size=5, max_overflow=5` and instead reads `settings_module.settings.db_pool_size` and `settings_module.settings.db_max_overflow` at call time (via the module reference, so tests can monkeypatch `app.settings.settings` before invoking `_build_engine`). Everything else in the engine builder is unchanged. Because the defaults equal the previous hardcoded values, a deploy with no new env vars set produces a byte-for-identical pool configuration — the change is inert until an operator opts in. + +## 6. Surface area + +### 6.1 Modified files + +| File | Change | +| --- | --- | +| `backend/app/settings.py` | Add `db_pool_size: int = 5` (env `DB_POOL_SIZE`) and `db_max_overflow: int = 5` (env `DB_MAX_OVERFLOW`), in the backend-tuning settings family near `gh_ingest_concurrency`. | +| `backend/app/db/engine.py` | (1) `import app.settings as settings_module` (replacing or alongside the existing `from app.settings import settings` used for `database_url`). (2) In `_build_engine`, replace literal `pool_size=5, max_overflow=5` with `pool_size=settings_module.settings.db_pool_size, max_overflow=settings_module.settings.db_max_overflow`. (3) Add a short docstring/comment noting the 105-usable ceiling and the pooler-vs-direct sizing rule. | +| `backend/.env.example` | Add commented `# DB_POOL_SIZE=5` / `# DB_MAX_OVERFLOW=5` lines under a new `# ── v0.9.4: DB pool sizing ───────────────` header, with a one-line ceiling note. | +| `docs/DEPLOY.md` | New env-table rows for `DB_POOL_SIZE` (optional, default 5) and `DB_MAX_OVERFLOW` (optional, default 5), plus the ceiling note: *105 usable connections (112 − 7 reserved on the current ~0.25 CU compute); on the PgBouncer pooler this is buffered by multiplexing, on a direct connection keep `(pool_size + max_overflow) × peak_instances < 105`.* | + +### 6.2 New files + +| File | Responsibility | +| --- | --- | +| `backend/tests/db/test_engine_pool.py` (new) | Two non-DB unit tests that build an engine via `_build_engine` and inspect the resulting pool — no live database connection required (so they run in the normal CI gate, not the DB-fixture gate). | + +### 6.3 Untouched (intentionally) + +- Default pool values — stay 5/5. +- `pool_pre_ping`, `pool_recycle`, `connect_args`, `_normalize_async_url` — no change. +- Alembic / `DATABASE_DIRECT_URL` path — migrations are out of scope. +- Frontend — no logic change (version-literal bumps only, per the docs ritual). +- Neon compute size — not raised; if the ceiling ever matters, that's a Neon-dashboard action, not a code change. + +## 7. Risks + mitigations + +- **Someone sets the knob too high and exhausts the 105 ceiling on a direct connection.** Mitigated by the documented sizing rule at the config site + DEPLOY.md. Not enforced in code (YAGNI); the operator setting an env var is expected to read the note. On the pooler (current path) the risk is minimal due to multiplexing. +- **Test brittleness inspecting SQLAlchemy pool internals.** `pool.size()` is public and returns `pool_size`; `max_overflow` is read via the (private) `pool._max_overflow`. To avoid coupling to a private attribute, the override test will instead assert on the kwargs passed to `create_async_engine` (spy/monkeypatch), which is a stable public contract. Final assertion mechanism is locked in the implementation plan. +- **Backwards compatibility.** Both fields are optional with defaults equal to the prior literals; existing deployments are unaffected. +- **Settings-binding footgun.** Reading via the module reference (`settings_module.settings.*`) is exactly the v0.8.1/v0.9.0 fix; the override test exercises it directly, so a regression to `from app.settings import settings` would fail the test. + +## 8. Exit criteria + +- [ ] `Settings.db_pool_size` and `Settings.db_max_overflow` fields exist (default 5); pydantic-settings reads `DB_POOL_SIZE` / `DB_MAX_OVERFLOW`. +- [ ] `_build_engine` reads both via `settings_module.settings.*`; no hardcoded `5` remains. +- [ ] `test_engine_pool_defaults`: with env unset, the built engine's pool reflects size 5 / overflow 5. Passes. +- [ ] `test_engine_pool_env_override`: with `app.settings.settings` monkeypatched to `db_pool_size=10, db_max_overflow=20`, `_build_engine` produces an engine configured with those values. Passes. +- [ ] Backend `pytest -q` non-DB suite passes (281 → 283). +- [ ] `ruff check .` + `ruff format --check .` clean. +- [ ] CI green on PR (Backend, Frontend, Config jobs). +- [ ] Post-merge prod `/health` reports `version: 0.9.4`, `db: up`. +- [ ] `CHANGELOG.md` `[0.9.4]` + `docs/PROGRESS_LOG.md` entry + `PLAN.md` v0.9.4 row flipped ✅ (section rewritten from "bump to 10/20" to "make tunable"); tag `v0.9.4` pushed; release workflow publishes. + +## 9. Test plan + +Two new non-DB unit tests in `backend/tests/db/test_engine_pool.py`: + +**Test 1 — defaults honored when env unset.** + +```python +def test_engine_pool_defaults(): + from app.db.engine import _build_engine + engine = _build_engine("postgresql+asyncpg://u:p@localhost:5432/db") + assert engine.pool.size() == 5 # pool_size +``` + +**Test 2 — env override propagates through the builder.** + +```python +def test_engine_pool_env_override(monkeypatch): + from app.settings import Settings + monkeypatch.setattr( + "app.settings.settings", + Settings(db_pool_size=10, db_max_overflow=20), + ) + from app.db import engine as engine_module + engine = engine_module._build_engine("postgresql+asyncpg://u:p@localhost:5432/db") + assert engine.pool.size() == 10 + # max_overflow assertion: prefer spying on create_async_engine kwargs over + # touching pool._max_overflow — exact mechanism locked in the plan. +``` + +These build an `AsyncEngine` but never connect (no `await engine.connect()`), so no live database is needed and they run in the standard non-DB CI gate. + +## 10. Out of scope + +- **Raising the default pool size.** Evidence says no. Defaults stay 5/5. +- **Raising Neon's compute size / `max_connections`.** A dashboard action, only relevant if real load ever approaches the ceiling. +- **Tuning `pool_pre_ping` / `pool_recycle` / `pool_timeout`.** No evidence they need changing. +- **Runtime cap/validation on the env values.** YAGNI; documented guidance suffices. +- **Switching pooled ↔ direct endpoints.** Not part of this slice. + +## 11. Implementation ordering + +Three tasks; the first two are code, the third is doc-ritual + ship. + +1. **Add the two `Settings` fields.** `pytest -q` confirms no regression. Commit `feat(v0.9.4): add DB_POOL_SIZE / DB_MAX_OVERFLOW settings`. +2. **Wire `_build_engine` to read them via the module reference + add both tests.** `ruff` clean; both tests pass; non-DB suite 281 → 283. Commit `feat(v0.9.4): make DB pool size env-tunable (defaults unchanged)`. +3. **Version bumps + doc ritual + ship.** `pyproject.toml`, `app/settings.py::VERSION`, `frontend/package.json`, landing pill, results-view footer, README, `docs/DEPLOY.md` (two env rows + ceiling note), `.env.example`, `CHANGELOG [0.9.4]`, PROGRESS_LOG entry, PLAN v0.9.4 row flip + section rewrite. `uv lock`. Push → CI green → merge → prod smoke (`/health` → `version: 0.9.4`, `db: up`) → tag `v0.9.4` → release. + +**Reversibility.** All three steps are independently revertable. No step changes runtime behavior at the shipped defaults (the pool config is byte-identical to today's until an env var is set), so there is no schema/cache/connection-count implication to deploying this slice. From 0cc4afcc725f95144231f7c1c17014816c050b99 Mon Sep 17 00:00:00 2001 From: Shaan Satsangi Date: Thu, 28 May 2026 12:59:41 +0530 Subject: [PATCH 2/8] docs(v0.9.4): implementation plan for env-tunable DB pool --- .../2026-05-28-v0.9.4-db-pool-tunable.md | 327 ++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-28-v0.9.4-db-pool-tunable.md diff --git a/docs/superpowers/plans/2026-05-28-v0.9.4-db-pool-tunable.md b/docs/superpowers/plans/2026-05-28-v0.9.4-db-pool-tunable.md new file mode 100644 index 0000000..b5d9039 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-v0.9.4-db-pool-tunable.md @@ -0,0 +1,327 @@ +# v0.9.4 — Env-tunable DB connection pool Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the SQLAlchemy async engine's `pool_size` and `max_overflow` configurable via `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` env vars, keeping the current `5`/`5` defaults so deploys are byte-identical until an operator opts in. + +**Architecture:** Two new optional `Settings` fields (default 5). `_build_engine` reads them via a module reference (`app.settings as settings_module`) so test monkeypatching of `app.settings.settings` propagates — the v0.8.1/v0.9.0 settings-binding lesson. No default change, no migration, no runtime cap (sizing guidance is documented, not enforced). + +**Tech Stack:** Python 3.12, FastAPI, SQLAlchemy 2.0 async + asyncpg, pydantic-settings, pytest. + +**Spec:** [`docs/superpowers/specs/2026-05-28-v0.9.4-db-pool-tunable-design.md`](../specs/2026-05-28-v0.9.4-db-pool-tunable-design.md). + +--- + +## File structure + +| File | Responsibility | Action | +| --- | --- | --- | +| `backend/app/settings.py` | Declares `db_pool_size` / `db_max_overflow` fields | Modify | +| `backend/app/db/engine.py` | Builds the async engine from those settings | Modify | +| `backend/tests/db/test_engine_pool.py` | Verifies defaults + env override flow through `_build_engine` | Create | +| `backend/.env.example` | Documents the two new optional env vars | Modify | +| `docs/DEPLOY.md` | Env-table rows + ceiling note | Modify | +| Version literals + CHANGELOG + PLAN + PROGRESS_LOG + `uv.lock` | Release ritual | Modify | + +--- + +### Task 1: Add the two `Settings` fields + +**Files:** +- Modify: `backend/app/settings.py:98-99` (append a new block after the v0.9.2 `internal_proxy_secret` field, before `settings = Settings()`) + +- [ ] **Step 1: Add the fields** + +In `backend/app/settings.py`, immediately after the line ` internal_proxy_secret: str | None = None` (currently line 98) and before the blank line + `settings = Settings()`, insert: + +```python + + # v0.9.4 — DB connection pool sizing + # SQLAlchemy async engine pool. Defaults match the pre-v0.9.4 hardcoded + # values, so a deploy with neither env var set is byte-identical to before. + # Raise via env only when RUM shows pool exhaustion (QueuePool timeouts). + # Ceiling: ~105 usable Postgres connections (112 max_connections − 7 + # reserved on the current ~0.25 CU Neon compute). On the PgBouncer pooler + # this is buffered by multiplexing; on a direct connection keep + # (pool_size + max_overflow) × peak_instances < 105. + db_pool_size: int = 5 + db_max_overflow: int = 5 +``` + +- [ ] **Step 2: Verify the fields load** + +Run: `cd backend && uv run python -c "from app.settings import Settings; s = Settings(); print(s.db_pool_size, s.db_max_overflow)"` +Expected: `5 5` + +- [ ] **Step 3: Verify env override is read** + +Run: `cd backend && DB_POOL_SIZE=10 DB_MAX_OVERFLOW=20 uv run python -c "from app.settings import Settings; s = Settings(); print(s.db_pool_size, s.db_max_overflow)"` +Expected: `10 20` + +- [ ] **Step 4: Lint** + +Run: `cd backend && uv run ruff check app/settings.py && uv run ruff format --check app/settings.py` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/settings.py +git commit -m "feat(v0.9.4): add DB_POOL_SIZE / DB_MAX_OVERFLOW settings" +``` + +--- + +### Task 2: Wire `_build_engine` to the settings + add tests + +**Files:** +- Test: `backend/tests/db/test_engine_pool.py` (create) +- Modify: `backend/app/db/engine.py:10` (import), `:81-88` (pool kwargs), `:91-94` (module-level build) + +- [ ] **Step 1: Write the failing tests** + +Create `backend/tests/db/test_engine_pool.py` with exactly: + +```python +from __future__ import annotations + +from unittest.mock import patch + +import app.settings as settings_module +from app.db.engine import _build_engine +from app.settings import Settings + +_URL = "postgresql+asyncpg://u:p@localhost:5432/db" + + +def test_engine_pool_uses_settings_defaults(): + """With no env override, the builder passes the 5/5 defaults through.""" + with patch("app.db.engine.create_async_engine") as mock_create: + _build_engine(_URL) + kwargs = mock_create.call_args.kwargs + assert kwargs["pool_size"] == 5 + assert kwargs["max_overflow"] == 5 + + +def test_engine_pool_reads_env_override(monkeypatch): + """Monkeypatching the live settings object flows into the built engine — + proves _build_engine reads via the module reference, not an import-time bind.""" + monkeypatch.setattr( + settings_module, + "settings", + Settings(db_pool_size=10, db_max_overflow=20), + ) + with patch("app.db.engine.create_async_engine") as mock_create: + _build_engine(_URL) + kwargs = mock_create.call_args.kwargs + assert kwargs["pool_size"] == 10 + assert kwargs["max_overflow"] == 20 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd backend && uv run pytest tests/db/test_engine_pool.py -v` +Expected: `test_engine_pool_reads_env_override` FAILS — the current `_build_engine` hardcodes `pool_size=5, max_overflow=5`, so after the monkeypatch it still passes 5/5 and the `== 10` / `== 20` assertions fail. (`test_engine_pool_uses_settings_defaults` may already pass since the hardcoded values happen to be 5/5 — that's fine; the override test is the real gate.) + +- [ ] **Step 3: Change the import in `engine.py`** + +In `backend/app/db/engine.py`, replace line 10: + +```python +from app.settings import settings +``` + +with: + +```python +import app.settings as settings_module +``` + +- [ ] **Step 4: Read pool sizes from settings in `_build_engine`** + +In `backend/app/db/engine.py`, change the `create_async_engine(...)` call (currently lines 81-88) so the two pool literals read from settings: + +```python + return create_async_engine( + normalized, + connect_args=connect_args, + pool_size=settings_module.settings.db_pool_size, + max_overflow=settings_module.settings.db_max_overflow, + pool_pre_ping=True, + pool_recycle=1800, + ) +``` + +- [ ] **Step 5: Fix the module-level `database_url` read (was using the now-removed `settings` name)** + +In `backend/app/db/engine.py`, change the module-level engine construction (currently lines 91-94) from `settings.database_url` to the module reference: + +```python +engine: AsyncEngine = _build_engine( + settings_module.settings.database_url + or "postgresql+asyncpg://placeholder:placeholder@localhost:5432/placeholder" +) +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `cd backend && uv run pytest tests/db/test_engine_pool.py -v` +Expected: both tests PASS. + +- [ ] **Step 7: Run the full non-DB suite + lint** + +Run: `cd backend && uv run pytest -q --no-header && uv run ruff check . && uv run ruff format --check .` +Expected: `283 passed` (281 baseline + 2 new), DB-fixture tests skipped, ruff clean. + +- [ ] **Step 8: Commit** + +```bash +git add backend/app/db/engine.py backend/tests/db/test_engine_pool.py +git commit -m "feat(v0.9.4): make DB pool size env-tunable (defaults unchanged)" +``` + +--- + +### Task 3: Docs ritual + version bump + ship + +**Files:** +- Modify: `backend/.env.example`, `docs/DEPLOY.md`, `backend/pyproject.toml:3`, `backend/app/settings.py:5`, `frontend/package.json:3`, `frontend/src/app/page.tsx:26`, `frontend/src/components/results-view.tsx:355`, `README.md:46,79`, `CHANGELOG.md`, `PLAN.md` (map row + section), `docs/PROGRESS_LOG.md`, `backend/uv.lock` + +- [ ] **Step 1: Add env vars to `.env.example`** + +In `backend/.env.example`, after the v0.9.2 rate-limiting block (the final block, ending with `# INTERNAL_PROXY_SECRET=`), append: + +```bash + +# ── v0.9.4: DB connection pool sizing ───────────────────────────────── +# SQLAlchemy async engine pool. Optional — defaults shown match the +# previous hardcoded values, so leaving these unset changes nothing. +# Raise only if RUM shows QueuePool timeouts. Ceiling ~105 usable +# connections on the current Neon compute (112 − 7 reserved); on the +# PgBouncer pooler that's buffered by multiplexing, on a direct +# connection keep (pool_size + max_overflow) × peak_instances < 105. +# DB_POOL_SIZE=5 +# DB_MAX_OVERFLOW=5 +``` + +- [ ] **Step 2: Add env rows + ceiling note to `docs/DEPLOY.md`** + +In `docs/DEPLOY.md`, add two rows to the environment-variable table (near the other optional backend-tuning rows like `GH_INGEST_CONCURRENCY`): + +```markdown +| `DB_POOL_SIZE` | No | `5` | SQLAlchemy pool size per Fluid Compute instance. Raise only on confirmed pool exhaustion. | +| `DB_MAX_OVERFLOW` | No | `5` | Extra connections beyond `DB_POOL_SIZE` under burst. | +``` + +Then add this note immediately below that table: + +```markdown +> **DB pool ceiling.** The Neon compute exposes ~105 usable connections (`max_connections` 112 − 7 `superuser_reserved_connections` on the current ~0.25 CU compute). The app connects through the PgBouncer pooler (`statement_cache_size=0`), which multiplexes many client connections onto few server ones — so the ceiling is heavily buffered. If ever switched to a direct connection, keep `(DB_POOL_SIZE + DB_MAX_OVERFLOW) × peak_instances < 105`. +``` + +- [ ] **Step 3: Bump backend version literals** + +In `backend/pyproject.toml` line 3: `version = "0.9.3"` → `version = "0.9.4"`. +In `backend/app/settings.py` line 5: `VERSION = "0.9.3"` → `VERSION = "0.9.4"`. + +- [ ] **Step 4: Bump frontend version literals** + +In `frontend/package.json` line 3: `"version": "0.9.3",` → `"version": "0.9.4",`. +In `frontend/src/app/page.tsx` line 26: `Deterministic engineering reports · v0.9.3` → `... · v0.9.4`. +In `frontend/src/components/results-view.tsx` line 355: `Skill Issue — GitHub Reputation Protocol v0.9.3` → `... v0.9.4`. + +- [ ] **Step 5: Update README** + +In `README.md` line 46: change the trailing `v0.9.3 ...; **v0.9.4 — DB pool tune** is next (after PostHog baseline).` so the v0.9.3 clause is followed by a v0.9.4 *shipped* clause and the "next" pointer moves to v0.9.5. Use: + +``` +... v0.9.3 adds deletable `/me` history with undo, fixes the back-nav search spinner, and gilds the creator's scorecard; v0.9.4 makes the DB connection pool size env-tunable (defaults unchanged — RUM showed no pool exhaustion). **v0.9.5 — security review + load test** is next. +``` + +In `README.md` line 79: change the health-curl example version `"version":"0.9.3"` → `"version":"0.9.4"`. + +- [ ] **Step 6: Add the CHANGELOG entry** + +In `CHANGELOG.md`, immediately after the `---` that follows the header preamble (above `## [0.9.3]`), insert: + +```markdown +## [0.9.4] — 2026-05-28 + +### Changed +- **Database connection pool size is now configurable** via the `DB_POOL_SIZE` and `DB_MAX_OVERFLOW` environment variables (defaults unchanged at 5 each), so it can be tuned in production without a redeploy. Telemetry showed no connection-pool pressure at current scale, so this ships the capability without changing the running defaults. + +--- +``` + +- [ ] **Step 7: Update PLAN.md — map row + section** + +In `PLAN.md`, the version-map table: change the v0.9.4 row status from `pending` to `✅ shipped` and retitle it from "DB pool tune (5→10, 5→20) after PostHog baseline" to "DB pool size env-tunable (defaults unchanged)". + +Then replace the `## v0.9.4 — DB pool tune (deferred)` section body (the Goal + "Exit criteria: TBD" lines) with: + +```markdown +## v0.9.4 — DB pool size env-tunable (shipped 2026-05-28) + +**Goal:** Make the SQLAlchemy engine's `pool_size` / `max_overflow` configurable via `DB_POOL_SIZE` / `DB_MAX_OVERFLOW`, keeping the 5/5 defaults. + +**Why not the planned 10/20 bump:** Direct telemetry on 2026-05-28 (Neon `max_connections=112`, ~1 live app connection; Vercel 0% error rate; Sentry clean) showed no pool-exhaustion symptom. A blind bump to 30 connections/instance would also risk the 105-usable ceiling under multi-instance Fluid Compute. So the slice ships tunability instead of a default change; flip the env var if RUM ever shows the symptom. + +**Design spec:** [`docs/superpowers/specs/2026-05-28-v0.9.4-db-pool-tunable-design.md`](./docs/superpowers/specs/2026-05-28-v0.9.4-db-pool-tunable-design.md). +**Sub-plan:** [`docs/superpowers/plans/2026-05-28-v0.9.4-db-pool-tunable.md`](./docs/superpowers/plans/2026-05-28-v0.9.4-db-pool-tunable.md). + +**Exit criteria:** +- [x] `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` settings (default 5); `_build_engine` reads them via the module reference. +- [x] 2 new non-DB tests (defaults + override) pass; suite 281 → 283. +- [x] Docs ritual + version bump to 0.9.4; tag + release. +``` + +- [ ] **Step 8: Re-sync `uv.lock` for the version bump** + +Run: `cd backend && uv lock` +Expected: `skill-issue-backend` entry updates `0.9.3 → 0.9.4`; no other dependency churn. + +- [ ] **Step 9: Add the PROGRESS_LOG entry** + +In `docs/PROGRESS_LOG.md`, add a new top entry (above the current top `## 2026-05-28 — ... post-v0.9.3 fix-forward`) following the file's format: Slice `v0.9.4`; Done (the env-tunable pool + tests + docs); Decisions (ship tunability not a bump, per the 2026-05-28 telemetry; defaults unchanged; module-reference read for testability); Learned (the Neon ceiling 112→105, app footprint ~1 conn, pooler buffers it); Verified (ruff + 283 pytest + frontend lint/tsc/test/build); Next (v0.9.5 security review + load test). + +- [ ] **Step 10: Full verification before shipping** + +Run backend: `cd backend && uv run pytest -q --no-header && uv run ruff check . && uv run ruff format --check .` +Expected: `283 passed`, ruff clean. + +Run frontend: `cd frontend && npm run lint && npx tsc --noEmit && npm run test:run && npm run build` +Expected: lint clean, tsc clean, vitest 51/51, build succeeds. + +- [ ] **Step 11: Commit the release** + +```bash +git add backend/.env.example docs/DEPLOY.md backend/pyproject.toml backend/app/settings.py frontend/package.json frontend/src/app/page.tsx frontend/src/components/results-view.tsx README.md CHANGELOG.md PLAN.md docs/PROGRESS_LOG.md backend/uv.lock +git commit -m "chore(v0.9.4): bump version + docs ritual" +``` + +- [ ] **Step 12: Push, open PR, merge after CI green** + +Push the branch, open a PR, wait for CI (Backend, Frontend, Config jobs) green, then merge to `main`. Vercel auto-deploys on merge. + +- [ ] **Step 13: Post-merge prod smoke** + +Run: `curl https://skill-issue-tau.vercel.app/_/backend/health` +Expected: `{"status":"ok","version":"0.9.4","db":"up",...}`. + +- [ ] **Step 14: Tag + release** + +```bash +git checkout main && git pull +git tag v0.9.4 +git push origin v0.9.4 +``` +Expected: `.github/workflows/release.yml` fires and publishes a GitHub Release with the `[0.9.4]` CHANGELOG body. + +--- + +## Self-review notes + +- **Spec coverage:** §6.1 settings + engine → Tasks 1–2; §6.2 test file → Task 2; §6.1 `.env.example` + DEPLOY.md → Task 3 Steps 1–2; §8 exit criteria → Task 3 Steps 6–10, 13–14. All covered. +- **Test mechanism:** the spec deferred the `max_overflow` assertion mechanism to this plan. Locked: spy on `create_async_engine` kwargs (public contract) rather than touching `pool._max_overflow` (private). Both tests use it uniformly. +- **No default change:** Task 1's defaults are 5/5; Task 2 changes only the *source* of the values, not the values. The defaults test guards against an accidental default drift. +- **Type/name consistency:** field names `db_pool_size` / `db_max_overflow` and env names `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` are identical across Tasks 1, 2, 3 and the tests. From 8666e177c9caeada5f0bd6c6733283af6210d936 Mon Sep 17 00:00:00 2001 From: Shaan Satsangi Date: Thu, 28 May 2026 13:02:14 +0530 Subject: [PATCH 3/8] feat(v0.9.4): add DB_POOL_SIZE / DB_MAX_OVERFLOW settings --- backend/app/settings.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/app/settings.py b/backend/app/settings.py index aebe620..c87cf19 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -97,5 +97,16 @@ class Settings(BaseSettings): # collapse into one Vercel-infra-IP bucket); narrative + user limits stay on. internal_proxy_secret: str | None = None + # v0.9.4 — DB connection pool sizing + # SQLAlchemy async engine pool. Defaults match the pre-v0.9.4 hardcoded + # values, so a deploy with neither env var set is byte-identical to before. + # Raise via env only when RUM shows pool exhaustion (QueuePool timeouts). + # Ceiling: ~105 usable Postgres connections (112 max_connections - 7 + # reserved on the current ~0.25 CU Neon compute). On the PgBouncer pooler + # this is buffered by multiplexing; on a direct connection keep + # (pool_size + max_overflow) x peak_instances < 105. + db_pool_size: int = 5 + db_max_overflow: int = 5 + settings = Settings() From c1bf2906dc67271da11dcb285a57d543da5abf39 Mon Sep 17 00:00:00 2001 From: Shaan Satsangi Date: Thu, 28 May 2026 13:05:01 +0530 Subject: [PATCH 4/8] feat(v0.9.4): make DB pool size env-tunable (defaults unchanged) --- backend/app/db/engine.py | 8 +++---- backend/tests/db/test_engine_pool.py | 33 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 backend/tests/db/test_engine_pool.py diff --git a/backend/app/db/engine.py b/backend/app/db/engine.py index b10d29d..1c246c5 100644 --- a/backend/app/db/engine.py +++ b/backend/app/db/engine.py @@ -7,7 +7,7 @@ create_async_engine, ) -from app.settings import settings +import app.settings as settings_module def _normalize_async_url(url: str) -> tuple[str, bool]: @@ -81,15 +81,15 @@ def _build_engine(url: str) -> AsyncEngine: return create_async_engine( normalized, connect_args=connect_args, - pool_size=5, - max_overflow=5, + pool_size=settings_module.settings.db_pool_size, + max_overflow=settings_module.settings.db_max_overflow, pool_pre_ping=True, pool_recycle=1800, ) engine: AsyncEngine = _build_engine( - settings.database_url + settings_module.settings.database_url or "postgresql+asyncpg://placeholder:placeholder@localhost:5432/placeholder" ) diff --git a/backend/tests/db/test_engine_pool.py b/backend/tests/db/test_engine_pool.py new file mode 100644 index 0000000..8c30401 --- /dev/null +++ b/backend/tests/db/test_engine_pool.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from unittest.mock import patch + +import app.settings as settings_module +from app.db.engine import _build_engine +from app.settings import Settings + +_URL = "postgresql+asyncpg://u:p@localhost:5432/db" + + +def test_engine_pool_uses_settings_defaults(): + """With no env override, the builder passes the 5/5 defaults through.""" + with patch("app.db.engine.create_async_engine") as mock_create: + _build_engine(_URL) + kwargs = mock_create.call_args.kwargs + assert kwargs["pool_size"] == 5 + assert kwargs["max_overflow"] == 5 + + +def test_engine_pool_reads_env_override(monkeypatch): + """Monkeypatching the live settings object flows into the built engine — + proves _build_engine reads via the module reference, not an import-time bind.""" + monkeypatch.setattr( + settings_module, + "settings", + Settings(db_pool_size=10, db_max_overflow=20), + ) + with patch("app.db.engine.create_async_engine") as mock_create: + _build_engine(_URL) + kwargs = mock_create.call_args.kwargs + assert kwargs["pool_size"] == 10 + assert kwargs["max_overflow"] == 20 From 72beeb874487059ae696ff883cfc22fb2d4b0a1e Mon Sep 17 00:00:00 2001 From: Shaan Satsangi Date: Thu, 28 May 2026 13:10:10 +0530 Subject: [PATCH 5/8] chore(v0.9.4): bump version + docs ritual --- CHANGELOG.md | 7 +++++++ PLAN.md | 16 ++++++++++++---- README.md | 4 ++-- backend/.env.example | 10 ++++++++++ backend/app/settings.py | 2 +- backend/pyproject.toml | 2 +- backend/uv.lock | 2 +- docs/DEPLOY.md | 4 ++++ docs/PROGRESS_LOG.md | 24 ++++++++++++++++++++++++ frontend/package.json | 2 +- frontend/src/app/page.tsx | 2 +- frontend/src/components/results-view.tsx | 2 +- 12 files changed, 65 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eacebf0..a3f23d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ Every version listed here must correspond to a slice in [`PLAN.md`](./PLAN.md) w --- +## [0.9.4] — 2026-05-28 + +### Changed +- **Database connection pool size is now configurable** via the `DB_POOL_SIZE` and `DB_MAX_OVERFLOW` environment variables (defaults unchanged at 5 each), so it can be tuned in production without a redeploy. Telemetry showed no connection-pool pressure at current scale, so this ships the capability without changing the running defaults. + +--- + ## [0.9.3] — 2026-05-28 ### Added diff --git a/PLAN.md b/PLAN.md index 678e73c..cefdc03 100644 --- a/PLAN.md +++ b/PLAN.md @@ -42,7 +42,7 @@ | **v0.9.1** | `/me/analyses` N+1 fix + Layer A cache schema version | ✅ shipped | | **v0.9.2** | Rate limiting (IP + user) on `/analyze` + `/narrative` | ✅ shipped | | **v0.9.3** | Deletable `/me` history + back-nav loading fix + creator flair | ✅ shipped | -| **v0.9.4** | DB pool tune (5→10, 5→20) after PostHog baseline | pending | +| **v0.9.4** | DB pool size env-tunable (defaults unchanged) | ✅ shipped | | **v0.9.5** | `/security-review` pass + load test to 100 RPS | pending | | **v0.9.6** | Privacy policy + terms (legal docs) | pending | | **v1.0.0** | Public launch | pending | @@ -630,11 +630,19 @@ The narrative-mode CHECK constraint was a third drift in the same family — the --- -## v0.9.4 — DB pool tune (deferred) +## v0.9.4 — DB pool size env-tunable (shipped 2026-05-28) -**Goal:** Raise `pool_size=5, max_overflow=5` to `pool_size=10, max_overflow=20` once PostHog/Sentry baseline confirms the symptom in v0.8.0-shipped RUM data. Mind Neon's pooled-host (PgBouncer) connection caps when sizing. +**Goal:** Make the SQLAlchemy engine's `pool_size` / `max_overflow` configurable via `DB_POOL_SIZE` / `DB_MAX_OVERFLOW`, keeping the 5/5 defaults. -**Exit criteria:** TBD when the slice begins. +**Why not the planned 10/20 bump:** Direct telemetry on 2026-05-28 (Neon `max_connections=112`, ~1 live app connection; Vercel 0% error rate; Sentry clean) showed no pool-exhaustion symptom. A blind bump to 30 connections/instance would also risk the 105-usable ceiling under multi-instance Fluid Compute. So the slice ships tunability instead of a default change; flip the env var if RUM ever shows the symptom. + +**Design spec:** [`docs/superpowers/specs/2026-05-28-v0.9.4-db-pool-tunable-design.md`](./docs/superpowers/specs/2026-05-28-v0.9.4-db-pool-tunable-design.md). +**Sub-plan:** [`docs/superpowers/plans/2026-05-28-v0.9.4-db-pool-tunable.md`](./docs/superpowers/plans/2026-05-28-v0.9.4-db-pool-tunable.md). + +**Exit criteria:** +- [x] `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` settings (default 5); `_build_engine` reads them via the module reference. +- [x] 2 new non-DB tests (defaults + override) pass; suite 281 → 283. +- [x] Docs ritual + version bump to 0.9.4; tag + release. --- diff --git a/README.md b/README.md index dc510c0..dd9b757 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Engineering insight first. AI flavor second. Scoring is deterministic and explai ## Status -Pre-alpha. Latest shipped release is **v0.9.3** (deletable `/me` history with undo, a fix for the search spinner sticking after browser-back, and a golden "creator" scorecard for the project's creator account). Live at https://skill-issue-tau.vercel.app — GitHub OAuth sign-in, Neon Postgres persistence, `/me` history, opt-in `/share/[slug]` public links. The AI narrative layer (Roast + Mentor) runs on **Groq** (`llama-3.3-70b-versatile`). v0.7.0 added Upstash Redis caching (warm `/analyze` ≤ 200 ms); v0.7.2 prod-certified the perf budget (CLS 0.080 → **0** structurally, perf 90 → 94, LCP 2,804 → 2,773 ms); v0.8.0 shipped Sentry (FE+BE), PostHog (events + web vitals), structlog JSON logging, on-voice 404, and a full axe a11y pass; v0.8.1 ships the nightly cron with bearer auth; v0.8.2 pairs it with the manual force-refresh button on `/me`; v0.8.3 hotfixes the empty-repo crash; v0.8.4 fixes the silent narrative misattribution; v0.8.5 closes the post-deploy-Sentry loop with a pre-merge CI gate; v0.8.6 closes v0.7.1's deferred share-page caching; v0.8.7 modernizes project config; v0.9.0 opens Beta hardening with bounded GH fan-out; v0.9.1 closes the /me N+1 + adds per-namespace Report cache versioning; v0.9.2 adds rate limiting (per-IP for anonymous, higher per-user caps for signed-in) on `/analyze` and `/narrative`; v0.9.3 adds deletable `/me` history with undo, fixes the back-nav search spinner, and gilds the creator's scorecard. **v0.9.4 — DB pool tune** is next (after PostHog baseline). See [`CHANGELOG.md`](./CHANGELOG.md) for shipped slices, [`PLAN.md`](./PLAN.md) for the full roadmap, and [`docs/PROGRESS_LOG.md`](./docs/PROGRESS_LOG.md) for the most recent session handoff. +Pre-alpha. Latest shipped release is **v0.9.3** (deletable `/me` history with undo, a fix for the search spinner sticking after browser-back, and a golden "creator" scorecard for the project's creator account). Live at https://skill-issue-tau.vercel.app — GitHub OAuth sign-in, Neon Postgres persistence, `/me` history, opt-in `/share/[slug]` public links. The AI narrative layer (Roast + Mentor) runs on **Groq** (`llama-3.3-70b-versatile`). v0.7.0 added Upstash Redis caching (warm `/analyze` ≤ 200 ms); v0.7.2 prod-certified the perf budget (CLS 0.080 → **0** structurally, perf 90 → 94, LCP 2,804 → 2,773 ms); v0.8.0 shipped Sentry (FE+BE), PostHog (events + web vitals), structlog JSON logging, on-voice 404, and a full axe a11y pass; v0.8.1 ships the nightly cron with bearer auth; v0.8.2 pairs it with the manual force-refresh button on `/me`; v0.8.3 hotfixes the empty-repo crash; v0.8.4 fixes the silent narrative misattribution; v0.8.5 closes the post-deploy-Sentry loop with a pre-merge CI gate; v0.8.6 closes v0.7.1's deferred share-page caching; v0.8.7 modernizes project config; v0.9.0 opens Beta hardening with bounded GH fan-out; v0.9.1 closes the /me N+1 + adds per-namespace Report cache versioning; v0.9.2 adds rate limiting (per-IP for anonymous, higher per-user caps for signed-in) on `/analyze` and `/narrative`; v0.9.3 adds deletable `/me` history with undo, fixes the back-nav search spinner, and gilds the creator's scorecard. v0.9.4 makes the DB connection pool size env-tunable (defaults unchanged — RUM showed no pool exhaustion). **v0.9.5 — security review + load test** is next. See [`CHANGELOG.md`](./CHANGELOG.md) for shipped slices, [`PLAN.md`](./PLAN.md) for the full roadmap, and [`docs/PROGRESS_LOG.md`](./docs/PROGRESS_LOG.md) for the most recent session handoff. --- @@ -76,7 +76,7 @@ cp .env.example .env # then edit .env and add your GITHUB_TOKEN and OPENA uv run uvicorn app.main:app --reload --port 8000 ``` -Verify: `curl http://localhost:8000/health` → `{"status":"ok","version":"0.9.3","db":"up"|"down","cache":"up"|"down"|"unconfigured"}`. The `db` field reports DB reachability when `DATABASE_URL` is configured; the `cache` field reports Upstash reachability (`unconfigured` when `UPSTASH_REDIS_REST_URL` isn't set — perfectly fine for local dev, the in-process fallback covers it). +Verify: `curl http://localhost:8000/health` → `{"status":"ok","version":"0.9.4","db":"up"|"down","cache":"up"|"down"|"unconfigured"}`. The `db` field reports DB reachability when `DATABASE_URL` is configured; the `cache` field reports Upstash reachability (`unconfigured` when `UPSTASH_REDIS_REST_URL` isn't set — perfectly fine for local dev, the in-process fallback covers it). Hit the analyzer: `curl http://localhost:8000/analyze/octocat`. ### Frontend (`:3000`) diff --git a/backend/.env.example b/backend/.env.example index da090a3..3105db5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -95,3 +95,13 @@ UPSTASH_REDIS_REST_TOKEN= # throttled by mistake); narrative + signed-in limits stay active. # Generate: python -c "import secrets; print(secrets.token_hex(32))" # INTERNAL_PROXY_SECRET= + +# ── v0.9.4: DB connection pool sizing ───────────────────────────────── +# SQLAlchemy async engine pool. Optional — defaults shown match the +# previous hardcoded values, so leaving these unset changes nothing. +# Raise only if RUM shows QueuePool timeouts. Ceiling ~105 usable +# connections on the current Neon compute (112 - 7 reserved); on the +# PgBouncer pooler that's buffered by multiplexing, on a direct +# connection keep (pool_size + max_overflow) x peak_instances < 105. +# DB_POOL_SIZE=5 +# DB_MAX_OVERFLOW=5 diff --git a/backend/app/settings.py b/backend/app/settings.py index c87cf19..6de2cf1 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict -VERSION = "0.9.3" +VERSION = "0.9.4" class Settings(BaseSettings): diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 48c5566..ec91a86 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "skill-issue-backend" -version = "0.9.3" +version = "0.9.4" description = "Skill Issue backend — FastAPI service that ingests a GitHub profile and returns a deterministic engineering report." readme = "README.md" authors = [ diff --git a/backend/uv.lock b/backend/uv.lock index 0f330e5..630b123 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -906,7 +906,7 @@ fastapi = [ [[package]] name = "skill-issue-backend" -version = "0.9.3" +version = "0.9.4" source = { virtual = "." } dependencies = [ { name = "alembic" }, diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 276820e..7da0153 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -76,6 +76,10 @@ In Vercel → **Settings** → **Environment Variables**, add (Production + Prev | `ANALYZE_USER_PER_HOUR` | Signed-in per-user `/analyze` cap. Default `60`. Backend env only. (v0.9.2+) | — | | `NARRATIVE_ANON_PER_IP_PER_HOUR` | Anonymous per-IP `/narrative` cap. Default `30`. Backend env only. (v0.9.2+) | — | | `NARRATIVE_USER_PER_HOUR` | Signed-in per-user `/narrative` cap. Default `90`. Backend env only. (v0.9.2+) | — | +| `DB_POOL_SIZE` | SQLAlchemy pool size per Fluid Compute instance. Default `5`. Backend env only. Raise only on confirmed pool exhaustion. (v0.9.4+) | — | +| `DB_MAX_OVERFLOW` | Extra connections beyond `DB_POOL_SIZE` under burst. Default `5`. Backend env only. (v0.9.4+) | — | + +> **DB pool ceiling.** The Neon compute exposes ~105 usable connections (`max_connections` 112 - 7 `superuser_reserved_connections` on the current ~0.25 CU compute). The app connects through the PgBouncer pooler (`statement_cache_size=0`), which multiplexes many client connections onto few server ones — so the ceiling is heavily buffered. If ever switched to a direct connection, keep `(DB_POOL_SIZE + DB_MAX_OVERFLOW) × peak_instances < 105`. ### 5. Run the initial Alembic migration diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 571fabb..2864270 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -19,6 +19,30 @@ Format: --- +## 2026-05-28 — Claude (Opus 4.7) — v0.9.4 shipped (DB pool size env-tunable) + +**Slice:** v0.9.4. + +**Done:** +- Added `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` settings (default 5); `_build_engine` reads them via the `settings_module` module reference; 2 new non-DB tests (defaults + override) asserting the kwargs passed to `create_async_engine`; suite 281 → 283. Docs ritual across CHANGELOG/PLAN/DEPLOY/.env.example/README + version literals + uv.lock. + +**Decisions:** +- Ship *tunability*, NOT the originally-planned 10/20 bump. Evidence gathered 2026-05-28 via Neon SQL + Vercel logs + Sentry: `max_connections=112`, `superuser_reserved_connections=7` → 105 usable; live app footprint ~1 connection (`neondb_owner`); Vercel 0% error rate; Sentry clean. No pool-exhaustion symptom exists, and a blind bump to 30 conns/instance would risk the 105 ceiling under multi-instance Fluid Compute. Defaults stay 5/5 → byte-identical runtime; flip env var if RUM ever shows the symptom. Module-reference read chosen for test monkeypatch propagation (v0.8.1/v0.9.0 lesson). + +**Learned / surprises:** +- App connects through Neon PgBouncer pooler (`statement_cache_size=0`), which multiplexes and buffers the 112 ceiling heavily. + +**Verified:** +- Backend ruff clean + pytest 283 passed/69 skipped; (frontend lint/tsc/test/build to be confirmed by controller). + +**Blocked / open:** +- Push/PR/tag/release pending controller+user (this entry written pre-ship). + +**Next:** +- v0.9.5 — security review + load test. + +--- + ## 2026-05-28 — Claude (Opus 4.7) — post-v0.9.3 fix-forward (creator glow removed; deploy unblocked) **Slice:** post-v0.9.3, no version bump (fix-forward on `main`, matching the v0.8.0 next.config precedent). diff --git a/frontend/package.json b/frontend/package.json index 48fc4c0..70887ba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.9.3", + "version": "0.9.4", "private": true, "scripts": { "dev": "next dev", diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ab2c726..f4ed922 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -23,7 +23,7 @@ export default function Home() { transition={{ delay: 0.2, duration: 0.5 }} className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium uppercase tracking-wider text-muted-foreground" > - Deterministic engineering reports · v0.9.3 + Deterministic engineering reports · v0.9.4

diff --git a/frontend/src/components/results-view.tsx b/frontend/src/components/results-view.tsx index 761d17b..531db0c 100644 --- a/frontend/src/components/results-view.tsx +++ b/frontend/src/components/results-view.tsx @@ -352,7 +352,7 @@ export function ResultsView({
-

Skill Issue — GitHub Reputation Protocol v0.9.3

+

Skill Issue — GitHub Reputation Protocol v0.9.4

From 70766389260abc9e4a51293f60130b4cdabfefbe Mon Sep 17 00:00:00 2001 From: Shaan Satsangi Date: Thu, 28 May 2026 13:11:46 +0530 Subject: [PATCH 6/8] docs(v0.9.4): bump README status lead to v0.9.4 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dd9b757..d9d9c7c 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Engineering insight first. AI flavor second. Scoring is deterministic and explai ## Status -Pre-alpha. Latest shipped release is **v0.9.3** (deletable `/me` history with undo, a fix for the search spinner sticking after browser-back, and a golden "creator" scorecard for the project's creator account). Live at https://skill-issue-tau.vercel.app — GitHub OAuth sign-in, Neon Postgres persistence, `/me` history, opt-in `/share/[slug]` public links. The AI narrative layer (Roast + Mentor) runs on **Groq** (`llama-3.3-70b-versatile`). v0.7.0 added Upstash Redis caching (warm `/analyze` ≤ 200 ms); v0.7.2 prod-certified the perf budget (CLS 0.080 → **0** structurally, perf 90 → 94, LCP 2,804 → 2,773 ms); v0.8.0 shipped Sentry (FE+BE), PostHog (events + web vitals), structlog JSON logging, on-voice 404, and a full axe a11y pass; v0.8.1 ships the nightly cron with bearer auth; v0.8.2 pairs it with the manual force-refresh button on `/me`; v0.8.3 hotfixes the empty-repo crash; v0.8.4 fixes the silent narrative misattribution; v0.8.5 closes the post-deploy-Sentry loop with a pre-merge CI gate; v0.8.6 closes v0.7.1's deferred share-page caching; v0.8.7 modernizes project config; v0.9.0 opens Beta hardening with bounded GH fan-out; v0.9.1 closes the /me N+1 + adds per-namespace Report cache versioning; v0.9.2 adds rate limiting (per-IP for anonymous, higher per-user caps for signed-in) on `/analyze` and `/narrative`; v0.9.3 adds deletable `/me` history with undo, fixes the back-nav search spinner, and gilds the creator's scorecard. v0.9.4 makes the DB connection pool size env-tunable (defaults unchanged — RUM showed no pool exhaustion). **v0.9.5 — security review + load test** is next. See [`CHANGELOG.md`](./CHANGELOG.md) for shipped slices, [`PLAN.md`](./PLAN.md) for the full roadmap, and [`docs/PROGRESS_LOG.md`](./docs/PROGRESS_LOG.md) for the most recent session handoff. +Pre-alpha. Latest shipped release is **v0.9.4** (the database connection pool size is now tunable via environment variables without a redeploy). v0.9.3 before it added deletable `/me` history with undo, a fix for the search spinner sticking after browser-back, and a golden "creator" scorecard for the project's creator account. Live at https://skill-issue-tau.vercel.app — GitHub OAuth sign-in, Neon Postgres persistence, `/me` history, opt-in `/share/[slug]` public links. The AI narrative layer (Roast + Mentor) runs on **Groq** (`llama-3.3-70b-versatile`). v0.7.0 added Upstash Redis caching (warm `/analyze` ≤ 200 ms); v0.7.2 prod-certified the perf budget (CLS 0.080 → **0** structurally, perf 90 → 94, LCP 2,804 → 2,773 ms); v0.8.0 shipped Sentry (FE+BE), PostHog (events + web vitals), structlog JSON logging, on-voice 404, and a full axe a11y pass; v0.8.1 ships the nightly cron with bearer auth; v0.8.2 pairs it with the manual force-refresh button on `/me`; v0.8.3 hotfixes the empty-repo crash; v0.8.4 fixes the silent narrative misattribution; v0.8.5 closes the post-deploy-Sentry loop with a pre-merge CI gate; v0.8.6 closes v0.7.1's deferred share-page caching; v0.8.7 modernizes project config; v0.9.0 opens Beta hardening with bounded GH fan-out; v0.9.1 closes the /me N+1 + adds per-namespace Report cache versioning; v0.9.2 adds rate limiting (per-IP for anonymous, higher per-user caps for signed-in) on `/analyze` and `/narrative`; v0.9.3 adds deletable `/me` history with undo, fixes the back-nav search spinner, and gilds the creator's scorecard. v0.9.4 makes the DB connection pool size env-tunable (defaults unchanged — RUM showed no pool exhaustion). **v0.9.5 — security review + load test** is next. See [`CHANGELOG.md`](./CHANGELOG.md) for shipped slices, [`PLAN.md`](./PLAN.md) for the full roadmap, and [`docs/PROGRESS_LOG.md`](./docs/PROGRESS_LOG.md) for the most recent session handoff. --- From d6b9f4e7eccd1eb726e98d1ff9abe2de8d97dbde Mon Sep 17 00:00:00 2001 From: Shaan Satsangi Date: Thu, 28 May 2026 13:24:38 +0530 Subject: [PATCH 7/8] fix(v0.9.4): stop search spinner sticking on browser-back Root cause: Cache Components (cacheComponents: true) keeps the landing page mounted in a hidden React on navigation instead of unmounting it, so the manual isLoading useState was preserved and reappeared as a stuck spinner + disabled input on browser-back. The v0.9.3 pageshow fix was inert (same-document soft-nav never fires pageshow). Switch to useTransition so the pending state is derived from the live navigation and is idle on return by construction. Replace the false-positive bfcache test with normalize/ validation/navigation coverage. --- .../components/__tests__/search-bar.test.tsx | 31 ++++++++++++------- frontend/src/components/search-bar.tsx | 24 +++++++------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/__tests__/search-bar.test.tsx b/frontend/src/components/__tests__/search-bar.test.tsx index 37d26f6..c69bd6f 100644 --- a/frontend/src/components/__tests__/search-bar.test.tsx +++ b/frontend/src/components/__tests__/search-bar.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, act } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { vi, describe, it, expect, beforeEach } from "vitest"; const push = vi.fn(); @@ -9,21 +9,30 @@ import { SearchBar } from "../search-bar"; describe("SearchBar", () => { beforeEach(() => push.mockReset()); - it("re-enables the button on pageshow after a navigation (bfcache restore)", async () => { + function submit(value: string) { render(); - const input = screen.getByLabelText("GitHub username"); - fireEvent.change(input, { target: { value: "shaan-alpha" } }); + fireEvent.change(screen.getByLabelText("GitHub username"), { target: { value } }); fireEvent.click(screen.getByRole("button", { name: "Analyze profile" })); + } - // After submit the button is in the loading state (disabled). - expect(screen.getByRole("button", { name: "Analyze profile" })).toBeDisabled(); + it("navigates to the normalized username on submit", () => { + submit("shaan-alpha"); expect(push).toHaveBeenCalledWith("/u/shaan-alpha"); + }); + + it("extracts the username from a pasted github.com URL", () => { + submit("https://github.com/shaan-alpha/some-repo"); + expect(push).toHaveBeenCalledWith("/u/shaan-alpha"); + }); - // Browser back restores the page from bfcache -> pageshow fires. - act(() => { - window.dispatchEvent(new Event("pageshow")); - }); + it("strips a leading @ handle", () => { + submit("@shaan-alpha"); + expect(push).toHaveBeenCalledWith("/u/shaan-alpha"); + }); - expect(screen.getByRole("button", { name: "Analyze profile" })).not.toBeDisabled(); + it("shows an error and does not navigate on an invalid username", () => { + submit("-bad-"); + expect(push).not.toHaveBeenCalled(); + expect(screen.getByText(/valid GitHub username/i)).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/search-bar.tsx b/frontend/src/components/search-bar.tsx index 7fc06b0..532f1ae 100644 --- a/frontend/src/components/search-bar.tsx +++ b/frontend/src/components/search-bar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { Search, ArrowRight, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -24,17 +24,14 @@ function normalize(raw: string): string { export function SearchBar() { const [raw, setRaw] = useState(""); const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(false); const router = useRouter(); - - // After navigating to /u/ the browser may restore THIS page from the - // bfcache on back-navigation, with isLoading still true (stuck spinner + - // disabled input). Reset it whenever the page is shown again. - useEffect(() => { - const reset = () => setIsLoading(false); - window.addEventListener("pageshow", reset); - return () => window.removeEventListener("pageshow", reset); - }, []); + // isLoading comes from useTransition, not a stored useState. Under Cache + // Components (next.config.ts) the App Router keeps this page mounted in a + // hidden React on navigation rather than unmounting it, so a + // manual loading flag would be preserved and reappear as a stuck spinner on + // browser-back. A transition has no pending work once navigation settles, so + // on return the button is idle by construction. + const [isLoading, startTransition] = useTransition(); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); @@ -48,8 +45,9 @@ export function SearchBar() { return; } setError(null); - setIsLoading(true); - router.push(`/u/${username}`); + startTransition(() => { + router.push(`/u/${username}`); + }); }; return ( From 8a4982809584a8aeb0103fbf0f3e8adb817c165c Mon Sep 17 00:00:00 2001 From: Shaan Satsangi Date: Thu, 28 May 2026 13:27:11 +0530 Subject: [PATCH 8/8] docs(v0.9.4): record back-nav spinner fix + correct the v0.9.3 attribution --- CHANGELOG.md | 3 +++ PLAN.md | 11 +++++++---- README.md | 2 +- docs/PROGRESS_LOG.md | 17 ++++++++++------- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3f23d3..a2af344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Every version listed here must correspond to a slice in [`PLAN.md`](./PLAN.md) w ### Changed - **Database connection pool size is now configurable** via the `DB_POOL_SIZE` and `DB_MAX_OVERFLOW` environment variables (defaults unchanged at 5 each), so it can be tuned in production without a redeploy. Telemetry showed no connection-pool pressure at current scale, so this ships the capability without changing the running defaults. +### Fixed +- **Search button no longer sticks on a spinner after pressing browser Back.** Returning to the landing page from a report could leave the analyze button spinning and its input disabled. The v0.9.3 attempt fixed the wrong mechanism; this is the real fix. + --- ## [0.9.3] — 2026-05-28 diff --git a/PLAN.md b/PLAN.md index cefdc03..58928a8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -42,7 +42,7 @@ | **v0.9.1** | `/me/analyses` N+1 fix + Layer A cache schema version | ✅ shipped | | **v0.9.2** | Rate limiting (IP + user) on `/analyze` + `/narrative` | ✅ shipped | | **v0.9.3** | Deletable `/me` history + back-nav loading fix + creator flair | ✅ shipped | -| **v0.9.4** | DB pool size env-tunable (defaults unchanged) | ✅ shipped | +| **v0.9.4** | DB pool size env-tunable + real back-nav spinner fix | ✅ shipped | | **v0.9.5** | `/security-review` pass + load test to 100 RPS | pending | | **v0.9.6** | Privacy policy + terms (legal docs) | pending | | **v1.0.0** | Public launch | pending | @@ -630,18 +630,21 @@ The narrative-mode CHECK constraint was a third drift in the same family — the --- -## v0.9.4 — DB pool size env-tunable (shipped 2026-05-28) +## v0.9.4 — DB pool size env-tunable + back-nav spinner fix (shipped 2026-05-28) -**Goal:** Make the SQLAlchemy engine's `pool_size` / `max_overflow` configurable via `DB_POOL_SIZE` / `DB_MAX_OVERFLOW`, keeping the 5/5 defaults. +**Goal:** Make the SQLAlchemy engine's `pool_size` / `max_overflow` configurable via `DB_POOL_SIZE` / `DB_MAX_OVERFLOW`, keeping the 5/5 defaults. Plus a genuine fix for the landing-page search spinner sticking on browser-back (the v0.9.3 attempt fixed the wrong mechanism). **Why not the planned 10/20 bump:** Direct telemetry on 2026-05-28 (Neon `max_connections=112`, ~1 live app connection; Vercel 0% error rate; Sentry clean) showed no pool-exhaustion symptom. A blind bump to 30 connections/instance would also risk the 105-usable ceiling under multi-instance Fluid Compute. So the slice ships tunability instead of a default change; flip the env var if RUM ever shows the symptom. +**Back-nav spinner root cause:** Cache Components (`cacheComponents: true`, shipped v0.8.6) keeps the landing page mounted in a hidden React `` on navigation instead of unmounting it, so the manual `isLoading` `useState` was preserved and reappeared as a stuck spinner on browser-back. Fixed by switching `search-bar.tsx` to `useTransition` (pending state derived from the live navigation, idle on return by construction). The v0.9.3 `pageshow` listener was inert (same-document soft-nav never fires it) and its test was a false positive. + **Design spec:** [`docs/superpowers/specs/2026-05-28-v0.9.4-db-pool-tunable-design.md`](./docs/superpowers/specs/2026-05-28-v0.9.4-db-pool-tunable-design.md). **Sub-plan:** [`docs/superpowers/plans/2026-05-28-v0.9.4-db-pool-tunable.md`](./docs/superpowers/plans/2026-05-28-v0.9.4-db-pool-tunable.md). **Exit criteria:** - [x] `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` settings (default 5); `_build_engine` reads them via the module reference. -- [x] 2 new non-DB tests (defaults + override) pass; suite 281 → 283. +- [x] 2 new non-DB tests (defaults + override) pass; backend suite 281 → 283. +- [x] `search-bar.tsx` uses `useTransition`; inert `pageshow` effect removed; bfcache test replaced with normalize/validation/nav coverage (frontend vitest 51 → 54). - [x] Docs ritual + version bump to 0.9.4; tag + release. --- diff --git a/README.md b/README.md index d9d9c7c..e131bc1 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Engineering insight first. AI flavor second. Scoring is deterministic and explai ## Status -Pre-alpha. Latest shipped release is **v0.9.4** (the database connection pool size is now tunable via environment variables without a redeploy). v0.9.3 before it added deletable `/me` history with undo, a fix for the search spinner sticking after browser-back, and a golden "creator" scorecard for the project's creator account. Live at https://skill-issue-tau.vercel.app — GitHub OAuth sign-in, Neon Postgres persistence, `/me` history, opt-in `/share/[slug]` public links. The AI narrative layer (Roast + Mentor) runs on **Groq** (`llama-3.3-70b-versatile`). v0.7.0 added Upstash Redis caching (warm `/analyze` ≤ 200 ms); v0.7.2 prod-certified the perf budget (CLS 0.080 → **0** structurally, perf 90 → 94, LCP 2,804 → 2,773 ms); v0.8.0 shipped Sentry (FE+BE), PostHog (events + web vitals), structlog JSON logging, on-voice 404, and a full axe a11y pass; v0.8.1 ships the nightly cron with bearer auth; v0.8.2 pairs it with the manual force-refresh button on `/me`; v0.8.3 hotfixes the empty-repo crash; v0.8.4 fixes the silent narrative misattribution; v0.8.5 closes the post-deploy-Sentry loop with a pre-merge CI gate; v0.8.6 closes v0.7.1's deferred share-page caching; v0.8.7 modernizes project config; v0.9.0 opens Beta hardening with bounded GH fan-out; v0.9.1 closes the /me N+1 + adds per-namespace Report cache versioning; v0.9.2 adds rate limiting (per-IP for anonymous, higher per-user caps for signed-in) on `/analyze` and `/narrative`; v0.9.3 adds deletable `/me` history with undo, fixes the back-nav search spinner, and gilds the creator's scorecard. v0.9.4 makes the DB connection pool size env-tunable (defaults unchanged — RUM showed no pool exhaustion). **v0.9.5 — security review + load test** is next. See [`CHANGELOG.md`](./CHANGELOG.md) for shipped slices, [`PLAN.md`](./PLAN.md) for the full roadmap, and [`docs/PROGRESS_LOG.md`](./docs/PROGRESS_LOG.md) for the most recent session handoff. +Pre-alpha. Latest shipped release is **v0.9.4** (the database connection pool size is now tunable via environment variables without a redeploy, and the search spinner that could stick after pressing browser Back is genuinely fixed). v0.9.3 before it added deletable `/me` history with undo, a golden "creator" scorecard for the project's creator account, and a first (incomplete) attempt at the back-nav spinner fix. Live at https://skill-issue-tau.vercel.app — GitHub OAuth sign-in, Neon Postgres persistence, `/me` history, opt-in `/share/[slug]` public links. The AI narrative layer (Roast + Mentor) runs on **Groq** (`llama-3.3-70b-versatile`). v0.7.0 added Upstash Redis caching (warm `/analyze` ≤ 200 ms); v0.7.2 prod-certified the perf budget (CLS 0.080 → **0** structurally, perf 90 → 94, LCP 2,804 → 2,773 ms); v0.8.0 shipped Sentry (FE+BE), PostHog (events + web vitals), structlog JSON logging, on-voice 404, and a full axe a11y pass; v0.8.1 ships the nightly cron with bearer auth; v0.8.2 pairs it with the manual force-refresh button on `/me`; v0.8.3 hotfixes the empty-repo crash; v0.8.4 fixes the silent narrative misattribution; v0.8.5 closes the post-deploy-Sentry loop with a pre-merge CI gate; v0.8.6 closes v0.7.1's deferred share-page caching; v0.8.7 modernizes project config; v0.9.0 opens Beta hardening with bounded GH fan-out; v0.9.1 closes the /me N+1 + adds per-namespace Report cache versioning; v0.9.2 adds rate limiting (per-IP for anonymous, higher per-user caps for signed-in) on `/analyze` and `/narrative`; v0.9.3 adds deletable `/me` history with undo, attempts the back-nav search-spinner fix, and gilds the creator's scorecard. v0.9.4 makes the DB connection pool size env-tunable (defaults unchanged — RUM showed no pool exhaustion) and lands the real back-nav spinner fix (the v0.9.3 attempt addressed the wrong mechanism). **v0.9.5 — security review + load test** is next. See [`CHANGELOG.md`](./CHANGELOG.md) for shipped slices, [`PLAN.md`](./PLAN.md) for the full roadmap, and [`docs/PROGRESS_LOG.md`](./docs/PROGRESS_LOG.md) for the most recent session handoff. --- diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 2864270..82af025 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -19,24 +19,27 @@ Format: --- -## 2026-05-28 — Claude (Opus 4.7) — v0.9.4 shipped (DB pool size env-tunable) +## 2026-05-28 — Claude (Opus 4.7) — v0.9.4 shipped (DB pool size env-tunable + real back-nav spinner fix) -**Slice:** v0.9.4. +**Slice:** v0.9.4. Two changes: the planned DB-pool work, plus a genuine fix for the back-nav search spinner that v0.9.3 only *appeared* to fix. **Done:** -- Added `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` settings (default 5); `_build_engine` reads them via the `settings_module` module reference; 2 new non-DB tests (defaults + override) asserting the kwargs passed to `create_async_engine`; suite 281 → 283. Docs ritual across CHANGELOG/PLAN/DEPLOY/.env.example/README + version literals + uv.lock. +- **DB pool:** Added `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` settings (default 5); `_build_engine` reads them via the `settings_module` module reference; 2 new non-DB tests (defaults + override) asserting the kwargs passed to `create_async_engine`; backend suite 281 → 283. Docs ritual across CHANGELOG/PLAN/DEPLOY/.env.example/README + version literals + uv.lock. +- **Back-nav spinner (real fix):** `search-bar.tsx` now uses `useTransition` for the pending state instead of a manual `isLoading` `useState`. Removed the inert v0.9.3 `pageshow` effect. Replaced the false-positive bfcache test with normalize/validation/navigation coverage (search-bar tests 1 → 4; frontend vitest 51 → 54). **Decisions:** -- Ship *tunability*, NOT the originally-planned 10/20 bump. Evidence gathered 2026-05-28 via Neon SQL + Vercel logs + Sentry: `max_connections=112`, `superuser_reserved_connections=7` → 105 usable; live app footprint ~1 connection (`neondb_owner`); Vercel 0% error rate; Sentry clean. No pool-exhaustion symptom exists, and a blind bump to 30 conns/instance would risk the 105 ceiling under multi-instance Fluid Compute. Defaults stay 5/5 → byte-identical runtime; flip env var if RUM ever shows the symptom. Module-reference read chosen for test monkeypatch propagation (v0.8.1/v0.9.0 lesson). +- **DB pool — ship tunability, NOT the planned 10/20 bump.** Evidence gathered 2026-05-28 via Neon SQL + Vercel logs + Sentry: `max_connections=112`, `superuser_reserved_connections=7` → 105 usable; live app footprint ~1 connection (`neondb_owner`); Vercel 0% error rate; Sentry clean. No pool-exhaustion symptom exists, and a blind bump to 30 conns/instance would risk the 105 ceiling under multi-instance Fluid Compute. Defaults stay 5/5 → byte-identical runtime; flip env var if RUM ever shows the symptom. Module-reference read chosen for test monkeypatch propagation (v0.8.1/v0.9.0 lesson). +- **Back-nav — `useTransition`, not an effect reset.** A mount effect that resets `isLoading` would trip the `react-hooks/set-state-in-effect` lint gate. `useTransition`'s `isPending` is derived from the live navigation, so on browser-back (which never invokes this page's `startTransition`) it's idle by construction — no preserved-state to get stuck. Folded into v0.9.4 (unshipped branch) at the user's request rather than renumbering. **Learned / surprises:** -- App connects through Neon PgBouncer pooler (`statement_cache_size=0`), which multiplexes and buffers the 112 ceiling heavily. +- **Cache Components (`cacheComponents: true`, shipped v0.8.6) was the real culprit.** With it enabled, the App Router keeps the previous route mounted in a hidden React `` instead of unmounting it — so a manual loading `useState` is *preserved* and reappears as a stuck spinner on browser-back. v0.9.3 misdiagnosed this as bfcache; its `pageshow` listener never fires on same-document soft-nav, and its unit test was a false positive (mocked the router, fired a synthetic `pageshow`). Memo: UI behavior that depends on Activity hide/show is not reproducible in happy-dom — verify in a real browser. **Verified:** -- Backend ruff clean + pytest 283 passed/69 skipped; (frontend lint/tsc/test/build to be confirmed by controller). +- Backend ruff clean + pytest 283 passed/69 skipped. Frontend lint + tsc clean, vitest 54 passed, `next build` clean (PPR routes intact). +- **Back-nav fix live behavior: pending user confirmation in-browser** (Activity show/hide can't be exercised headlessly; static checks pass). **Blocked / open:** -- Push/PR/tag/release pending controller+user (this entry written pre-ship). +- Live browser confirmation of the spinner fix + push/PR/tag/release pending controller+user (this entry written pre-ship). **Next:** - v0.9.5 — security review + load test.