Skip to content

Learner progress in a Google Sheet, AI quota in Redis - #33

Open
amielnoy wants to merge 17 commits into
mainfrom
spec/sheets-progress-store
Open

amielnoy wants to merge 17 commits into
mainfrom
spec/sheets-progress-store

Conversation

@amielnoy

@amielnoy amielnoy commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Two stores move off Postgres, so this deployment needs no Supabase project.

AI rate-limit counters → Redis. api_rate_limits was emulating INCR with an
upsert and a window comparison. It is now a Redis key with a TTL, set NX so a
steady caller cannot push their own window out.

Learner progress → the LearnPracticeWorkData spreadsheet, behind a Google
Apps Script web app that holds LockService.getScriptLock() around
read-modify-write. Apps Script was chosen over the Sheets REST API for exactly
that lock: the merge is a union, and without a mutex two devices syncing at once
lose one device's work.

ProgressService already took its two operations as injected callables, so
nothing above the seam changed — not the client, not routes/progress.py, not
ProgressBody. Only the Google sub reaches the sheet; a spreadsheet has no
row-level security, so the email argument was removed from the interface
entirely.

Spec: docs/superpowers/specs/2026-09-27-sheets-progress-store-design.md
Plan: docs/superpowers/plans/2026-09-27-sheets-progress-store.md

It also fixes a live outage

/api/ai/generate answers 429 on the first request of the day, verified
against production. DATABASE_URL is set while the database is unreachable, so
the burst limiter cannot count, and the AI buckets are when_unavailable="refuse"
by design — they guard a key that bills per call. The refusal reads to a visitor
exactly like an exhausted quota. Unsetting DATABASE_URL does not help; the
quota needed a store that is not Postgres.

Readiness was hiding the diagnostic too: it returned 503 whenever DATABASE_URL
was unset — the intended end state of this branch — and only reported the
rateLimiting field after that check passed, so the CI grep for it could never
fire. An absent database is now not_configured and still ready; a
configured-but-unreachable one still 503s.

The review found three Criticals no task-level test could

A whole-branch review caught three defects that would have shipped, all on the
one path the feature depends on, all invisible because the stubs modelled a
platform's happy path rather than its rules:

  1. No request ever reached the script. An Apps Script /exec answers 302 and
    serves the body at a second URL; httpx defaults to follow_redirects=False.
    Every call would have raised, forever, and the client swallows the 500.
  2. A real Google sub corrupted, which erases progress. A sub is a ~21-digit
    numeric string; Sheets parsed it as a number past IEEE-754 precision. The row
    lookup never matched, so every sync appended a row and every load returned
    empty progress with a 200 — which the client writes over the learner's real
    localStorage.
  3. The first request after deployment always threw. getRange(2, 1, 0, 1) is
    invalid; a freshly created tab has only headers.

Each fix came with a stub that enforces the real rule. The numRows < 1 stub
turned a pre-existing test red — the bug reproducing against code already on the
branch, which is the difference between a test that documents a fix and one that
would have caught the defect.

Before this does anything

The branch lands complete but inert. Three steps provision resources, write
secrets and deploy, so they are deliberately not automated:

  • vercel integration add redis --yes --no-claim, then confirm the variable
    name it sets is one of REDIS_URL / KV_URL / REDIS_TLS_URL, and that
    the server is Redis 7.0 or newer — the window uses EXPIRE … NX, which
    older servers reject.
  • Deploy server/sheets/Progress.gs as a web app bound to the spreadsheet,
    set its ACADEMY_TOKEN script property (≥32 random characters), and set
    SHEETS_WEBAPP_URL and SHEETS_WEBAPP_TOKEN on the Vercel project.
    If you ever created the learner_progress tab by hand, set column A's
    format to plain text first
    — the @ format is only applied on the
    creation path.
  • Deploy, then verify the union: sign in on two browsers, complete a
    different challenge in each, and confirm both survive a reload.

Until then, progress answers 503 and the client keeps using localStorage —
which is what it does today. Note also that SESSION_SECRET is still unset on
the project, so sign-in itself returns 503 and none of the above is reachable
until it exists.

Known and deferred

Nine minor findings were triaged and deferred rather than fixed, listed with
reasons in the review trail: the maxDuration note omits the 12s lock wait; a
third "quota rows" falsehood survives in database.py; four tests are weaker
than the properties they name; reset_client() does not aclose(); the spec's
request sketch still shows a header that was replaced by a body field; and
whether test_runs/test_suite_results still belong in the API's schema is an
open question.

Verification

218 pytest · 377 Node unit tests · ruff · typecheck — all green.

Seventeen commits: six implementation tasks each gated by its own review, one
whole-branch review, one fix wave, one scoped re-review.

🤖 Generated with Claude Code

amielnoy and others added 4 commits September 27, 2026 22:23
The design for moving the one thing that still needs a database off Postgres,
and for the 429 that discovery turned up on the way.

`routes/progress.py` has been able to keep a signed-in reader's progress since
it was written and has never run in production. The table was only ever created
by `initialize_database()` at boot, boot-time DDL went away when the API became
a Vercel function, and the Supabase project it would have lived in no longer
answers.

The spec puts progress in the `LearnPracticeWorkData` spreadsheet, behind an
Apps Script web app — chosen over a service account because `LockService` gives
the read-modify-write merge a real mutex, and because it keeps a Google private
key out of a function bundle already tuned against a 225 MB ceiling. Only the
Google `sub` is stored; email is dropped, since a spreadsheet has no row level
security.

It also records what the investigation found: with `DATABASE_URL` set and the
database unreachable, the AI buckets are `when_unavailable="refuse"`, so every
AI request comes back 429 — a refusal that reads to a visitor exactly like an
exhausted quota. Verified against production. The quota moves to Redis, which
is what `api_rate_limits` was emulating in SQL anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight tasks in two phases, each ending in something independently testable.

Phase A moves the AI quota to Redis and can ship on its own — it fixes a live
outage, since production answers 429 to every AI request today. Phase B puts
learner progress in the spreadsheet behind an Apps Script web app.

The plan's Review Focus names the five things the spec implies but does not
say, and each one has a test attached to the task that owns the code: an id
beginning with `=` is a formula to Sheets and the ids come from localStorage;
two rows for one sub must be an error rather than a coin toss; a lock that
times out must fail rather than write unlocked; the shared token is the only
guard on a web app deployed to anyone with the link; and Redis going away must
still refuse the AI buckets while sign-in degrades.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@vercel

vercel Bot commented Sep 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
learn-practice-work-ai-testing-academy Error Error Sep 27, 2026 7:47pm UTC

@amielnoy amielnoy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed

amielnoy and others added 13 commits September 27, 2026 22:54
SharedRateLimiter now asks quota_store.redis_url() instead of
config.database_url() whether it can share a quota, and calls
quota_store.hit_rate_limit/release_rate_limit instead of the Postgres
functions of the same name (Task 1, 82f5db4). The four buckets, their
limits/windows, and every when_unavailable policy are unchanged.

Retires the Postgres counters this replaces: hit_rate_limit,
_hit_rate_limit, release_rate_limit, _release_rate_limit and the
now-unused STALE_RATE_LIMIT_DAYS cleanup in database.py, and the
api_rate_limits table (plus its index and its entry in the RLS table
list) from schema.sql. Updates the stale schema ownership table in
deploy/README.md to match.

test_rate_limit_config.py gets three new tests for the Redis-missing
path, and its `production` fixture and one existing test move from
faking DATABASE_URL to faking REDIS_URL, since the question the
limiter asks changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…QL rules

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
The 500-cap on id arrays uses stored-first ordering to prevent an incoming
payload from evicting recorded progress. Real ceilings (40 challenges, 12
lectures) are far below the cap, so it only engages under tampering.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tasks 1 and 2 moved the AI quota off Postgres, and four documents plus one
class docstring were still describing the table that no longer exists.

- `deploy/README.md` — the serverless-quota paragraph now describes Redis keys
  and `EXPIRE … NX` rather than "atomic Postgres rows"; a `REDIS_URL` row joins
  the environment table, naming the 7.0 floor that the `NX` flag imposes and
  what a missing URL does to the AI buckets; the `DATABASE_URL` row says plainly
  that it no longer has anything to do with quotas.
- `replit.md`, `architecture.html`, `artifacts/ai-testing-academy/README.md` —
  same correction, and each now distinguishes the AI buckets failing closed from
  sign-in and admin degrading, which the old text collapsed into "fails closed".
- `server/app/rate_limit.py` — the `SharedRateLimiter` docstring opened with
  "Postgres-backed in production". Flagged as a deferred minor in the Task 2
  review; it is one line and it is the first thing anyone reads about the class.

Also records the second occurrence of a foreign Vercel project building this
repo: `learn-practice-work-ai-testing-academy`, connected 25 August, disconnected
2026-09-27. Twice is a pattern, so the section now leads with the diagnostic —
read the project name on the failing deployment first, because if it is not
`learn-practice-work` then nothing here caused it and nothing here will fix it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Finding 1: the lock guarding doPost's read-modify-write had no test proving
tryLock(false) refuses the write instead of falling through unlocked, or that
releaseLock still runs when the locked work throws. Added a doPost harness
that passes live LockService/SpreadsheetApp/ContentService mocks (not JSON
splices) so call tracking is possible, plus tests for: a denied lock writes
nothing, a throw inside the lock still releases it, and an unknown-sub load
returns emptyProgress and writes nothing.

Finding 2: Apps Script's doPost(e) cannot read custom headers, so the
X-Academy-Token header from the brief was never implementable; a token in
the query string was the wrong fix too, since it lands in execution and
proxy logs. authorized() now reads body.token, and doPost parses postData
before authorizing, treating a malformed body as unauthorized rather than
throwing outside the lock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The shared token travels in the JSON body, not the query string, matching
Task 5's authorized(body) contract; a query-string token would land in
Google's execution logs and any proxy log along the way. Test pins both
that the token is in the body and that the URL carries no query.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Narrow MergeProgress to (subject, incoming) — the email argument is gone,
so ProgressService can no longer hand a client's email to a store that has
no row-level security. Point dependencies.py at sheets_store instead of
database, delete the Postgres progress path (EMPTY_PROGRESS,
MAX_PROGRESS_IDS, _progress_view, _PROGRESS_COLUMNS, load_progress,
_load_progress, merge_progress, _merge_progress) and the learner_progress
table from schema.sql, keeping every other table's RLS entry intact.
Update the seed script's --check query and deploy/README.md, which both
still named the removed table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three defects that existed because the stubs modelled Sheets' happy path
rather than its rules, so the stubs now enforce the rules.

A Google `sub` is a ~21-digit decimal string. `text()` only disarms a
formula, so the sub went into the cell bare and Sheets parsed it as a
number: past IEEE-754 precision it reads back with its tail zeroed, so
`rowIndexFor` never matches, every sync appends another row, and every
load answers `emptyProgress()` that the client adopts over the learner's
real `localStorage`. `forcedText()` prefixes an apostrophe for that one
column unconditionally, and tab creation sets the column's number format
to `@` so a hand-typed sub behaves the same.

`rowIndexFor` asked for `Math.max(getLastRow() - 1, 0)` rows. Apps Script
requires `numRows >= 1`, and `sheet()` creates a tab with only a header
row — so the first request after every deployment threw. It returns -1
without touching `getRange` when there are no data rows.

Also: the lock waits `LOCK_WAIT_MS` (12s) rather than the full 20s HTTP
timeout, so `{"error":"busy"}` can reach a client that is still
listening; an `op` that is neither `load` nor `merge` is refused instead
of falling through to a write; and `union()`'s seen-set is
`Object.create(null)`, so an id called `constructor` or `toString` is no
longer read as already seen and dropped.

The doPost harness now models one grid with a header row, refuses a
range of fewer than one row with Apps Script's own message, and coerces
every written cell the way a cell does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…with

An `/exec` URL answers 302 with a Location on
`script.googleusercontent.com/macros/echo`, and only that second URL
serves the body. `httpx.AsyncClient` does not follow redirects unless
told to and `raise_for_status()` raises on a 3xx, so every load and every
merge was a 500 — for every signed-in learner, permanently.

`follow_redirects=True` is passed explicitly, the way `monitor.py` passes
True and `relay.py` passes False: which one it is should be readable at
the call site. The test serves a 302 from a MockTransport and the JSON
from the URL it points at.

`TIMEOUT` now says why it is larger than the script's lock wait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Readiness answered 503 `not_ready` whenever `database_ready()` was false,
and that probe cannot tell an unconfigured `DATABASE_URL` from an
unreachable one. The end state this branch builds towards has no Supabase
project at all — progress in a spreadsheet, quotas in Redis — so
readiness would have answered 503 permanently, on a path Fly
health-checks and the deploy workflow reads.

An absent URL now reports `database: not_configured` and stays ready. A
configured database that cannot be reached is still a 503, unchanged.

`rateLimiting` is reported whatever the database is doing. It sat behind
the database check, so the one field that names the cause of a quota
outage was unreachable in exactly the deployment it was written for — and
the workflow step that greps for it could never have fired. Its warning
now names `REDIS_URL` rather than `DATABASE_URL`, which stopped having
anything to do with quotas one commit ago.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_client()` called `redis.from_url` on every `hit_rate_limit` and
`release_rate_limit`, and nothing ever awaited `aclose()`. Every AI,
sign-in and admin request paid a fresh TCP+TLS handshake for a client
that only `__del__` would reclaim. Under burst traffic that reaches a
hosted Redis connection cap, `hit_rate_limit` raises, and the AI buckets
refuse with a 429 indistinguishable from an exhausted quota — the failure
this branch exists to remove.

A `redis.asyncio` client is itself a connection pool and is safe to
share, so it is built on first use and kept, keyed on the resolved URL.
Not at import time: `redis_url()` reads the environment, which tests
change per test. `reset_client()` is the seam, and an autouse fixture
drops it around every test so nothing leaks between them under xdist.

Also two test gaps this file had:

- both "Redis is broken" tests deleted `REDIS_URL`, which exercises
  `shared_quota_problem()` and returns long before the `except` arm a
  real outage takes. One test now stubs a client that refuses on `incr`
  and holds the trade on that path: billed buckets shut, sign-in
  degrades to a per-worker bound.
- `test_the_window_is_set_once_when_the_key_is_created` asserted two
  EXPIRE calls, the opposite of its name. Renamed to what it checks, and
  `FakeRedis.expire` now insists on `nx=True` — which is what makes the
  second call a no-op inside Redis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… is false

`deploy/README.md` never mentioned `SHEETS_WEBAPP_URL` or
`SHEETS_WEBAPP_TOKEN` — not in the environment table, not in the
verification section. Unconfigured, `/api/progress` answers 503 and the
academy carries on from `localStorage`, which is exactly what it does
correctly for a signed-out reader: a broken deployment is
indistinguishable from a working one to the visitor *and* to the
operator. Both now have rows saying what they do and what happens
without them, the token's 32-character minimum and its pairing with the
Apps Script `ACADEMY_TOKEN` property are stated, and the verification
section says plainly that neither health check can see this store and
that the spreadsheet is the only place to look.

Recorded there too: `vercel.json` sets no `maxDuration`, so the platform
default of 10s sits under `sheets_store.TIMEOUT` of 20 and a genuinely
cold Apps Script start returns a platform 504 rather than the clean 500
the design expects. `vercel.json` is left alone.

Statements this branch made false:

- `seed-academy-content.ts` credited `schema.sql` with "learner progress"
  and "quota rows"; both tables were deleted.
- the README's ownership table listed four of the six tables
  `schema.sql` creates — `test_runs` and `test_suite_results` were
  missing, and a table nobody lists is a table nobody applies.
- `progressApi.ts` pointed at `database.merge_progress`, deleted here.
- `replit.md`'s module table mentioned none of `progress.py`,
  `sheets_store.py` or `quota_store.py`.
- `schemas.py` mirrored `database.MAX_PROGRESS_IDS`, which no longer
  exists; the cap it mirrors is `MAX_IDS` in `Progress.gs`.
- the spec claimed 500 ids are "a few kilobytes, far under the
  50,000-character cell limit". `ProgressBody` accepts 500 ids of 200
  characters, which serialise to ~101,500 — twice the limit. The real
  bound is stated, along with the fact that it is the count cap and not
  any headroom in the cell that does the work.

`tests/unit/deployDocs.spec.ts` holds the first three of those, so the
ownership table and the two variables cannot drift out again silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amielnoy amielnoy changed the title Spec/sheets progress store Learner progress in a Google Sheet, AI quota in Redis Sep 27, 2026

This branch was successfully deployed

1 active deployment
preview — 342ffcce Deployed Sep 27, 2026 by amielnoy via Build and deploy to Vercel #38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant