Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/verify-debatelab/recipes/account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ test("timer-run: the speech timer starts, pauses, and resets", async ({ signedIn
await page.getByRole("button", { name: "Pause" }).click();
await expect(page.getByText("PAUSED").first()).toBeVisible();
const paused = await display.textContent();
// eslint-disable-next-line no-restricted-properties -- observation window: a paused timer must not change.
await page.waitForTimeout(1_500);
expect(await display.textContent()).toBe(paused);
await evidence.shot("01-paused");
Expand Down
18 changes: 18 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Merges with the organization settings in the CodeRabbit UI; only the keys
# below are set here.
inheritance: true
knowledge_base:
code_guidelines:
enabled: true
filePatterns:
- "AGENTS.md"
- "CODING_STANDARDS.md"
- "docs/standards/*.md"
reviews:
path_instructions:
- path: "**/*"
instructions: >-
Follow the review steps in CODING_STANDARDS.md and apply the area files
in docs/standards/ that match the changed files. Report a rule violation
as a finding with the rule's link. Skip what lint, typecheck, the test
suites and npm run docs:check already catch.
31 changes: 31 additions & 0 deletions CODING_STANDARDS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Coding standards

Review rules for DebateLab diffs. They come from review findings on PRs #5 to #106 that the team accepted and fixed, and each rule links to a thread that produced it. A rule marked "single finding" came up once; weigh it below the rules reviewers raised repeatedly.

## Review steps

1. Map each changed file to its area below and read that area's file. Read [reviews.md](docs/standards/reviews.md) for every PR.
2. Check the diff against every rule in those files. A violation is a finding like any bug: give its severity, `file:line`, and the rule's link.
3. Done when every changed file has been checked against its area file, and the PR body has been checked against the diff.

Skip what lint, typecheck, the test suites, and `npm run docs:check` already catch. Spend the review on what they cannot see.

## Areas

- [Async work and client state](docs/standards/async-and-state.md): stores, pages, and hooks that await anything; stale responses, races with user actions, cancellation, timeouts.
- [Trust boundaries and validation](docs/standards/boundaries.md): `src/lib/ai/`, Edge handlers, anything parsing model or network output; request budgets, failing closed, honest controls, usage and cost records.
- [Tests](docs/standards/tests.md): any test file; tests that fail when the behavior they name breaks.
- [Database and migrations](docs/standards/database.md): `supabase/migrations/`, `supabase/tests/`, privileged database clients.
- [CI, secrets, and privacy](docs/standards/security.md): `.github/workflows/`, credentials, telemetry and error reporting.
- [UI, language, and accessibility](docs/standards/ui.md): components, pages, and `src/locales/`.
- [PR claims and documentation](docs/standards/reviews.md): the PR body, commit messages, and the documents a change must update.

## The rules reviewers raised most

1. A test must fail when the behavior it names breaks. Check both sides of every limit, and exercise behavior rather than matching source text. (about 29 findings)
2. An async result that writes state checks that it is still current when it lands. Read the target of a user action (debate, user, record) when the user acts, before any `await`. (about 17)
3. Validate untrusted data where it enters: model output, Edge responses, persisted browser state, request bodies. Reject or normalize it there, before anything renders or saves it. (about 15)
4. Every user-facing string goes through `t()` with keys in all three catalogs, and every control has an accessible name. (about 15)
5. A timeout or abort stops the work underneath it, not only the promise the caller awaits. (about 9)
6. When a read fails, the answer is "unknown". Persist nothing and report no success on a failed read or write. (6)
7. The owning document changes in the same PR as the behavior it describes. (7)
4 changes: 4 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ Start with the smallest document that answers the task instead of loading every
- [Agent guide](../AGENTS.md), current. Always-loaded commands, safety rules, and routing.
- [Root README](../README.md), current. Product overview and quick start.

## Write and review code

- [Coding standards](../CODING_STANDARDS.md), current. Review rules drawn from accepted review findings, split by area under [`standards/`](standards/). Read by reviewers; open an area file when a review finding cites it.

## Set up and develop

- [Development guide](development.md), current. Open for installation, environment behavior, linked worktrees, frontend, backend, database, or iOS workflows.
Expand Down
55 changes: 55 additions & 0 deletions docs/standards/async-and-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Async work and client state

Part of the [coding standards](../../CODING_STANDARDS.md). Race conditions were the second most common finding: a response from earlier work landing after the user moved on.

## Capture the target when the user acts

Read the debate, user, or record ID at the moment of the click, and pass it through the async chain. Do not read a store again after an `await`, because the user may have switched debates or accounts while you waited.

- Clear All read the Current Debate after awaiting auth, so a switch during the wait deleted the other debate's sources ([#72](https://github.com/Programcode/DebateLab/pull/72#discussion_r4112813743)).
- Workspace research saved its sources into whichever personal debate was active when the response arrived ([#72](https://github.com/Programcode/DebateLab/pull/72#discussion_r4112813741)).
- A fallback that reads "the current debate" must respect one-off mode; use `activeDebateId()`, not `currentDebateId` ([#72](https://github.com/Programcode/DebateLab/pull/72#discussion_r4112813737)).

## Stale responses must not overwrite newer state

When a load and a write can overlap, give the write a counter or request key and have the load discard its result if the counter moved while it ran. This is the pattern `debate-store.ts` and the context pins use now.

- Hydration replaced a debate created while it was loading ([#72](https://github.com/Programcode/DebateLab/pull/72#discussion_r4112813746)). A pin load overwrote a pin change made during the read ([#75](https://github.com/Programcode/DebateLab/pull/75#issuecomment-5873019386)).
- A load that started before a dismissal brought the dismissed announcement back ([#19](https://github.com/Programcode/DebateLab/pull/19#discussion_r3896612965)).
- A failed optimistic write undoes only its own change. Restoring a snapshot taken earlier also reverts other writes that succeeded since ([#75](https://github.com/Programcode/DebateLab/pull/75#issuecomment-5873818351)).
- Count against the store's current state, not a value captured at render, so two quick clicks see each other ([#77](https://github.com/Programcode/DebateLab/pull/77#issuecomment-5877394644)).
- Errors tied to one item carry that item's ID and render only beside it ([#19](https://github.com/Programcode/DebateLab/pull/19#discussion_r3896430653)).

## Supersede in-flight work explicitly

Starting new work for the same slot aborts the old request and hides its output. Track ownership with a request identity and check it before every state update in `then`, `catch`, and `finally`.

- A clarify request started while a stream was running; the old stream later appended text and cleared the loading flag ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289857)).
- Cancel in-flight work on unmount as well as on clear and load ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861496792)).
- Undo during a section regeneration was reverted when the regeneration landed. Drop a result if the record it targets changed after the work started ([#89](https://github.com/Programcode/DebateLab/pull/89#issuecomment-5951346940)).
- Key deduplication by a stable identity (user ID), not by a rotating access token ([#65](https://github.com/Programcode/DebateLab/pull/65#discussion_r4082668135)).

## React effects and refs

- Do not use an `active` closure flag in an effect that can return early without registering cleanup; a re-render can leave it `false` forever. Request-key refs already reject stale results ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649640)).
- Write refs that track the previous render inside an effect, not during render ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378571)).
- Derive view state from the same condition that chooses the rendered branch ([#20](https://github.com/Programcode/DebateLab/pull/20#discussion_r3896378587)).
- One-shot guards must flip after their first use ([#14](https://github.com/Programcode/DebateLab/pull/14#discussion_r3894645041)).

## Auth transitions and persisted state

- Persisted browser state records its owner. On sign-in, clear state whose owner differs or is missing. Do not use "was there a previous user" as the ownership check ([#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033)).
- Clear only the previous owner's data on an account switch, never the incoming user's ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641261)).
- Validate persisted state on every rehydrate. Zustand calls `migrate` only on a version change, so put validation in `merge` ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649636)).
- Do not serialize a whole store on every keystroke. Debounce drafts and cap stored history ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649632)).

## Timeouts and cancellation

A timeout that rejects the caller's promise but leaves the work running still spends money and CPU.

- Pass one `AbortSignal` through every layer of the work: research, Exa, model calls, body reads ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805781), [#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289875)). A `Promise.race` timeout without cancellation leaves a DOCX conversion running ([#63](https://github.com/Programcode/DebateLab/pull/63#discussion_r4081818971)).
- Start the deadline and listen to the caller's signal before the first `await`, including `getSession()` ([#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033), [#16](https://github.com/Programcode/DebateLab/pull/16#discussion_r3895854139)).
- Every external fetch has a bound, and anything that delays app startup has a fail-open timeout ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805765), [#21](https://github.com/Programcode/DebateLab/pull/21#discussion_r3896608554)).
- A promise that wraps a callback API settles on every outcome, including cancel. The Google Picker promise never settled on cancel and left the panel disabled ([#63](https://github.com/Programcode/DebateLab/pull/63#discussion_r4081818959)).
- After a caller abort, normalize the error to `AbortError` so callers do not report it as a failure ([#16](https://github.com/Programcode/DebateLab/pull/16#discussion_r3895854139)).
- Secondary work after the main result (such as source-usage checks) gets its own budget and must not lose a result that already finished ([#69](https://github.com/Programcode/DebateLab/pull/69#discussion_r4093476756)).
56 changes: 56 additions & 0 deletions docs/standards/boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Trust boundaries and validation

Part of the [coding standards](../../CODING_STANDARDS.md). The client AI boundary is [`src/lib/ai/client.ts`](../../src/lib/ai/client.ts); the server boundaries are the Edge Function handlers described in [supabase/README.md](../../supabase/README.md).

## Validate at the boundary, then trust the type

Model output, Edge responses, URLs, and request bodies are untrusted until a validator accepts them. Validate the complete shape where the data enters, before rendering, autosave, or a cast.

- Validate the whole output with its schema before saving sources. A count check plus a cast let malformed output through ([#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289852)).
- Normalize optional fields to their primitive types. An object in `qualityLabel` reached React as a child ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805775)).
- Use `validateUrl` for every URL you render as a link, and store the value it returns, not the input ([#17](https://github.com/Programcode/DebateLab/pull/17#discussion_r3896309984), [#17 review](https://github.com/Programcode/DebateLab/pull/17#issuecomment-5481176881)).
- Validate raw types before coercing. `Boolean("false")` is `true` ([#29](https://github.com/Programcode/DebateLab/pull/29#discussion_r3937934126)).
- Invalid client input returns a 4xx, never a 500 from a property access on `null` ([#13 review](https://github.com/Programcode/DebateLab/pull/13#issuecomment-5477866033)).
- When the client validates locally, apply the same rules as the server, so a request the client accepted does not fail as a generic error ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641258)).
- Send only values the server contract accepts. Local-only source IDs made every AI request fail with 400 ([#66](https://github.com/Programcode/DebateLab/pull/66#discussion_r4085167559)).
- Prefer a typed result over `as` casts at call sites. Unchecked casts hide shape drift ([#18](https://github.com/Programcode/DebateLab/pull/18), single finding).

## Model output can omit what the prompt asked for

The shared JSON rules tell the model to omit a part it cannot fill. Map an omitted field to its documented empty value instead of failing the request ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641334)). A response variant that does not match the operation is an error the UI can recover from, not a silent return that leaves the page pending ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641293)).

## Size limits are UTF-8 bytes

The Edge limit is on serialized bytes, so budget in bytes, across the whole serialized payload, before transport ([#66](https://github.com/Programcode/DebateLab/pull/66#discussion_r4085167588), [#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641325)).

- Do not silently shorten what the user typed. Reject it with a visible message, or keep the truncation marker so the server can report it ([#66](https://github.com/Programcode/DebateLab/pull/66#discussion_r4085302451)).
- A value exactly at the limit is allowed. Check the boundary with a test on each side ([#12](https://github.com/Programcode/DebateLab/pull/12#discussion_r3893641317)).
- Check `response.ok` before reading a body, and read bounded bodies inside the timeout ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004292)).

## Fail closed, and say "unknown" when you do not know

- A failed entitlement read is indeterminate. Treating it as "not Pro" persisted a downgrade of the user's model choice ([#47](https://github.com/Programcode/DebateLab/pull/47#discussion_r4054031767)).
- Check the result of every write before reporting success ([#63](https://github.com/Programcode/DebateLab/pull/63#discussion_r4081818967)). Lint rejects unhandled promises; a `void` in front of one is correct only when the promise cannot reject or handles its own errors and tells the user ([#63 outside diff](https://github.com/Programcode/DebateLab/pull/63)).
- Catch the specific error you expect (a missing file, a missing column) and rethrow the rest ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805773)).
- Read provider per-item status before falling back to a generic message ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004284)).
- A fix for a permission change covers every write path, not only the one that failed first ([#44](https://github.com/Programcode/DebateLab/pull/44#discussion_r4049229043)).

## Controls must be honest

A visible toggle or setting must change the request or deterministic post-processing. This is an existing team rule that reviewers enforced:

- `useActiveSources` did not change the opponent request ([#11](https://github.com/Programcode/DebateLab/pull/11#discussion_r3872004224)).
- Research instructions were attached when research returned nothing, inviting unsupported citations ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805784)).
- A privacy disclosure stays only while the code enforces it ([#22](https://github.com/Programcode/DebateLab/pull/22#discussion_r3896703404)).
- Tier defaults must reach every fallback path, not only a migration ([#47](https://github.com/Programcode/DebateLab/pull/47#discussion_r4054031797)).

## Usage and cost records

Every provider call that can bill gets recorded, including retries, repairs, research sub-calls, and cache-only usage. A request that did not complete records its observed usage with a null cost.

- Cache-only usage returned before pricing ([#5](https://github.com/Programcode/DebateLab/pull/5#discussion_r3835805777)). Repair attempts dropped the failed call's tokens ([#5 review](https://github.com/Programcode/DebateLab/pull/5#issuecomment-5379671525), [#9](https://github.com/Programcode/DebateLab/pull/9#discussion_r3861289886)).
- A stream that errors after its finish frame is incomplete ([#46](https://github.com/Programcode/DebateLab/pull/46#discussion_r4053914209)).

## Keep one copy of a contract

When two code paths implement the same privacy-sensitive payload or validation, move it to one helper. The two copies of the AI failure report had already drifted ([#18](https://github.com/Programcode/DebateLab/pull/18), single finding). Configuration that exists in two places (such as two CSP lists) changes together, with a test that compares them ([#15](https://github.com/Programcode/DebateLab/pull/15#discussion_r3894972424)).
Loading
Loading