<?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: Anton Brilliantov</title>
    <description>The latest articles on DEV Community by Anton Brilliantov (@anton_brilliantov).</description>
    <link>https://dev.to/anton_brilliantov</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4056475%2F7d56fe3c-096c-42b1-88dd-e2ea7367624d.png</url>
      <title>DEV Community: Anton Brilliantov</title>
      <link>https://dev.to/anton_brilliantov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anton_brilliantov"/>
    <language>en</language>
    <item>
      <title>Breaking the Monolith - Part 1: A 200 OK That Saved Nothing</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Mon, 10 Aug 2026 10:09:37 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/breaking-the-monolith-part-1-a-200-ok-that-saved-nothing-16ef</link>
      <guid>https://dev.to/anton_brilliantov/breaking-the-monolith-part-1-a-200-ok-that-saved-nothing-16ef</guid>
      <description>&lt;p&gt;&lt;em&gt;How a single spurious &lt;code&gt;was_updated&lt;/code&gt; exposed a hidden rule about ownership - when a system is split, exactly one place is allowed to own identity. This is the expanded cut of Part 1: the debugging and the fix, plus the piece the first version only gestured at - the staged migration this bug lives inside, and where the seam goes right after it.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go. For a while now I've been on the kind of project that teaches you the most: carefully breaking a large PHP monolith into Go microservices while it's still very much alive and serving a real business. This is an expanded re-cut of Part 1 - same bug, same fix, but with the migration map drawn in properly, because half the lesson is &lt;em&gt;which stage&lt;/em&gt; you're standing in when the ground moves. The running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;. No hype, just the real work.&lt;/p&gt;

&lt;p&gt;A teammate pinged me with a vague, ordinary-sounding report: domain markup for a region was coming back &lt;strong&gt;empty&lt;/strong&gt; in a snapshot. Not an error, not a stack trace - just &lt;em&gt;wrong output&lt;/em&gt; a human noticed because the business process it feeds looked off. I reproduced the write against production and the API told me, cheerfully, that everything was fine - &lt;code&gt;200 OK&lt;/code&gt;, &lt;code&gt;action: was_updated&lt;/code&gt;, a response body carrying the correct, freshly computed rule set. And yet nothing had changed in the database. &lt;code&gt;updated_at&lt;/code&gt; frozen. The downstream materialized count flat. A silent no-op wearing a success badge.&lt;/p&gt;

&lt;p&gt;That one bug turned out to be the perfect lens on the whole migration I'm living in - but you can't see why until you can see the &lt;em&gt;stages&lt;/em&gt;. So before the code, let me draw the map.&lt;/p&gt;




&lt;h2&gt;
  
  
  The migration, in stages
&lt;/h2&gt;

&lt;p&gt;A big-bang rewrite is a bet you make once and lose slowly. The alternative - the one that actually works on a live business - is a strangler fig: move the seam &lt;strong&gt;one named stage at a time&lt;/strong&gt;, and never let two migrations run at once. Here is the seam this whole series lives on - the rule-set write path - laid out as stages, with a marker on where &lt;em&gt;this&lt;/em&gt; bug happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stage 0  Monolith only.
         PHP computes the cascade, persists the rule data, owns identity implicitly.

Stage 1  Identity extracted.          &amp;lt;-- THIS ARTICLE
         A small Go service becomes the immutable, content-addressed master of
         identity (id + hash). PHP still computes and persists the rule DATA.
         The bug: the monolith and the new identity master quietly DISAGREE.

Stage 2  Write logic extracted.       (Part 1.5 — next)
         The cascade is re-implemented in a stateless Go service that writes
         directly into the monolith's DB. Two engines run in parallel.

Stage 3  Prove they agree.
         An agent drives both engines and diffs persisted truth, case by case.

Stage 4  Flip, then delete.
         Promote the Go engine, retire PHP, and only then move the DATA out.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fepi4w9dw80l8oxt4dhjg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fepi4w9dw80l8oxt4dhjg.png" alt=" " width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This article is Stage 1 - the first moment a &lt;em&gt;second system&lt;/em&gt; has an opinion about identity. Everything downstream (the parallel run, the cutover) is only safe if this stage is honest. It wasn't yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Context: what this is and how it works
&lt;/h2&gt;

&lt;p&gt;Strip away the specifics and the domain is simple. The system stores &lt;strong&gt;classification rules&lt;/strong&gt; - patterns that tag web domains with a meaning ("this domain is ours," "this one is a competitor," "this one is irrelevant"). A &lt;strong&gt;rule&lt;/strong&gt; is roughly &lt;code&gt;{pattern, type, match-mode, priority}&lt;/code&gt;. A &lt;strong&gt;rule set&lt;/strong&gt; is an ordered collection of those rules attached to one node in a hierarchy.&lt;/p&gt;

&lt;p&gt;The hierarchy has three levels, and it &lt;strong&gt;cascades&lt;/strong&gt; top-down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CLIENT            rules here apply to everything beneath
  └── PROJECT     rules here apply to every config of the project
        └── CONFIG   a specific target (search-engine × device × locale)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzpfea14o9jgebbrmueo7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzpfea14o9jgebbrmueo7.png" alt=" " width="799" height="329"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Writing a rule set at a parent level doesn't just save that node - it &lt;strong&gt;fans out&lt;/strong&gt;: the engine recomputes every descendant's &lt;em&gt;effective&lt;/em&gt; rule set by merging what it inherits from above with what it owns locally, with a clear precedence (a narrower level overrides a broader one). The per-node result of that merge is the &lt;strong&gt;materialized state&lt;/strong&gt;: the fully-resolved set of rules a given config &lt;em&gt;actually&lt;/em&gt; sees at request time. That snapshot is what the business runs on.&lt;/p&gt;

&lt;p&gt;Two concepts matter for the bug:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;origin&lt;/code&gt;&lt;/strong&gt; - a label on each rule in a materialized set saying &lt;em&gt;which level it came from&lt;/em&gt; (client / project / own). Descriptive metadata about the cascade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;identity&lt;/strong&gt; - the answer to &lt;em&gt;"is this the same rule set or a different one?"&lt;/em&gt; Historically an internal detail of the monolith. It stops being an internal detail the moment a second system needs to agree on it. Hold that thought - it's the whole article.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now the Stage-1 overlay, and this is the part that took me a couple of iterations and a correction from a colleague to state correctly. The write logic - the cascade, the validation, the transactional integrity - lives in the PHP monolith. A newer Go service, &lt;strong&gt;the domain-rule-map service&lt;/strong&gt; (I'll also call it the rule-set store when its identity role is the point), has been introduced as the authority for rule-set &lt;strong&gt;identity&lt;/strong&gt;. It is deliberately, aggressively dumb: &lt;strong&gt;immutable and content-addressed&lt;/strong&gt;. You hand it a rule set, it hashes the content, and it either returns the existing master for that hash or creates a fresh one. It creates &lt;em&gt;exactly&lt;/em&gt; what you give it - nothing more. The data and the cascade logic stay in the monolith; the store owns only the id/hash, in &lt;strong&gt;its own&lt;/strong&gt; database.&lt;/p&gt;

&lt;p&gt;There's a subtlety I initially got wrong, and a teammate set me straight in one sentence: &lt;strong&gt;creating a rule set is not the same operation as recalculating.&lt;/strong&gt; Creation is &lt;em&gt;send the computed set to the store + save our mirror, transactionally, in one shot.&lt;/em&gt; Recalculation is a separate, &lt;em&gt;post-commit&lt;/em&gt; process that fans out over everything that changed. Conflating the two - treating the store's echo of a create as the source of truth for our content - is a category error, and it's exactly the kind of thing that reads fine in a diagram and detonates in production.&lt;/p&gt;

&lt;p&gt;So at Stage 1 the monolith computes and writes, the store owns identity, and both back the same reads. Every actor now has to agree on two things: &lt;strong&gt;the content of a rule set&lt;/strong&gt;, and &lt;strong&gt;whether a given write actually changed the persisted state&lt;/strong&gt;. This bug is what happens when they quietly disagree about the second one.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I got it wrong first - the iteration that matters
&lt;/h2&gt;

&lt;p&gt;I want to be honest about the shape of the investigation, because the wrong turns are the lesson.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Iteration 0 - "there's no bug."&lt;/strong&gt; My first conclusion, embarrassingly, was that nothing was broken. I had a green functional test exercising the write, and a production &lt;code&gt;dry_run&lt;/code&gt; that returned the correct cascade. Both said fine. So I told the team it was probably stale data from deploy lag - re-submit and it'll re-materialize.&lt;/p&gt;

&lt;p&gt;That was wrong for a precise, instructive reason. The test ran the &lt;strong&gt;legacy local write path&lt;/strong&gt;. The &lt;code&gt;dry_run&lt;/code&gt; only &lt;em&gt;computes&lt;/em&gt; the cascade - it persists nothing. But production runs behind a &lt;strong&gt;runtime master-switch flag&lt;/strong&gt; that routes writes through the &lt;strong&gt;store path&lt;/strong&gt; instead. A different branch of code entirely. Neither of my two "proofs" had touched the path that was actually failing in prod. &lt;em&gt;A green test on the wrong code path is worse than no test - it manufactures false confidence.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Iteration 1 - reproduce on the real path.&lt;/strong&gt; The only way forward was an authorized, real (non-&lt;code&gt;dry_run&lt;/code&gt;) write against a production polygon, on the store path, watching persisted state rather than the response. And there it was, undeniable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RESPONSE  200 OK   action: was_updated
          config:15  -&amp;gt;  master A   (16 rules)     "looks great"

DATABASE  config:15  -&amp;gt;  master B   (8 rules)      the scope row points at B
          master A    -&amp;gt;  backs NOTHING            A was never linked

          the 200 described a world that does not exist in the DB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F09hhziz3p4dmkcl81ru4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F09hhziz3p4dmkcl81ru4.png" alt=" " width="800" height="306"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The monolith's computed identity and the store's persisted identity had diverged - and the scope row that ties a config to its rule set pointed at neither of the things the response bragged about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Iteration 2 - the wrong hypothesis, corrected by a human.&lt;/strong&gt; With the divergence in hand I built a tidy theory: &lt;em&gt;the store must be origin-blind - it's collapsing two different sets into one master.&lt;/em&gt; I even took it to the team as an architecture question, floating the idea of changing the store. A colleague shut it down in one line: &lt;em&gt;the store is immutable; it builds exactly what you pass it; if a rule didn't survive, the monolith is what dropped it.&lt;/em&gt; He was right, and it reframed the entire hunt. &lt;strong&gt;The fault had to be entirely on the monolith's side.&lt;/strong&gt; That correction saved me from "fixing" the one component that was behaving correctly.&lt;/p&gt;

&lt;p&gt;That's the part I most want to land: the tests were green, the AI-assisted code generation was fast, the diagram was clean - and the thing that turned the investigation was &lt;em&gt;a person who knew the invariant&lt;/em&gt; and &lt;em&gt;a person on the business side who noticed the output was wrong.&lt;/em&gt; Tooling found none of that. Judgment did.&lt;/p&gt;




&lt;h2&gt;
  
  
  The root cause, at the level of the code
&lt;/h2&gt;

&lt;p&gt;Under the store path, three pieces of monolith code conspired to produce the silent no-op. None is exotic; all survive review because each looks locally reasonable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The creator was insert-only.&lt;/strong&gt; The store master id is stable per &lt;code&gt;(scope, entity)&lt;/code&gt;. When a rule got re-stamped - say &lt;code&gt;origin: own&lt;/code&gt; → &lt;code&gt;origin: project&lt;/code&gt;, identical pattern/type/match/priority - the &lt;em&gt;content&lt;/em&gt; changed but the resolved id stayed the same. The creator looked the id up, found an existing master, and returned the &lt;strong&gt;stale local mirror as-is&lt;/strong&gt;. Create had quietly become "create-if-absent, ignore otherwise."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Correlation was by content hash.&lt;/strong&gt; After computing the cascade, a sync builder had to match each &lt;em&gt;computed&lt;/em&gt; item to the &lt;em&gt;persisted&lt;/em&gt; master it produced. It did that by re-hashing the computed content and looking it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The fragile correlation: re-hash the (possibly mutated) content and hope it matches.&lt;/span&gt;
&lt;span class="nv"&gt;$persisted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$createdByHash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$newRuleSet&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getHash&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$persisted&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ← silent drop. The scope row never re-links. Data lost, no error.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The monolith's hash included &lt;code&gt;origin&lt;/code&gt;; the store's, at that point, did not. So the instant a re-stamp changed the local hash, the lookup missed, the item fell into a silent &lt;code&gt;continue&lt;/code&gt;, and the scope row was never re-linked. Cascade descendants and shrinking removes vanished exactly this way - no exception, no log, no trace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The action verdict was decided by a local hash, never by what persisted.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The spurious verdict — decided entirely by the client's local, origin-inclusive hash.&lt;/span&gt;
&lt;span class="nv"&gt;$action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$old&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;BatchUpsertAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;WasUpdated&lt;/span&gt;    &lt;span class="c1"&gt;// origin changed → local hash changed → "updated!"&lt;/span&gt;
    &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BatchUpsertAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;WasUnchanged&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;old.hash !== new.hash&lt;/code&gt; was true - origin had changed - so the endpoint stamped &lt;code&gt;was_updated&lt;/code&gt;, while under it nothing had landed. &lt;strong&gt;Two hash functions is one hash function too many.&lt;/strong&gt; Asymmetric identity between a client and its master is a silent-corruption generator: no status code will ever reveal it, because the code that writes the status never asks the database what happened.&lt;/p&gt;

&lt;p&gt;And note again where the fault is &lt;em&gt;not&lt;/em&gt;. The store did its one job correctly. This is the whole reason it was worth extracting: a small, immutable, content-addressed authority is &lt;em&gt;easy to reason about&lt;/em&gt;. The mess was in the seam - the monolith's assumptions about a partner that had only just come into existence.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix, and the constraint it enforces
&lt;/h2&gt;

&lt;p&gt;The fix had to do three things, and - the part I'm proud of - it had to do them &lt;strong&gt;without mutating a single existing interface or DI contract&lt;/strong&gt;, because those contracts are shared with the paths that were already working. In Symfony, that's what the container is &lt;em&gt;for&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 1 - one resolver, one method
&lt;/h3&gt;

&lt;p&gt;A resolver resolves. It has exactly one method - &lt;code&gt;resolve&lt;/code&gt; - and the fact that now matters (the store-assigned master that actually landed) enters as a nullable argument, not as a second method or a parallel &lt;code&gt;executed&lt;/code&gt;/&lt;code&gt;executedFor&lt;/code&gt; interface zoo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// One method. The persisted master is just an argument — nullable, because&lt;/span&gt;
&lt;span class="c1"&gt;// "nothing landed" is a real, first-class outcome that must be expressible.&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ActionResolverInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;StateItemInterface&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;?RuleSetInterface&lt;/span&gt; &lt;span class="nv"&gt;$persisted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;BatchUpsertAction&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;Every implementation &lt;em&gt;is-an&lt;/em&gt; &lt;code&gt;ActionResolverInterface&lt;/code&gt; everywhere it matters, so the runner routes by polymorphism, never by an &lt;code&gt;instanceof&lt;/code&gt; ladder in shared code. Adding an implementation never touches the orchestrator.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 2 - carry the truth on the aggregate, captured at creation
&lt;/h3&gt;

&lt;p&gt;The verdict needs one fact that didn't exist before: for a given computed item, &lt;em&gt;what master did the store actually assign?&lt;/em&gt; The wrong way to deliver it is to thread a handle through five signatures. The right way is to capture it &lt;strong&gt;at creation time, keyed by the item's original input hash&lt;/strong&gt;, and hang it on the aggregate the pipeline already passes around - behind a readable micro-interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The deterministic calculated → persisted link, captured by submission&lt;/span&gt;
&lt;span class="c1"&gt;// alignment at creation — never re-derived from (divergent) persisted content.&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PersistedMasterReadableInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @return array&amp;lt;string, RuleSetInterface&amp;gt; keyed by input hash */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;createdByInputHash&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&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;The key move is &lt;em&gt;when&lt;/em&gt; and &lt;em&gt;by what key&lt;/em&gt; the link is captured: at the moment of creation, under the &lt;strong&gt;original&lt;/strong&gt; input hash, before any re-stamping can make the content diverge. That single decision makes correlation deterministic instead of hopeful.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 3 - correlate by store master, and make the action honest
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Correlate calculated → persisted on the store-assigned master, captured&lt;/span&gt;
&lt;span class="c1"&gt;// under the ORIGINAL input hash — never a re-hash of the mutated content.&lt;/span&gt;
&lt;span class="nv"&gt;$persisted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createdByInputHash&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;inputHash&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$persisted&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Reported on this item — never a silent `continue` that drops a cascade child.&lt;/span&gt;
    &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;reportUnchanged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;link&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$persisted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the scope row re-links to real persisted identity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the verdict now comes from persisted identity, never a local hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PersistedActionResolver&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ActionResolverInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;StateItemInterface&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;?RuleSetInterface&lt;/span&gt; &lt;span class="nv"&gt;$persisted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;BatchUpsertAction&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$persisted&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;BatchUpsertAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;WasUnchanged&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// nothing landed → never claim updated&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$persisted&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isNewMaster&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;BatchUpsertAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;WasUpdated&lt;/span&gt;
            &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BatchUpsertAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;WasUnchanged&lt;/span&gt;&lt;span class="p"&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;The principle underneath all three: &lt;strong&gt;identity is computed by the store, and only the store.&lt;/strong&gt; The client never re-hashes, never dedups, never decides &lt;code&gt;created/updated/unchanged&lt;/code&gt; from a local hash. Same rules submitted from two places resolve to one store id; the verdict is desired-versus-&lt;em&gt;actually-persisted&lt;/em&gt; identity.&lt;/p&gt;




&lt;h2&gt;
  
  
  The tail: symmetry, on both sides of the wire
&lt;/h2&gt;

&lt;p&gt;Fixing the monolith closed the silent no-op, but left a quieter asymmetry exposed. The store hashed content &lt;strong&gt;without&lt;/strong&gt; &lt;code&gt;origin&lt;/code&gt;; the monolith hashed &lt;strong&gt;with&lt;/strong&gt; it. After the honest-verdict fix, a pure re-stamp &lt;code&gt;own → project&lt;/code&gt; correctly reported &lt;code&gt;was_unchanged&lt;/code&gt;... but a genuine &lt;code&gt;project → own&lt;/code&gt; markup move could still collapse into a single master, because the two sides didn't agree on whether &lt;code&gt;origin&lt;/code&gt; was part of identity.&lt;/p&gt;

&lt;p&gt;The resolution was symmetry, not cleverness - one field, appended on the side that was missing it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// origin IS part of the canonical form — the client includes it, so the store&lt;/span&gt;
&lt;span class="c"&gt;// must too. Two sets identical in patterns but differing only in origin (own vs&lt;/span&gt;
&lt;span class="c"&gt;// project) must hash differently, else FindOrCreate collapses them and prod&lt;/span&gt;
&lt;span class="c"&gt;// loses markup on brand→region moves.&lt;/span&gt;
&lt;span class="n"&gt;serialized&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Match&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson generalizes past this bug: &lt;strong&gt;if a field is part of a rule set's identity for one participant, it must be part of it for every participant - or you get silent divergence no status code will ever reveal.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Testing: prove it against the real persist path, with real people in the loop
&lt;/h2&gt;

&lt;p&gt;The uncomfortable truth that started everything is that the first conclusion - "there's no bug" - came from testing the wrong path. So the regression test drives the &lt;strong&gt;real store-persist path&lt;/strong&gt;: RED before the fix, GREEN after. A &lt;code&gt;dry_run&lt;/code&gt; or compute-only test would never catch it, by construction.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;testRestampCascadesToEveryConfig&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;forceStoreWriteSource&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;willResolveDivergent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;      &lt;span class="c1"&gt;// reproduce the prod shape in-process&lt;/span&gt;

    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;batchApply&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// the real batch apply, real persist path&lt;/span&gt;

    &lt;span class="c1"&gt;// Assert on PERSISTED state, never the response body.&lt;/span&gt;
    &lt;span class="c1"&gt;// Before the fix this is RED: the response claims was_updated, the DB has nothing.&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertScopeLinkedToPersistedMaster&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'config:15'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertMaterializedRuleCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'config:15'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&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;And - the part the automated suite can't do alone - real people validate the business process. A 21-case production write-matrix drove the hunt; two cases stayed red until the identity fix, then flipped green. Those cases live in a shared harness with &lt;strong&gt;stable, numbered case ids&lt;/strong&gt;, so a verdict is a coordinate &lt;code&gt;(case, deploy-version)&lt;/code&gt; rather than a vibe:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;before the fix&lt;/th&gt;
&lt;th&gt;after the fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4 - plain create&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 - restamp own→project&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 - move project→own&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18 - shrinking REMOVE&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22 - cascade to N configs&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh228351muq8ffqoe4l0i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh228351muq8ffqoe4l0i.png" alt=" " width="800" height="415"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Unit tests prove the mechanism; a person running the real process proves it &lt;em&gt;matters&lt;/em&gt;. During a migration you need the second one most, because the failures that hurt are the ones that return &lt;code&gt;200&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three things make the automated side fast and honest: &lt;strong&gt;one PostgreSQL testcontainer for the whole run&lt;/strong&gt; (not one per test - isolate at the data layer with a transaction that rolls back), a &lt;strong&gt;stub that reproduces the divergence deterministically&lt;/strong&gt;, and assertions on &lt;strong&gt;what landed in the DB, not on the &lt;code&gt;200&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this goes next - the parallel run
&lt;/h2&gt;

&lt;p&gt;This is a snapshot of a seam mid-migration, and the interesting question isn't "is it fixed" - it is - but "what does Stage 1 make possible." The answer is Stage 2: the cascade compute itself leaves PHP for a stateless Go service, and for a while &lt;strong&gt;two engines run in parallel&lt;/strong&gt; over the same data. Here's the topology that Stage 1's identity fix unlocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LEVEL 1  two separate front doors — NOT connected to each other:
         PHP monolith (PHP-FPM, legacy)        Go API gateway (new service)

LEVEL 2  services:
         domain-rule-map svc                   rule-set-markup service
         identity (id+hash), its OWN DB,        new cascade engine, Go,
         a microservice FOR PERFORMANCE,        STATELESS: routes + mirrors,
         never touches the monolith DB          no DB of its own — writes the
                                                monolith DB directly

LEVEL 3  storage:
         monolith PostgreSQL DB (both engines' only write sink)  +  DRM's own DB

  Both engines resolve identity from domain-rule-map over gRPC. Both write the
  monolith DB. The identity service never touches it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft0oc2vjato2k795h2b3c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft0oc2vjato2k795h2b3c.png" alt=" " width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three things about this picture are only safe &lt;em&gt;because&lt;/em&gt; of the Stage-1 fix:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The new engine writes the monolith's DB directly.&lt;/strong&gt; It has no store of its own yet - only the &lt;em&gt;compute&lt;/em&gt; is moving, not the &lt;em&gt;data&lt;/em&gt;. That's a deliberate, temporary coupling with an exit at Stage 4, not the end state. It's safe only because the service is &lt;strong&gt;stateless&lt;/strong&gt;: it keeps nothing, so it can't drift from the DB it borrows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;domain-rule-map&lt;/code&gt; is a separate microservice for one reason: performance&lt;/strong&gt; - one fast, immutable identity lookup that &lt;em&gt;both&lt;/em&gt; engines share over gRPC. It owns identity in its own DB and &lt;strong&gt;never touches the monolith DB&lt;/strong&gt;. If Stage 1 hadn't made identity a single honest authority, running two write engines over one database would multiply exactly the divergence this article is about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The two front doors are independent.&lt;/strong&gt; The gateway fronts the new service; the PHP-FPM monolith is its own legacy front door. They aren't wired together - which is what lets an automated consistency agent drive both engines and diff &lt;em&gt;persisted&lt;/em&gt; truth, case by case, until the new engine is provably backward-compatible. (That's Part 1.5.)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every one of those is guarded by a constraint pulled straight out of this incident: &lt;strong&gt;one identity model&lt;/strong&gt; (no second hasher on any client), &lt;strong&gt;honest action&lt;/strong&gt; (verdict from persisted state, never a computed hash), &lt;strong&gt;cascade and shrinking-remove must actually persist&lt;/strong&gt;, and &lt;strong&gt;symmetric identity&lt;/strong&gt; across the wire. A fixed bug protects one line of code. A constraint plus a regression test protects the &lt;em&gt;design&lt;/em&gt; - so when Stage 2 runs two engines over one database, it physically cannot resurrect a ghost we already exorcised.&lt;/p&gt;




&lt;h2&gt;
  
  
  AI as a multiplier, and what that means for a migration
&lt;/h2&gt;

&lt;p&gt;I lean hard on AI coding assistants for work like this, and my honest, strongly-held take is that &lt;strong&gt;AI amplifies good engineers and exposes weak ones.&lt;/strong&gt; It's a multiplier, not a crutch.&lt;/p&gt;

&lt;p&gt;Look back at what actually caught and fixed this bug. A teammate noticed the business output was wrong. A colleague corrected a false hypothesis with one sentence about an invariant. A real production write, watched at the persistence layer, exposed the divergence. AI made &lt;em&gt;generating&lt;/em&gt; the code - the harness, the interfaces, the fixtures - cheap and fast. It did nothing to tell me a &lt;code&gt;was_updated&lt;/code&gt; could be a lie; in fact it happily helped me build a plausible, tidy &lt;em&gt;wrong&lt;/em&gt; hypothesis that a human who knew the system dismantled in a line.&lt;/p&gt;

&lt;p&gt;Point a multiplier at a disciplined process - real design, small interfaces, deterministic tests, verification against truth, and humans validating the actual business flow - and it collapses the old "fast &lt;em&gt;or&lt;/em&gt; correct" trade-off into fast &lt;em&gt;and&lt;/em&gt; correct. Point it at no methodology and it produces plausible-looking wrongness faster than you can review it. A silent no-op wearing a &lt;code&gt;200&lt;/code&gt; is &lt;em&gt;exactly&lt;/em&gt; that kind of wrongness.&lt;/p&gt;




&lt;h2&gt;
  
  
  This is Part 1 of a series
&lt;/h2&gt;

&lt;p&gt;I started here because identity split across a migration boundary is where the breakup can hurt users most quietly. The rest of the series follows the other fronts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-1-a-200-ok-that-saved-nothing-16ef"&gt;Part 1: A 200 OK That Saved Nothing&lt;/a&gt;:&lt;/strong&gt; the parallel run - two engines over one database, and how an agent proves they agree on persisted truth.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-15-two-rule-engines-one-truth-4mhb"&gt;Part 1.5 - Two rule engines, one truth&lt;/a&gt;:&lt;/strong&gt; the parallel run - two engines over one database, and how an agent proves they agree on persisted truth.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Part 2 - Design trade-offs:&lt;/strong&gt; what "identity" means when a migration splits it, and why the ownership of a hash function is an architecture decision.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Part 3 - Performance:&lt;/strong&gt; why the cascade is leaving PHP for a stateless Go service - the timeout budget and the one-pass in-memory merge.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Part 4 - Cutover strategies:&lt;/strong&gt; running both write paths live behind a runtime master-switch, and flipping the master safely.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build serious backends - Symfony, Go, or the messy space between a monolith and the services growing out of it - follow along. &lt;em&gt;And if you're already running two systems that must agree on identity: how are you proving they agree - on the response, or on what actually persisted?&lt;/em&gt; I'd genuinely like to compare notes.&lt;/p&gt;

</description>
      <category>microservices</category>
      <category>go</category>
      <category>php</category>
      <category>testing</category>
    </item>
    <item>
      <title>Breaking the Monolith - Part 4: Flipping the Master Live</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Mon, 10 Aug 2026 09:59:17 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/breaking-the-monolith-part-4-flipping-the-master-live-2kn9</link>
      <guid>https://dev.to/anton_brilliantov/breaking-the-monolith-part-4-flipping-the-master-live-2kn9</guid>
      <description>&lt;p&gt;&lt;em&gt;How you retire a live write path without a flag day: run both paths at once, make the switch a runtime decision, prove the two systems agree on what actually persisted - before and after you flip - and only then let the switch stop being reversible, because there's nothing left to switch back to.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go. For a while now I've been on the kind of project that teaches you the most: carefully breaking a large PHP monolith into Go microservices while it's still very much alive and serving a real business. The first three parts of this series lived on one seam - a &lt;code&gt;200 OK&lt;/code&gt; that saved nothing, the design fork of &lt;em&gt;who owns identity&lt;/em&gt;, and moving the slow cascade off PHP. This part is the payoff: how that seam actually gets &lt;strong&gt;cut over&lt;/strong&gt; in production, with nobody noticing. The running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;. No hype, just the real work.&lt;/p&gt;

&lt;p&gt;Here's the thing nobody tells you about a monolith breakup: writing the new service is the easy half. The dangerous half is the day you make it &lt;em&gt;authoritative&lt;/em&gt; - the moment the new code, not the old, decides what lands in the database. Get that wrong and you don't get a stack trace. You get a &lt;code&gt;200 OK&lt;/code&gt; that saved nothing - the exact production incident that opened this series, and the exact reason everything below exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  The migration, in stages
&lt;/h2&gt;

&lt;p&gt;A big-bang rewrite is a bet you make once and lose slowly. The alternative - the one that actually works on a live business - is a strangler fig: move the seam &lt;strong&gt;one named stage at a time&lt;/strong&gt;, and never let two migrations run at once. Here is the seam this whole series lives on - the rule-set write path - laid out as stages, with a marker on where &lt;em&gt;this&lt;/em&gt; article stands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stage 0  Monolith only.
         PHP computes the cascade, persists the data, owns identity implicitly.

Stage 1  Identity extracted.
         A small Go service becomes the immutable, content-addressed master of
         identity (id + hash). PHP still computes and persists the DATA.

Stage 2  Write logic extracted.
         The cascade is re-implemented in a stateless Go service that writes
         directly into the monolith's DB. Two engines run in parallel.

Stage 3  Prove they agree.            &amp;lt;-- THIS ARTICLE
         Drive both paths and diff PERSISTED truth, case by case, on the deploy
         that's actually running.

Stage 4  Flip, then delete.           &amp;lt;-- THIS ARTICLE
         Promote the new authority behind a live, reversible master-switch, then
         retire the old path - and only then move the DATA out.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F15tt7hxzsb8hw0jjxzv2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F15tt7hxzsb8hw0jjxzv2.png" alt=" " width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything upstream (the identity fix, the stateless cascade, the parallel run) exists to earn Stages 3 and 4. A cutover isn't a moment; it's the disciplined walk from "two systems, one is master" to "one system, no switch left." This article is that walk.&lt;/p&gt;




&lt;h2&gt;
  
  
  Context: what a cutover actually is
&lt;/h2&gt;

&lt;p&gt;Strip away the specifics and the domain is simple. The system stores &lt;strong&gt;classification rules&lt;/strong&gt; attached to a three-level hierarchy that &lt;strong&gt;cascades&lt;/strong&gt; top-down - &lt;code&gt;CLIENT ⊃ PROJECT ⊃ CONFIG&lt;/code&gt; - so writing a rule set at a parent node fans out and recomputes every descendant's effective set. That fan-out is the expensive write path this series has been extracting from PHP, piece by piece. (The full primer is in Part 1; here I only need the shape of it.)&lt;/p&gt;

&lt;p&gt;Now the cutover. Strip away the domain and a cutover is a single question asked on every write: &lt;em&gt;which system is allowed to be right?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Before the migration, there's one answer, hard-wired: the monolith. After the migration, there's one answer, hard-wired: the new path. A cutover is the messy middle where &lt;strong&gt;both answers are wired in at once&lt;/strong&gt; and something has to choose between them per request - ideally something you can change while the service is running, without a rebuild and without a deploy.&lt;/p&gt;

&lt;p&gt;That "something" is a &lt;strong&gt;master-switch flag&lt;/strong&gt;. Not a feature flag for a UI toggle - a flag that decides which of two write paths is the &lt;em&gt;master&lt;/em&gt;, the authority whose result is the truth. Two properties make it worth the name:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live.&lt;/strong&gt; You flip it on the running service. No rebuild, no image push, no rolling restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reversible.&lt;/strong&gt; If the new path misbehaves under real traffic, you flip it back in seconds - not "revert the PR and wait 20 minutes for CI and a deploy."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reversible-in-seconds is the whole game. It's the difference between a cutover and a gamble.&lt;/p&gt;




&lt;h2&gt;
  
  
  The anti-pattern that quietly kills reversibility
&lt;/h2&gt;

&lt;p&gt;I'll name the mistake first, because it's the one I see most and it's invisible until the worst possible moment.&lt;/p&gt;

&lt;p&gt;You build the two write paths. You wire the switch. Then, somewhere in the name of "clean production config," someone &lt;strong&gt;prunes the unused path out of the object graph at build time&lt;/strong&gt; - a compile flag, a DI profile, an &lt;code&gt;if BUILD_ENV == "prod"&lt;/code&gt; that only ever constructs the new path. The reasoning sounds responsible: don't ship dead code, don't instantiate what you won't use.&lt;/p&gt;

&lt;p&gt;The result is a switch that can't switch. A runtime environment variable &lt;em&gt;cannot&lt;/em&gt; flip a seam that was compiled away. The legacy path isn't "off" - it doesn't exist in the running process. So the day the new path misbehaves, your reversible cutover reveals itself as a one-way door, and your rollback plan is a redeploy of the old binary under incident pressure. That's a flag day wearing a feature-flag costume.&lt;/p&gt;

&lt;p&gt;The rule that avoids it: &lt;strong&gt;always register both implementations; let a factory decide at runtime.&lt;/strong&gt; Dead code that a switch can reach is not dead - it's your rollback. In Go, that's a factory holding both, choosing per request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// WritePath is the seam the cutover flips. Exactly two implementations exist —&lt;/span&gt;
&lt;span class="c"&gt;// the legacy local path and the store path. BOTH are always constructed and&lt;/span&gt;
&lt;span class="c"&gt;// registered. The switch chooses one per request; it never chooses at build time.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;WritePath&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;desired&lt;/span&gt; &lt;span class="n"&gt;RuleSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Persisted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// MasterSwitch reads a runtime flag — env var, config row, control-plane value.&lt;/span&gt;
&lt;span class="c"&gt;// The point is that StoreIsMaster can return a different answer on the very next&lt;/span&gt;
&lt;span class="c"&gt;// request, on the same running process, with no rebuild.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;MasterSwitch&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;StoreIsMaster&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;WritePathFactory&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;legacy&lt;/span&gt; &lt;span class="n"&gt;WritePath&lt;/span&gt;   &lt;span class="c"&gt;// always here — this IS the rollback&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt;  &lt;span class="n"&gt;WritePath&lt;/span&gt;   &lt;span class="c"&gt;// always here&lt;/span&gt;
    &lt;span class="n"&gt;master&lt;/span&gt; &lt;span class="n"&gt;MasterSwitch&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// One method: Resolve. It returns which path is authoritative right now.&lt;/span&gt;
&lt;span class="c"&gt;// No instanceof ladder, no build tag — polymorphism decides, at runtime.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;WritePathFactory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;WritePath&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;master&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StoreIsMaster&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;legacy&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the naming discipline, because it's load-bearing across this whole series: the factory has &lt;strong&gt;one&lt;/strong&gt; method, &lt;code&gt;Resolve&lt;/code&gt; - a resolver resolves, it doesn't grow a &lt;code&gt;resolveOrBuild&lt;/code&gt; cousin. Both paths satisfy the same interface, so the caller routes by polymorphism, never by a type switch in shared code. And it's &lt;code&gt;WritePath&lt;/code&gt;, &lt;code&gt;MasterSwitch&lt;/code&gt;, &lt;code&gt;RuleSet&lt;/code&gt; - plain PascalCase, initialisms as words, &lt;code&gt;ID&lt;/code&gt; the one exception - not &lt;code&gt;WRITEPATH&lt;/code&gt; or &lt;code&gt;HTTPClient&lt;/code&gt;. Small rules, but they're what let a new implementation slot in behind the same contract without anyone downstream changing a line.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the switch really lives - and where it scales to
&lt;/h2&gt;

&lt;p&gt;The factory above chooses between two &lt;em&gt;code paths&lt;/em&gt; inside the monolith: the legacy local write and the store-backed write from Part 1. That's the smallest instance of the cutover. But the same discipline scales up one level, to the topology the parallel run runs on - and this is the picture that has to be exactly right, because it's easy to draw wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LEVEL 1  two separate front doors — NOT connected to each other:
         PHP monolith (PHP-FPM, legacy)        Go API gateway (new service)

LEVEL 2  services:
         domain-rule-map svc                   rule-set-markup service
         identity (id+hash), its OWN DB,        new cascade engine, Go,
         a microservice FOR PERFORMANCE,        STATELESS: routes + mirrors,
         never touches the monolith DB          no DB of its own — writes the
                                                monolith DB directly

LEVEL 3  storage:
         monolith PostgreSQL DB (both engines' only write sink)  +  DRM's own DB

  Both engines resolve identity from domain-rule-map over gRPC. Both write the
  monolith DB. The identity service never touches it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F82nt2k2fq2gj0ume09li.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F82nt2k2fq2gj0ume09li.png" alt=" " width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three things about this picture matter for a cutover:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The new engine writes the monolith's DB directly.&lt;/strong&gt; It has no store of its own yet - only the &lt;em&gt;compute&lt;/em&gt; has moved, not the &lt;em&gt;data&lt;/em&gt;. That's a deliberate, temporary Stage-2 coupling with an exit at Stage 4, not the end state. It's safe only because the engine is &lt;strong&gt;stateless&lt;/strong&gt;: it keeps nothing, so it can't drift from the DB it borrows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;domain-rule-map&lt;/code&gt; is a separate microservice for one reason: performance&lt;/strong&gt; - one fast, immutable identity lookup that &lt;em&gt;both&lt;/em&gt; engines share over gRPC. It owns identity in its own DB and &lt;strong&gt;never touches the monolith DB&lt;/strong&gt;. Identity resolution is off both engines' write path to the monolith.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The two front doors are independent.&lt;/strong&gt; The gateway fronts the new service; the PHP-FPM monolith is its own legacy front door. They aren't wired together - which is exactly what lets a consistency agent drive both engines and diff &lt;em&gt;persisted&lt;/em&gt; truth, case by case. &lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-15-two-rule-engines-one-truth-4mhb"&gt;The companion to Part 1 tells that story in full&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Whether the switch is choosing two code paths or two whole services, the cutover discipline is identical: register both, flip live, prove agreement against the DB, promote, delete.&lt;/p&gt;




&lt;h2&gt;
  
  
  Proving the two paths agree - before you dare to flip
&lt;/h2&gt;

&lt;p&gt;A live, reversible switch tells you that you &lt;em&gt;can&lt;/em&gt; flip. It says nothing about whether you &lt;em&gt;should&lt;/em&gt;. For that you need evidence that both paths produce the &lt;strong&gt;same truth in the database&lt;/strong&gt; for the same input - because the failure mode from Part 1 was precisely two systems that agreed on the HTTP response and disagreed on what persisted.&lt;/p&gt;

&lt;p&gt;So I built a harness whose only job is to answer "do these two agree?" - and to leave proof on disk. It runs one loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot.&lt;/strong&gt; Capture the current persisted state of a production &lt;em&gt;polygon&lt;/em&gt; (a safe, disposable slice of real data) - &lt;code&gt;before.json&lt;/code&gt;. You cannot verify a change you didn't measure before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Act.&lt;/strong&gt; Run the write. &lt;strong&gt;Dry-run by default&lt;/strong&gt; - it computes and diffs but persists nothing. Real writes are &lt;strong&gt;gated&lt;/strong&gt; behind an explicit flag &lt;em&gt;and&lt;/em&gt; are reversible, so "act for real" is a deliberate, recoverable choice, never the default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify truth in the DB.&lt;/strong&gt; This is the step Part 1 was built on: don't trust the response. Re-fetch the persisted state and &lt;strong&gt;diff response-versus-persisted&lt;/strong&gt;. A &lt;code&gt;200&lt;/code&gt; with &lt;code&gt;was_updated&lt;/code&gt; means nothing until the row says so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore the polygon.&lt;/strong&gt; Leave the data exactly as found. The next run must start from the same known state, or your matrix drifts into noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persist the full transcript to disk.&lt;/strong&gt; Input, response, persisted state, diff, verdict - all of it, written out. &lt;strong&gt;This path has no production logs.&lt;/strong&gt; If the harness doesn't record what happened, nothing did. The transcript &lt;em&gt;is&lt;/em&gt; the evidence.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The harness is one interface with one honest method. It does not "test"; it&lt;/span&gt;
&lt;span class="c"&gt;// records a verdict against truth and leaves a transcript behind.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;AgreementCheck&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Harness&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;polygon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"snapshot: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;polygon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Restore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// restore always — even on failure&lt;/span&gt;

    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;act&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// dry-run unless real writes are gated on&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrDryRun&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"act: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;persisted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;polygon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// TRUTH, not the response body&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"verify: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Case&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Deploy&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Agree&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;persisted&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;persisted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// no logs — this is the record&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;errors.Is(err, ErrDryRun)&lt;/code&gt; rather than &lt;code&gt;err == ErrDryRun&lt;/code&gt;, because the moment a decorator wraps that error the direct comparison goes silently false and the harness starts "acting for real" when it thinks it's dry - the exact class of quiet lie this series is about.&lt;/p&gt;




&lt;h2&gt;
  
  
  A verdict is a coordinate, not a vibe
&lt;/h2&gt;

&lt;p&gt;One run of the harness produces one fact: &lt;em&gt;case X agreed (or didn't) on deploy version Y.&lt;/em&gt; That pairing is the unit that matters. A verdict isn't "it works now" - it's a &lt;strong&gt;coordinate &lt;code&gt;(case, deploy-version)&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The cases are &lt;strong&gt;stable and numbered&lt;/strong&gt;, shared verbatim with a reviewer. When they and I say "case 16," we mean the same scenario forever - the &lt;code&gt;project → own&lt;/code&gt; markup move that stayed red in Part 1 until the identity fix landed. That stability is what turns a conversation about correctness into a conversation about a grid:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;&lt;code&gt;v4.5.8&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;v4.5.9&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4 - plain create&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 - restamp own→project&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 - move project→own&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18 - shrinking REMOVE&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22 - cascade to N configs&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgw8jj9q8i83dnnysrrot.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgw8jj9q8i83dnnysrrot.png" alt=" " width="800" height="543"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The ❌→✅ flips between two deploy tags &lt;em&gt;are&lt;/em&gt; the safety argument for the cutover. I don't flip the master because the code looks right; I flip it because every case that used to disagree now agrees, on the deploy that's actually running, with a transcript on disk to prove it. And the reason this rigor exists at all is coordinate &lt;code&gt;(any case, the day we shipped a silent no-op)&lt;/code&gt; - a &lt;code&gt;200 OK&lt;/code&gt; that persisted nothing, in production, from Part 1. You earn the right to flip a master by making disagreement a thing you can &lt;em&gt;see&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The end state: when the switch stops being reversible
&lt;/h2&gt;

&lt;p&gt;Here's the part that surprised me. A reversible switch is not the goal - it's scaffolding. The goal is to make it &lt;em&gt;un&lt;/em&gt;-reversible on purpose.&lt;/p&gt;

&lt;p&gt;The cutover progresses in stages, each guarded by a green agreement matrix on the running deploy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Both paths live, legacy is master.&lt;/strong&gt; The new path runs in shadow; the harness proves it agrees. Flip risk: zero, because you haven't flipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New path becomes master, legacy stays registered.&lt;/strong&gt; You flip the switch live. If anything drifts, you flip back in seconds - the legacy path is still in the object graph, still your rollback. This is the reversible window, and you live in it deliberately until the matrix is boringly green across many deploys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity moves fully into the store.&lt;/strong&gt; The monolith's local copy stops being a source of truth and becomes a &lt;strong&gt;projection&lt;/strong&gt; - a read-model derived from the store, not an authority that can disagree with it. There's now only one place that can be right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The switch stops being reversible - because there's nothing to switch back to.&lt;/strong&gt; Once the legacy path owns no truth, keeping it registered guards nothing; it just preserves a ghost. You remove it. The &lt;code&gt;MasterSwitch&lt;/code&gt; collapses to a constant, then disappears. The seam is cut - and only then does the &lt;em&gt;data&lt;/em&gt; migration (Stage 4 proper) begin.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last step is how you retire an old write path &lt;strong&gt;without a flag day&lt;/strong&gt;. There's no heart-stopping midnight deploy where the old world ends and the new one begins in one transaction. There's a slow, evidenced walk: register both, flip live, watch the matrix, promote the store to sole authority, and only &lt;em&gt;then&lt;/em&gt; delete the path you no longer need. The big-bang deploy everyone fears is avoided not by being brave on the night, but by never having a night.&lt;/p&gt;




&lt;h2&gt;
  
  
  AI as a multiplier, and what it can't do for a cutover
&lt;/h2&gt;

&lt;p&gt;I lean hard on AI coding assistants for work like this, and my honest, strongly-held take is that &lt;strong&gt;AI amplifies good engineers and exposes weak ones.&lt;/strong&gt; It's a multiplier, not a crutch.&lt;/p&gt;

&lt;p&gt;AI made the &lt;em&gt;mechanics&lt;/em&gt; of this cutover cheap - generating the factory, the harness scaffold, the transcript serializer, the matrix formatter. Fast, tireless, genuinely useful. What it did not do - could not do - is any of the judgment the cutover actually turns on. It won't tell you that pruning the legacy path at build time silently voids your rollback. It won't insist you diff persisted state instead of the response, because the response looks authoritative and the model has no scar from a &lt;code&gt;200&lt;/code&gt; that lied. It won't decide &lt;em&gt;when&lt;/em&gt; the matrix is green enough to promote the store to sole authority - that's a risk call a human owns.&lt;/p&gt;

&lt;p&gt;Point a multiplier at a disciplined cutover - both paths registered, a runtime switch, agreement proven against truth, verdicts as coordinates, an unhurried walk to irreversibility - and it collapses "fast &lt;em&gt;or&lt;/em&gt; safe" into fast &lt;em&gt;and&lt;/em&gt; safe. Point it at "the new service passes its unit tests, ship it as master" and it'll help you build a flag day faster than you can schedule the incident review.&lt;/p&gt;




&lt;h2&gt;
  
  
  This is Part 4 of a series
&lt;/h2&gt;

&lt;p&gt;Four parts, one seam, told honestly - from the bug that exposed it to the cutover that closes it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1 - &lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-1-a-200-ok-that-saved-nothing-16ef"&gt;A &lt;code&gt;200 OK&lt;/code&gt; that saved nothing&lt;/a&gt;:&lt;/strong&gt; the silent success, and why identity must live in exactly one place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 1.5 - &lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-15-two-rule-engines-one-truth-4mhb"&gt;Two rule engines, one truth&lt;/a&gt;:&lt;/strong&gt; the parallel run behind this cutover - old PHP engine and new stateless Go engine live at once, proven equal through the gateway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2 - &lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-2-who-owns-a-hash-function-4h3f"&gt;Who owns a hash function&lt;/a&gt;:&lt;/strong&gt; where to cut, and why ownership of identity is an architecture decision, not an implementation detail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3 - &lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-3-the-cascade-that-timed-out-43pa"&gt;A stateless cascade in Go&lt;/a&gt;:&lt;/strong&gt; why the slow cascade left PHP for a stateless Go service, and how to measure the win honestly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4 - this one:&lt;/strong&gt; flipping the master live, proving agreement against truth, and retiring the old path without a big-bang deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's one complete seam. The pattern generalizes to every remaining seam in the monolith: extract a service, prove it agrees with the old one against persisted truth, flip a live and reversible switch, watch the matrix, promote the new authority, delete the old path. No six-month freeze. No flag-day gamble. Just good engineering, amplified - and a human deciding when it's actually safe to flip.&lt;/p&gt;

&lt;p&gt;If you build serious backends - Symfony, Go, or the messy space between a monolith and its microservices - follow along. And if you're mid-cutover right now: &lt;em&gt;can you flip your master back in ten seconds?&lt;/em&gt; If the honest answer is "we'd have to redeploy," you don't have a cutover yet - you have a flag day with extra steps. I'd genuinely like to compare notes.&lt;/p&gt;

</description>
      <category>microservices</category>
      <category>devops</category>
      <category>architecture</category>
      <category>go</category>
    </item>
    <item>
      <title>Breaking the Monolith - Part 3: The Cascade That Timed Out</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Thu, 06 Aug 2026 10:31:29 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/breaking-the-monolith-part-3-the-cascade-that-timed-out-43pa</link>
      <guid>https://dev.to/anton_brilliantov/breaking-the-monolith-part-3-the-cascade-that-timed-out-43pa</guid>
      <description>&lt;p&gt;Why the rule-set &lt;strong&gt;write&lt;/strong&gt; path is leaving PHP for a single stateless Go service - the timeout budget that forced it, the one-pass in-memory cascade that replaces thousands of database round-trips, and how to measure the win without fabricating a hero number.&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go. For a while now I've been on the kind of project that teaches you the most: carefully breaking a large PHP monolith into Go microservices while it's still very much alive and serving a real business. The running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;. No hype, just the real work. Part 1 was about a silent &lt;code&gt;200 OK&lt;/code&gt; that saved nothing - the bug that taught me two systems, each with its own database, must agree on &lt;em&gt;identity&lt;/em&gt;. Part 2 went deeper on that identity question. This part is about the reason the whole write path moved at all: &lt;strong&gt;it was too slow, and on the biggest writes it timed out in production.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Where this stands today: the extraction is built and deployed. The new Go engine now runs &lt;strong&gt;in parallel&lt;/strong&gt; with the old PHP one - the same cascade computed by two engines at once - while an automated agent proves they agree before either is trusted. That parallel run is its own story: &lt;strong&gt;&lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-15-two-rule-engines-one-truth-4mhb"&gt;a companion to Part 1 covers it in full&lt;/a&gt;&lt;/strong&gt;. This part is the &lt;em&gt;why&lt;/em&gt; underneath it - the timeout that forced the extraction, and the shape of the service that replaced the slow path.&lt;/p&gt;

&lt;p&gt;The report that started it wasn't a bug. It was a dashboard. Our error tracker kept lighting up with the same signature: the Go API gateway returning &lt;strong&gt;&lt;code&gt;524&lt;/code&gt;&lt;/strong&gt; on rule-set writes, clustered on the largest parent nodes. &lt;code&gt;524&lt;/code&gt; is the gateway giving up - the origin took longer than its ceiling (~100s) to answer, so the connection got cut out from under it. The client retried. The retry was just as slow. Same node, over and over, a steady drip of red.&lt;/p&gt;

&lt;p&gt;Nothing was &lt;em&gt;wrong&lt;/em&gt;, exactly. The writes that finished were correct. They just didn't always finish.&lt;/p&gt;




&lt;h2&gt;
  
  
  The migration, in stages
&lt;/h2&gt;

&lt;p&gt;A big-bang rewrite is a bet you make once and lose slowly. The alternative - the one that actually works on a live business - is a strangler fig: move the seam &lt;strong&gt;one named stage at a time&lt;/strong&gt;, and never let two migrations run at once. Here is the seam this whole series lives on - the rule-set write path - laid out as stages, with a marker on where &lt;em&gt;this&lt;/em&gt; article stands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stage 0  Monolith only.
         PHP computes the cascade, persists the rule data, owns identity implicitly.

Stage 1  Identity extracted.          (Part 1)
         A small Go service becomes the immutable, content-addressed master of
         identity (id + hash). PHP still computes and persists the rule DATA.

Stage 2  Write logic extracted.       &amp;lt;-- THIS ARTICLE
         The cascade compute leaves PHP for a stateless Go service that writes
         directly into the monolith's DB. It moved because the PHP path TIMED OUT.

Stage 3  Prove they agree.            (Part 1.5)
         An agent drives both engines and diffs persisted truth, case by case.

Stage 4  Flip, then delete.
         Promote the Go engine, retire PHP, and only then move the DATA out.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4icr94bwekmay6zeh9ir.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4icr94bwekmay6zeh9ir.png" alt=" " width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few years ago this was one PHP/Symfony monolith on PostgreSQL. We've been carving it into focused Go services one seam at a time - the old system paying the bills while each new service earns its place next to it. Part 1's seam was &lt;em&gt;identity&lt;/em&gt;: a Go service, the &lt;strong&gt;rule-set store&lt;/strong&gt;, became the immutable, content-addressed authority for "is this the same rule set or a different one?" This part is the next seam over - the &lt;strong&gt;write and cascade&lt;/strong&gt; path itself - and unlike Part 1 it's a performance story, not a correctness one. The trigger was blunt: on the biggest tenants the PHP write path stopped finishing inside the gateway's budget.&lt;/p&gt;




&lt;h2&gt;
  
  
  Primer: what the cascade actually does
&lt;/h2&gt;

&lt;p&gt;Strip away the domain and it's simple. The system stores &lt;strong&gt;classification rules&lt;/strong&gt; attached to nodes in a three-level hierarchy that cascades top-down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CLIENT            rules here apply to everything beneath
  └── PROJECT     rules here apply to every config of the project
        └── CONFIG   a specific target (search-engine × device × locale)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F41j7l6hhaqsze1508sni.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F41j7l6hhaqsze1508sni.png" alt=" " width="799" height="329"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Writing a rule set at a parent doesn't just save that node - it &lt;strong&gt;fans out&lt;/strong&gt;. Every descendant's &lt;em&gt;effective&lt;/em&gt; rule set is recomputed by merging what it inherits from above with what it owns locally, with a clear precedence: a narrower level overrides a broader one. The rule is &lt;strong&gt;replace-own-keep-inherited&lt;/strong&gt; - a node's own rules replace its previous own rules, but everything inherited from ancestors is carried down untouched. The fully-resolved result for each node is its &lt;strong&gt;materialized state&lt;/strong&gt; - what a given CONFIG actually sees at request time.&lt;/p&gt;

&lt;p&gt;The cost hides in the fan-out. A CONFIG-level write touches one node. A CLIENT-level write can touch &lt;strong&gt;every CONFIG under that client&lt;/strong&gt; - realistically a few thousand of them for a big tenant. That's the write that returns &lt;code&gt;524&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why PHP timed out: O(descendants) round-trips inside one request
&lt;/h2&gt;

&lt;p&gt;The PHP cascade was written the way you'd naturally write it against a database: node by node. For each descendant it did a small read-modify-write cluster - load the node's set, merge the parent's rules in, resolve identity against the rule-set store, write the mirror. Correct, readable, and completely fine for one node. But it ran &lt;strong&gt;serially, inside one synchronous request, once per descendant.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For a CLIENT with a few thousand CONFIGs that's a few thousand round-trip clusters, chained - each a millisecond or three of network and query time that's invisible at N=1 and lethal at N=thousands. Wall-clock climbed linearly with subtree size until the biggest parents drifted past the gateway's ~100s ceiling. The database wasn't the bottleneck. The &lt;em&gt;chattiness&lt;/em&gt; was - thousands of sequential trips across a wire, each waiting for the last.&lt;/p&gt;

&lt;p&gt;You don't fix that by making PHP faster. You fix it by stopping the round-trips.&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape of the new service
&lt;/h2&gt;

&lt;p&gt;The extracted service - I'll call it &lt;strong&gt;the rule-set-markup service&lt;/strong&gt; - is deliberately small. It is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One synchronous gRPC process. One binary.&lt;/strong&gt; No worker. No queue. No async anything. Desired state comes in on a gRPC call, the fully-persisted result goes back on the same call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stateless.&lt;/strong&gt; It owns &lt;em&gt;zero&lt;/em&gt; persistent state of its own. It machines over two things it doesn't own: the monolith's database and the rule-set store. Restart it, run N replicas, kill it between phases - all equivalent, because there's no in-process state to lose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One consequence deserves its own sentence, because it looks like a sin until you see the stage it's in: &lt;strong&gt;the service has no database of its own - it writes straight into the monolith's PostgreSQL.&lt;/strong&gt; That's deliberate. Only the &lt;em&gt;compute&lt;/em&gt; (the cascade) is moving out right now; the rule &lt;em&gt;data&lt;/em&gt; still belongs to the monolith, which is still the source of truth. Moving the data is a separate, later migration. Letting the new engine write the monolith's own tables means its output lands exactly where the old engine's did - which is precisely what makes "do the two engines agree?" a question you answer by reading one database. The coupling is temporary, documented, and safe &lt;em&gt;only because the service is stateless&lt;/em&gt;: it keeps nothing, so it can't drift from the database it borrows.&lt;/p&gt;

&lt;p&gt;The whole thing is one method behind one interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The entire service surface: desired state in, persisted result out.&lt;/span&gt;
&lt;span class="c"&gt;// One method — no per-level variants, no per-node-kind branches.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Markup&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;desired&lt;/span&gt; &lt;span class="n"&gt;DesiredState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&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;Inside &lt;code&gt;Resolve&lt;/code&gt;, four phases run in strict order: &lt;strong&gt;merge → cascade in memory → persist in one transaction → post-commit notify.&lt;/strong&gt; No phase reaches for a resource a later phase hasn't earned yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 1-2: the whole cascade, in memory, in one pass
&lt;/h2&gt;

&lt;p&gt;The core move is to stop treating each node as a database trip. The service loads the entire affected subtree with &lt;strong&gt;one&lt;/strong&gt; query, then does the whole cascade in memory - a single top-down walk, parent to child, carrying the inherited set down as it goes.&lt;/p&gt;

&lt;p&gt;The cascade itself is one small interface with one method - the direct descendant of Part 1's &lt;code&gt;resolve&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// A resolver resolves. One method. Inherited set + a node's own set → its&lt;/span&gt;
&lt;span class="c"&gt;// effective set. Pure function of its inputs — no I/O, no clock, no DB.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Resolver&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inherited&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;own&lt;/span&gt; &lt;span class="n"&gt;RuleSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;RuleSet&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// replace-own-keep-inherited: own rules win at their own level; everything&lt;/span&gt;
&lt;span class="c"&gt;// from ancestors is carried down as-is.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;CascadeResolver&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CascadeResolver&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inherited&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;own&lt;/span&gt; &lt;span class="n"&gt;RuleSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;RuleSet&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;effective&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;inherited&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Clone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;effective&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplaceOwn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;own&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;effective&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the one-pass walk that applies it across the loaded subtree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// One top-down pass. Each node's resolved set becomes the inherited input for&lt;/span&gt;
&lt;span class="c"&gt;// its children. No recursion into the database — the subtree is already in RAM.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;markup&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;desired&lt;/span&gt; &lt;span class="n"&gt;DesiredState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Resolved&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;Resolved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DescendantCount&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;walk&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inherited&lt;/span&gt; &lt;span class="n"&gt;RuleSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;walk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inherited&lt;/span&gt; &lt;span class="n"&gt;RuleSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;own&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;desired&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OwnFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// the write; empty for untouched nodes&lt;/span&gt;
        &lt;span class="n"&gt;effective&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inherited&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;own&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Resolved&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;NodeID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;effective&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Children&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;effective&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;desired&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InheritedInto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Round-trips for the whole cascade: &lt;strong&gt;one&lt;/strong&gt; read for the subtree, regardless of how many thousand descendants it has. The identity resolution against the rule-set store is batched too - the computed sets go over in one call, not one-per-node. &lt;code&gt;O(descendants)&lt;/code&gt; collapsed to &lt;code&gt;O(1)&lt;/code&gt; in trips.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 3: persist in one transaction - and tx is not an argument
&lt;/h2&gt;

&lt;p&gt;Everything the cascade produced lands in &lt;strong&gt;one&lt;/strong&gt; transaction. Either the whole fan-out commits or none of it does; there's no half-cascaded subtree to reconcile after a crash.&lt;/p&gt;

&lt;p&gt;The part I care about stylistically: the transaction is a property of the store, never a parameter threaded through method signatures. A &lt;code&gt;func Persist(ctx, tx, rows)&lt;/code&gt; is procedural and leaks the transaction into every caller. Instead the executor (pool or tx) is a &lt;strong&gt;field&lt;/strong&gt;, and a scoped instance is minted with &lt;code&gt;WithTx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The executor — pool or tx — is a field, never a method argument.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Store&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt; &lt;span class="n"&gt;Executor&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// WithTx returns a tx-scoped Store. Same methods, now bound to the tx.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;WithTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="n"&gt;pgx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// The tx lifecycle is encapsulated here. Callers never see BEGIN/COMMIT/ROLLBACK&lt;/span&gt;
&lt;span class="c"&gt;// and never hold a tx handle — they get a scoped Store and use it.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;RunInTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Begin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"begin: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rbErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rollback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;rbErr&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rbErr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pgx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrTxClosed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rbErr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&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;So the persist phase reads as one atomic block, with no transaction plumbing visible at the call site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RunInTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PersistCascade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// one batched write, tx-scoped&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"persist cascade: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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;Error identity, everywhere, goes through &lt;code&gt;errors.Is&lt;/code&gt; / &lt;code&gt;errors.As&lt;/code&gt; - never &lt;code&gt;err == x&lt;/code&gt;, never a type switch - so a wrapped error five layers down still matches. (The &lt;code&gt;ErrTxClosed&lt;/code&gt; check above is exactly why: something upstream may already have closed the tx, and a bare &lt;code&gt;==&lt;/code&gt; would miss it the moment anyone adds a &lt;code&gt;%w&lt;/code&gt;.)&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 4: post-commit notify - after the wire is quiet
&lt;/h2&gt;

&lt;p&gt;Recalculation of anything downstream is &lt;strong&gt;not&lt;/strong&gt; part of this transaction. Once the commit succeeds, the service tells the domain-rule-map service which CONFIGs actually changed, and &lt;em&gt;that&lt;/em&gt; system owns the recompute. Notify-after-commit, never inside it: the write path stays short, and a slow or unavailable consumer can never hold the write transaction open. If notification fails, the committed state is still correct - the recalc is re-derivable from what landed, not from a message we have to guarantee delivery of.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Go here, and not Symfony
&lt;/h2&gt;

&lt;p&gt;I write plenty of the write domain in Symfony and I'd defend it. But this service isn't rich domain logic - it's &lt;strong&gt;fan-out and serialization&lt;/strong&gt;: load a subtree, merge sets in a tight loop, serialize thousands of rows, write them once. That workload rewards tight tail latency and lean, predictable memory, and punishes per-request warmup and per-object overhead - exactly Go's shape. The decision wasn't "Go beats PHP." It was that this specific shape (hot, wide, serialization-bound, no deep domain rules) is what Go is &lt;em&gt;for&lt;/em&gt;, and the domain-heavy write logic that stays in the monolith is what Symfony is for.&lt;/p&gt;




&lt;h2&gt;
  
  
  Statelessness is what makes the cutover safe
&lt;/h2&gt;

&lt;p&gt;Here's the property that matters more than the speed. The service holds no state of its own - every input is loaded fresh from the monolith DB and the rule-set store on each call, every output written straight back. There is nothing in the process to be inconsistent with the world.&lt;/p&gt;

&lt;p&gt;That's what makes the parallel run safe - and it's not upcoming, it's live. The old PHP path and the new Go path run side by side today, traffic flips per-request, the Go service restarts mid-migration, scales to N replicas, or gets killed between phase 2 and phase 3 - none of it can corrupt anything, because a killed call simply didn't commit and the next one recomputes the same cascade from the same source of truth. Statelessness isn't a résumé buzzword here; it's the precondition that lets me change the write path under live traffic without a flag-day gamble. (Proving the two engines actually agree, run-for-run, is &lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-15-two-rule-engines-one-truth-4mhb"&gt;its own article&lt;/a&gt;.)&lt;/p&gt;




&lt;h2&gt;
  
  
  Measuring the win honestly
&lt;/h2&gt;

&lt;p&gt;I'm not going to hand you a hero number bolted to a real tenant - that's how anonymized case studies quietly leak client identity, and the &lt;em&gt;shape&lt;/em&gt; of the win is the honest part anyway.&lt;/p&gt;

&lt;p&gt;The unit that matters is &lt;strong&gt;round-trips per write&lt;/strong&gt;, because that's what scaled with subtree size and blew the budget:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;PHP client-side cascade&lt;/th&gt;
&lt;th&gt;Stateless Go service&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DB round-trips&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;O(descendants)&lt;/code&gt; - one cluster per node&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;O(1)&lt;/code&gt; - one subtree read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store identity calls&lt;/td&gt;
&lt;td&gt;one per node&lt;/td&gt;
&lt;td&gt;one batched call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cascade&lt;/td&gt;
&lt;td&gt;node-by-node, serial&lt;/td&gt;
&lt;td&gt;one in-memory pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persist&lt;/td&gt;
&lt;td&gt;interleaved per node&lt;/td&gt;
&lt;td&gt;one transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worst-case parent&lt;/td&gt;
&lt;td&gt;past the gateway's ~100s ceiling → &lt;code&gt;524&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;sub-second for the same subtree&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2kvpxg3ywia7u06ft53.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2kvpxg3ywia7u06ft53.png" alt=" " width="800" height="262"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The budget frame kept me honest: the gateway gives a write ~100 seconds before it returns &lt;code&gt;524&lt;/code&gt;. The PHP path spent that budget on &lt;em&gt;network chatter&lt;/em&gt; - thousands of sequential trips, each mostly waiting. The Go path spends almost none of it there: one read, one in-memory pass, one write. I trust that framing because it's a structural claim - trips went from linear to constant - not a benchmark I can accidentally cherry-pick.&lt;/p&gt;




&lt;h2&gt;
  
  
  AI as a multiplier - the same throughline
&lt;/h2&gt;

&lt;p&gt;I lean hard on AI assistants for work like this, and my honest take hasn't changed across the series: &lt;strong&gt;AI amplifies a good engineer and exposes a weak one.&lt;/strong&gt; Generating this service - the interfaces, the walk, the store scaffolding, the tests - was fast. What AI did &lt;em&gt;not&lt;/em&gt; do was tell me the problem was round-trip count rather than "PHP is slow," or that the fix was to load the subtree once rather than to parallelize the existing per-node loop (which would have kept the chattiness and just raced it). It didn't decide that the transaction belongs to the store as a field, or that notify has to live after the commit. Point the multiplier at a diagnosed problem and a designed shape and it collapses the old "fast &lt;em&gt;or&lt;/em&gt; correct" into fast &lt;em&gt;and&lt;/em&gt; correct. Point it at "make the cascade faster" with no diagnosis and it will cheerfully help you parallelize the wrong thing at high speed.&lt;/p&gt;




&lt;h2&gt;
  
  
  This is Part 3 of a series
&lt;/h2&gt;

&lt;p&gt;The seam is diagnosed, designed, and built stateless on purpose. What's left is the scary part - swapping it in under live traffic.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1 - &lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-1-a-200-ok-that-saved-nothing-16ef"&gt;A 200 OK that saved nothing&lt;/a&gt;.&lt;/strong&gt; The silent &lt;code&gt;200 OK&lt;/code&gt;, and why identity must live in exactly one place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 1.5 - &lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-15-two-rule-engines-one-truth-4mhb"&gt;Two rule engines, one truth.&lt;/a&gt;&lt;/strong&gt; The service in this part is now live &lt;em&gt;in parallel&lt;/em&gt; with the PHP one - how an agent proves the two engines agree, through the gateway, on persisted truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2 - &lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-2-who-owns-a-hash-function-4h3f"&gt;Who owns a hash function.&lt;/a&gt;&lt;/strong&gt; What "identity" means when two systems each own a database, and why owning a hash function is an architecture decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4 - Flipping the master live.&lt;/strong&gt; Running the old PHP cascade and the new stateless Go service side by side behind a runtime master-switch, flipping traffic safely, and retiring the old path without a big-bang deploy. Statelessness (this part) is what makes that flip reversible - Part 4 is how you actually pull it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build serious backends - Symfony, Go, or the messy space between a monolith and the services growing out of it - follow along. Concrete, code-first, honest about the mistakes.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;And if you've moved a write path off a monolith: how did you measure the win without a number that fingerprints your client? I'd genuinely like to compare notes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>microservices</category>
      <category>performance</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Breaking the Monolith - Part 2: Who Owns a Hash Function</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Tue, 04 Aug 2026 07:55:02 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/breaking-the-monolith-part-2-who-owns-a-hash-function-4h3f</link>
      <guid>https://dev.to/anton_brilliantov/breaking-the-monolith-part-2-who-owns-a-hash-function-4h3f</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 1 was a bug story: a &lt;code&gt;200 OK&lt;/code&gt; that saved nothing. This is the design story underneath it - why the ownership of a hash function stopped being an implementation detail and became an architecture decision, and how I reasoned about the trade-offs while both systems were live and one of them had just been handed the right to say "these two things are the same."&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi again, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, carving a large live monolith into services one seam at a time. In Part 1 (and its expanded cut) I chased a silent no-op: the API cheerfully reported &lt;code&gt;was_updated&lt;/code&gt; while the database saved nothing. That post was about the &lt;em&gt;symptom&lt;/em&gt;. This one is about the &lt;em&gt;decision&lt;/em&gt; that made the symptom possible - and the decision that fixed it for good. Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;. Honest version, mistakes left in.&lt;/p&gt;




&lt;h2&gt;
  
  
  The migration, in stages
&lt;/h2&gt;

&lt;p&gt;You cannot reason about "who owns identity" without knowing &lt;em&gt;which stage&lt;/em&gt; you're standing in when you ask. A big-bang rewrite is a bet you make once and lose slowly; the strangler-fig alternative moves the seam one named stage at a time. Here is the seam this whole series lives on - the rule-set write path - with a marker on the stage this article is about:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3oi26kh5gs5lap3zzhlp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3oi26kh5gs5lap3zzhlp.png" alt=" " width="800" height="367"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stage 0  Monolith only.
         PHP computes the cascade, persists the rule data, owns identity implicitly.

Stage 1  Identity extracted.          &amp;lt;-- THIS ARTICLE
         A small Go service becomes the immutable, content-addressed master of
         identity (id + hash), in its OWN database, answering over gRPC.
         PHP still computes and persists the rule DATA.
         The question: WHO is allowed to decide two rule sets are "the same"?

Stage 2  Write logic extracted.       (Part 1.5)
         The cascade is re-implemented in a stateless Go service that writes
         directly into the monolith's DB. Two engines run in parallel — and both
         resolve identity from the Stage-1 service over gRPC.

Stage 3  Prove they agree.
         An agent drives both engines and diffs persisted truth, case by case.

Stage 4  Flip, then delete.
         Promote the Go engine, retire PHP, and only then move the DATA out.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Part 1 was a bug that lived at Stage 1. Part 2 is the &lt;em&gt;decision&lt;/em&gt; at Stage 1 that the bug was a violation of. Get this decision right and Stage 2 - two write engines over one database - is safe. Get it wrong and Stage 2 multiplies the divergence Part 1 was about.&lt;/p&gt;




&lt;h2&gt;
  
  
  Primer: what "identity" even means here
&lt;/h2&gt;

&lt;p&gt;Strip away the specifics and the domain is simple. The system stores &lt;strong&gt;classification rules&lt;/strong&gt; - patterns that tag web domains with a meaning ("this domain is ours", "this one is a competitor", "this one is irrelevant"). A &lt;strong&gt;rule&lt;/strong&gt; is roughly &lt;code&gt;{pattern, type, match-mode, priority}&lt;/code&gt;. A &lt;strong&gt;rule set&lt;/strong&gt; is an ordered collection of those rules attached to one node in a hierarchy that &lt;strong&gt;cascades&lt;/strong&gt; top-down:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flb4ixa490qbtjmsvklvf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flb4ixa490qbtjmsvklvf.png" alt=" " width="799" height="329"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CLIENT            rules here apply to everything beneath
  └── PROJECT     rules here apply to every config of the project
        └── CONFIG   a specific target (search-engine × device × locale)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A write at a parent level fans out: the engine recomputes every descendant's &lt;em&gt;effective&lt;/em&gt; rule set, merging what it inherits from above with what it owns locally, narrower level winning.&lt;/p&gt;

&lt;p&gt;Now the word that carries this whole article: &lt;strong&gt;identity&lt;/strong&gt;. It's the answer to one question - &lt;em&gt;"is this the same rule set I already have, or a different one?"&lt;/em&gt; You answer it with a &lt;strong&gt;hash function&lt;/strong&gt;: reduce the content to a fixed fingerprint, and two sets are "the same" exactly when their fingerprints match. This is &lt;strong&gt;content-addressing&lt;/strong&gt; - the same idea Git uses for commits and blobs. Same content → same hash → same object. Change one byte → new hash → new object.&lt;/p&gt;

&lt;p&gt;Sounds like plumbing. It isn't. The instant a &lt;em&gt;second&lt;/em&gt; system needs to agree on whether two rule sets are the same, "how do we hash content" stops being a private detail of the monolith and becomes a &lt;strong&gt;contract between two services&lt;/strong&gt;. And a contract has an owner.&lt;/p&gt;




&lt;h2&gt;
  
  
  The design fork: where does the hash function live?
&lt;/h2&gt;

&lt;p&gt;When I introduced the new Go service - I'll call it &lt;strong&gt;the rule-set store&lt;/strong&gt; (the domain-rule-map service, when its identity-master role is the point) - I had exactly three places to put the authority for identity. This is the fork the whole series turns on:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F884a9o0wssrogdi5bumq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F884a9o0wssrogdi5bumq.png" alt=" " width="799" height="229"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 who computes a rule set's ID?

  A  each system for itself      B  the client owns it        C  the store owns it
     ┌────────┐  ┌────────┐         ┌────────┐  ┌──────┐         ┌────────┐  ┌──────┐
     │monolith│  │ store  │         │monolith│  │ store│         │monolith│  │ store│
     │ hash() │  │ hash() │         │ hash() │  │ bytes│         │ content│  │ hash()│
     └───┬────┘  └───┬────┘         └───┬────┘  └──┬───┘         └───┬────┘  └──┬───┘
         │  DISAGREE │                  │  owns    │                 │  asks   │
         └────┬──────┘                  └───→ id   ▼                 └───→ id   ▼
       two answers = a bug        migration never moves       ONE owner of identity
         ✗ REJECTED (a law)         ✗ REJECTED (owns nothing)     ✓ CHOSEN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Option A - every system hashes for itself.&lt;/strong&gt; The monolith computes its hash, the store computes its own, each trusts its own answer. This is the one I &lt;em&gt;accidentally&lt;/em&gt; shipped, and Part 1 is the crater. Two hash functions is one hash function too many: the moment they disagree by a single field, you get silent divergence that no status code will ever reveal. Rejected - not as a preference, as a law.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B - the client owns identity, the store is a dumb blob bucket.&lt;/strong&gt; The store just persists bytes under a key the monolith picks. Simple, but it means the &lt;em&gt;monolith&lt;/em&gt; stays the authority forever - the migration makes no progress, because the thing I'm trying to extract (the decision "these are the same") never actually leaves PHP. Rejected: it's a service that doesn't own anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option C - the store owns identity, everyone asks it.&lt;/strong&gt; The store is the single place that decides what a rule set's id is. The monolith computes &lt;em&gt;content&lt;/em&gt;; the store computes &lt;em&gt;identity&lt;/em&gt;, in its own database, and answers over gRPC. Chosen - and everything else in this article is a consequence of that choice.&lt;/p&gt;




&lt;h2&gt;
  
  
  Consequence 1: the store is deliberately, aggressively dumb
&lt;/h2&gt;

&lt;p&gt;Because the store owns identity and &lt;em&gt;only&lt;/em&gt; identity, I could make it the simplest service on the platform. It is &lt;strong&gt;immutable and content-addressed&lt;/strong&gt;: you hand it a canonical rule set, it returns the one master for that content, creating it only if absent. It never mutates, never cascades, never validates business rules. It creates &lt;em&gt;exactly&lt;/em&gt; what you give it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The store owns identity and nothing else. Hand it a canonical rule set,&lt;/span&gt;
&lt;span class="c"&gt;// it returns the single master for that content — creating one only if absent.&lt;/span&gt;
&lt;span class="c"&gt;// It lives in its OWN database and is reached over gRPC; it never touches the&lt;/span&gt;
&lt;span class="c"&gt;// monolith's DB.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;RuleSetStore&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;FindOrCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;CanonicalRuleSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RuleSetID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="n"&gt;CanonicalRuleSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;Hash&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rules&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;// rules already in canonical order&lt;/span&gt;
        &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Match&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Origin&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;nil&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;The trade-off I &lt;em&gt;accepted&lt;/em&gt; on purpose: the store is dumb, so the data and the cascade logic stay in the monolith. It can't answer "what rules does config 15 actually see" - that's not its job. A less disciplined design would have let the store grow a little validation here, a little cascade there, until it became a second monolith. Keeping it a small, immutable, content-addressed authority is precisely what makes it &lt;em&gt;easy to reason about&lt;/em&gt; - and easy to trust as the one owner of identity.&lt;/p&gt;

&lt;p&gt;There's a second reason it's a separate service and not a library: &lt;strong&gt;performance&lt;/strong&gt;. Identity resolution is one fast, immutable lookup that &lt;em&gt;both&lt;/em&gt; engines - the PHP monolith today, the stateless Go cascade at Stage 2 - share over gRPC. A shared, cache-friendly authority is worth a network hop; a library copied into two runtimes is Option A wearing a disguise.&lt;/p&gt;




&lt;h2&gt;
  
  
  Consequence 2: create is not recalculate - and conflating them is a category error
&lt;/h2&gt;

&lt;p&gt;This is the distinction a teammate corrected me on in one sentence, and it's structural, not cosmetic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Creation&lt;/strong&gt; is one transaction: send the computed set to the store, get back the master ids, and save the monolith's local &lt;strong&gt;mirror&lt;/strong&gt; - all or nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recalculation&lt;/strong&gt; is a &lt;em&gt;different&lt;/em&gt; operation entirely: a &lt;strong&gt;post-commit&lt;/strong&gt; fan-out over everything that changed, recomputing descendants' effective rule sets. It runs &lt;em&gt;after&lt;/em&gt; the create transaction has committed, because it reacts to what actually landed.&lt;/p&gt;

&lt;p&gt;I don't want that boundary living in a comment or an inline &lt;code&gt;runInTransaction(fn () =&amp;gt; …)&lt;/code&gt; at the call site - I want it to be a &lt;strong&gt;type&lt;/strong&gt;. The write handler knows only the domain; a &lt;strong&gt;transactional decorator&lt;/strong&gt; runs it in exactly one transaction; an &lt;strong&gt;after-commit decorator&lt;/strong&gt; schedules the recompute through a port the transaction dispatches only once it commits. Nothing in the handler ever sees &lt;code&gt;BEGIN&lt;/code&gt;/&lt;code&gt;COMMIT&lt;/code&gt; or calls an &lt;code&gt;afterCommit()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The domain handler — zero transaction awareness, zero commit-hook awareness.&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;WriteHandler&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RuleSetWrite&lt;/span&gt; &lt;span class="nv"&gt;$write&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;WriteResult&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateRuleSet&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WriteHandler&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;RuleSetStore&lt;/span&gt; &lt;span class="nv"&gt;$store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;MirrorProjection&lt;/span&gt; &lt;span class="nv"&gt;$mirror&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RuleSetWrite&lt;/span&gt; &lt;span class="nv"&gt;$write&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;WriteResult&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$masters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$write&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// identity — the store decides&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;mirror&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$write&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$masters&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// local projection of that identity&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WriteResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$masters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$write&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&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;// Creation runs INSIDE one transaction — a decorator, not an inline closure at the call site.&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Transactional&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WriteHandler&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;WriteHandler&lt;/span&gt; &lt;span class="nv"&gt;$inner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;TransactionManager&lt;/span&gt; &lt;span class="nv"&gt;$tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RuleSetWrite&lt;/span&gt; &lt;span class="nv"&gt;$write&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;WriteResult&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;WriteResult&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$write&lt;/span&gt;&lt;span class="p"&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;// Recalculation is a SEPARATE, post-commit concern — also a decorator. It schedules the&lt;/span&gt;
&lt;span class="c1"&gt;// recompute through an AfterCommit port that the transaction fires only after COMMIT.&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RecalcAfterCommit&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;WriteHandler&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;WriteHandler&lt;/span&gt; &lt;span class="nv"&gt;$inner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;AfterCommit&lt;/span&gt; &lt;span class="nv"&gt;$afterCommit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RuleSetWrite&lt;/span&gt; &lt;span class="nv"&gt;$write&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;WriteResult&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$write&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;afterCommit&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;recompute&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&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;// The boundary is now a TYPE, not a comment: schedule inside the tx, dispatch after commit.&lt;/span&gt;
&lt;span class="nv"&gt;$write&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Transactional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RecalcAfterCommit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CreateRuleSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$mirror&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$afterCommit&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nv"&gt;$tx&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;Why does this matter for &lt;em&gt;ownership&lt;/em&gt;? Because the tempting shortcut is to treat the store's echo of a create as the source of truth for content - to read identity back and re-derive what changed from it. That's the category error. The store answers "what is this set's id"; it does &lt;strong&gt;not&lt;/strong&gt; answer "what did this write change downstream." Recalculation owns the second question, and it owns it &lt;em&gt;after&lt;/em&gt; commit. Blur the two and you get exactly Part 1's ghost: a create that reports success while the recalc it implied never happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  Consequence 3: dual authority over data is a trade-off; dual authority over identity is a bug
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable part. During the transition the monolith &lt;strong&gt;still keeps a local mirror&lt;/strong&gt; of every rule set, and a runtime &lt;strong&gt;master-switch flag&lt;/strong&gt; chooses the write path per request: store path or legacy path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$writeSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;masterSwitch&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;current&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// WriteSource::Store | WriteSource::Legacy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So for a while, &lt;em&gt;two&lt;/em&gt; places hold rule-set data. That looks like exactly the two-authorities trap I just spent three sections rejecting. It isn't - and the distinction is the whole point of this section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;     DATA                                    IDENTITY
     ┌─────────────┐   ┌─────────────┐        ┌─────────────┐
     │ store       │   │ monolith    │        │   store     │  ← the ONLY authority
     │ (own DB)    │   │ mirror      │        │  (own DB)   │    that decides an id
     │ id + hash   │   │ (monolith   │        └──────┬──────┘
     └─────────────┘   │  DB)        │               │ FindOrCreate over gRPC
        two data           projection               │
        stores, kept       of the id the      monolith ── asks ──┘
        coherent           store handed it     mirror ── never decides, only records
     ── a TRADE-OFF ──                          ── a LAW ──
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mirror is a &lt;strong&gt;projection&lt;/strong&gt;, not a second opinion. It never computes an id; it only records the id the store handed it. The legacy path still writes the mirror the old way, which is why the mirror has to exist at all during cutover - but nothing on the mirror is ever allowed to &lt;em&gt;decide&lt;/em&gt; identity. The client never re-hashes, never dedups, never rules &lt;code&gt;created&lt;/code&gt;/&lt;code&gt;updated&lt;/code&gt;/&lt;code&gt;unchanged&lt;/code&gt; from a local hash. The one moment I let the mirror's hash cast a verdict is the exact moment Part 1 blew up.&lt;/p&gt;

&lt;p&gt;Note the storage picture this implies, because it's a fact people get wrong: the identity store keeps identity in &lt;strong&gt;its own database&lt;/strong&gt; and is reached over gRPC - it is &lt;em&gt;not&lt;/em&gt; squatting in the monolith's schema. The rule &lt;strong&gt;data&lt;/strong&gt; (and the mirror) live in the monolith's PostgreSQL DB. So there really are two databases here, but only one of them is ever allowed to answer the identity question.&lt;/p&gt;

&lt;p&gt;The cost of the data trade-off is real: two data stores to keep coherent, a flag to reason about, a window where a bug can hide in the seam. I took it deliberately because the alternative - freeze writes, migrate identity, thaw - means downtime a live business won't grant me. &lt;strong&gt;Dual authority over data is the price of a zero-downtime cutover. Dual authority over identity is a bug. Knowing which is which is the whole job.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Consequence 4: symmetry is a law, not a nicety
&lt;/h2&gt;

&lt;p&gt;Once identity has one owner, a second law falls out of it. In Part 1, after the honest-verdict fix, a quieter asymmetry was still exposed: the store hashed content &lt;strong&gt;without&lt;/strong&gt; the &lt;code&gt;origin&lt;/code&gt; field; the monolith hashed &lt;strong&gt;with&lt;/strong&gt; it. Two participants, two different notions of "same". Result: a genuine &lt;code&gt;project → own&lt;/code&gt; markup move could still collapse two distinct sets into one master, and prod would silently lose markup.&lt;/p&gt;

&lt;p&gt;The fix wasn't cleverness - it was symmetry. One field, appended on the side that was missing it (you can see &lt;code&gt;r.Origin&lt;/code&gt; already in the &lt;code&gt;Hash()&lt;/code&gt; above):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If a field is part of a rule set's identity for one participant, it must be part of it for every participant - or you get silent divergence no status code will ever reveal.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the general law. But symmetry-by-copying-a-field is the &lt;em&gt;tactical&lt;/em&gt; fix; it leaves two hashers that I now have to keep in lockstep forever. The &lt;strong&gt;end state&lt;/strong&gt; is cleaner and it's where this seam is heading: stop &lt;em&gt;storing&lt;/em&gt; &lt;code&gt;origin&lt;/code&gt; as sticky data on a rule at all, and instead &lt;strong&gt;derive&lt;/strong&gt; it from where the rule sits in the cascade at read time. A rule's level would then follow from structure, not from a label that can drift out of sync between two services. Derived identity can't diverge, because there's only one thing to compute it from. That's the difference between patching a symptom and dissolving a bug class.&lt;/p&gt;




&lt;h2&gt;
  
  
  The discipline that keeps this legible: naming and interfaces
&lt;/h2&gt;

&lt;p&gt;None of the above survives contact with a codebase unless the code &lt;em&gt;reads&lt;/em&gt; like the design. A few rules I refuse to bend, because they're what let a new implementation slot in without a rewrite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A resolver has exactly one method: &lt;code&gt;resolve&lt;/code&gt;.&lt;/strong&gt; Not &lt;code&gt;resolve&lt;/code&gt;, &lt;code&gt;resolveFor&lt;/code&gt;, &lt;code&gt;executed&lt;/code&gt;, &lt;code&gt;executedFor&lt;/code&gt; - one verb, one contract. The fact that now matters (what the store actually persisted) enters as a nullable argument, not as a parallel method or an interface zoo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything behind an interface; &lt;code&gt;instanceof&lt;/code&gt; ladders in shared code are banned.&lt;/strong&gt; The runner routes by polymorphism, so adding a write path never touches the orchestrator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;final readonly&lt;/code&gt; classes, &lt;code&gt;declare(strict_types=1)&lt;/code&gt; everywhere.&lt;/strong&gt; Immutable value objects can't drift after construction - the same discipline the store enforces at the service level, enforced again at the object level.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ActionResolverInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;StateItemInterface&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;?RuleSetInterface&lt;/span&gt; &lt;span class="nv"&gt;$persisted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;BatchUpsertAction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PersistedActionResolver&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ActionResolverInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;StateItemInterface&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;?RuleSetInterface&lt;/span&gt; &lt;span class="nv"&gt;$persisted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;BatchUpsertAction&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Binary by nature: a newly minted master → updated; anything else&lt;/span&gt;
        &lt;span class="c1"&gt;// (nothing landed, or an existing master reused) → unchanged.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$persisted&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isNewMaster&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;BatchUpsertAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;WasUpdated&lt;/span&gt;
            &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BatchUpsertAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;WasUnchanged&lt;/span&gt;&lt;span class="p"&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;On the Go side the same instinct shows up in naming: identifiers read as ordinary words - &lt;code&gt;HttpClient&lt;/code&gt;, &lt;code&gt;RuleSetID&lt;/code&gt;, &lt;code&gt;HeadSha&lt;/code&gt; - never shouted as &lt;code&gt;HTTPClient&lt;/code&gt; or &lt;code&gt;HeadSHA&lt;/code&gt;. The only initialism that stays uppercase is &lt;code&gt;ID&lt;/code&gt;. It's a small rule, but consistent naming is how a reader trusts that &lt;code&gt;resolve&lt;/code&gt; means resolve everywhere, and that identity means the store everywhere.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this goes next - the parallel run
&lt;/h2&gt;

&lt;p&gt;This decision - one owner of identity, in its own DB, reachable over gRPC - is what makes the &lt;em&gt;next&lt;/em&gt; stage safe. At Stage 2 the cascade compute itself leaves PHP for a stateless Go service, and for a while &lt;strong&gt;two write engines run in parallel&lt;/strong&gt; over the monolith's database. Here's the topology the identity decision unlocks:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fou29y4tbavmccqcx93n4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fou29y4tbavmccqcx93n4.png" alt=" " width="800" height="471"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LEVEL 1  two separate front doors — NOT connected to each other:
         PHP monolith (PHP-FPM, legacy)        Go API gateway (new service)

LEVEL 2  services:
         domain-rule-map svc                   rule-set-markup service
         identity (id+hash), its OWN DB,        new cascade engine, Go,
         a microservice FOR PERFORMANCE,        STATELESS: routes + mirrors,
         never touches the monolith DB          no DB of its own — writes the
                                                monolith DB directly

LEVEL 3  storage:
         monolith PostgreSQL DB (both engines' only write sink)  +  DRM's own DB

  Both engines resolve identity from domain-rule-map over gRPC. Both write the
  monolith DB. The identity service never touches it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things about this picture are only safe &lt;em&gt;because&lt;/em&gt; the identity decision was made honestly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The new engine writes the monolith's DB directly.&lt;/strong&gt; It has no store of its own yet - only the &lt;em&gt;compute&lt;/em&gt; is moving, not the &lt;em&gt;data&lt;/em&gt;. That's a deliberate, temporary coupling with an exit at Stage 4, not the end state. It's safe only because the service is &lt;strong&gt;stateless&lt;/strong&gt;: it keeps nothing, so it can't drift from the DB it borrows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;domain-rule-map&lt;/code&gt; is a separate microservice for one reason: performance&lt;/strong&gt; - one fast, immutable identity lookup that &lt;em&gt;both&lt;/em&gt; engines share over gRPC, in its own DB, never touching the monolith DB. If identity weren't a single honest authority, running two write engines over one database would multiply exactly the divergence Part 1 was about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The two front doors are independent.&lt;/strong&gt; The gateway fronts the new service; the PHP-FPM monolith is its own legacy front door. They aren't wired together - which is what lets an automated consistency agent drive both engines and diff &lt;em&gt;persisted&lt;/em&gt; truth, case by case, until the new engine is provably backward-compatible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's Part 1.5.&lt;/p&gt;




&lt;h2&gt;
  
  
  AI as a multiplier, again - because this is where it bites
&lt;/h2&gt;

&lt;p&gt;I lean hard on AI coding assistants for work like this, and the throughline from Part 1 holds: &lt;strong&gt;AI amplifies a good engineer and exposes a weak one.&lt;/strong&gt; Nowhere is that sharper than in a design decision like "who owns the hash function." An assistant will happily generate Option A - each system hashing for itself - because locally, in each file, it looks &lt;em&gt;completely reasonable&lt;/em&gt;. Two tidy &lt;code&gt;Hash()&lt;/code&gt; methods, both correct in isolation, that together form a silent-corruption generator. The tool produces plausible-looking code at high speed; it does not tell you that identity must have exactly one owner, or that create and recalc are different operations, or that a field in one hash must be in the other. That judgment came from a person who knew the invariant and a teammate who corrected me in a sentence.&lt;/p&gt;

&lt;p&gt;Point the multiplier at a real design - one identity owner, a dumb immutable store, an explicit create/recalc boundary, symmetry as a law, single-method interfaces - and it collapses "fast &lt;em&gt;or&lt;/em&gt; correct" into fast &lt;em&gt;and&lt;/em&gt; correct: I get the boilerplate, the value objects, the tests for free, and I spend my time on the decisions that don't have a locally-obvious answer. Point it at no design and it generates the crater faster than you can review it. That's the difference between amplifying engineering and automating its absence.&lt;/p&gt;




&lt;h2&gt;
  
  
  This is Part 2 of a series
&lt;/h2&gt;

&lt;p&gt;Part 1 was the bug; Part 2 was the decision under it - that ownership of identity is an architecture choice, not plumbing. The seam keeps moving:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1 -&lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-1-a-200-ok-that-saved-nothing-16ef"&gt; A &lt;code&gt;200 OK&lt;/code&gt; that saved nothing&lt;/a&gt;.&lt;/strong&gt; The silent no-op this decision fixes, and its expanded cut with the full staged-migration map.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 1.5 - &lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-15-two-rule-engines-one-truth-4mhb"&gt;Two rule engines, one truth.&lt;/a&gt;&lt;/strong&gt; The cascade now runs as two live services - the PHP engine and a new stateless Go one - writing the same database in parallel, proven equal through the gateway before either is trusted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3 - A stateless cascade in Go.&lt;/strong&gt; Why the write/cascade path is leaving PHP for a &lt;em&gt;stateless&lt;/em&gt; Go service: the timeout budget forcing it, the one-pass in-memory merge, and how to measure the win honestly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4 - Flipping the master live.&lt;/strong&gt; Running both write paths behind the runtime master-switch and flipping the master safely - retiring the legacy path and the mirror without a big-bang deploy, until identity lives fully in the store and the switch has nothing left to switch back to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build serious backends - Symfony, Go, or the messy space between a monolith and the services growing out of it - follow along. Concrete, code-first, honest about the mistakes.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;And if you're mid-migration with two systems that must agree on identity: which one owns it? If your answer is "both", that's not two answers - it's a bug waiting for a business-side human to notice. I'd genuinely like to compare notes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>microservices</category>
      <category>architecture</category>
      <category>go</category>
      <category>php</category>
    </item>
    <item>
      <title>Breaking the Monolith - Part 1.5: Two Rule Engines, One Truth</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Fri, 31 Jul 2026 11:48:40 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/breaking-the-monolith-part-15-two-rule-engines-one-truth-4mhb</link>
      <guid>https://dev.to/anton_brilliantov/breaking-the-monolith-part-15-two-rule-engines-one-truth-4mhb</guid>
      <description>&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go. This is a companion to Part 1 of a series about carefully breaking a large PHP monolith into Go microservices while it keeps serving a real business. Part 1 was a bug story: an API that answered &lt;code&gt;was_updated&lt;/code&gt; while the database saved nothing. This part is what came after the fix - the phase where the write logic is being lifted out of PHP into a new service, and for a while both the old engine and the new one are &lt;em&gt;live at the same time&lt;/em&gt;. Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;. No hype, just the real work.&lt;/p&gt;

&lt;p&gt;There's a moment in a monolith breakup that the tutorials skip. It's not writing the new service - that's the fun part. It's the stretch where the new service is done, deployed, and running next to the old one, and &lt;em&gt;both&lt;/em&gt; compute the same answer for real production writes, and your entire job is to prove that they never, ever disagree. Two engines. One truth. That's this article.&lt;/p&gt;




&lt;h2&gt;
  
  
  The migration, in stages
&lt;/h2&gt;

&lt;p&gt;I'll keep coming back to a staged picture, because the whole point of a strangler-fig migration is that it happens in &lt;em&gt;steps you can name&lt;/em&gt;, not in one heroic deploy. Here is the seam this series lives on - the rule-set write path - laid out as stages:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8mdsawkmsiyjbci8srvd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8mdsawkmsiyjbci8srvd.png" alt=" " width="800" height="459"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stage 0  Monolith only.
         PHP computes the cascade, persists rule data, owns identity implicitly.
         This is where the big parent writes timed out in prod (Part 3).

Stage 1  Identity extracted.
         A small Go service - the domain-rule-map service - becomes the
         immutable, content-addressed master of identity (id + hash).
         PHP still computes and persists the rule data. (Part 1 &amp;amp; 2.)

Stage 2  Write logic extracted.  &amp;lt;-- WE ARE HERE
         The cascade is re-implemented in a stateless Go service - the
         rule-set-markup service. But the monolith still owns the rule DATA,
         so the new service writes DIRECTLY into the monolith's database.
         Same cascade logic now lives in TWO engines: PHP and Go, in parallel.

Stage 3  Prove they agree.       &amp;lt;-- AND HERE
         An automated agent drives BOTH engines through the Go API gateway
         and diffs the persisted result, case by case, until the new engine
         is provably backward-compatible with the old one.

Stage 4  Flip, then delete.  (future)
         Promote the Go engine to master, retire the PHP path, and only later
         move the DATA itself out of the monolith. The duplication ends here.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of this article is Stages 2 and 3 - the awkward, interesting middle where the system runs two rule engines on purpose.&lt;/p&gt;




&lt;h2&gt;
  
  
  Primer: what the two engines actually do
&lt;/h2&gt;

&lt;p&gt;Strip away the domain and it's simple. The system stores &lt;strong&gt;classification rules&lt;/strong&gt; - patterns that tag web domains ("this one is ours", "this one is a competitor", "this one is irrelevant"). Rules attach to nodes in a three-level hierarchy that cascades top-down:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9d2ziu0qc0g9nsfwmdjp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9d2ziu0qc0g9nsfwmdjp.png" alt=" " width="799" height="329"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CLIENT            rules here apply to everything beneath
  └── PROJECT     rules here apply to every config of the project
        └── CONFIG   a specific target (search-engine × device × locale)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A write at a parent doesn't just save that node - it &lt;strong&gt;fans out&lt;/strong&gt;, recomputing every descendant's effective rule set by merging what it inherits from above with what it owns locally. The fully-resolved result per node is its &lt;strong&gt;materialized state&lt;/strong&gt; - what a given config actually sees at request time.&lt;/p&gt;

&lt;p&gt;That fan-out is the logic being duplicated. In Stage 0 it was a PHP loop, node by node, one database round-trip cluster per descendant - correct, and on the biggest parents, slow enough to time out (that's Part 3's whole story). In Stage 2 it's a stateless Go service that loads the subtree once and does the whole cascade in memory in a single pass. Same &lt;em&gt;meaning&lt;/em&gt;, two &lt;em&gt;implementations&lt;/em&gt;, running side by side.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why duplicate the logic at all?
&lt;/h2&gt;

&lt;p&gt;The honest answer: because you cannot safely delete the old engine until you've proven the new one agrees with it, and you cannot prove agreement without running both. Duplication isn't the accident here - it's the &lt;em&gt;method&lt;/em&gt;. The alternative is a flag day: swap PHP for Go in one deploy and find out in production whether they match. On a live business, that's not a migration, it's a bet.&lt;/p&gt;

&lt;p&gt;So for the length of Stage 2-3, the same cascade is computed by two engines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The legacy engine&lt;/strong&gt; - the PHP cascade inside the monolith. Mature, correct, and slow on big writes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The new engine&lt;/strong&gt; - the stateless rule-set-markup service in Go. Fast, and unproven until the agreement matrix says otherwise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A runtime &lt;strong&gt;master-switch&lt;/strong&gt; decides which engine's result is authoritative per request (that's Part 4's mechanism). But authoritative-or-not, &lt;em&gt;both&lt;/em&gt; can run - the new one in shadow, computing and persisting-or-diffing without being trusted yet. The duplication is the cost of admission for a reversible cutover.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part that surprises people: the new service writes into the monolith's database
&lt;/h2&gt;

&lt;p&gt;Here's the design decision that gets a raised eyebrow every time I explain it, so let me be blunt about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The new Go service does not have its own database. It writes directly into the monolith's PostgreSQL database.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not through the monolith's API. Not into a store of its own that gets synced back. It opens a connection to the &lt;em&gt;same&lt;/em&gt; Postgres the PHP monolith owns, and writes the scope links and rule rows there, in one transaction.&lt;/p&gt;

&lt;p&gt;Why on earth would a "microservice" reach straight into another service's database - the textbook cardinal sin? Because of &lt;em&gt;what stage we're in&lt;/em&gt;. The monolith is still the monolith: it is still the source of truth for rule &lt;strong&gt;data&lt;/strong&gt;. Moving the data out is a separate, later, riskier migration (Stage 4). What's moving &lt;em&gt;now&lt;/em&gt; is only the &lt;strong&gt;compute&lt;/strong&gt; - the cascade logic - not the storage. Decoupling those two migrations is deliberate:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwbz93rzfp9duysnirchk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwbz93rzfp9duysnirchk.png" alt=" " width="800" height="471"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LEVEL 1  two separate front doors — NOT connected to each other:

    ┌────────────────────┐                    ┌────────────────────────┐
    │  PHP monolith      │                    │  Go API gateway        │
    │  (PHP-FPM)         │                    │  front door for the    │
    │  legacy front door │                    │  new service           │
    └────────────────────┘                    └────────────────────────┘
             │  │                                   │ routes
      writes │  │ gRPC                              ▼
             │  │ id+hash            ┌────────────────────────────────┐
             │  │                    │  rule-set-markup service       │
             │  │                    │  new cascade · Go · STATELESS  │
             │  │                    │  routes + mirrors · no DB yet  │
             │  │                    └────────────────────────────────┘
             │  │                          │ writes directly   │ gRPC id+hash
LEVEL 2      │  └──────────┐               │        ┌──────────┘
             │             ▼               │        ▼
             │   ┌────────────────────────┐│   (same gRPC identity call)
             │   │  domain-rule-map svc   ││
             │   │  identity (id+hash)    ││
             │   │  a microservice, FOR   ││
             │   │  PERFORMANCE — never   ││
             │   │  touches the monolith  ││
             │   │  DB · its OWN DB       ││
             │   └────────────────────────┘│
LEVEL 3      │                             │
             ▼                             ▼
    ┌─────────────────────────────────────────────────────────────────┐
    │                   monolith PostgreSQL DB                         │
    │          owns the rule DATA — both engines' write sink           │
    └─────────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule of the seam is: &lt;strong&gt;migrate one thing at a time.&lt;/strong&gt; Extract the &lt;em&gt;identity&lt;/em&gt; decision first (Stage 1, its own service, its own store). Extract the &lt;em&gt;cascade compute&lt;/em&gt; next (Stage 2, a stateless service that borrows the monolith's DB). Extract the &lt;em&gt;data ownership&lt;/em&gt; last (Stage 4). If I tried to move compute and data in one step, I'd be debugging a new algorithm and a new storage layer at the same time, with no way to tell which one broke a case. By letting the new engine write the monolith's own tables, its output lands in &lt;em&gt;exactly&lt;/em&gt; the place the old engine wrote to - so "do they agree?" becomes a question I can answer by reading one database, not by reconciling two.&lt;/p&gt;

&lt;p&gt;The statelessness is what makes this safe rather than horrifying. The new service holds no state of its own - every input is read fresh from the monolith DB and the domain-rule-map service on each call, every output written straight back. There is nothing in the service to be &lt;em&gt;inconsistent&lt;/em&gt; with the database it borrows, because it keeps nothing. Restart it, run N replicas, kill it between phases: a killed call simply didn't commit, and the next call recomputes the same cascade from the same source of truth. Borrowing another service's database is a sin when the borrower is stateful and drifts; it's a controlled, temporary coupling when the borrower keeps nothing and the coupling is written down as a stage with an exit.&lt;/p&gt;

&lt;p&gt;Concretely, the new engine is a thin &lt;strong&gt;stateless&lt;/strong&gt; layer on the hot write path: it routes each request, mirrors the cascade into the monolith's own tables, and resolves identity from the domain-rule-map service over gRPC. That last point matters and the diagram makes it explicit - &lt;strong&gt;the domain-rule-map service never touches the monolith's database.&lt;/strong&gt; It owns identity (&lt;code&gt;id + hash&lt;/code&gt;) in its &lt;em&gt;own&lt;/em&gt; store, and it's a separate microservice for one reason: &lt;strong&gt;performance&lt;/strong&gt; - a single fast, immutable identity lookup that both engines share. &lt;em&gt;Both&lt;/em&gt; the legacy PHP monolith and the new Go engine ask it for identity over gRPC and then write the resolved rows into the monolith DB themselves; the identity service is on neither engine's write path to that DB. And because the markup service sits inline on every write, its latency budget is tight - "stateless" here also means "nothing to warm up, nothing to fsync, nothing between the request and the answer."&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 3: an agent that proves the two engines agree
&lt;/h2&gt;

&lt;p&gt;Two engines writing the same tables is only safe if something is relentlessly checking that they produce the &lt;em&gt;same truth&lt;/em&gt;. That something is an automated consistency agent, and it is the most important piece of the whole parallel run.&lt;/p&gt;

&lt;p&gt;Its job is narrow and it does not "test" in the unit-test sense. It runs the real business flow through the real front door and records a verdict against the database. One loop, per case:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enter through the gateway.&lt;/strong&gt; Every check goes through the &lt;strong&gt;Go API gateway&lt;/strong&gt;, not by calling a service directly. This matters: backward compatibility is a promise about what a &lt;em&gt;client&lt;/em&gt; observes through the front door, not about internal wiring. If the answer through the gateway is identical, the swap is invisible to everyone who matters - which is the entire definition of backward compatible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot the polygon.&lt;/strong&gt; Capture the current persisted state of a production &lt;em&gt;polygon&lt;/em&gt; - a safe, disposable slice of real data - as &lt;code&gt;before&lt;/code&gt;. You cannot verify a change you didn't measure first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the same input through both engines.&lt;/strong&gt; Same request, legacy engine and new engine. Dry-run by default: it computes and diffs but persists nothing. Real writes are gated behind an explicit flag and are reversible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff the persisted truth, not the response.&lt;/strong&gt; This is the lesson Part 1 was built on: don't trust the &lt;code&gt;200&lt;/code&gt;. Re-fetch what actually landed in the monolith DB from each engine and compare &lt;em&gt;those&lt;/em&gt;. A response that says &lt;code&gt;was_updated&lt;/code&gt; means nothing until the row agrees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore the polygon.&lt;/strong&gt; Leave the data exactly as found, so the next case starts from the same known state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the transcript to disk.&lt;/strong&gt; Input, both responses, both persisted states, the diff, the verdict. This path has no production logs - if the agent doesn't record what happened, nothing did.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The verdict is not "it works". It's a &lt;strong&gt;coordinate&lt;/strong&gt;: &lt;code&gt;(case, engine, deploy-version) -&amp;gt; agrees | differs&lt;/code&gt;. The cases are stable and numbered, so when a reviewer and I say "case 16" we mean the same scenario forever - the &lt;code&gt;project -&amp;gt; own&lt;/code&gt; markup move that stayed red in Part 1 until the identity fix landed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The consistency agent runs one case end to end and records a verdict.&lt;/span&gt;
&lt;span class="c"&gt;// It does not assert in a test runner; it diffs persisted truth and leaves&lt;/span&gt;
&lt;span class="c"&gt;// a transcript. Everything enters through the gateway - the real front door.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ConsistencyAgent&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;Case&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;polygon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"snapshot: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;polygon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Restore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// restore always, even on failure&lt;/span&gt;

    &lt;span class="c"&gt;// Same input, both engines, through the gateway. Dry-run unless gated.&lt;/span&gt;
    &lt;span class="n"&gt;legacy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EngineLegacy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrDryRun&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"legacy apply: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EngineMarkup&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrDryRun&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"markup apply: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// TRUTH, not the response body: read what each engine persisted.&lt;/span&gt;
    &lt;span class="n"&gt;legacyState&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;polygon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;legacy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;freshState&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;polygon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Case&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Deploy&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Agrees&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;legacyState&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;freshState&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c"&gt;// persisted-vs-persisted&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;legacy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;legacyState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;freshState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&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;&lt;code&gt;errors.Is(err, ErrDryRun)&lt;/code&gt; rather than &lt;code&gt;err == ErrDryRun&lt;/code&gt;, because the moment a decorator wraps that error a bare comparison goes silently false and the agent starts acting for real when it thinks it's dry - the exact class of quiet lie this whole series is about.&lt;/p&gt;




&lt;h2&gt;
  
  
  A verdict is a grid, and the grid is the backward-compatibility argument
&lt;/h2&gt;

&lt;p&gt;One run produces one fact: &lt;em&gt;case X agreed (or didn't) between the two engines on deploy Y.&lt;/em&gt; Stack those facts and you get the only artifact that lets me flip an engine to master with a straight face:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz0d8n56gcru0v4gb8uj3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz0d8n56gcru0v4gb8uj3.png" alt=" " width="800" height="344"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;legacy vs new &lt;code&gt;v4.6.0&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;legacy vs new &lt;code&gt;v4.6.1&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4 - plain create&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 - restamp own→project&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 - move project→own&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18 - shrinking REMOVE&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22 - cascade to N configs&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ❌→✅ flips between two deploy tags &lt;em&gt;are&lt;/em&gt; the compatibility proof. I don't promote the new engine because the code looks right; I promote it because every case that used to differ now agrees, on the deploy that's actually running, with a transcript on disk. Backward compatibility stops being a hope and becomes a grid you can point at.&lt;/p&gt;

&lt;p&gt;And the grid earns its keep in the ugly direction too. When the new engine diverges on a case, the diff is &lt;em&gt;specific&lt;/em&gt; - this config, these rules, this field - because both engines wrote the same tables and I'm comparing rows, not vibes. A divergence is a bug report with coordinates, not a "sometimes it's off" ticket.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest edges of running two engines
&lt;/h2&gt;

&lt;p&gt;I'd be lying if I sold duplication as free. The costs are real and worth naming:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Drift.&lt;/strong&gt; Two implementations of one algorithm will drift unless something forces them together. The agreement agent &lt;em&gt;is&lt;/em&gt; that force - but it only catches what's in the case matrix. A behavior nobody wrote a case for can diverge silently. The mitigation is that the matrix grew out of real production incidents (Part 1's cases came from a 21-case write-matrix), so it covers the shapes that actually bite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A shared database is a coupling with a deadline.&lt;/strong&gt; The new engine writing the monolith's tables is a temporary, documented coupling, not a pattern to copy. It has an exit (Stage 4). The danger is letting "temporary" become "forever" - so the coupling is written into the stage plan with the step that removes it, not left as tribal knowledge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two write paths is two chances to be wrong.&lt;/strong&gt; The parallel run &lt;em&gt;doubles&lt;/em&gt; the surface where a bug can hide, for the duration. That's the price of not doing a flag day. You buy reversibility with complexity, and you pay it back the moment you delete the legacy path in Stage 4.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these is a reason not to run in parallel. They're the reasons the consistency agent is not optional.&lt;/p&gt;




&lt;h2&gt;
  
  
  AI as a multiplier, and what it can't do for a parallel run
&lt;/h2&gt;

&lt;p&gt;I lean hard on AI coding assistants for work like this, and my take hasn't shifted across the series: &lt;strong&gt;AI amplifies a good engineer and exposes a weak one.&lt;/strong&gt; It's a multiplier, not a crutch.&lt;/p&gt;

&lt;p&gt;AI made the &lt;em&gt;mechanics&lt;/em&gt; of the parallel run cheap - generating the second engine's scaffolding, the agent loop, the snapshot/restore, the transcript serializer, the matrix formatter. Fast and genuinely useful. What it did not do is any of the judgment the parallel run turns on. It won't tell you to migrate compute before data, or that the new service borrowing the monolith's database is safe &lt;em&gt;only because it's stateless and the coupling has an exit&lt;/em&gt;. It won't insist you diff persisted rows instead of the response, because the response looks authoritative and a model has no scar from a &lt;code&gt;200&lt;/code&gt; that lied. It won't decide when the grid is green enough to promote an engine - that's a risk call a human owns. Point a multiplier at a disciplined parallel run - staged migration, stateless borrowing, agreement proven against persisted truth through the front door - and it collapses "fast &lt;em&gt;or&lt;/em&gt; safe" into fast &lt;em&gt;and&lt;/em&gt; safe. Point it at "the new service passes its tests, cut over" and it'll help you build a flag day faster than you can schedule the incident review.&lt;/p&gt;




&lt;h2&gt;
  
  
  This is a companion to Part 1
&lt;/h2&gt;

&lt;p&gt;Part 1 was one bug across two code paths. This part is the same discipline scaled up to two whole &lt;em&gt;services&lt;/em&gt;: prove they agree on persisted truth before you trust either one. The seam keeps moving:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1&lt;/strong&gt; - &lt;a href="https://medium.com/@brilliantov.anton/breaking-the-monolith-part-1-a-200-ok-that-saved-nothing-4c013dad5fc7" rel="noopener noreferrer"&gt;A &lt;code&gt;200 OK&lt;/code&gt; that saved nothing: the silent no-op, and why identity must live in exactly one place.&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2 - &lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-2-who-owns-a-hash-function-4h3f"&gt;Who owns identity&lt;/a&gt;:&lt;/strong&gt; why the ownership of a hash function is an architecture decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3 - &lt;a href="https://dev.to/anton_brilliantov/breaking-the-monolith-part-3-the-cascade-that-timed-out-43pa"&gt;The cascade that timed out&lt;/a&gt;:&lt;/strong&gt; why the write engine left PHP for a stateless Go service - the extraction this article runs in parallel with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4 - Flipping the master:&lt;/strong&gt; the runtime switch that decides which engine is authoritative, and how the parallel run ends.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build serious backends - Symfony, Go, or the messy space between a monolith and the services growing out of it - follow along. And if you're mid-extraction right now with old and new code computing the same thing: &lt;em&gt;how are you proving they agree - on the response, or on what actually persisted?&lt;/em&gt; I'd genuinely like to compare notes.&lt;/p&gt;

</description>
      <category>microservices</category>
      <category>go</category>
      <category>testing</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
