← Research Labs
A decision reversed, and why

We argued against the rewrite. Then we counted.

2026-08-25 · commits 753e90c, af8af94, ed21eeb · 3 min read

Check the work

Highlight every sentence in this post that is backed by a real commit, board entry or measurement. The ones that stay plain are analysis, not evidence.

A plan to skip a framework migration was wrong for a reason that had been checkable the entire time. Nobody had checked it.

This is an engineering decision published for a reason that isn't engineering: the argument against migrating was careful, specific, and confidently reasoned, and it fell apart the moment anyone counted. That pattern is not unique to code.

What we thought was true

The plan said the product didn't need a framework yet: “the v1 planner is a form plus an array of editable objects; vanilla JS handles that in a few hundred lines.” That is a real, defensible claim about the increment, the one screen about to be added. It never measured the base that screen would be landing on top of.

What was actually true

One file. 903 lines, 699 of them inline JavaScript, and 178 direct DOM manipulation calls. The threshold the plan claimed we had not reached had been crossed some time earlier, and nobody noticed, because measuring it had never been anyone's task.

It measured the increment. It never measured the base.

The part worth reading if you aren't an engineer

Those 178 call sites were assembling HTML by hand out of two things: language-model output, and text scraped from third-party websites. Untrusted content by definition, sitting on the exact data path this product exists to handle. A modern framework escapes that content by default. Hand-built HTML strings do not.

That single fact moved the migration out of the category of developer preference and into the category of risk. If you are evaluating any tool that reads the open web on your behalf and renders what it finds back to you, that is a fair thing to ask its makers about directly.

The reversal

Two other consequences fell out of the same number. Deferring meant building the planner twice, once in vanilla JS now and again after a port that was going to happen eventually regardless, so the cheaper option was the more expensive one. And the migration never actually competed with that week's other priority the way the original argument assumed: the customer conversations were the founder's hours, the port was an engineer's. Parallel tracks, not a queue.

The decision was Next.js, TypeScript and Tailwind on the front, with FastAPI replacing a hand-rolled request handler behind it. And one deliberate exception: the research and brief engine, 2,129 lines of working, verified Python, was not ported. That engine is the actual product. Rewriting proven code to make a stack look uniform is not a business reason.

The port ran under stated conditions rather than good intentions. Migrate on a branch. Keep the main branch deployable throughout. Never mix engine changes and stack changes in one commit. Require the verification script, route parity included, to pass before cutover rather than after.

How we verified it

Ported on 2026-08-23, merged on 2026-08-25, with the deploy configuration landing the same day. Four days from writing the reversal down to running it in production, verification green through the cutover rather than checked afterward. The page you are reading is the output of that decision, not a description of one.

What to take from it

The failure was not reaching the wrong conclusion. It was a confident argument that never checked its own load-bearing number, and “a few hundred lines” was quietly doing all the work in that sentence.

The original argument is still sitting in the planning document above its own correction, not quietly edited out. That is the same standard we are asking a buyer to hold us to on the product itself: the claim, and what it was based on, both stay checkable after the fact.

What this post does not establish

  • Whether the untrusted content on those 178 call sites was ever actually exploited. No evidence of exploitation was found, and none was looked for. The migration removed the risk surface rather than proving it had been used.
  • What the port cost in hours. It was never tracked, so any figure quoted here would be invented.
  • Whether the same reasoning error, sizing an increment without sizing its base, is currently sitting in other decisions in this repo. Almost certainly yes, and no audit has been run.

Evidence

  1. 1
    Planning doc
    team/SPRINT-W3-CHARTER.md §7

    the v1 planner is a form plus an array of editable objects; vanilla JS handles that in a few hundred lines.

  2. 2
    Measurement
    team/SPRINT-W3-CHARTER.md §7, measured against web/index.html

    | `web/index.html` | 903 | 699 | 178 | (total lines | inline JS | direct DOM calls)

  3. 3
    Planning doc
    team/SPRINT-W3-CHARTER.md §7

    `innerHTML` string-building is rendering LLM output and scraped page content, untrusted data by definition, on 178 call sites. React escapes by default. This is a security argument, not an ergonomic one, and it lands on the exact data path this product exists to handle.

  4. 4
    Planning doc
    team/SPRINT-W3-CHARTER.md §7

    core/ engine: Untouched. Not rewritten, not ported. 2,129 lines of working, verified Python. This is the actual IP.

  5. 5
    Commit · 2026-08-23
    753e90c

    W3 §7 Days 1-2: Next.js port of the three named screens

  6. 6
    Commit · 2026-08-25
    af8af94

    Merge maya/w3-nextjs-port: Next.js frontend + landing page revamp

  7. 7
    Commit · 2026-08-25
    ed21eeb

    Deploy config: vercel.json wires Next.js + Python into one project

← Back to Research Labs