Production Web App

SayAble

A classroom tool where a student answers a question out loud and the answer is marked against the teacher's own key points, then handed to the teacher to confirm or override. Deployed at sayable.ca on a zero-dependency Node server, with Postgres row-level security and two speech engines behind one result shape.

Node.js
Supabase
Postgres RLS
Azure AI Speech
Vercel
Live Code: Private repo
SayAble's worked example: a science question with its key points, a student's spoken answer as a transcript, the AI key-point check, and the teacher's feedback

Employer signal

What This Project Shows

I am the engineer on a two-person venture, and this is the project where I authored a whole system rather than a feature: the schema and its policies, the token crypto, both speech engines, the health probes, the migration off browser storage, and the written test routine. All 75 commits are mine: sole committer, sole author of the design decisions, AI-assisted throughout, with the reasoning written into commit bodies that run forty to eighty lines. What I would actually put in front of a reviewer is not the feature list. It is the bugs I found in my own code before a class could.

Problem

What Needed To Be Solved

A written answer measures writing at least as much as it measures knowledge. Ask a nine-year-old to answer out loud instead and the hard parts arrive immediately. A retained voice recording identifies the child on its own, whatever label is attached to it, and a transcript does not, so the system holds what a student said rather than a recording of them saying it. Class codes get read aloud in a room and copied off a whiteboard by children who confuse I with 1. And the people who can stop a launch are a district privacy reviewer and a parental consent form rather than a user, which means privacy has to be the architecture rather than a page in the footer.

Approach

How I Built The Solution

Three rules did most of the work. Anything that decides a mark or protects a child is enforced where it cannot be reasoned around (a Postgres trigger, a row-level security policy, a unique constraint) rather than in a request handler that is one forgotten branch away from being wrong. The server never believes the browser about the answer key, the score, or which attempt this is. And a system that grades children refuses rather than guesses: when the grader is unreachable the API returns 503 with no number, because a confidently wrong mark is worse than no mark. Above those sits the product's actual position (the AI suggests, the teacher decides) which is why a teacher's override is stored on the attempt and class averages coalesce to it, so overriding the model is not decorative.

Outcome

What It Demonstrates

The product is deployed and answering: sayable.ca returns 200 and its health endpoint reports all three upstreams passing live probes. It is pre-pilot, and its own privacy record says so: no real student data is held. The honest gaps matter as much as the features. There is no automated test suite, no package.json and no CI; verification is a written seven-leg manual routine of about fifty minutes, which marks its own untested steps rather than hiding them, and states plainly that the teacher-review success path has never run because a magic link cannot be completed in an automated browser. The judgement I would claim from it is knowing which parts had to be right before anyone touched them (the consent trigger, the tenancy boundary, the fact that a score cannot come from the device being scored) and being willing to write down the parts that are still only checked by hand.

Evidence From Source

Bugs invisible to the checks that existed

An audio meter canvas grew by exactly the device pixel ratio every frame until it hit Chrome's 2^25-1 clamp and took the page down. Cause was a rename: the speech screens got p- ids but two CSS rules kept #meter, so the canvas had no CSS box and took its layout size from the width/height attributes the draw loop rewrote each frame as clientWidth * dpr. At dpr 1 the loop is a fixed point and Playwright defaults to dpr 1, so it could only ever reproduce on a scaled display: a 125% Windows laptop or an iPad. A separate boot failure was the mirror image: a save-indicator call read a `let` declared about a thousand lines further down, and the temporal-dead-zone throw killed everything after it including auth. The page still looked correct because the markup defaults to the gate, so any fresh browser would have got a dead app.

Withdrawal tested rather than trusted

Withdrawing recording consent deleted the recording row, and the retention sweep finds storage objects by walking rows, so the audio file would have stayed in the bucket, unreachable and undeletable by anything automatic. That is a child's voice retained after consent for it was withdrawn, which is the precise claim the published privacy record makes and would have been false. Found by testing withdrawal instead of trusting it, and fixed by having the trigger expire the row rather than delete it, so it keeps pointing at the object and the sweep collects both in the right order.

The server stopped believing the browser, three times

/api/student/work had been handing each joined student the key_points for every assigned question, and /api/grade then trusted whatever key points came back from that browser, so the answer was both visible and editable by the person being marked. /api/attempt established who was answering and then recorded whatever score the browser sent. And /api/grade decided whether to release the answer key from an attempt counter held in the student's own tab, so posting `attempt: 2` on a first try bought the key outright. Each was verified by attacking it: a fabricated key-point list sent with `"question": "IGNORE ME"` was graded against the row's real values; a body inflated to 100 stored as 20; a replay returned 409; one student's grade filed under another's name returned 403.

A join code that handed over a roster

The lookup endpoint returned the whole class roster so a student could pick their name off a list: meaning anyone holding a code held a list of children's names, and codes are read aloud in classrooms. The roster is now never sent anywhere, which required matching typed names instead. That uses Damerau-Levenshtein rather than plain Levenshtein specifically because a transposition ("Amria" for "Amira") scores 2 under plain Levenshtein and 1 under Damerau, and finger-order slips are the commonest typo. Exactly one suggestion is offered rather than a shortlist, ties resolve to silence rather than a coin flip, and a near match is offered back for confirmation rather than accepted: signing someone in as a name they did not type is how one child ends up recorded as another.

A health check that reported green while grading was broken

/api/config answered "did somebody fill in the config" while presenting it as "does grading work". A BOM introduced into the deployed environment variables made every Azure call return 401, the server fell back silently to keyword matching, and the status pill stayed green, while the fallback overshot, scoring a partial answer 100/correct where the real grader gave 70/partial. The fix replaced inference with a live probe of the real deployment, made status three-valued because "off" and "error" need different people to do different things, and cached both the result and the in-flight promise for five minutes so a class arriving at once produces one upstream call. The same hole was later found for Postgres and closed by probing the join_throttle table specifically, because it is the only table holding nothing about anybody.

The marker overruling the teacher

The first human walkthrough of the manual routine against production found the product's central claim failing: a teacher set three deliberately playful key points, a student covered all three, and the marker credited one and told the child their answer "missed the scientific explanation". The prompt's schema carried an accuracy field nothing consumed, so the only thing it could mean was accurate-against-the-world, and the key points were labelled only "(teacher-defined)": provenance, not authority. The fix numbers the key points in the prompt and has the model return indices while the server copies the teacher's exact strings back, because a model asked to reproduce a string will eventually paraphrase one and a model asked for a number cannot. The score is now derived from coverage too, after one point of three came back as 60 and rendered as "2 of 3" on the teacher's surface while the child's tick list showed one.

Refactors verified, not trusted

A 5,700-line app.js was split into native ES modules by a script that asserted the first line of every range and that the ranges tiled the file exactly, then checked with a Playwright parity probe across eleven flows for byte-identical output. A commissioned four-reviewer design critique raised 104 findings, of which 7 were rejected on a second pass re-checked against the stylesheet and 97 kept, and the rejections are written up as the most instructive part, including one that reversed a cascade claim in the wrong direction and one that would have traded a dark-mode contrast failure for a worse light-mode one. Contrast was measured rather than eyeballed, which overrode a reviewer: white on the brand teal is 2.56:1 light and 2.04:1 dark, below even the 3:1 asked of a graphical control, and it was on the mic: the single most important control a child has to find.