← All posts

October 29, 2026

One Exam Engine, Three Hosts: Shipping a Shared SDK Behind a Canary

At a Brazilian financial-education company, I work on our exam platform: timed practice exams for a national financial-certification exam, taken on the web, inside the course platform (LMS), and in our Flutter app. This year I took a TypeScript SDK for those exams from an empty repository in June to production in all three by October. I wrote the specs, made the design calls and reviewed every change; most of the code was written by AI agents working from those specs. This post is about what it took to put a shared library into production, which turned out to be a different job from shipping an app.

Five Copies of One Exam

Before the SDK, the exam existed five times. A legacy PHP monolith (CodeIgniter 3) with a jQuery front end, the LMS's React app, a React Native app, the Flutter app and a NestJS proxy each carried their own state machine, navigation, timer, progress persistence, answer serialization and scoring. Together that was roughly 12,800 lines of duplicated logic.

The duplication had stopped being theoretical, and I documented the divergences before writing any code. The Flutter app only counted elapsed time, so it never auto-submitted when the clock ran out. It had none of the exam tools the web had. It graded tree-structured questions as simply right or wrong where the other clients classified the path a student took. Every change to the exam meant three to five reimplementations, each a fresh chance to drift.

The answer was one repository: a framework-agnostic core in plain TypeScript, a React UI on top, an API client with one adapter per backend, and a thin integration layer per host. It was designed for four consumers and is live in three.

The Oldest Host Set the Rules

The PHP monolith serves our production web exams, so it went first. The goal was to replace a stack of PHP partials and a 71 KB jQuery bundle with a single script tag: a UMD build of the SDK with React embedded, so the host installs nothing and only calls a mount function. Three constraints made it hard:

500 Tests, Three Bugs They Could Not See

When the first integration ran end to end, the SDK had more than 500 unit tests, all green, all on synthetic fixtures. The first end-to-end run against a real exam from the real database found three bugs none of them had caught:

  1. The backend sends some flags as strings. In PHP, "0" is falsy; in JavaScript it is truthy. A flag meaning "this is not an open-answer question" was read as the opposite, and the exam refused to advance.
  2. Pressing Enter called the "next" handler directly and skipped the validation a click went through.
  3. The adapter already unwrapped the response envelope, and the bootstrap code unwrapped it again, checking a field that was no longer there. Every submission failed.
// PHP: "0" is falsy. JavaScript: "0" is truthy.
if (question.openAnswer) { /* ... */ }            // wrong for "0"
if (toBoolish(question.openAnswer)) { /* ... */ } // coerce once, at the boundary

None of these is exotic. They are what happens when logic moves between runtimes and the tests describe the data you imagined rather than the data the backend sends. The rule I wrote down that day: extracting a feature means testing against the backend's real response shapes, strings and envelopes included, and fixing at the boundary, where one fix protects every host.

A Parity Catalog, Briefly

"Same behavior everywhere" needs a definition, so I wrote one. Five existing implementations served as references, each authoritative on one axis: the PHP monolith for brand and production web behavior, the Flutter app for mobile UX, the LMS for hierarchy and animation. Wherever they disagreed, the catalog records a "Decision:" entry saying which behavior wins and why. Every rule has an ID that maps one to one to a test, and a coverage check fails the test run if any rule has none. The catalog grew from 104 rules in early September to 373 across 20 areas.

Delivering a Bundle You Don't Want Copied

The bundle is intellectual property, so it is not public. It sits in a private S3 bucket that only CloudFront can read, through Origin Access Control, and the distribution rejects any request without a valid CloudFront signature. Each host's backend, which already knows who the user is, issues that signature.

The real decision was signed cookies over signed URLs. Signed URLs work one file at a time. Our bundle includes an embedded financial-calculator emulator that runs in an iframe and loads its own relative files, requests the host backend never sees, so they would arrive unsigned and get a 403. One signed cookie scoped to a path prefix covers the script, the stylesheet and every sub-request the emulator makes.

Versions are immutable. Each publish uploads the build under a {sha}/ path, cached for a year, along with a manifest mapping every entry file to a sha384 hash. Hosts read the manifest, load the pinned path and set Subresource Integrity on the tags, so a browser refuses bytes that don't match. Only the latest/ lane moves: it carries a five-minute cache and is invalidated on every publish.

Two provisioning choices I'd repeat. I reused an existing distribution with one additive path behavior, diffed against a backup of the live config, with the rollback written before the change. And I drafted a formal IAM permission request before mapping each step to its permission and proving that the two roles I already held covered all of them. The step that cost time: with OAC and public access blocked, S3 answers a missing key with 403, not 404, so it doesn't leak which objects exist. Our publish prefix didn't match the path the behavior forwarded, and a path bug spent far too long looking like a permissions bug.

An Independent Security Review

Before any rollout, the SDK and its backend went through an independent security review. It found real problems:

Two other findings went the other way: one was triaged as dormant, and one was accepted as a recorded risk with a condition for revisiting it. Writing those down was part of the work, not a footnote to it.

The testing pattern I'd reuse anywhere: each finding first became an expected-fail test, 22 of them, documenting the hole. When a fix lands, the expected failure stops happening, the test goes red, and that is the cue to flip it into a normal test.

// Before the fix: documents the hole, passes because the assertion fails.
it.fails('restore drops keys outside the progress schema', () => { /* ... */ });

// After the fix: same body, now a regression guard.
it('restore drops keys outside the progress schema', () => { /* ... */ });

A Canary for Something Live Nowhere

In mid-September the SDK was in production nowhere; all the integration work sat on release branches. Before planning stages, I checked how we would undo them, and found a trap: on the release branch, the PHP exam view had already been rewritten into the SDK's bootstrap. "Turning the SDK off" would not have restored what production ran; it would have dropped those exams onto a generic legacy page. So the canary's first task was restoring the legacy path, with a strict acceptance test: flag off must be byte-equivalent to current production. Later I extended that ruling to every host where the legacy had been removed.

Then the gate. Each host (web, LMS, app) has its own row in a small rollout table, with four modes: off, allowlist, percent, all. Cohorts are deterministic and sticky:

// The gate lives in the PHP backend; the shape is the same in any language.
function sdkEnabled(row: Rollout, user: User): boolean {
  if (row.mode === 'off') return false;
  if (row.mode === 'all') return true;
  if (row.allowlist.matches(user)) return true;
  if (row.mode !== 'percent') return false;
  return crc32(SALT + user.customerId) % 100 < row.percent;
}

Hashing against a threshold makes cohorts monotonic: a student inside 2% stays inside 20%. And because MySQL's CRC32() and PHP's crc32() compute the same checksum, the cohort is reproducible in plain SQL, with no schema change, which let me compare completion rates between the SDK arm and the legacy arm:

SELECT COUNT(*) FROM attempts
WHERE CRC32(CONCAT(@salt, student_id)) % 100 < 20;

The rest of the safety model:

The stages were dark launch, team allowlist, 20%, then 100%. The first draft of the plan held each stage for 48 hours, ended in a blocking week-long soak and ran the hosts strictly in sequence. With instant reversal and sticky cohorts that caution bought nothing, so I sent it back: gates by criterion, not calendar. The one fixed hold was a real evening peak at the canary stage, because that data has no substitute, and app store review moved off the critical path by validating and submitting the app in parallel.

One fact reshaped the runbook late: staging and production share one MySQL database. The rollout row is a single control plane for both, so a staging validation flip becomes a production flip the moment the gate code reaches production. The four-mode validation matrix therefore ran strictly before promotion, and the end-to-end suite that exercises the rollout refuses to run against a production base URL.

On October 16, the gate sat at 2% plus the allowlist, and SDK attempts were about 0.7% of all exam attempts. Small, sticky, and one UPDATE away from zero.

Three Hosts, Three Ways to Fail

Every host got the same rule: never a blank exam.

The app also changed what a release means. Because the exam UI is the CDN bundle, a UI fix reaches installed apps by publishing a new sha, with no store review. Only native plumbing (cookies, manifest fetch, WebView wiring) needs a new binary.

What Shipping Means for a Library

Shipping an app ends with a deploy. Shipping a library ends when every host can run it, turn it off and roll it back without calling you. That changes what the work is: byte-for-byte compatibility with a decoder I didn't write, an adapter for an API I wasn't allowed to change, a legacy path restored and kept honest so that "off" means something, a delivery chain that degrades instead of going blank, and a rollout anyone can audit in SQL.

The SDK is now about 16,800 lines of TypeScript with roughly 1,150 tests, and an exam rule changes in one place instead of five. The part I value most is less visible: in each of the three hosts, the safe state is one UPDATE away, and getting there needs no deploy.