08

Shipping Safely

A build retrospective on the adaptive knowledge graph behind Amal, a kids' Arabic-learning app used by 95,000+ families. • Day 8 is the engineering discipline: how to drop a brand-new engine underneath a live app used by tens of thousands of families without a single child seeing a broken screen.

The risk: swapping the engine under a live app

By Day 7 there are two ways Amal can build a child's lessons: the new graph engine (frontier → tree → generated bytes) and the old legacy path (a static, pre-built lesson tree). The scary part is that the app is already live for 95,000+ families. You cannot just delete the old path and turn on the new one and hope. If the new engine has a bug, real children — mid-lesson, mid-word — hit it.

So the whole of Day 8 is one principle: make the switch reversible, invisible, and safe by default. The child's app must not be able to tell which engine produced its lesson, the switch must be flippable without a redeploy, and any failure in the new path must fall back to the old one rather than show an error. Three mechanisms deliver that: a registry seam, a feature flag, and fail-closed fallback.

The diagram shows the one place the decision is made: a registry seam picks legacy or graph based on a flag, and if the graph path errors it silently returns legacy. The child gets a lesson either way.

   THE SWITCH (reversible, invisible, safe)

   request for a child's lesson
             │
             ▼
      ┌─────────────────┐   flag OFF ──► legacy static tree  ┐
      │ generator_      │                                    │ SAME
      │ registry (seam) │   flag ON  ──► graph engine        │ output
      └─────────────────┘        │          shape
                                 └── on ANY error ──► legacy (fail-closed)

The IoC seam: `generator_registry`

The first mechanism is a registry — a small lookup that decides, at request time, which lesson generator to use — called generator_registry. It is an example of Inversion of Control (IoC): instead of the app hard-coding "call the legacy builder", the app asks the registry "give me the current generator" and calls whatever it hands back. Control over which implementation runs is inverted out of the caller and into the registry.

Why this matters for safety: the caller — the request handler that builds a child's lesson — never changes. It always does the same thing ("ask the registry, call the result"). Swapping engines is just swapping what the registry returns, in one place, behind a stable interface both engines implement.

# the caller is engine-agnostic: it just asks the registry
generator = generator_registry.get()   # legacy OR graph, decided here
byte = generator.build(concept, child) # SAME call, either way

Because both the legacy builder and GraphByteGenerator expose the same build(...) interface, the registry can return either and the caller is none the wiser. That single indirection is what turns "rewrite the engine" into "change one lookup" — the precondition for everything else in this day.

The feature flag: graph ON, legacy OFF, byte-identical

The second mechanism is a feature flag — a runtime on/off switch that selects behavior without shipping new code. The flag tells generator_registry which generator to hand out: ON routes to the graph engine, OFF routes to the legacy static tree. Because it is runtime, you can flip it for a small group, watch, and flip it back in seconds if anything looks wrong — no app-store release, no redeploy.

The non-negotiable requirement on the flag is that the two paths are byte-identical in shape: whatever the child's app receives must have the exact same structure whether it came from the graph engine or the legacy tree. Same fields, same lesson shape, same contract. If ON and OFF produced different shapes, flipping the flag would itself be a risky change; making them identical is what makes the flag boring to flip.

  • Flag OFF (default): registry returns the legacy generator → the app behaves exactly as it always has.
  • Flag ON (for a chosen cohort): registry returns GraphByteGenerator → the app gets graph-generated bytes in the same shape as before.
  • Byte-identical guarantee: the output shape does not depend on the flag, so the app cannot tell the difference and no client change is needed.

Fail-closed: an error becomes legacy, never a 500

The third mechanism is fail-closed behavior — when the risky path fails, the system falls back to the safe path instead of surfacing the failure. Here, if the graph engine throws any error while building a byte (a missing asset, a bad edge, an unexpected state), the registry path catches it and returns the legacy lesson instead. The child never sees a 500 error screen; they see a normal lesson, just from the old engine.

The diagram shows the safety net: the graph path is tried, and on any exception the code quietly serves legacy. "Fail-closed" is the opposite of "fail-open" — a fail-open system would let the error through and break the screen. Choosing fail-closed means the worst case of the new engine is "this child temporarily got a legacy lesson", not "this child saw a crash."

   FAIL-CLOSED (the safe default when the new path breaks)

   flag ON ──► graph engine.build()
                    │
                    ├── success ──► graph byte  (great)
                    │
                    └── throws  ──► catch ──► legacy byte  (still fine)

   the child ALWAYS gets a lesson; an error degrades, never breaks

Beta cohort and one unchanged contract

The last two pieces are about who gets the new path and how little else changes. Rolling out to everyone at once, even behind a flag, is more risk than needed, and changing the app to match a backend change doubles the surface area of what can break.

  • Beta cohort first. The flag is turned ON only for a small beta cohort — a chosen subset of families — before any wider rollout. If something is wrong, it is wrong for a handful of users you are watching closely, not for all 95,000+. Widen the cohort only once the narrow one is clean.
  • Same frontend contract — a backend-only swap. The entire engine change lives behind the API. The app (the frontend) calls the same endpoints and receives the same shapes as before; nothing in the client is aware the engine changed. That is what "byte-identical" buys you — the swap is backend-only, so there is no coordinated app release to get wrong.

The diagram sums up the strategy: the only thing that moved is a backend flag guarding a registry, exercised on a small cohort, with legacy as the floor. Everything a child's app does, and every other family, is untouched. Day 9 tells the story of a real bug this safety net let the team catch and fix without anyone getting hurt — the moment the adaptivity signal turned out to be lying.

   BLAST RADIUS, DELIBERATELY TINY

   frontend (app)        UNCHANGED  ── same endpoints, same shapes
        │
        ▼
   backend flag + registry   ← the ONLY thing that changed
        │
        ├─ beta cohort: graph engine (fail-closed to legacy)
        └─ everyone else: legacy, exactly as before

Key takeaways

  • Dropping a new engine under a live app is dangerous, so the rule is: make the switch reversible, invisible, and safe by default — delivered by a registry seam, a feature flag, and fail-closed fallback.
  • generator_registry is an Inversion-of-Control seam: the request handler asks the registry for a generator and calls whatever it returns, so swapping engines is a one-place change behind a stable build(...) interface both engines share.
  • A runtime feature flag routes ON → graph engine, OFF → legacy, and the two paths are byte-identical in shape, so flipping the flag needs no client change and can be reversed in seconds.
  • Fail-closed means any error in the graph path is caught and served as a legacy byte — the child sees a normal lesson, never a 500; the worst case degrades instead of breaking.
  • Rollout is a beta cohort first behind an unchanged frontend contract, so the change is backend-only with a deliberately tiny blast radius.

Checklist

  • [ ] I can state the three properties a safe engine swap needs (reversible, invisible, safe by default) and name the mechanism for each.
  • [ ] I can define Inversion of Control and explain how generator_registry keeps the caller engine-agnostic.
  • [ ] I can explain what "byte-identical" means and why it makes the feature flag safe to flip.
  • [ ] I can define fail-closed and contrast it with fail-open using the graph-vs-legacy fallback.
  • [ ] I can explain why a beta cohort plus an unchanged frontend contract keeps the blast radius small.