DEV Community

Daniel Pertu
Daniel Pertu

Posted on

We store what you picked and not what you saw, so the report rebuilds itself on every open

A finished practice sitting in CogniPrep saves one row. Since we added the answer review to reports, that row carries an extra jsonb column, and what goes in it is deliberately the smallest possible thing:

// What was answered, for the report's answer review: ids and choices only,
// shaped as SessionResponses in lib/games/review/types.ts. Null for games
// without a review and for every session saved before reviews existed.
responses: jsonb(),
Enter fullscreen mode Exit fullscreen mode

Ids and choices. Not the scenario you read, not the options you chose between, not the key, not the explanation. I have written about why that column has to be allowed to fail; this post is about the consequence of it being so small, which is that the review on your report does not exist until you open it.

The join happens on the server, per request

/**
 * Server side of the answer review: reads the question bank a session was
 * played from and pairs it with the responses saved on the session.
 *
 * Runs on the server so the report page receives only the questions the
 * candidate answered, never the whole bank with every key in it. The `node:fs`
 * read in bank-source keeps this file out of client bundles.
 */
export async function buildAnswerReview(
  gameId: string,
  responses: SessionResponses | null | undefined
): Promise<AnswerReview | null> {
  if (responses?.kind !== 'cs-battery' && responses?.kind !== 'cs-adaptive') return null;
  // Every Civil Service game with a bank reads the one named after it.
  if (!isGameQuestionBankId(gameId)) return null;
  const { questions } = JSON.parse(await readQuestionBank(gameId)) as { questions: unknown[] };
  return responses.kind === 'cs-battery'
    ? buildCSBatteryReview(gameId, questions, responses)
    : buildCSAdaptiveReview(gameId, questions, responses);
}
Enter fullscreen mode Exit fullscreen mode

That is a filesystem read and a JSON parse on every report open. It is not free, and it is the cheapest of the four designs available.

Store the rendered review in the row. Then the row contains scenarios, every option, the key and the written explanation, duplicated per session. A user row would hold the answer key to a test, which inverts the entire reason the banks live outside public/. It also freezes the wording, which matters more than it sounds: we ship a UK and US spelling preference, and a stored review would be frozen in whichever spelling was current the day you sat the test.

Build it in the browser. Then the browser needs the bank, so every report open ships every key for that test to a machine we do not control. No.

Recompute from scratch on open. The numbers are read back from what was written at the time, not recalculated today. A report should show the attempt you made, not the attempt you would have made against this week's content.

Pair ids with content, server side, on read. One disk read, no duplication, no key in the payload beyond the questions you actually answered.

What "only the questions you answered" means in bytes

This is the part I would check if I were reading someone else's claim about this. Every entry in the saved responses is a question you submitted an answer to, and the builder iterates the responses rather than the bank. A question you never reached is not in the responses, so it is not in the review, so it is not in the JSON the page receives, so its key is not in your browser's memory at all.

For the adaptive tests that is the entire security model of the feature in one sentence: the only keys that leave the server are the keys to questions you have already answered. A bank of a couple of hundred items hands over the few dozen you sat.

The builder is also willing to return nothing. Three cases, all real:

  • responses is null, which is every session saved before the feature shipped and every game that does not have a review
  • the game has no bank under its own name
  • nothing in the responses still matches the bank

That last one is content drift, and it is the price of joining on ids. We edit question banks continuously. An item that was reworded renders with today's wording, which is what we want. An item that was removed cannot render at all, so the builder drops it and an old report comes back one question shorter. A review that renders half a question would be worse than one that renders none.

A failure here is not allowed to cost the report

// The answer review is built here rather than in the browser so the page
// gets only the questions this session answered, not the bank's keys. A
// failure costs the review, never the report.
let review: AnswerReview | null = null;
try {
  review = await buildAnswerReview(
    session.game_id,
    session.responses as SessionResponses | null
  );
} catch (reviewError) {
  logError(`Answer review failed for session ${session.id}`, reviewError);
}
Enter fullscreen mode Exit fullscreen mode

The review is the newest, most content-dependent, most likely to throw part of the response. The score and the percentile are the thing the user came for. So the new feature gets a try and the old feature gets the happy path, which is the correct ranking of those two and the reason I am comfortable with the cost: a bad bank edit degrades a section instead of breaking a page.

What it does cost is debuggability, and I found that out on a deploy where the JSON files were not in the production bundle at all. The symptom was not an error. It was a report with one fewer section than the same report locally.

The order of the gates

The build is the last thing the route does, and that ordering is the design rather than an accident. Before it: validate the id is a UUID, verify the signed-in user owns the session, check the plan allows reports, fetch the row. I have written separately about the five things that happen before any of our API routes run and about why somebody else's report is a 404 rather than a 403.

The relevant point for this feature is narrower: a route that reads files off disk should do so after every authorisation check, not before. Our bank ids are an exact-match allowlist rather than a sanitised string, so params can never reach readFile unfiltered, but the cheap ordering rule holds anyway. Nothing touches the filesystem on behalf of a request that was going to be refused.

It is in the export, and it expires

Two obligations came with the column, both of which are easy to forget on the day you ship a feature.

It goes in the data export, which is one line in the select and one in the mapping:

metrics: gameSessionsTable.metrics,
responses: gameSessionsTable.responses,
Enter fullscreen mode Exit fullscreen mode

What you get in that file is ids and choices: your answers, without the content they were answers to. That reads like an unhelpful export, and it is the honest one. Your choices are your data. The question bank is not.

And it expires with the row it lives in. Game data is deleted automatically after two years by a daily job, as our privacy policy says, so the review on a report has exactly the lifetime of the session that produced it. A stored review would have needed its own retention rule. A derived one inherits.

Where to see it

The review is live on the eight UK Civil Service online tests, which includes the two adaptive ones where the "only what you answered" property does the most work, because an adaptive sitting draws from a bank several times the length of the sitting. The numerical reasoning format page covers how that style of test works across providers if you want the format rather than the implementation.

Top comments (0)