diff --git a/.github/workflows/AGENTS.md b/.github/workflows/AGENTS.md
index b711322..f4cd00c 100644
--- a/.github/workflows/AGENTS.md
+++ b/.github/workflows/AGENTS.md
@@ -43,7 +43,7 @@ For each GitHub environment, `staging` and `production` (deployment branches: `m
- `VERCEL_TOKEN`: scoped to that Vercel project.
- **Variables:** `SUPABASE_URL`, `CLOUDFLARE_ACCOUNT_ID`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`.
-Vercel project settings (Production environment only, credentials as type Secret): `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, `CLOUDFLARE_ACCOUNT_ID`, `AI_PROVIDER=cloudflare`, `CLOUDFLARE_API_TOKEN`. `README.md` has the owner's steps, click by click.
+Vercel project settings (Production environment only, credentials as type Secret): `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, `CLOUDFLARE_ACCOUNT_ID`, `AI_PROVIDER=cloudflare`, `SUPABASE_SECRET_KEY` (the rate limit's counts, since slice 8), `CLOUDFLARE_API_TOKEN`. `README.md` has the owner's steps, click by click.
## Gotchas
- **Migrations must not break the running code.** The old code keeps running between migrate and deploy, after a failed deploy, and after a rollback. Add before you remove: a new column first, dropping the old one in a later change.
diff --git a/README.md b/README.md
index e7af885..2cb73ca 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
Next.js · TypeScript · Vercel AI SDK · gpt-oss-120b on Cloudflare Workers AI · Supabase Postgres with pgvector · Playwright · GitHub Actions
-**Status:** built and running on a private staging site. The public demo opens at launch, after a per-visitor message limit. [More below](#status).
+**Status:** built and running on a private staging site. The public demo opens at launch. [More below](#status).
## The story
@@ -28,15 +28,15 @@ The check proves a quote is real, not that it's read fairly, which is why every
| **Tool calling with limits** | The model decides when to search and when to save. The app caps it at three searches, then allows only saving, then takes the tools away so it replies. | [`chat.ts`](src/server/chat.ts) |
| **AI output checked in code** | Every quote is checked against the cited person's answer, never the interviewer's question or the assistant's own words. | [`citations.ts`](src/server/citations.ts) |
| **AI behaviour, measured** | Fixed requests, five runs each on the real model, scored on the product's rules. A change is compared with the baseline on a one-sided Fisher's exact test, and any other rule that gets worse is flagged. | [`measurement/`](src/server/measurement/) |
-| **Failures in plain sight** | A broken or cut-off answer stays on screen marked unfinished. A used-up daily AI allowance and a busy provider each get their own message. | [`cloudflare.ts`](src/server/ai/cloudflare.ts), [`quota.tsx`](src/features/workspace/quota.tsx) |
+| **Failures in plain sight** | A broken or cut-off answer stays on screen marked unfinished. A used-up daily AI allowance, a visitor's used-up 10 messages for the day and a busy provider each get their own message. The limit counts a keyed hash of the visitor's address and the day, never the address itself. | [`cloudflare.ts`](src/server/ai/cloudflare.ts), [`rate-limit.ts`](src/server/rate-limit.ts), [`quota.tsx`](src/features/workspace/quota.tsx) |
| **Safe by default** | Runs locally with no accounts or keys. A guard stops every command that points at a hosted database or holds a stray key. Every table starts closed to the public API. | [`env-guard.ts`](src/lib/env-guard.ts), [`supabase/AGENTS.md`](supabase/AGENTS.md) |
| **Tested in four layers** | Unit, integration, SQL and end-to-end tests. CI runs on every pull request, and every merge deploys to staging. | [`tests/`](tests/), [`.github/workflows/`](.github/workflows/) |
## Status
-- **Built:** everything in the story, running on a private staging site (Vercel, Supabase in Frankfurt, Cloudflare Workers AI on its free plan).
+- **Built:** everything in the story, running on a private staging site (Vercel, Supabase in Frankfurt, Cloudflare Workers AI on its free plan), with a limit of 10 messages per visitor a day, so one visitor can't use up the free daily AI allowance.
- **Measured:** the AI's behaviour, on fixed requests against a baseline ([how](#measuring-the-ais-behaviour)).
-- **Before launch:** a per-visitor message limit, so one visitor can't use up the free daily AI allowance; the public demo; and a short screen recording here.
+- **Before launch:** the public demo, and a short screen recording here.
The [product description](docs/product/prd.md), its [scope](docs/product/mvp.md), the [decisions](docs/decisions.md) and the [build plan](docs/plan.md) are all in the repository.
@@ -181,7 +181,7 @@ The staging and production keys never sit on a development machine; a Workers AI
| Name | Where | What |
| --- | --- | --- |
| `SUPABASE_DB_URL` | GitHub environment secret | The database's session pooler address, with its password |
-| `SUPABASE_SECRET_KEY` | GitHub environment secret | `sb_secret_…`, for loading the interviews |
+| `SUPABASE_SECRET_KEY` | GitHub environment secret, and Vercel (Secret) | `sb_secret_…`, for loading the interviews and counting each visitor's messages |
| `CLOUDFLARE_API_TOKEN` | GitHub environment secret, and Vercel (Secret) | The Workers AI token for that environment |
| `VERCEL_TOKEN` | GitHub environment secret | A Vercel token for that environment's project only |
| `SUPABASE_URL` | GitHub environment variable, and Vercel (Config) | `https://.supabase.co` |
@@ -231,6 +231,7 @@ First for `prd-assistant-staging`, then for `prd-assistant`.
2. Open **Settings → Git** and select **Disconnect**. From now on, only GitHub Actions deploys.
3. Under **Settings → Environment Variables**, add these to the **Production** environment only, leaving Preview and Development empty:
- `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, `CLOUDFLARE_ACCOUNT_ID` and `AI_PROVIDER` = `cloudflare`, as **Config**.
+ - `SUPABASE_SECRET_KEY`, as **Secret**. Without it the chat refuses every message, since it can't count them against each visitor's daily limit.
- `CLOUDFLARE_API_TOKEN`, as **Secret**. The production project gets this one at launch.
4. For staging only, under **Settings → Deployment Protection**, turn on **Vercel Authentication** and choose **All Deployments**, not Standard Protection, which leaves the main address public. All Deployments is available on every plan (https://vercel.com/docs/security/deployment-protection). Then only you can open staging, and strangers can't use up the free AI allowance.
5. Note the **Project ID** from **Settings → General**, and the **Team ID** from your team's **Settings → General**.
diff --git a/docs/decisions.md b/docs/decisions.md
index ee46660..480ed24 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -45,7 +45,7 @@ Decisions that shape the app, one line each: `YYYY-MM-DD · decision · conseque
## Access
- 2026-10-17 · Visitors are anonymous: no sign-in, no roles, no admin screen; the owner works only through staging, the quality check and the hosting settings; English only, no currency · No sign-in or translation setup in stage 3
- 2026-10-17 · When the free quota runs out, the chat input is hidden and the message links to the README's screen recording and the repository · Replaces the plain friendly message
-- 2026-10-17 · Rate limit: 30 messages per visitor per day, recognised by hashed IP address kept only for the limit window; the provider's daily free allocation is the tighter cap · Needs a legal basis the owner chooses (see Money and legal)
+- 2026-10-26 · Rate limit: 10 messages per visitor per UTC day, recognised by a keyed hash of the IP address (an IPv6 address by its /64) and the day, never the address itself; a row is deleted by the first message after its day; it counts only messages to the real model (the owner, slice 8) · At about 350 neurons an answer, 30 a day would let one visitor use the whole day's allocation; 10 caps one visitor at about a third. Replaces 30 a day (2026-10-17). How it's built: `src/server/AGENTS.md`, *The rate limit*
## External services
- 2026-10-17 · Every AI key comes from an account or project with no billing attached, so running out of quota fails instead of charging · No spending limit to manage; quota exhaustion is the expected failure. The measurement stays on the Free plan too (External services, 2026-10-19)
@@ -69,6 +69,7 @@ Decisions that shape the app, one line each: `YYYY-MM-DD · decision · conseque
## Money and legal
- 2026-10-17 · Official sources for the IP-based rate limit (not legal advice; the owner decides the legal basis): GDPR (https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32016R0679) recital 30 (IP addresses are online identifiers), recital 26 (pseudonymised, e.g. hashed, data is still personal data), recital 49 (processing strictly necessary for network and information security can be a legitimate interest), Art. 5(1)(c) and (e) (data minimisation, storage limitation), Art. 6(1)(f) (legitimate interests), Art. 13 (information to give visitors); CJEU C-582/14 Breyer (https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:62014CJ0582): a dynamic IP address can be personal data for a website operator · Decide the legal basis and whether a short privacy notice is needed before launch
+- 2026-10-26 · The rate limit's legal basis: the owner treats it as strictly necessary to keep the free demo available to everyone, like a necessary cookie, so no consent is asked and no banner shown; it sets no cookie. A deletion request needs no process of its own: a count is a keyed hash that can't be traced to a visitor and is gone after its day (the owner: "lets not care too much about the legal basis"; not legal advice) · Settles the item deferred on 2026-10-17. Whether a short privacy notice mentions it stays with the legal pages in stage 5
- 2026-10-17 · Cloudflare's terms (Self-Serve Agreement and Developer Platform terms, read 2026-10-17; not legal advice): no clause limiting the free plan to private, test or commercial use, and no restriction for EEA visitors; each model's own licence and use policy apply · The public demo can run on the free plan. Replaces the reading of Mistral's terms
- 2026-10-17 · A line under the chat box says it's a demo with fictional notes and not to enter personal information · Covers free-tier data use by the provider
- 2026-10-17 · No cost display and no prices file; token usage is logged on the server per answer and not shown in the app, and the quality check prints the total tokens · Replaces brief feature 5 (cost per answer and per PRD in the app)
@@ -81,8 +82,6 @@ Decisions that shape the app, one line each: `YYYY-MM-DD · decision · conseque
## Deferred
-- 2026-10-17 · The legal basis for the hashed-IP rate limit, and handling requests to delete personal data (keeping it only for the limit window is decided) · decide before the rate-limit piece of work in stage 4, the first that stores personal data · affects the rate limit and a possible privacy notice
- 2026-10-17 · Legal pages, including whether a short privacy notice is needed; the domain, search engines, alerts, cookies, tracking, tax and receipts are already settled as none · decide in stage 5 · affects the launch
-- 2026-10-18 · The per-visitor rate limit's size (now 30 messages a day, under Access), from what an answer costs on the chat model as the staging log shows: later sections cost more, because every call resends the conversation, and at 30 messages one visitor could use the whole day's allocation, which staging and production share · decide before the rate-limit piece of work (slice 8), from the token counts the staging log shows by then · affects the rate limit
- 2026-10-17 · A short README section, "If this became a product", written from the PRD's later list: each item says what it adds and which part of the existing code it builds on; text only, about an hour, no new features · do in stage 5 · affects the README
- 2026-10-19 · What "the grocery delivery request has to pass before launch" means once it's measured over several runs, and what happens after a miss · decide before launch · affects the launch
diff --git a/docs/plan.md b/docs/plan.md
index a1180ac..1473654 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -21,14 +21,15 @@
### Build lane
- **Where:** its own worktree per piece, on a branch from an up-to-date `main`. If the session isn't in a worktree yet (`git worktree list` shows its folder as the main checkout, not under `.claude/worktrees/`), it creates one before touching anything, and never checks out a branch in the main folder, where the measurement lane works. Claude Code's worktree option puts it under `.claude/worktrees/`; run `npm ci` there first. A worktree made with `git worktree add` gets no `.env.local`: run `npm run env:local` there, or the end-to-end tests find no interviews. Its browser check uses `app-build` in `.claude/launch.json` (port 3200), never `app` or `app-real`, which are the measurement lane's on port 3000. It never runs `measure`, `measure:fake` or `dev:real`, never edits the measurement lane's files above, and changes code in `src/server` only where its piece needs it, never what the model receives (listed above); its pull request says which `src/server` files it changed and why.
-- **Doing:** nothing. Slice 28, the owner's interface review so far, is merged (0ca1d52, 2026-10-26): "Start over" asks in a pop-over under its button; the neutral palette of the owner's site, with `--prose` for replies and statements; the PRD's headings lighter; the first screen centred, with the households linking to their interviews and a glimpse of the workspace, arriving line by line once. How it's built: `src/features/AGENTS.md`, *"Start over" asks first*, *The palette* and *The intro*.
-- **Before it:** slice 27 is merged (b5ba6c0, 2026-10-25), with the owner's fixes from testing it on the same pull request (28ab6ec to 07b17c0): working lines that stay steady on slow steps and retries, one "Thinking" line through an answer's steps, the search count that stays in view and glides, and replies typed out as opencode shows a streaming one (`docs/decisions.md`, Design, 2026-10-25). How it's built: `src/features/AGENTS.md`, *Desktop layout*, *The chat's column* and *A reply is typed out*. A local build or dev server can keep an old `globals.css` until `.next` is deleted (KI-11).
+- **Doing:** slice 8, the rate limit, built on `feat/rate-limit` (worktree `.claude/worktrees/rate-limit`): 10 messages a visitor a UTC day, counted by a keyed hash of the address and the day in `message_counts`, which only the secret key reaches; only messages to the real model count. Its independent review (2026-10-26) found no blocker, and none of its four low findings needed a code change; the branch waits for the push. Before its merge, the owner adds `SUPABASE_SECRET_KEY` to the Vercel staging project (README, step 3), or staging's chat refuses every message. How it's built: `src/server/AGENTS.md`, *The rate limit*.
+- **Before it:** slice 28, the owner's interface review so far, is merged (0ca1d52, 2026-10-26): "Start over" asks in a pop-over under its button; the neutral palette of the owner's site, with `--prose` for replies and statements; the PRD's headings lighter; the first screen centred, with the households linking to their interviews and a glimpse of the workspace, arriving line by line once. How it's built: `src/features/AGENTS.md`, *"Start over" asks first*, *The palette* and *The intro*.
+- **Before that:** slice 27 is merged (b5ba6c0, 2026-10-25), with the owner's fixes from testing it on the same pull request (28ab6ec to 07b17c0): working lines that stay steady on slow steps and retries, one "Thinking" line through an answer's steps, the search count that stays in view and glides, and replies typed out as opencode shows a streaming one (`docs/decisions.md`, Design, 2026-10-25). How it's built: `src/features/AGENTS.md`, *Desktop layout*, *The chat's column* and *A reply is typed out*. A local build or dev server can keep an old `globals.css` until `.next` is deleted (KI-11).
- **The README's demo** (merged, a4d5574): `docs/media/prd-assistant-demo.gif` and its source copy `prd-assistant-demo.mp4`, a motion piece about the interface made in Remotion 4.0.538 in its own project, `D:\IT_Projects\Web Dev\prd-assistant-video`, outside the repository; its copy, colours and timings are in that project's `src/config.ts`. `npx remotion render PrdAssistantDemo out/prd-assistant-demo.mp4 --codec=h264 --crf=16 --image-format=png`, and for the GIF `--codec=gif --every-nth-frame=2 --scale=0.5`. The GIF's click opens the MP4 through GitHub's own video hosting.
- **Next:**
1. The rest of the owner's interface review, if any, as its next piece, and the measurement lane's staging try of the merged chat, whose screenshots may bring changes.
- 2. Slice 8, the rate limit, once the owner has decided its legal basis and size (*Deferred*).
-- **Unverified:** on staging, a reload keeping the PRD and the downloaded file (local Chromium only); a stop ending the answer on Cloudflare too, where the call in flight may still be finished and counted; the quota message against Cloudflare's real refusal (the tests replay its reply of 2026-10-20) and whether its per-minute 429 carries a retry header; a source name's pop-over, "Your decision, not from the interviews." under a claim of the visitor's, the table fix and the intro screen; Safari and Firefox (only Chromium ran; "Start over"'s pop-over under its button, on anchor positioning like the source's, else a centred box; the intro's arrival timed from the first paint, and its glimpse's fade at its foot; the chat's centred column and the PRD's narrower one; without anchor positioning the pop-over is a centred box, by design, and without `field-sizing`, before Safari 26.2 and Firefox 152, the message box keeps its three lines; the moving band, whose fallback is the still grey line; the PRD's highlight and rolling count; Jump to latest; the folded line's slide, which falls back to showing at once; the words' arrival, on inline words' relative offsets and a list marker's colour, and the searches drawing together; the pop-over growing in on `@starting-style`, before which it opens at once, and closing the same way only where `overlay` keeps it on top (Chrome), elsewhere at once, by design; the copy button, its label and the clipboard; Inter and its resized fallback, which needs Arial installed, so Linux and Android show the system's font until Inter arrives; a table's cells keeping their 144px and its box's sideways scroll; the Thinking line's band; what arrives, on `linear()` and `@property`, whose plain fallbacks are an ease-out and, without `@property`, a pane's rise and delay passing to what's in it). Motion on a phone and a slow machine (only headless Chromium on this one). On staging, how arrivals look at the real model's pace, a real reply's table, and the Thinking line through the real model's waits with the times it shows (the fake model's are about 0s). On staging, a reply that arrives whole coming in word by word, and a section's copy over HTTPS pasted into another app. Expect the Security Advisor's lint 0028 on `search_passages` on staging; it's intended (`supabase/AGENTS.md`). Production waits for launch and its Cloudflare token.
-- **Waiting on the owner:** pushing `docs/record-slice-28-merge` and its merge; the rest of the interface review, if any; whether `next dev` may keep adding its block to `AGENTS.md` (KI-8); the GitHub description and topics whenever they like.
+ 2. Slice 8's push and merge, after `SUPABASE_SECRET_KEY` is in Vercel's staging project, then a check on staging: the 11th message of the day refused.
+- **Unverified:** on staging, the rate limit (Vercel's `x-forwarded-for`, the secret key there, the 11th message refused; locally only against the fake's 429 and the database); a reload keeping the PRD and the downloaded file (local Chromium only); a stop ending the answer on Cloudflare too, where the call in flight may still be finished and counted; the quota message against Cloudflare's real refusal (the tests replay its reply of 2026-10-20) and whether its per-minute 429 carries a retry header; a source name's pop-over, "Your decision, not from the interviews." under a claim of the visitor's, the table fix and the intro screen; Safari and Firefox (only Chromium ran; "Start over"'s pop-over under its button, on anchor positioning like the source's, else a centred box; the intro's arrival timed from the first paint, and its glimpse's fade at its foot; the chat's centred column and the PRD's narrower one; without anchor positioning the pop-over is a centred box, by design, and without `field-sizing`, before Safari 26.2 and Firefox 152, the message box keeps its three lines; the moving band, whose fallback is the still grey line; the PRD's highlight and rolling count; Jump to latest; the folded line's slide, which falls back to showing at once; the words' arrival, on inline words' relative offsets and a list marker's colour, and the searches drawing together; the pop-over growing in on `@starting-style`, before which it opens at once, and closing the same way only where `overlay` keeps it on top (Chrome), elsewhere at once, by design; the copy button, its label and the clipboard; Inter and its resized fallback, which needs Arial installed, so Linux and Android show the system's font until Inter arrives; a table's cells keeping their 144px and its box's sideways scroll; the Thinking line's band; what arrives, on `linear()` and `@property`, whose plain fallbacks are an ease-out and, without `@property`, a pane's rise and delay passing to what's in it). Motion on a phone and a slow machine (only headless Chromium on this one). On staging, how arrivals look at the real model's pace, a real reply's table, and the Thinking line through the real model's waits with the times it shows (the fake model's are about 0s). On staging, a reply that arrives whole coming in word by word, and a section's copy over HTTPS pasted into another app. Expect the Security Advisor's lint 0028 on `search_passages` on staging; it's intended (`supabase/AGENTS.md`). Production waits for launch and its Cloudflare token.
+- **Waiting on the owner:** `SUPABASE_SECRET_KEY` in Vercel's staging project, then pushing `feat/rate-limit` and its merge; pushing `docs/record-slice-28-merge` and its merge; the rest of the interface review, if any; whether `next dev` may keep adding its block to `AGENTS.md` (KI-8); the GitHub description and topics whenever they like.
### Shared rules
- One branch per piece, from an up-to-date `main`; the owner pushes it, opens the pull request and merges it (`AGENTS.md`, Branches). After the other lane's merge, rebase an unpushed branch onto `main`, and merge `main` into a pushed one; never rewrite a pushed branch.
@@ -65,12 +66,7 @@
### 8a · The quota message · risk: normal · status: done (5267952)
-### 8 · Rate limit · risk: risky (personal data, permissions) · status: todo
-- **Goal:** one visitor can't use up the free quota.
-- **Acceptance:**
- - Each visitor gets 30 messages a day, recognised by a hashed IP address that's kept only for the limit window. The counts live in a table only the server's secret key reaches, with tests for each role.
- - The 31st message in a day is refused with a friendly message, and no raw IP address is ever stored.
- - Before starting, the owner decides the legal basis and the limit's size (see *Deferred*).
+### 8 · Rate limit · risk: risky (personal data, permissions) · status: done (on `feat/rate-limit`; its id on `main` goes here after the merge)
### 9a · The measurement's recorder · risk: risky (secrets) · status: done (b339dbd)
diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts
index be7e57f..191a581 100644
--- a/src/app/api/chat/route.ts
+++ b/src/app/api/chat/route.ts
@@ -1,6 +1,7 @@
import { createUIMessageStreamResponse } from "ai";
-import type { ChatMessage } from "@/lib/chat";
+import { DAILY_LIMIT_REACHED, type ChatMessage } from "@/lib/chat";
import { ChatRequestError, readChatRequest, streamAnswer } from "@/server/chat";
+import { admitMessage } from "@/server/rate-limit";
// The chat's endpoint: the browser posts the conversation, and the answer
// streams back as the AI SDK's UI message stream (server-sent events).
@@ -30,6 +31,10 @@ export async function POST(request: Request) {
throw error;
}
+ // Only a request that reads as a conversation counts against the visitor's
+ // day (src/server/rate-limit.ts), and a refused one reaches no model.
+ if (!(await admitMessage(request))) return refuse(DAILY_LIMIT_REACHED, 429);
+
return createUIMessageStreamResponse({
// A visitor who leaves stops the model call, and its tokens with it.
stream: streamAnswer(messages, { abortSignal: request.signal }),
diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md
index ab0b395..cadaf3e 100644
--- a/src/features/AGENTS.md
+++ b/src/features/AGENTS.md
@@ -57,7 +57,7 @@
## The intro (`workspace/intro.tsx`, `workspace/disclosure.ts`)
- **Its form** (slice 28, `docs/decisions.md`, Design, 2026-10-26): centred, at most 48rem wide; the heading in weight 300; one short line; "Interviewed:" and the six names, each a link to `/transcripts#