<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: StateKeep</title>
    <description>The latest articles on DEV Community by StateKeep (@statekeep_ad41bae1c031c53).</description>
    <link>https://dev.to/statekeep_ad41bae1c031c53</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3944368%2F33f903c2-5aa2-4919-90be-94dbc326c4f6.png</url>
      <title>DEV Community: StateKeep</title>
      <link>https://dev.to/statekeep_ad41bae1c031c53</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/statekeep_ad41bae1c031c53"/>
    <language>en</language>
    <item>
      <title>We built a statechart hosting platform where two actors in the same state can migrate to different versions — here's why that matters</title>
      <dc:creator>StateKeep</dc:creator>
      <pubDate>Thu, 21 May 2026 15:10:04 +0000</pubDate>
      <link>https://dev.to/statekeep_ad41bae1c031c53/we-built-a-statechart-hosting-platform-where-two-actors-in-the-same-state-can-migrate-to-different-18n1</link>
      <guid>https://dev.to/statekeep_ad41bae1c031c53/we-built-a-statechart-hosting-platform-where-two-actors-in-the-same-state-can-migrate-to-different-18n1</guid>
      <description>&lt;p&gt;If you have built anything with long-running stateful workflows — loan approvals, order processing, subscription lifecycles, insurance claims, onboarding funnels — you have probably hit a wall that nobody talks about cleanly.&lt;br&gt;
You need to change the workflow. But you already have thousands of instances running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem nobody has a clean answer to&lt;/strong&gt;&lt;br&gt;
The standard options are all painful.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Wait for instances to drain naturally. Fine if your workflows complete in minutes. Useless if they run for weeks or months waiting for human approval, document submission, or payment settlement.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Write a migration script. You query your database, move rows between tables, pray nothing is mid-transition, and hope you did not accidentally re-trigger a side effect for 40,000 customers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Keep old code running forever alongside new code. Now you are maintaining two versions of your business logic indefinitely, and the operational complexity compounds with every release.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Temporal's approach: version markers in your workflow code. This works, but it means every code change requires careful getVersion() calls throughout your workflow function, and a non-determinism error on a long-running production workflow is a genuine incident. We have seen threads from teams where a change they believed was backwards-compatible broke in rare production scenarios after deployment.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;None of these answers are wrong exactly. They are just the best available options in a space where the fundamental problem — migrating running stateful instances to a new version of their logic — has never been solved cleanly.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;What we built&lt;/strong&gt;&lt;br&gt;
StateKeep is a statechart hosting platform. You upload an XState-compatible machine definition, spawn actors against it, and send events. StateKeep handles persistence, event history, encryption at rest, and version migration.&lt;br&gt;
The part that is different: when you deploy a new version, each running actor migrates based on its event history fingerprint — not its current state.&lt;br&gt;
Every actor carries a compact hash of every event type it has processed, in order. When you deploy a new version with a historyPath, the platform checks each actor's fingerprint against the path you declared. Actors whose history contains that path migrate. Actors whose history does not contain it stay on the current version.&lt;br&gt;
The consequence: two actors in the same state can receive different migration decisions in the same deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The concrete example&lt;/strong&gt;&lt;br&gt;
A loan application workflow. Two customers, Alice and Bob. Both are currently in awaiting_documents.&lt;br&gt;
Alice paid the verification fee to get there. Bob waived it.&lt;br&gt;
You deploy a new version that adds an income verification step — but only for customers who paid the fee, because that is the regulatory requirement for that path.&lt;br&gt;
You declare:&lt;br&gt;
&lt;u&gt;json&lt;/u&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"loan-v2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"parentId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"loan-v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"historyPath"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"START_APPLICATION"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SUBMIT_INFO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PAY_FEE"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"definition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The platform evaluates every actor. Alice's history contains that path. She migrates to loan-v2, landing in the new income_verify state. Bob's history does not contain PAY_FEE. He stays on loan-v1, continuing to awaiting_documents as before.&lt;br&gt;
Both actors keep working. Neither restarts. Neither loses context. No migration script was written. No side effects were re-fired. The decision was made per-actor, based on history, in under a second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this looks like in practice&lt;/strong&gt;&lt;br&gt;
Deploy a new version targeting a specific path:&lt;br&gt;
&lt;u&gt;typescript&lt;/u&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@statekeep/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://your-instance.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sk_...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Deploy v2 — only actors who paid the fee are eligible&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loan-v2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loanV2Definition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;parentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loan-v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;historyPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;START_APPLICATION&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUBMIT_INFO&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PAY_FEE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Deploy a wildcard version — all actors migrate&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order-v2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orderV2Definition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;parentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order-v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// no historyPath = all actors eligible&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Preview what will happen before committing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;preview&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loan-v2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loanV2Definition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;parentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loan-v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;historyPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;START_APPLICATION&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUBMIT_INFO&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PAY_FEE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;migration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wouldMigrate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 1,203 actors&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;migration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wouldStay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// 847 actors&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The preview calls the exact same evaluation function as the live deployment. What you see is what will happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What StateKeep does and does not do&lt;/strong&gt;&lt;br&gt;
StateKeep is a state tracker, not a side effect executor. It does not run your action handlers or evaluate your guards.&lt;br&gt;
Guards (guard: 'isEligible') are stubbed to false — guarded transitions never fire. Actions (actions: 'sendEmail') are no-ops — state changes but nothing executes. Your backend reads the new stateValue from the event response and handles side effects in its own code.&lt;br&gt;
This is intentional. It means migration never accidentally re-fires side effects. An actor migrating from v1 to v2 does not trigger emails, charges, or notifications — because StateKeep never ran any of those in the first place.&lt;br&gt;
The supported pattern: model routing decisions as explicit events rather than guards. Your backend evaluates the condition and sends APPROVE_FAST_TRACK or APPROVE_STANDARD. The machine routes deterministically from there. No guards needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rescue deployments&lt;/strong&gt;&lt;br&gt;
When a buggy version reaches actors before you catch it, you deploy a rescue version targeting only the actors whose history includes the buggy path:&lt;/p&gt;

&lt;p&gt;&lt;u&gt;typescript&lt;/u&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loan-v2-rescue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fixedDefinition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;parentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loan-v2-buggy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;historyPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;START_APPLICATION&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUBMIT_INFO&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PAY_FEE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TRIGGER_BUG&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only actors whose history contains TRIGGER_BUG migrate to the fix. Everyone else is unaffected. No system-wide freeze. Forward-only. No rollback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The audit trail&lt;/strong&gt;&lt;br&gt;
Every routing decision is logged. For every actor evaluated in a deployment, there is a record of: which version it was on, which version it moved to (or why it stayed), its history fingerprint at decision time, and the registered prefix hash it was compared against.&lt;br&gt;
GET /v1/actors/:id/decisions returns the full routing history for a single actor. When a customer asks "why didn't my application get the new income verification step," the answer is in the database, not in a support ticket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Early access&lt;/strong&gt;&lt;br&gt;
We are at early access stage. The platform is running on a VPS, 432 tests passing, real migration engine deployed.&lt;br&gt;
We are specifically looking for developers who have hit the workflow migration problem in production — people who have written migration scripts they were not happy with, people who have hit non-determinism errors on Temporal after a versioning change, people who have kept old workflow code running forever because they had no other option.&lt;br&gt;
Free access, no strings attached. We want honest feedback from people who understand the problem space. If that is you, reach out at &lt;u&gt;&lt;a href="mailto:statekeep.support@gmail.com"&gt;statekeep.support@gmail.com&lt;/a&gt;&lt;/u&gt; with a sentence about what you are building. We will get you set up.&lt;br&gt;
We are not looking for validation. We are looking for the edge cases we have not thought of yet.&lt;/p&gt;

</description>
      <category>workflow</category>
      <category>statemachine</category>
      <category>backend</category>
      <category>node</category>
    </item>
  </channel>
</rss>
