skip to content

Adoption engineering, not software

8 min read Updated:

The persuasion stack that drove adoption of a data tool wasn't narrative: it was coded and enforced server-side. Atomic attempt limits, exam windows, idempotent certificates, and a notification pipeline as a delta job.

In the previous installment I told the story of why adoption of the Commercial Compass, Diveco’s revenue-reporting tool, wasn’t solved by improving the tool, but by wrapping it in an internal business school with a course, an exam, a certificate, and notifications. That story can be read as product narrative. This installment is different: it’s the one that describes how it was built, and the thesis is uncomfortable for anyone who believes persuasion lives in the copy.

When you want a behavior to happen reliably, you don’t ask for it more nicely. You move it from the client (optional, evadable) to the server (enforced).

Adoption engineering is treating the behaviour you want to produce as a system requirement rather than a communication problem: instead of asking for the conduct with better copy or more reminders, you encode it server-side, where the user cannot evade it. An attempt limit stops being a counter in the browser and becomes an atomic condition in the database; a deadline stops being a banner and becomes an authorization the backend grants or denies against the clock. Persuasion, seen that way, isn’t writing: it’s architecture.

Every mechanism in the Diveco Business School I’m about to describe has that same shape: something that in a naive product would live in the browser, as mutable state the user controls, and here lives in DynamoDB behind an atomic condition. The difference between “suggest” and “enforce” isn’t a matter of tone. It’s a matter of where the code runs.

Executable scarcity: how do you cap exam attempts?

The naive pattern for limiting exam attempts is a client-side counter: a state variable, maybe persisted in localStorage. It gets gamed with an F5 or by opening DevTools. That’s not an exotic vulnerability. It’s the first experiment any mildly curious user runs.

The Business School’s quiz uses two phases, reserve and finalize. reserve is the one that matters for scarcity: it burns an attempt atomically against DynamoDB, via a conditional UpdateItem. If the condition fails because no attempts are left, the response is a 403 attempts_exhausted, and the client never even gets to see the questions. There’s no “first check if you can, then subtract one”: it’s a single atomic operation that subtracts and checks in the same step, so two tabs open at once can’t both slip through the same slot.

One detail gives away the intent: abandoned attempts count. If you reserve an attempt and close the tab without finishing the exam, that attempt is already spent. The scarcity isn’t “completed attempts,” it’s “started attempts,” because otherwise the limit would be trivially evadable by closing and reopening until you got lucky through attrition.

Why abandoned attempts count too

finalize is the other half: it grades server-side, on the answers that arrive, not on a score the client reports. Between reserve and finalize there’s no point where the browser has authority over how many attempts remain or what grade was achieved.

Deadline as server state

The same principle applies to urgency. A “2 days left” banner is copy: information the client decides to show, and it stops nothing if the user chooses to ignore it. The Business School’s exam window is different: availableFrom and availableUntil are fields the server evaluates to derive examWindowStatus, with three possible values (open, scheduled, closed). Outside the window, the quiz isn’t shown in some degraded or disabled state: it’s hidden. There’s no path by which a user with the right URL and their network tools open can hit the exam endpoint after hours and get anything other than a rejection.

Urgency, here, isn’t a copywriting technique. It’s an authorization the server grants or denies based on the clock. The “2 days left” in the email (see below) is honest because it describes a state the backend already enforces, not manufactured pressure for the message.

Shareable reward without data leakage

The certificate is the piece that leaves the system and lives in the world: a PDF the person uploads to LinkedIn. That imposes a constraint the other pieces don’t have: it has to be correct exactly once, without duplication, and it has to be verifiable in public without leaking the holder’s personal data.

Idempotency is resolved by key: the certificate is unique per (courseId, userId). It’s issued inside finalize, in the same flow that grades the attempt crossing the threshold, and issuance runs in a try/catch isolated from the rest of the operation. A failure sending through SES never breaks the grade or the attempt’s state. emailSentAt gets stamped only after SES succeeds; if it fails, it stays unstamped and gets retried on the next approved finalize for the same course. It’s a small detail with a big implication: the email system can go down without the user losing their pass, and without the certificate landing in an ambiguous state.

The QR that verifies without leaking the email

The PDF itself is generated client-side with jsPDF (landscape A4, logo, signature, a QR code). Client-side rendering isn’t a concession to the executable scarcity from earlier: the PDF isn’t the source of truth, it’s a projection. The source of truth is the server-side record, and that’s why the QR doesn’t point to an endpoint that returns the full certificate model. It points to a verification with op:'validate', which projects exactly three fields (name, course, date) and never the userId, which in this system is the person’s email. Anyone who scans someone else’s certificate QR confirms it’s legitimate without learning the owner’s email. It’s the same discipline as the earlier sections applied to the inverse side of the problem: not just enforcing server-side what must be enforced, but also deciding server-side exactly what gets exposed when something goes public.

Surgical nudge: who gets notified and who doesn’t?

Notifications are the only piece of this stack that touches the user directly, which is why they’re the easiest to turn into noise if done wrong. The implementation avoids the obvious mistake of notifying everyone equally by treating them as a delta job: enviar_brujula.py scans DynamoDB, computes enrolled − passed to figure out who to remind, and passed to figure out who to congratulate. Nobody gets a reminder for an exam they already passed, and nobody gets congratulated for something they didn’t do.

The script runs in dry-run mode by default: you have to pass --send explicitly to fire real emails. It’s a design decision simple enough to overlook, but it’s the one that stops a typo in a cron job from blasting “you have 2 attempts left” to the entire user base at 3am on a Sunday. The cost of an accidental send in this kind of system (active persuasion, with real scarcity and a real deadline) is higher than in any ordinary transactional notification, because the message actually applies pressure.

And the email content, just like the exam-window banner, describes real pressure: scarcity (“2 attempts max”), a deadline in the subject line, a reward (the certificate, the LinkedIn link). It’s not manipulation dressed up as honesty. It is honesty, because every claim in the email matches a rule the server already enforces somewhere else in the system.

The honest debt

No real system comes without seams, and this series has set out not to hide them. The quiz-attempt-gate Lambda that runs reserve/finalize can’t import code from app/: they’re two different execution environments within the same Amplify repo, and that boundary doesn’t get crossed. The consequence is that lib.ts, inside the Lambda itself, reimplements gradeQuiz and the CERT_THRESHOLD constant used by the rest of the application. Right next to that reimplementation is a comment that says, literally, that it needs to be kept in sync with the original.

That’s real technical debt, the kind a linter doesn’t catch: two copies of the same grading logic, separated by a deployment boundary, held together by a human convention instead of a compiler import. The alternative (extracting a shared package, publishing it, versioning it) would have been the right solution mid-term and obvious over-engineering for a scoped, reversible pilot. Naming the tradeoff instead of hiding it is, of all the decisions in this post, the easiest one to make and the easiest one to skip.

What’s portable outside this system

Every mechanism in this article solves the same problem from a different angle: attempts, time, public reputation, selective attention. And in all five cases the solution has the same shape: move the decision from the side the user controls to the side they don’t. That’s the lesson that’s actually portable outside this system: designing adoption isn’t writing better copy or sending more emails. It’s systems engineering applied to a problem normally treated as if it were marketing’s job.

Enforcing server-side what the client can’t enforce on its own isn’t a new discipline on this blog. It’s the same one I explored, from another angle, in property testing with eris: declare the law in a place no one can evade it, and let the system, not the good faith of whoever uses it, enforce it.