Guide

The type, the fixture, and the render stop agreeing

Data model drift is what happens when a vibe-coded app's data shape moves faster than the code reading it. mk0r's answer is not a context system or a rules file. It is one git command per turn, and one git command for undo.

M
Matthew Diakonov
8 min read

Direct answer (verified 2026-05-09)

Data model drift in a vibe-coded appis when the data shape (TypeScript types, seed fixtures, localStorage keys, JSON shapes your handlers read and write) changes faster than the components and storage that consume it, leaving stale data, broken renders, and orphan fields between iteration turns. mk0r's mitigation is structural: every successful turn commits the whole working tree as one git SHA via git add -A in commitTurn (src/core/e2b.ts:1771), and undo runs git checkout sha -- . (src/core/e2b.ts:1831), so types, fixtures, and consumers always move together. Source: src/core/e2b.ts on GitHub.

A four-turn drift, walked through

Drift is concrete. To get away from the abstract advice that fills most articles on this topic, here is a sequence I have watched happen in real iteration sessions, and what each turn does to the data shape.

1

Turn 1: 'a todo list with checkboxes'

The agent writes Todo as { id: string, text: string, done: boolean } in src/types.ts, a fixture array in src/lib/seed.ts, and a TodoList component that maps over it.

Three files touched. One coherent shape. Renders fine.

2

Turn 2: 'add a priority field, high or normal'

The agent edits the Todo type, edits the fixture, and adds a small badge to the TodoList. So far so good. The shape moved by one field.

If iteration stopped here, you would not have drift. You almost never stop here.

3

Turn 3: 'rename text to title and let me reorder them'

Now src/types.ts says { id, title, done, priority, order }. The fixture moves. The TodoList component moves. But the Add Todo handler at src/components/AddTodo.tsx still writes { text } from a previous turn the agent never re-read. The first add silently writes a stale shape.

This is the moment the type, the fixture, and the consumer stop agreeing. Drift has started.

4

Turn 4: 'why are new todos rendering blank?'

The agent re-reads files, finds the AddTodo handler still writing 'text', fixes it. But the localStorage that the persistent Chromium profile saved during turn 3 already holds three records with the old shape. New renders crash on todo.title.toUpperCase(). You debug.

At this point most builders ask you to undo, regenerate, or paste back the old version. mk0r does something different.

The git command that bounds the cost

Here is what runs inside the E2B sandbox after every successful turn. The path and line numbers are not decorative; you can open the file and see them.

commitTurn (src/core/e2b.ts:1759)

The single load-bearing flag is -A. It stages every modified, added, or deleted file under /app at once. Turn 3 edited src/types.ts, src/lib/seed.ts, src/components/TodoList.tsx, and forgot src/components/AddTodo.tsx. All four states (the three that moved and the one that did not) collapse to one SHA. There is no commit where half-migrated is the persisted state.

What undo actually restores

undoTurn (src/core/e2b.ts:1855) walks the historyStack back one step and runs revertToSha (line 1815), which is git checkout sha -- . followed by an empty-allowed commit so the new state is itself a SHA. Here is the difference between what survives the undo and what does not. Be honest about both.

Survives undo (whole tree restore)

  • src/types.ts (the shape)
  • src/lib/seed.ts (the fixtures)
  • src/components/AddTodo.tsx (the writer)
  • src/components/TodoList.tsx (the reader)
  • Any new file the turn added (orphan or otherwise)
  • Any file the turn deleted (recreated by checkout)

Does not survive undo (out of git's reach)

  • localStorage written before the undo
  • Anything the running React app holds in memory
  • Open WebSocket subscriptions to a now-removed endpoint
  • External writes (Resend audiences, Neon rows) made mid-turn

The pattern: undo restores the code, not the world the code touched. If a turn wrote rows to Neon or sent a Resend email, those persist past the undo. Treat external state as a separate timeline.

1 SHA / turn

Per-turn means undo is one click. Per-session means undo is a reset. That single difference is what makes a data shape change recoverable instead of lossy.

src/core/e2b.ts:1771

The fork-on-undo pattern (what changes about the workflow)

When you have per-turn SHAs and an activeIndex on the session, undo is not destructive. The historyStack still holds the abandoned branch. Three concrete moves I rely on when a turn introduced a shape I am uncertain about:

  1. Name the drift in the next prompt. “After this change, every consumer of Todo must read the new shape. Audit src/components and src/hooks for uses of todo.textand update them.” The agent re-reads, fixes the consumers, recommits. One SHA forward.
  2. Undo and try a smaller turn. “Add only the order field for now, do not rename text.” You walk the stack back one step, then forward in a different direction. The bigger change is still in the stack if you need to go back to it.
  3. Fork. Undo two steps, prompt a different shape entirely. The original branch sits in the historyStack as a sequence of SHAs you can checkout if the new direction is worse. Compare the two by switching activeIndex.

The move I avoid is the long restating prompt that asks the model to recreate a previous version from memory. That is itself a source of drift. The model gets it close, not exact, and the next turn drifts off the close-but-not-exact shape.

The honest limit: external state is a different timeline

Per-turn SHAs solve drift inside the working tree. They do not solve drift in systems the working tree wrote to. mk0r can provision Neon Postgres, Resend audiences, and PostHog projects from inside the VM (the provisioning hooks live in src/core/service-provisioning.tsand the agent learns about them from the project's CLAUDE.md). When a turn runs a Drizzle migration against Neon, the columns are real and persistent. Undoing the turn rolls back the migration file, not the database.

The mitigation we lean on: the agent is instructed to keep the canonical schema in src/db/schema.ts and write idempotent migrations, so the source file is on the same SHA timeline as the rest of the code even when the live database is not. If you intend to ship the app, plan for the database timeline to outlive any individual undo. If you are prototyping, prefer in-memory or localStorage state until the shape settles, and only provision Neon when the data model has stopped moving.

A reasonable rule of thumb: if you are still iterating on the type, you are too early to put it in a real database.

What competing advice gets wrong

Most articles on this topic frame drift as a context-management problem and recommend a rules file, a project spec, or a stricter prompt template. Those are useful but they are upstream advice: ways to reduce the rate at which the agent introduces a drift in the first place. They do not change the cost of recovery when drift happens anyway. In a vibe-coded session, drift will happen anyway.

The cost of recovery is what determines whether you keep iterating or restart. A high recovery cost (rewrite the prompt, regenerate the project, paste back the version you wrote two prompts ago) makes you converge to the first acceptable output and stop swinging. A low recovery cost (one undo, one SHA back) makes you take more swings, which is what the iteration loop is supposed to enable. The git substrate is what makes the cost low.

Want to walk a real drift session with us?

15 minutes, your prompt, our VM. We will iterate a small app, intentionally drift the shape on turn 3, and recover with one undo.

More on data model drift in mk0r

What is data model drift in a vibe-coded app, in one sentence?

It is when the data shape (the TypeScript type, the seed fixture, the localStorage key, the JSON your handlers read or write) moves faster than the code consuming it, so old data, old renders, and new code stop agreeing. In practice it shows up as 'why is this row blank,' 'why does Add throw,' or 'why does refresh wipe it.' The cause is almost always a turn that updated the shape in one file but missed a consumer that still expects the prior shape.

What does mk0r actually do about it?

It treats every iteration turn as a single atomic git commit. commitTurn at src/core/e2b.ts:1759 runs 'git add -A && git commit -q -m ...&& git rev-parse HEAD' inside the VM. The 'git add -A' stages every modified, added, or deleted file at once. So a turn that changes Todo in src/types.ts also captures the fixture rewrite, the consumer fix, the new component, and the deletion of the dead one as a single SHA. There is no half of a turn. Either the whole shape change lands or none of it does.

How does that prevent drift if the agent still forgets a consumer?

It does not prevent the forgetting. It bounds the cost of recovery. If turn 3 missed AddTodo, you can ask 'undo'. undoTurn at src/core/e2b.ts:1855 walks the historyStack back one step and runs 'git checkout <previous-sha> -- .' (revertToSha at line 1815). The whole tree restores: the type, the fixtures, the consumer, the renamed file, and the new component all snap back to a coherent shape. Without this, undo has to be 'please write back the version you wrote two prompts ago,' and the model gets it close but not exact, which is itself a source of drift.

What survives an undo, and what does not?

Source files survive, every byte of them, because git is restoring the working tree. What does not survive: localStorage written by the running app between commits, in-memory React state, external side effects like rows written to Neon or contacts added to a Resend audience. If the agent provisioned a database during turn 3 and undo brings the schema migration back, the rows you wrote in between are still there with the new shape. The honest framing is: undo restores the code, not the world the code touched.

Where does localStorage fit into all this?

Each project's Chromium runs with a persistent profile at /root/.chromium-profile/ inside the VM (vm-claude-md.ts:276), and localStorage persists within the session. That is what makes the drift painful in turn 4 of the example above: the old-shape records are still there after the type changed. The clean recovery is to reload after an undo, which means most state in localStorage is treated as soft. If you ask for hard durability, the agent provisions Neon and the data stops being your problem and starts being your schema-migration problem.

Is this just git? Could you do this with any vibe coding tool that has git?

Most builders that 'have git' commit at session checkpoints, not at every turn. The difference matters. Per-turn means undo is one click; per-session means undo is a reset. The historyStack and activeIndex on the session document (src/core/e2b.ts:99-102) also let you fork: undo three turns, prompt a different direction, and the original branch is still in the SHA list. We use the fork-on-undo pattern when a turn introduced a shape we are not sure about and want to compare two versions.

What about external schemas, Neon and Resend, where mk0r can provision them?

Those are out of git's reach by design. The provision_database, provision_audience, and related actions are documented in vm-claude-md.ts and write to real third-party services. A schema migration the agent runs against Neon during turn 3 will not undo when you undo turn 3 in-app; the row is in Neon. The agent's CLAUDE.md tells it to mirror schema changes back into the Drizzle migration files in src/db/, so at least the source-of-truth schema is on the same SHA timeline as the rest of your code, but the live database is not. Treat external state as a separate timeline and write idempotent migrations.

What is the practical workflow when I notice drift mid-iteration?

Three options. First, name it in the next prompt: 'after this change every consumer of Todo must read the new shape, audit src/components and fix.' The agent re-reads files and patches consumers. Second, undo and try a smaller turn: 'add only the order field, do not rename text.' Third, fork: undo two steps, prompt a different shape entirely, and compare. The historyStack keeps the abandoned branch around. The path you should not take is the long, restating prompt that asks the model to recreate the old version from memory.

How do I see the SHA history of a session?

The history endpoint exists at src/app/api/chat/history. It reads session.historyStack from Firestore and returns the ordered SHAs with their commit messages. The chat UI shows it as a per-turn timeline you can revert, undo, and redo against. activeIndex tells you which SHA is currently checked out, which matters after a chain of undos and redos because the live working tree may not be the latest SHA in the stack.

Does this also help when the agent introduces an orphan component?

Yes, but indirectly. The orphan-component bug is a subset of drift: a new file lands but is not imported anywhere, so it has no consumers. 'git add -A' captures the orphan in the SHA, which means undo reliably removes it instead of leaving a dead file behind. The agent's CLAUDE.md (built into the E2B template) instructs it to import every new component into src/App.tsx and verify the DOM rendered via Playwright after a UI change. The mitigation is a habit, the recovery is the SHA.

mk0r.AI app builder
© 2026 mk0r. All rights reserved.