engineering

The paper intake for an AuDHD assessment is 238 questions. I wrote an app.

Modelling 7 clinical instruments as database rows instead of code — so adding an eighth is a JSON import, not a branch.

By Justin Time · Jul 2026 · 7 min read

The intake packet for an adult AuDHD assessment ran to about 14 pages. Seven instruments, 238 items total, all of it to be filled in by hand before the first appointment. Here is what was in it.

  • PHQ-9, the depression screen: 9 items.
  • GAD-7, generalized anxiety: 7 items.
  • ASRS v1.1, the adult ADHD self-report scale: 18 items, split into two parts.
  • The Autism-Spectrum Quotient (AQ): 50 items.
  • WFIRS-S, the Weiss Functional Impairment Rating Scale: 69 items across 7 subscales.
  • Y-BOCS with its symptom checklist: 69 items combined.
  • The Sleep Disorders Questionnaire: 16 items.

Every one of these is a validated clinical instrument with its own response scale, its own scoring, and its own cutoffs. On paper they arrive as a stapled block. You sit down with a pen and work through all seven in order, page after page. If you stop, you can see exactly where you left off. Your own ink is right there. Resuming is not the mechanical problem. Starting again is.

The irony sits on the surface. The assessment measures, among other things, how well you initiate and sustain a boring sequential task, and it opens by asking you to initiate and sustain a boring sequential task. For the people this form is designed to catch, that is the hard part by construction.

The decision

Building an app to administer the whole thing was the lower-friction path. Writing the code took less activation energy than sitting down to fill out the paper. So I wrote the code.

Instruments as data

The decision that made the app worth building is that no instrument lives in application code. Every one is a tree of database rows. Adding a new questionnaire is a JSON import, not a change to the app.

The schema

The data model is a hierarchy.

An Instrument has one or more Scales. A Scale carries a typed enum, scaleType, whose values are ORDINAL | FREQUENCY | SEVERITY | CHECKLIST | FREE_TEXT | DATE. That enum is the whole trick: the renderer reads scaleType and knows what control to draw without ever knowing which instrument it is drawing. An ORDINAL scale renders as a Likert row. A CHECKLIST renders as a set of checkboxes. FREE_TEXT is a text box. The component switches on the type of the scale, never on the name of the instrument.

Below Scale sit the rest of the rows. A Scale owns its ScaleOptions (the labelled answer choices and their point values) and its Items (the individual questions). Items can roll up into a Subscale, which is how WFIRS-S keeps its 7 subscale scores separate. Scoring lives in ScoringRule, which has its own enum, aggregation, with values SUM | THRESHOLD_COUNT. SUM adds the point values; THRESHOLD_COUNT counts how many items cleared a cutoff, which is how the ASRS Part A screen works. A ScoringRule produces a raw score, and InterpretationBands map score ranges to their labels ("moderate," "severe," and so on). SafetyRule lives on the model too, next to the scoring.

The constraint that holds all of this together is a rule about what the codebase is not allowed to contain: there is no if (instrument.name === ...) anywhere. Not in the renderer, not in the scorer, not in the PDF generator. The moment one instrument needs a special case in code, the whole "instruments are data" claim is a lie, because now adding an instrument means editing that branch. So the branch does not exist. Everything an instrument needs to render and score itself is a field on a row, and the code reads the field. It sounds obvious stated plainly. It is also the kind of thing that quietly erodes the moment you are tired and an edge case is staring at you.

Config, not code

Adding the Y-BOCS was the test of that claim, and it passed.

Y-BOCS is not a plain Likert questionnaire. It pairs a symptom checklist with a severity scale, 69 items combined, and it is the instrument that would most tempt you into writing a special case. It did not need one. It needed a JSON file.

The import pipeline is short. Source PDFs go to Claude (claude-opus-4-8, using its native PDF input) through the Anthropic SDK, which returns the instrument as structured JSON: the scales, their types, the items, the options and point values, the scoring rules and bands. That JSON is validated against a Zod schema, so a malformed instrument fails loudly at the boundary instead of half-loading. A human reviews the result against the source PDF, because this is clinical scoring and a wrong point value is a wrong diagnosis. The validated JSON is then loaded on every deploy, idempotently, so the database converges to the JSON rather than accumulating drift.

Y-BOCS rendered and scored correctly the same day its JSON went in. No component changed. That is the entire argument for modelling instruments as data instead of code.

Boundaries the machine enforces

Three boundaries in the app are not maintained by good intentions. They are checked in CI by a single shell script, seam-check.sh, and a violation fails the build.

The identity seam

Application code never sees the auth provider. It sees { userId, role } and nothing else. The whole surface of authentication, sessions, tokens, provider quirks, is collapsed to those two fields by the time any instrument-rendering or scoring code runs.

That is enforced by counting imports. next-auth is imported in exactly 3 files. seam-check.sh asserts that count; a fourth file that imports next-auth fails the check and the build goes red. The point is not the number 3. The point is that the boundary cannot erode quietly. Nobody reaches past the seam in a hurry and has it slip through review, because the machine is counting. Swapping the auth provider stays a job contained to three files instead of a concern that has to be chased through the whole app.

The persistence seam

The second seam is the same idea for the database. @prisma/client is imported only under lib/repositories/prisma/. No component, no route handler, no scoring function issues a query directly. They call a repository; the repository talks to Prisma. seam-check.sh enforces this one too, on the same CI gate.

The health-data rules

This is health data, so the storage has rules of its own, and they are checked on the same gate.

Responses are encrypted with AES-256-GCM in the application before any row is written. The key never touches the database; the app holds it through the environment. Each encrypted row carries a keyVersion, which makes rotation additive: a new key gets a new version, new rows use it, and old rows stay readable under their old version without a bulk re-encrypt. Deletion is a cascade-purge. Deleting a user deletes every encrypted row that belongs to them, in one operation, with nothing orphaned behind it.

Consent is versioned, and a bump to the consent schema re-prompts the user rather than assuming the old agreement still holds. And because none of this is a diagnosis, the line "screening only, not a diagnosis" is required on 3 surfaces, checked by seam-check.sh alongside the import counts. The disclaimer cannot be dropped by accident any more than the seams can be crossed by one.

The stack under all this is deliberately ordinary: Next.js 15 (App Router), React 19, TypeScript in strict mode, Postgres on Neon, Prisma 6, Auth.js v5, Radix UI, Tailwind v4, Zod v4, Vitest for tests, PDF output through @react-pdf/renderer, and the Anthropic SDK for the import pipeline. Nothing exotic. The interesting decisions are in the seams and the schema, not the dependency list.

Attempt lifecycle and safety flagging

Two more choices matter here, because both put correctness ahead of the convenient thing: how an in-progress attempt is frozen against edits, and how a dangerous answer raises an alarm.

The definitionSnapshot pattern

When a user starts an assessment, the app creates an Attempt, and the full instrument definition, the whole tree of scales, items, options, rules, and bands, is snapshotted into Attempt.definitionSnapshot, a jsonb column. From that point on, resume, scoring, and PDF generation all read the snapshot. They never read the live instrument rows.

The reason is that the live rows can change, and clinical scoring must not. Suppose a clinician corrects an instrument, fixes a point value or edits a band, while someone is mid-assessment. If scoring read the live rows, that edit would retroactively change the scoring basis for the in-progress attempt, and the final score would be computed against a definition the respondent never actually answered. The snapshot makes the attempt immutable against changes to the instrument. What you started is what you finish and what gets scored, even if the underlying instrument moves the next day.

Safety flagging

SafetyRule, the model row I mentioned back in the schema, is what turns a specific answer into an alert.

Safety rules are evaluated by a pure function against the decrypted responses. It takes responses and rules in, returns which rules fired, and touches nothing else. The one that matters most is on PHQ-9 item 9, the question that screens for thoughts of self-harm. If item 9 is answered above zero, the rule fires and the app sends an operator email over SMTP. The email contains the respondent's name, a timestamp, and a link (the minimum the follow-up person needs to act), but not the raw responses. The person who needs to follow up gets enough to act and no protected health information they do not need.

There is deliberately no crisis interstitial, no full-screen hotline takeover when that item is answered. I left it out on purpose because in this context it reads as paternalistic and does nothing useful; the operator email is the action that actually connects a person to a person.

The close

The whole thing runs in production at $0 a month, on Vercel's Hobby tier and Neon's free tier.

For that, it administers 238 items across 7 validated clinical instruments. It encrypts every response with AES-256-GCM under a key that never reaches the database. It holds two architectural seams that fail CI when crossed, generates a PDF through @react-pdf/renderer, and signs people in with OAuth through Auth.js v5. Adding the eighth instrument is a JSON import.

The paper version of this cost nothing to host either. It cost a pen and 14 pages and the one thing the assessment is measuring the absence of. Building the app took less of that than filling out the form would have.