Rejecting wrong AI suggestions
Judge an AI suggestion against your installed versions and trust rules, then refuse it with a reason.
Predict first
Before you read: predict
ExamHall's candidate app runs on Next.js: its package.json pins next 16.3.6, and CI type-checks every file with strict TypeScript. For the exam page, the candidate asks the assistant how a server component should read the session cookie. It answers at once:
cookies() gives you the request's cookie store, so you call .get on it," it adds.
What does she do with the suggestion?
// lib/session.tsimport { cookies } from 'next/headers';export function sessionId() { return cookies().get('session')?.value;}The 30-second version
A model learned from code written across many versions, so a fluent answer can call an API your version removed, rely on one it quietly changed, or skip a server check. Compare it with your lockfile's release notes and the trust rules, then refuse it, saying why and what replaces it.
Judge an AI suggestion against your installed versions and trust rules, then refuse it with a reason.
How it works
Step 1 of 11
Analogy: A tax adviser checking a drafted return
Before a return is filed, the adviser reads the draft her junior built from a template carried over from earlier years. Every box is filled. She checks it against the rules of the return's tax year. A relief abolished since then is refused by the filing portal at once. A renamed box is still accepted this year, with a notice that it goes next year. A changed rate is accepted without a murmur and gives the wrong tax. The draft also skips the identity check, "since he's a returning client". She does not strike a relief just because she has never met it; she looks it up. Each strike gets a margin note: why, and the box to use now.
| At the adviser's desk | With an AI suggestion |
|---|---|
| The template carried over from earlier years | A model trained on many versions |
| The tax year the return is for | The versions in your lockfile |
| An abolished relief the portal refuses | A removed API: a throw or a type error |
| A renamed box accepted for one more year | A deprecated API that still runs |
| A changed rate accepted without a murmur | A silent behaviour change |
| Skipping the identity check | Skipping an authorization check |
| Looking up an unfamiliar relief | Checking a new API in the current docs |
| The margin note: why, and the box to use | Refusing with the reason and the replacement |
Breaks down when: tax rules change once a year in one published set, while each package changes on its own schedule, and its notes can miss a change. Wrong inference: "one read of the release notes settles every suggestion." The silent kind still needs a test of the output.
In real terms: before you accept generated code, each API it calls is checked against your installed version, and each check it should make sits on the server.
Examples, run for real
4.1 The exam that ends only in the browser (EXH-3180)
EXH-3180 is a synthetic generated change for ExamHall's candidate app, half of this concept's curriculum hook. Asked to "stop submissions when the exam ends", the assistant disabled Submit when the candidate's countdown reached zero, and left the handler saving whatever arrived.
4.1 EXH-3180 ECON-201 opens 09:00:00, 90 min · endsAt 10:30:00 on the server clock
4.1 laptop clock 3 min slow · server 10:32:00 · laptop 10:29:00 · Submit enabled · generated handler -> 200 saved
4.1 a request to the API from outside the page · server 10:40:00 · generated handler -> 200 saved
4.1 MR !3480 handler compares the server clock with endsAt: 10:29:30 -> 200 saved · 10:32:00 -> 409 exam over · 10:40:00 -> 409 exam over
A laptop running three minutes slow keeps Submit enabled after the exam has closed, and a request from outside the page is saved too. Neither path shows an error. MR !3480 makes the rule the server's: the handler compares its own clock with endsAt. The refusal says why: the deadline is a server rule, and the button reflects it. Why client clocks drift is fsd-clock-sync-timers' topic.
4.2 One loud removal and one silent one (MR !3481)
A chat-app snippet from the assistant does two things on react 19.3.0: it reaches the message box through ReactDOM.findDOMNode to focus it, and it adds a propTypes check that maxLength is a number.
4.2 react 19.3.0 · react-dom 19.3.0 · JSX runtime automatic (jsx: react-jsx)
4.2 focus via ReactDOM.findDOMNode -> TypeError: ReactDOM.findDOMNode is not a function
4.2 focus via a ref on the textarea -> focused textarea[aria-label="Message"]
4.2 propTypes check, maxLength "500" (a string) -> renders <textarea maxlength="500" aria-label="Message"></textarea>, console lines: 0
4.2 TypeScript prop type, maxLength '500' -> TS2322 at composer.ts:4
The first fails on its first run, so nobody ships it. The second runs clean: React 19 skips propTypes checks without a word (S2), so the check the assistant promised never runs. MR !3481 uses a ref for the focus and a TypeScript prop type, which the type-checker enforces.
Try it
Answer first, then check. Nothing is saved.
Question 1 of 6
The chat app's lockfile pins react and react-dom 19.3.0, and its TSX compiles with the automatic JSX runtime. The assistant sends four snippets, each with what it says the snippet does:
// theme.tsx · "everything inside the thread reads the dark theme
import { createContext, useContext, type ReactNode } from 'react';
export const Theme = createContext('light');
export function ThreadTheme({ children }: { children: ReactNode }) {
return <Theme value="dark">{children}</Theme>;
}
export function ThemeName() {
return <span>{useContext(Theme)}</span>;
}
// bubble.tsx · "a bubble passed no status shows 'sent'
export function Bubble({ text, status }: { text: string; status?: string }) {
return <p>{text} <small>{status}</small></p>;
}
Bubble.defaultProps = { status: 'sent' };
// main.tsx · "mounts the chat app into #root
import ReactDOM from 'react-dom';
import { App } from './app';
ReactDOM.render(<App />, document.getElementById('root'));
The candidate renders each snippet once and watches the console.
Which one must she refuse even though its render leaves the console empty?
// composer.tsx · "the thread can focus the message box through its ref"import { forwardRef } from 'react';export const Composer = forwardRef<HTMLTextAreaElement>(function Composer(props, ref) { return <textarea ref={ref} aria-label="Message" />;});Question 2 of 6
The chat app runs Next.js 16.3.6. Asked to send signed-out visitors from /inbox to /login, the assistant adds a middleware.ts file at the project root that exports a middleware(request) function redirecting any request without a session cookie.
As the Next.js 16 release post describes this file, as of 2026-10-06, what should she do with the suggestion?
Question 3 of 6
In the chat app each membership row holds a conversationId, a userId and a role, 'member' or 'owner'. Asked for a "remove from conversation" action, the assistant writes:
She refuses it.
Which reason for refusing does a run of the handler bear out?
// remove-member.mjsexport function removeMember(store, actorId, conversationId, targetId) { const here = store.members.filter((m) => m.conversationId === conversationId); if (!here.some((m) => m.userId === actorId)) return { status: 403 }; const target = here.find((m) => m.userId === targetId); if (!target) return { status: 404 }; store.members = store.members.filter((m) => m !== target); return { status: 200 };}Question 4 of 6
For the chat app's "Rename conversation" form, the assistant writes:
The lockfile pins react and react-dom 19.3.0. She renders the form once before deciding.
What does that render show, and what does she do?
// rename.tsximport { useFormState } from 'react-dom';export function Rename({ rename }: { rename: (name: string, form: FormData) => Promise<string> }) { const [name, formAction] = useFormState(rename, 'Team chat'); return ( <form action={formAction}> <input name="name" defaultValue={name} aria-label="Conversation name" /> <button>Save</button> </form> );}Question 5 of 6
The chat app's search must find every message whose text contains the query, ignoring upper and lower case; a blank search shows nothing. The assistant writes this, with its test:
// search.test.mjs
it('ignores the case of the query', () => expect(searchMessages([{ id: 1, text: 'see you at the gym' }, { id: 2, text: 'running late' }], 'GYM').map((m) => m.id)).toEqual([1]));
The test passes.
Which reason for refusing the function does a run bear out?
// search.mjsexport function searchMessages(messages, query) { const q = query.trim().toLowerCase(); if (q === '') return []; return messages.filter((m) => m.text.includes(q));}Question 6 of 6
The chat app lets a message's author edit it for 15 minutes after sending it. The phone sends each edit as { text, editedAt }, with editedAt read from the phone's own clock. The assistant writes the server's handler, which the server calls with its own clock as now:
All times are in seconds.
Which reason for refusing the handler does a run bear out?
// edit.mjsexport function editMessage(store, actorId, messageId, body, now) { const msg = store.messages.find((m) => m.id === messageId); if (msg?.authorId !== actorId) return { status: 403 }; if (body.editedAt - msg.sentAt > 15 * 60) return { status: 409 }; store.messages = store.messages.map((m) => (m === msg ? { ...m, text: body.text } : m)); return { status: 200 };}Go deeper
Pro tip
Keep your project's framework versions in view while you work with the assistant: open package.json beside the chat, and say the version in your prompt.
Related lessons
- Read first: The verification loop for generated code
- Related: The three AI failure modes
- Related: The three AI regimes and their rules
- Related: Owning every line, out loud
- Related: AI-free fluency (Bare mode)
- Related: Server Components vs Client Components
- Related: Context vs external stores
- Related: Authorization models and enforcement
- Related: Validation at the boundary: ValidationPipe, whitelisting, mass assignment
- Related: Server-authoritative time and client timers
Checked against the tools and docs as of 2026-10-06.