<?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>How Others Test Cross-Zone Locally</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Fri, 06 Nov 2026 05:04:07 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/how-others-test-cross-zone-locally-4g7k</link>
      <guid>https://dev.to/anton_brilliantov/how-others-test-cross-zone-locally-4g7k</guid>
      <description>&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%2F0aicx2awcal8oymck01z.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%2F0aicx2awcal8oymck01z.png" alt="How Others Test Cross-Zone Locally - the cover: eight tools, one comparison" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  How Others Test Cross-Zone Locally
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;tc netem, Toxiproxy, Pumba, Chaos Mesh, LitmusChaos, zone labels in kind and k3d, Jepsen and Testcontainers: what each gives, a short config form for each, and an honest comparison with what we built.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer. These days I work on a platform: contracts, generated code, data paths and the tooling around them. 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;.&lt;/p&gt;

&lt;p&gt;Every claim about a tool below links to its own documentation, read for this article. Our side of the comparison is what part 3 built; our failure-scenario runs are still planned.&lt;/p&gt;

&lt;p&gt;This is part 5 of the series on testing cross-zone, cross-datacenter and cross-provider behaviour on a laptop. Maybe you already do it better, maybe you'd do it another way - this is how it is going, and where it stops.&lt;/p&gt;




&lt;h2&gt;
  
  
  Five layers
&lt;/h2&gt;

&lt;p&gt;Part 4, &lt;em&gt;Placement In Every Trace&lt;/em&gt;, made every span say where it ran. Before going further with our own emulation, it is worth looking at what the world already uses. The tools sort into layers by where they act:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tools&lt;/th&gt;
&lt;th&gt;Acts on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;kernel&lt;/td&gt;
&lt;td&gt;tc netem&lt;/td&gt;
&lt;td&gt;packets on a device&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TCP proxy&lt;/td&gt;
&lt;td&gt;Toxiproxy, Testcontainers' Toxiproxy module&lt;/td&gt;
&lt;td&gt;one connection through a proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;container&lt;/td&gt;
&lt;td&gt;Pumba&lt;/td&gt;
&lt;td&gt;a container's namespace, from outside&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes&lt;/td&gt;
&lt;td&gt;Chaos Mesh, LitmusChaos, zone labels in kind and k3d&lt;/td&gt;
&lt;td&gt;pods selected by labels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;history&lt;/td&gt;
&lt;td&gt;Jepsen&lt;/td&gt;
&lt;td&gt;what the system did, checked afterwards&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%2Fhzro5j1ej8ri5jcjcmgz.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%2Fhzro5j1ej8ri5jcjcmgz.png" alt="Five tiles stacked by layer: kernel - netem; TCP proxy - Toxiproxy, Testcontainers; container - Pumba; Kubernetes - Chaos Mesh, Litmus, kind and k3d labels; history - Jepsen" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The lower the layer, the more it can do and the less it knows about your system. The higher, the more it knows and the more it needs installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  tc netem
&lt;/h2&gt;

&lt;p&gt;Everything network-shaped in this list ends at the kernel's netem sooner or later. It adds delay, jitter and loss to packets leaving a device (&lt;a href="https://man7.org/linux/man-pages/man8/tc-netem.8.html" rel="noopener noreferrer"&gt;tc-netem(8)&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tc qdisc change dev eth0 root netem delay 100ms 20ms distribution normal
tc qdisc change dev eth0 root netem loss 0.3% 25%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The man page has one warning worth repeating: for TCP performance tests, netem should sit on the ingress of the receiver, not on the sender. On its own, netem knows nothing about which peer is which; that is up to the filters around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Toxiproxy
&lt;/h2&gt;

&lt;p&gt;Toxiproxy is "a TCP proxy to simulate network and system conditions for chaos and resiliency testing" (&lt;a href="https://github.com/Shopify/toxiproxy" rel="noopener noreferrer"&gt;Toxiproxy&lt;/a&gt;). The client connects to the proxy instead of the real service, and toxics are added to the proxy over an HTTP API on port 8474:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;toxiproxy-cli create &lt;span class="nt"&gt;-l&lt;/span&gt; localhost:26379 &lt;span class="nt"&gt;-u&lt;/span&gt; localhost:6379 redis
toxiproxy-cli toxic add &lt;span class="nt"&gt;-t&lt;/span&gt; latency &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nv"&gt;latency&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000 redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The toxics are latency, down, bandwidth, slow_close, timeout, reset_peer, slicer, limit_data and packet_loss. That is a richer vocabulary than netem has, at one price: the client must be pointed at the proxy.&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%2Fb3gu9x3prbj3vnnsn0p1.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%2Fb3gu9x3prbj3vnnsn0p1.png" alt="A dark terminal card: two netem lines and two toxiproxy-cli lines, the latency toxic highlighted" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Pumba
&lt;/h2&gt;

&lt;p&gt;Pumba does chaos and network emulation for containers on Docker, containerd and Podman (&lt;a href="https://github.com/alexei-led/pumba" rel="noopener noreferrer"&gt;Pumba&lt;/a&gt;). It runs &lt;code&gt;tc&lt;/code&gt; from a helper that joins the target's namespace, so the target image needs nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pumba netem &lt;span class="nt"&gt;--duration&lt;/span&gt; 5m delay &lt;span class="nt"&gt;--time&lt;/span&gt; 3000 mydb
pumba iptables &lt;span class="nt"&gt;--duration&lt;/span&gt; 2m loss &lt;span class="nt"&gt;--probability&lt;/span&gt; 0.1 myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the same trick as our sidecar in part 3, packaged as a tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chaos Mesh and LitmusChaos
&lt;/h2&gt;

&lt;p&gt;In Kubernetes, network chaos becomes a resource. Chaos Mesh's &lt;code&gt;NetworkChaos&lt;/code&gt; supports netem, delay, loss, duplicate, corrupt, partition and bandwidth, selects pods by label and has a &lt;code&gt;direction&lt;/code&gt; of to, from or both (&lt;a href="https://chaos-mesh.org/docs/simulate-network-chaos-on-kubernetes/" rel="noopener noreferrer"&gt;Chaos Mesh&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chaos-mesh.org/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkChaos&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;delay-example&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;delay&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;one&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;labelSelectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;frontend&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;latency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;20ms'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;jitter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;5ms'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;correlation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&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%2F57s5hghcbajhu3fkd0hq.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%2F57s5hghcbajhu3fkd0hq.png" alt="A dark card with the NetworkChaos YAML: action delay, mode one, a label selector, latency and jitter, the delay line highlighted" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;LitmusChaos, a CNCF Incubating project, describes itself as "a Cloud-Native Chaos Engineering Framework with cross-cloud support" (&lt;a href="https://docs.litmuschaos.io/docs/introduction/what-is-litmus" rel="noopener noreferrer"&gt;Litmus&lt;/a&gt;). Its &lt;code&gt;pod-network-latency&lt;/code&gt; experiment starts tc with netem inside the target, takes &lt;code&gt;NETWORK_LATENCY&lt;/code&gt;, &lt;code&gt;JITTER&lt;/code&gt; and &lt;code&gt;DESTINATION_IPS&lt;/code&gt; or &lt;code&gt;DESTINATION_HOSTS&lt;/code&gt; to aim at one peer, and can use Pumba as its library (&lt;a href="https://litmuschaos.github.io/litmus/experiments/categories/pods/pod-network-latency/" rel="noopener noreferrer"&gt;pod-network-latency&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Zones in kind and k3d
&lt;/h2&gt;

&lt;p&gt;Zones on a laptop cluster are node labels. kind takes them in the cluster config (&lt;a href="https://kind.sigs.k8s.io/docs/user/configuration/" rel="noopener noreferrer"&gt;kind&lt;/a&gt;); k3d takes them on the command line with a node filter (&lt;a href="https://k3d.io/stable/usage/commands/k3d_cluster_create/" rel="noopener noreferrer"&gt;k3d&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kind&lt;/span&gt;
&lt;span class="na"&gt;nodes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;topology.kubernetes.io/zone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;a&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;topology.kubernetes.io/zone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;b&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# k3d&lt;/span&gt;
k3d cluster create &lt;span class="nt"&gt;--agents&lt;/span&gt; 3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--k3s-node-label&lt;/span&gt; &lt;span class="s2"&gt;"topology.kubernetes.io/zone=a@agent:0"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--k3s-node-label&lt;/span&gt; &lt;span class="s2"&gt;"topology.kubernetes.io/zone=b@agent:1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--k3s-node-label&lt;/span&gt; &lt;span class="s2"&gt;"topology.kubernetes.io/zone=c@agent:2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;topologySpreadConstraints&lt;/code&gt; spread the pods. &lt;code&gt;DoNotSchedule&lt;/code&gt; tells the scheduler not to place a pod that would break the skew; &lt;code&gt;ScheduleAnyway&lt;/code&gt; places it anyway, preferring nodes that reduce the skew (&lt;a href="https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/" rel="noopener noreferrer"&gt;Kubernetes&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;topologySpreadConstraints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;maxSkew&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;topologyKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;topology.kubernetes.io/zone&lt;/span&gt;
    &lt;span class="na"&gt;whenUnsatisfiable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DoNotSchedule&lt;/span&gt;
    &lt;span class="na"&gt;labelSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;my-app&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fffo3zv59uopo66c26z2p.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fffo3zv59uopo66c26z2p.gif" alt="An animation: three zone nodes; pods appear one by one and land in turn in zone a, b and c; a skew counter stays at one" width="800" height="405"&gt;&lt;/a&gt;&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%2F2p00jma2iaeohy7vexy6.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%2F2p00jma2iaeohy7vexy6.png" alt="A dark card with the kind node labels and a spread constraint on the zone key, DoNotSchedule highlighted" width="800" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Labels and spread give zones as a scheduling fact. They give no distance: two "zones" on a laptop are as close as two processes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Jepsen
&lt;/h2&gt;

&lt;p&gt;Jepsen is at a different layer altogether. It "aims to improve the safety of distributed databases, queues, consensus systems" (&lt;a href="https://jepsen.io/" rel="noopener noreferrer"&gt;Jepsen&lt;/a&gt;). While a test runs operations against a system, a nemesis process introduces faults, and a checker then analyses the recorded history for correctness (&lt;a href="https://github.com/jepsen-io/jepsen" rel="noopener noreferrer"&gt;jepsen-io/jepsen&lt;/a&gt;). Network manipulation in its tests is typically done with iptables.&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%2F0chuoii0qto626zku3wx.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%2F0chuoii0qto626zku3wx.png" alt="A strip of three panels: operations - clients write and read; nemesis - faults injected, a partition; checker - the history is checked for correctness" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What Jepsen adds is not a better way to cut a network. It is a rigorous answer to "was the result correct", which every tool above leaves to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testcontainers
&lt;/h2&gt;

&lt;p&gt;For tests in code, Testcontainers wraps Toxiproxy as a module. A &lt;code&gt;ToxiproxyContainer&lt;/code&gt; joins the same network as the service under test, a proxy is created in front of it, and toxics are added from the test (&lt;a href="https://java.testcontainers.org/modules/toxiproxy/" rel="noopener noreferrer"&gt;Testcontainers&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toxics&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;latency&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"latency"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ToxicDirection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DOWNSTREAM&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1_100&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;setJitter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toxics&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;bandwidth&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CUT_CONNECTION_DOWNSTREAM"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ToxicDirection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DOWNSTREAM&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toxics&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;bandwidth&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CUT_CONNECTION_UPSTREAM"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ToxicDirection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UPSTREAM&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A connection cut is bandwidth zero in both directions; removing the toxics restores it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side by side
&lt;/h2&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;What the world gives&lt;/th&gt;
&lt;th&gt;What we built&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;latency and loss&lt;/td&gt;
&lt;td&gt;netem directly; Pumba, Chaos Mesh, Litmus on top of it; Toxiproxy per connection&lt;/td&gt;
&lt;td&gt;the same netem, but every rule derived from one topology file, per pair of sites&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;zones&lt;/td&gt;
&lt;td&gt;node labels in kind and k3d, spread constraints&lt;/td&gt;
&lt;td&gt;the same in k3d, plus compose with a subnet per site and zone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;continents and providers&lt;/td&gt;
&lt;td&gt;not a concept in any of these tools&lt;/td&gt;
&lt;td&gt;levels of the declaration, with link kinds derived from them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;partitions&lt;/td&gt;
&lt;td&gt;Chaos Mesh partition, Jepsen's nemesis, Toxiproxy down&lt;/td&gt;
&lt;td&gt;iptables DROP on both sides, each cut remembering its cause&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;checking the result&lt;/td&gt;
&lt;td&gt;Jepsen's checker on the history&lt;/td&gt;
&lt;td&gt;QA thresholds declared; the runs are planned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;what we lack&lt;/td&gt;
&lt;td&gt;bandwidth limits, a Jepsen-grade history check&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%2Fd7s92i7nqz2os2u5fcy6.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%2Fd7s92i7nqz2os2u5fcy6.png" alt="A matrix: rows latency, zones, continents and providers, partitions, checking; columns the world and ours; ours marked built on the first four and planned on checking" width="800" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The honest summary: we did not invent a new way to delay a packet. Under the hood it is netem and iptables, like most of the list. What is ours is the model above them - one declaration of a planet, from which every rule, every placement and every chaos action is derived. None of the tools above has a continent or a provider as a concept.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would borrow
&lt;/h2&gt;

&lt;p&gt;Two things, both from the top of the list. Toxiproxy's vocabulary - slow_close, reset_peer, limit_data - names failures that netem cannot express. And Jepsen's stance: a chaos run without a strict check of the result is a demonstration, not a test. Both are ideas, not plans yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do not claim
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Not a ranking of these tools. Each is good at its own layer.&lt;/li&gt;
&lt;li&gt;Not that our emulation does what Jepsen does. It does not check histories.&lt;/li&gt;
&lt;li&gt;Not that any of our scenarios has run. They are planned.&lt;/li&gt;
&lt;li&gt;Not completeness. These are the tools I read for this part, not all of them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What of this is method
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Know the layer of a tool.&lt;/strong&gt; Kernel, proxy, container, cluster, history - each sees a different system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the documentation, not the reputation.&lt;/strong&gt; Every claim here has a link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cut is not a test.&lt;/strong&gt; The check of the result is the test.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Cross-anything on a laptop - Part 5.&lt;/strong&gt; Next: &lt;em&gt;Every Verdict Carries A Trace&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you use Toxiproxy in tests&lt;/strong&gt;, which toxic found the most? If &lt;strong&gt;you run Chaos Mesh or Litmus&lt;/strong&gt;, do you run it locally or only on a shared cluster? If &lt;strong&gt;you have run Jepsen&lt;/strong&gt;, what did the checker catch that nothing else would? Which of these would you add to a laptop first?&lt;/p&gt;

</description>
      <category>testing</category>
      <category>kubernetes</category>
      <category>docker</category>
      <category>devops</category>
    </item>
    <item>
      <title>Placement In Every Trace</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Thu, 05 Nov 2026 09:35:07 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/placement-in-every-trace-4bol</link>
      <guid>https://dev.to/anton_brilliantov/placement-in-every-trace-4bol</guid>
      <description>&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%2F1ru29p4oy8pilypq2ljd.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%2F1ru29p4oy8pilypq2ljd.png" alt="Placement In Every Trace - the cover: four placement variables flowing into traces, metrics and logs" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Placement In Every Trace
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Four variables from the topology - provider, region, site, zone - become trace attributes, metric labels and log fields, so a chaos run can be read by where things ran. Metadata only; it routes nothing.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer. These days I work on a platform: contracts, generated code, data paths and the tooling around them. 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;.&lt;/p&gt;

&lt;p&gt;The four variables are built and set on every copy. The platform change that turns them into telemetry is in review: unit tests are green, the full CI run is still pending. Nothing below is shipped.&lt;/p&gt;

&lt;p&gt;This is part 4 of the series on testing cross-zone, cross-datacenter and cross-provider behaviour on a laptop. Maybe you already do it better, maybe you'd do it another way - this is how it is going, and where it stops.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why placement belongs in telemetry
&lt;/h2&gt;

&lt;p&gt;Part 3, &lt;em&gt;A Planet On A Laptop&lt;/em&gt;, placed every copy of a service on a planet and gave the network between them distance. That opens a question the old telemetry cannot answer: when a zone goes down during a run, which spans were in it?&lt;/p&gt;

&lt;p&gt;A trace that says "the gateway called the store, and it was slow" is half a fact. The other half is where each end was. Without it, a chaos run produces a pile of traces that cannot be sorted by the one thing the run changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four variables
&lt;/h2&gt;

&lt;p&gt;Placement already tells every copy where it is. Each gets four environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;APP_PROVIDER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;prov-a
&lt;span class="nv"&gt;APP_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eu-frankfurt
&lt;span class="nv"&gt;APP_SITE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dc-a1
&lt;span class="nv"&gt;APP_ZONE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dc-a1-z1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They come from the deterministic placement of part 3 - round-robin over providers, datacenters, zones - so the same declaration always puts the same copy in the same place. That part is built.&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%2Fqvnqw58gwflf3diqyjei.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%2Fqvnqw58gwflf3diqyjei.png" alt="A flow: placement, four environment variables, the platform, then three outputs - traces, metrics, logs; a pill reads in review" width="799" height="522"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three signals
&lt;/h2&gt;

&lt;p&gt;The platform reads the four variables once at start and writes them into all three signals:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Trace attribute&lt;/th&gt;
&lt;th&gt;Metrics&lt;/th&gt;
&lt;th&gt;Logs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;APP_PROVIDER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cloud.provider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a &lt;code&gt;platform_info&lt;/code&gt; label&lt;/td&gt;
&lt;td&gt;a field on every line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;APP_REGION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cloud.region&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a &lt;code&gt;platform_info&lt;/code&gt; label&lt;/td&gt;
&lt;td&gt;a field on every line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;APP_SITE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a site attribute&lt;/td&gt;
&lt;td&gt;a &lt;code&gt;platform_info&lt;/code&gt; label&lt;/td&gt;
&lt;td&gt;a field on every line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;APP_ZONE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cloud.availability_zone&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a &lt;code&gt;platform_info&lt;/code&gt; label&lt;/td&gt;
&lt;td&gt;a field on every line&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%2Ftxfejfi0k98ydjpx8oi9.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%2Ftxfejfi0k98ydjpx8oi9.png" alt="Three icon tiles: a trace with cloud.provider, cloud.region, cloud.availability_zone and site; a metric with platform_info labels; a log line with the same four fields" width="799" height="349"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On a span's resource it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"service.name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;service&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"deployment.environment.name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;env&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cloud.provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prov-a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cloud.region"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eu-frankfurt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cloud.availability_zone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dc-a1-z1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"&amp;lt;site attribute&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dc-a1"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&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%2Fmwre3jesg8aa30nn0oon.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%2Fmwre3jesg8aa30nn0oon.png" alt="A dark JSON card with a span resource: service name, environment name, cloud provider, region, availability zone and a site attribute; the availability zone line highlighted; a pill reads in review" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With that in every span, a trace of a request that crossed zones shows the crossing. The waterfall below is the picture we want to read after a run:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fspejcdx1szl0yrt2yrc5.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fspejcdx1szl0yrt2yrc5.gif" alt="An animation: a trace waterfall grows span by span - the gateway in zone 1, a store in zone 2, a cross-continent call - each bar carrying its zone" width="799" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the convention has, and what it does not
&lt;/h2&gt;

&lt;p&gt;Three of the four names are not ours. OpenTelemetry's semantic conventions define a &lt;code&gt;cloud&lt;/code&gt; resource with &lt;code&gt;cloud.provider&lt;/code&gt; - the name of the cloud provider - &lt;code&gt;cloud.region&lt;/code&gt; - the geographical region within a provider - and &lt;code&gt;cloud.availability_zone&lt;/code&gt; - the zone where the resource runs (&lt;a href="https://opentelemetry.io/docs/specs/semconv/resource/cloud/" rel="noopener noreferrer"&gt;OpenTelemetry semconv&lt;/a&gt;). Using them means any backend that knows the convention can group by them without being taught.&lt;/p&gt;

&lt;p&gt;Two caveats, both from the same page. The &lt;code&gt;cloud&lt;/code&gt; attributes are marked Development, not stable. And there is no attribute for a site - a datacenter inside a region. That one is ours, and it is named plainly as a site attribute until the convention has something better.&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%2Ftw89fwv56ggd7ijxgrag.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%2Ftw89fwv56ggd7ijxgrag.png" alt="Four tiles: cloud.provider, cloud.region and cloud.availability_zone with a pill Development, from the convention; site with a pill ours, no convention yet" width="800" height="313"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Continent is not on the list at all. It is an attribute of the stand's topology, not of a running copy: QA's proposal keeps it there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metadata only
&lt;/h2&gt;

&lt;p&gt;This is the part most likely to be misread, so it gets its own section. The four variables change nothing about where a request goes. No router reads them, no client prefers a closer copy, no load balancer weights by zone.&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%2Frybiodxk7cqaf1fcwfwh.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%2Frybiodxk7cqaf1fcwfwh.png" alt="Two halves: before - requests routed as they were; after - requests routed exactly the same way, now labelled with where they ran" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Placement-aware routing is a real design and a different one. Mixing it into a telemetry change would make every run answer two questions at once. This change answers one: where did it run.&lt;/p&gt;

&lt;h2&gt;
  
  
  One environment name
&lt;/h2&gt;

&lt;p&gt;The same change fixes a smaller thing that kept getting in the way. An environment had several names in several places. Now one variable, &lt;code&gt;APP_ENV&lt;/code&gt;, feeds all three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;code&gt;env&lt;/code&gt; label on every metric;&lt;/li&gt;
&lt;li&gt;the path in the configuration and secret store;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deployment.environment.name&lt;/code&gt; on every trace.&lt;/li&gt;
&lt;/ul&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%2Fr51piko0dkt0s9bq5fb4.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%2Fr51piko0dkt0s9bq5fb4.png" alt="A flow: APP_ENV into three tiles - the env label of metrics, the configuration store path, deployment.environment.name of traces; a pill reads in review" width="799" height="522"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A run on &lt;code&gt;qa-1&lt;/code&gt; can no longer write metrics as &lt;code&gt;qa&lt;/code&gt;, read secrets of &lt;code&gt;dev&lt;/code&gt;, and trace as something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is for
&lt;/h2&gt;

&lt;p&gt;The point of all this is reading a chaos run by where things ran: filter the traces of a "zone down" scenario by &lt;code&gt;cloud.availability_zone&lt;/code&gt;, and see who noticed and who did not. That reading needs the scenarios to run, and they are planned. QA's proposal already lists the placement attributes a run report should carry: &lt;code&gt;deployment.environment.name&lt;/code&gt;, &lt;code&gt;cloud.provider&lt;/code&gt;, &lt;code&gt;cloud.region&lt;/code&gt;, &lt;code&gt;site&lt;/code&gt;, &lt;code&gt;cloud.availability_zone&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do not claim
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Not that this is shipped. It is in review; the full CI run is pending.&lt;/li&gt;
&lt;li&gt;Not that the &lt;code&gt;cloud&lt;/code&gt; attributes are stable. The convention marks them Development.&lt;/li&gt;
&lt;li&gt;Not placement-aware routing. This is metadata only.&lt;/li&gt;
&lt;li&gt;Not a chaos run read by zone. The runs are planned.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What of this is method
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Placement is a fact about every signal.&lt;/strong&gt; Traces, metrics and logs carry the same four fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the convention where it exists.&lt;/strong&gt; Name your own only where it does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata is not routing.&lt;/strong&gt; One change, one question.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Cross-anything on a laptop - Part 4.&lt;/strong&gt; Next: &lt;em&gt;How Others Test Cross-Zone Locally&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you already put &lt;code&gt;cloud.availability_zone&lt;/code&gt; on spans&lt;/strong&gt;, what do you put for a site? If &lt;strong&gt;you route by zone&lt;/strong&gt;, how do you keep it apart from telemetry? If &lt;strong&gt;you have one environment with three names&lt;/strong&gt;, which one wins? Which question would you ask first of a trace from a chaos run?&lt;/p&gt;

</description>
      <category>observability</category>
      <category>devops</category>
      <category>platformengineering</category>
      <category>architecture</category>
    </item>
    <item>
      <title>A Planet On A Laptop</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Wed, 04 Nov 2026 09:20:26 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/a-planet-on-a-laptop-5g1b</link>
      <guid>https://dev.to/anton_brilliantov/a-planet-on-a-laptop-5g1b</guid>
      <description>&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%2F7g23i1yk7re9dh7aigrn.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%2F7g23i1yk7re9dh7aigrn.png" alt="A Planet On A Laptop - the cover: two continents, providers and datacenters drawn as nested rings, measured round trip close to declared" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  A Planet On A Laptop
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Continents, providers, datacenters and zones declared in one file, turned into netem and iptables rules inside every container, broken at every level on purpose - and a measured round trip that came out close to what was declared.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer. These days I work on a platform: contracts, generated code, data paths and the tooling around them. 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;.&lt;/p&gt;

&lt;p&gt;The topology emulation is built and has unit tests; placement and links were applied on a laptop and a round trip was measured. The failure scenarios on top of it are written and not run - the owner postponed them.&lt;/p&gt;

&lt;p&gt;This is part 3 of the series on testing cross-zone, cross-datacenter and cross-provider behaviour on a laptop. Maybe you already do it better, maybe you'd do it another way - this is how it is going, and where it stops.&lt;/p&gt;




&lt;h2&gt;
  
  
  One file
&lt;/h2&gt;

&lt;p&gt;Part 2, &lt;em&gt;Four Failure Domains&lt;/em&gt;, ended with a ladder: zone, datacenter, provider, continent. This part puts that ladder on a laptop.&lt;/p&gt;

&lt;p&gt;Everything starts from one declaration file. It is switched on by an environment variable, and only in the high-availability profile - a normal developer environment does not pay for it. The form, with cities and round trips as an example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;continents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;eu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;prov-a&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;dc-a1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;city&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;Frankfurt&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;eu-frankfurt&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;zones&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;dc-a1-z1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;dc-a1-z2&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;dc-a2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;city&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;Amsterdam&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;…&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;prov-b&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;dc-b1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;city&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;Paris&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;…&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;us&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;prov-b&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;dc-b2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;city&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;Virginia&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;…&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;links&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;intra_dc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;rtt_ms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0.5&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;jitter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0.1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;loss&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;pairs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;dc-a1~dc-a2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;rtt_ms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;11&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;jitter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;loss&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;dc-a1~dc-b2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;rtt_ms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;87&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;jitter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;loss&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0.1&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;clients&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;client-eu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;continent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;eu&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;dc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;dc-a1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;rtt_ms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;20&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;jitter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;placement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;   &lt;span class="c1"&gt;# override &amp;lt;service|cluster&amp;gt;: [zone, …]&lt;/span&gt;
&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%2Ftpl2vqqxsbui3pgvitmx.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%2Ftpl2vqqxsbui3pgvitmx.png" alt="Two continent rings side by side; inside the left one two providers, inside them datacenters, inside those zone dots; the right ring holds one datacenter of the second provider; links drawn between datacenters" width="800" height="534"&gt;&lt;/a&gt;&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%2F1p6ij3ptionfbp4wdu52.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%2F1p6ij3ptionfbp4wdu52.png" alt="A dark card with the declaration: continents, providers, datacenters with a city and zones, links with rtt, jitter and loss, a client, an empty placement override; the transatlantic link highlighted" width="800" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the numbers come from
&lt;/h2&gt;

&lt;p&gt;The round trips in the file are not guesses. Each is the median of public ping data between the two cities, cross-checked against a second public source. The declaration says what the internet between those cities looks like, not what we wish it did.&lt;/p&gt;

&lt;p&gt;The kind of every link is derived from where its ends are, in the order from part 2: intra-dc, intra-metro, intra-continent, inter-continent. Continent ranks above provider; provider is only same or cross.&lt;/p&gt;

&lt;p&gt;Placement is deterministic: copies are dealt round-robin over providers, then datacenters, then zones. Three copies land in three datacenters; a fourth goes to the other continent. Each copy is told where it is through four variables - &lt;code&gt;APP_PROVIDER&lt;/code&gt;, &lt;code&gt;APP_REGION&lt;/code&gt;, &lt;code&gt;APP_SITE&lt;/code&gt;, &lt;code&gt;APP_ZONE&lt;/code&gt; - and part 4 is about what happens to them next.&lt;/p&gt;

&lt;h2&gt;
  
  
  From a declaration to rules
&lt;/h2&gt;

&lt;p&gt;The declaration becomes a model, and the model becomes rules inside every container.&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%2Fefztunoxgt675gcwmfmr.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%2Fefztunoxgt675gcwmfmr.png" alt="A flow: declaration, model, a sidecar per container, rules in the container's namespace; a pill reads built, unit tests" width="800" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rules are applied from a sidecar that joins the container's network namespace - so no service image needs &lt;code&gt;tc&lt;/code&gt;, &lt;code&gt;iptables&lt;/code&gt; or extra capabilities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--net&lt;/span&gt; container:&amp;lt;name&amp;gt; &lt;span class="nt"&gt;--cap-add&lt;/span&gt; NET_ADMIN nicolaka/netshoot &lt;span class="se"&gt;\&lt;/span&gt;
  sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'
    tc qdisc add dev eth0 root handle 1: prio bands &amp;lt;one per peer&amp;gt;
    tc qdisc add dev eth0 parent 1:&amp;lt;band&amp;gt; netem delay &amp;lt;rtt/2&amp;gt; &amp;lt;jitter&amp;gt; loss &amp;lt;loss&amp;gt;
    tc filter add dev eth0 parent 1: protocol ip u32 match ip dst &amp;lt;peer subnet&amp;gt; flowid 1:&amp;lt;band&amp;gt;
  '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;prio&lt;/code&gt; qdisc gives one band per peer; &lt;code&gt;netem&lt;/code&gt; in the band adds half the round trip, the jitter and the loss; a &lt;code&gt;u32&lt;/code&gt; filter sends traffic for the peer's subnet into its band. The mirror rule on the other side adds the other half, so the round trip equals the declared one. The netem syntax is the kernel's own (&lt;a href="https://man7.org/linux/man-pages/man8/tc-netem.8.html" rel="noopener noreferrer"&gt;tc-netem(8)&lt;/a&gt;); the sidecar image is &lt;a href="https://github.com/nicolaka/netshoot" rel="noopener noreferrer"&gt;netshoot&lt;/a&gt;.&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%2Ff7zirvk8oxn38yx966p5.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%2Ff7zirvk8oxn38yx966p5.png" alt="A dark terminal card with the sidecar command and three tc lines - prio, netem delay half the rtt with jitter and loss, a u32 filter on the peer subnet - and one iptables DROP line for a cut" width="800" height="414"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rules are re-applied on every up, start and restart, because a recreated container comes back with a fresh namespace and none of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A cut is a DROP
&lt;/h2&gt;

&lt;p&gt;A link cut is not a stopped container. It is a rule on both sides:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;iptables &lt;span class="nt"&gt;-I&lt;/span&gt; OUTPUT &lt;span class="nt"&gt;-d&lt;/span&gt; &amp;lt;peer subnet&amp;gt; &lt;span class="nt"&gt;-j&lt;/span&gt; DROP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both ends stay alive and both believe they are fine; only the path between them is gone. That is the case a stopped container never shows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compose: one network, many subnets
&lt;/h2&gt;

&lt;p&gt;In compose, the whole planet is one docker network. Each datacenter gets its own /20, each zone its own /24, and every container a static address in a generated overlay.&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%2F7bfjzlopqnmwrwecgviw.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%2F7bfjzlopqnmwrwecgviw.png" alt="One wide network tile; inside it datacenter blocks each labelled /20, inside each two zone blocks labelled /24 with container dots; a crossed-out alternative reads a network per datacenter - docker DNS is per network" width="799" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A network per datacenter looked cleaner and was rejected: docker's DNS is per network, so service names would stop resolving across datacenters. One network with subnets keeps every name working and still gives every link its own address range to match on.&lt;/p&gt;

&lt;h2&gt;
  
  
  k3d: a node per zone
&lt;/h2&gt;

&lt;p&gt;In k3d, each zone is one agent node, labelled with &lt;code&gt;topology.kubernetes.io/region&lt;/code&gt; and &lt;code&gt;topology.kubernetes.io/zone&lt;/code&gt; plus provider and datacenter labels. The spread is two constraints with different strictness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;topologySpreadConstraints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;maxSkew&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;topologyKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;&amp;lt;datacenter label&amp;gt;&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;        &lt;span class="nv"&gt;whenUnsatisfiable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;DoNotSchedule&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;maxSkew&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;topologyKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;topology.kubernetes.io/zone&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;whenUnsatisfiable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ScheduleAnyway&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across datacenters the scheduler must spread; across zones it should (&lt;a href="https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/" rel="noopener noreferrer"&gt;Kubernetes&lt;/a&gt;). The same netem and iptables rules run inside the node containers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a real VPN
&lt;/h2&gt;

&lt;p&gt;In production the links between datacenters are a VPN. WireGuard was considered and rejected: it would need &lt;code&gt;NET_ADMIN&lt;/code&gt; and route changes in every service image. The emulation gives the links the latency and loss of a VPN without touching a single service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Breaking it at every level
&lt;/h2&gt;

&lt;p&gt;Every level of the ladder has its own action:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;zone-down&lt;/code&gt; / &lt;code&gt;dc-down&lt;/code&gt; / &lt;code&gt;provider-down&lt;/code&gt; and &lt;code&gt;-up&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;stop and start the members; rules re-applied on start&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;dc-isolate&lt;/code&gt; / &lt;code&gt;dc-heal&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;DROP from one datacenter to every other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;continent-isolate&lt;/code&gt; / &lt;code&gt;continent-heal&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the same, for a continent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;link-down&lt;/code&gt; / &lt;code&gt;link-up&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;cut one pair&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;link-degrade&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tc qdisc change … netem&lt;/code&gt; with a given rtt, loss and jitter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;link-restore&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;back to the declared values&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%2F3zmsc27c1xy4jgcwb02v.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%2F3zmsc27c1xy4jgcwb02v.png" alt="Five icon tiles, one per level - zone, datacenter, provider, continent, link - each naming its actions" width="799" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A scenario can aim at a role, &lt;code&gt;instance: leader&lt;/code&gt;, rather than at a name. Every run records which latency variant it used, &lt;code&gt;--latency none&lt;/code&gt; or &lt;code&gt;--latency declared&lt;/code&gt;, so a report always says whether the network was there.&lt;/p&gt;

&lt;h2&gt;
  
  
  A cut remembers why
&lt;/h2&gt;

&lt;p&gt;Cuts overlap. A link can be cut on its own, and a datacenter at one end can be isolated on top. Healing the datacenter must not quietly heal the link.&lt;/p&gt;

&lt;p&gt;So every cut remembers its cause - link, datacenter or continent - and healing removes only the cuts of that cause. The state lives in a JSON file per environment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F60skfcu67f4ataj5qt3i.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F60skfcu67f4ataj5qt3i.gif" alt="An animation: a link is cut, cause link; then the datacenter is isolated, cause dc; then the datacenter is healed and its cuts disappear, but the link cut stays red" width="799" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Measured
&lt;/h2&gt;

&lt;p&gt;Placement and links were applied on a laptop. A ping across a declared 11 ms link came out slightly above what was declared: the minimum almost exact, the average a little higher. Inside one zone, near zero.&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%2Fgb3w9j37rkysufgfj5qn.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%2Fgb3w9j37rkysufgfj5qn.png" alt="Two halves: on the left declared - the link as written in the file; on the right measured - minimum almost exact, average slightly above; same zone near zero" width="800" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The earlier store failover runs of &lt;em&gt;Seven Ways To Break It On Purpose&lt;/em&gt; were done without any of this latency. That is worth saying out loud: their results hold for a flat network, and the next runs are the first with distance in them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Written, not run
&lt;/h2&gt;

&lt;p&gt;The scenarios are written. None has run - the owner postponed them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a zone down under load, in compose and in k3d;&lt;/li&gt;
&lt;li&gt;the zone of a database leader down;&lt;/li&gt;
&lt;li&gt;a broker's or a cache's datacenter isolated;&lt;/li&gt;
&lt;li&gt;a provider down;&lt;/li&gt;
&lt;li&gt;one link down; one link degraded to a high round trip with loss; the transatlantic link degraded;&lt;/li&gt;
&lt;li&gt;a continent cut off; an even split.&lt;/li&gt;
&lt;/ul&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%2Fjo68vkzeyc0oj19t0hcc.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%2Fjo68vkzeyc0oj19t0hcc.png" alt="A flow of dashed ghost tiles: zone down, leader zone, dc isolate, provider down, link degrade, continent cut, even split; a pill reads planned, the owner postponed the runs" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;QA has declared what a pass would mean. It is a proposal, agreed with DevOps and postponed by the owner, and these numbers live only here, in the labelled block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# HA thresholds - a proposal, agreed with DevOps, postponed by the owner&lt;/span&gt;
&lt;span class="na"&gt;rtt_per_declared_link&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;±20 %&lt;/span&gt;
&lt;span class="na"&gt;leader_election&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;       &lt;span class="s"&gt;≤ 35 s&lt;/span&gt;
&lt;span class="na"&gt;replication_lag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;       &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;within_continent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;≤ 1 s&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;across&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;≤ RTT×3 + 1 s&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;rejoin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                &lt;span class="s"&gt;≤ 60 s&lt;/span&gt;
&lt;span class="na"&gt;broker_elections&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="s"&gt;recorded only&lt;/span&gt;
&lt;span class="na"&gt;split_brain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;           &lt;span class="s"&gt;always ❌&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They are declared, not met.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not emulated
&lt;/h2&gt;

&lt;p&gt;Bandwidth limits, BGP and anycast, a provider's DNS failing, clock skew between datacenters, and how operator-managed StatefulSets spread in k3d. Each is a known gap, not a forgotten one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do not claim
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Not that any failure scenario has run on this topology. They are written and planned.&lt;/li&gt;
&lt;li&gt;Not that the thresholds are met. They are a proposal.&lt;/li&gt;
&lt;li&gt;Not that a laptop network is a planet. The measured round trip came close to declared, slightly above, and that is all that was measured.&lt;/li&gt;
&lt;li&gt;Not bandwidth, BGP, provider DNS or clock skew. They are not emulated.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What of this is method
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Declare the planet once.&lt;/strong&gt; Rules, placement and chaos all read one file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules in the namespace, not in the image.&lt;/strong&gt; A sidecar keeps every service untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cuts remember their cause.&lt;/strong&gt; Healing one failure must not hide another.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Cross-anything on a laptop - Part 3.&lt;/strong&gt; Next: &lt;em&gt;Placement In Every Trace&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you emulate latency with netem&lt;/strong&gt;, do you set it per peer or per container? If &lt;strong&gt;you use a chaos tool for partitions&lt;/strong&gt;, how does it heal overlapping cuts? If &lt;strong&gt;you run k3d with zone labels&lt;/strong&gt;, what spreads and what does not? What is the first link on your planet you would cut?&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>kubernetes</category>
      <category>testing</category>
    </item>
    <item>
      <title>Four Failure Domains</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Tue, 03 Nov 2026 07:25:34 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/four-failure-domains-ia7</link>
      <guid>https://dev.to/anton_brilliantov/four-failure-domains-ia7</guid>
      <description>&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%2F6ph6u6rmccni4ck1w02v.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%2F6ph6u6rmccni4ck1w02v.png" alt="Four Failure Domains - the cover: four nested layers, continent, provider, datacenter, zone" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Four Failure Domains
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Zone, datacenter, provider, continent: what a system feels at each level - latency, loss, a split, the whole level gone - and what the clouds themselves say about it.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer. These days I work on a platform: contracts, generated code, data paths and the tooling around them. 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;.&lt;/p&gt;

&lt;p&gt;This part is conceptual. The ordering of link kinds at the end is built; the failure-scenario runs across these levels are planned, not run.&lt;/p&gt;

&lt;p&gt;This is part 2 of the series on testing cross-zone, cross-datacenter and cross-provider behaviour on a laptop. Maybe you already do it better, maybe you'd do it another way - this is how it is going, and where it stops.&lt;/p&gt;




&lt;h2&gt;
  
  
  One ladder
&lt;/h2&gt;

&lt;p&gt;An availability zone, a datacenter, a provider and a continent sound like four separate topics. They are one ladder. Each step is a failure domain one size larger than the last, and each step up changes two things at once: how far apart the pieces are, and what can go wrong between them.&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%2Ft7rhba2rhozs5kmqrtzs.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%2Ft7rhba2rhozs5kmqrtzs.png" alt="Four nested tiles: a continent holds a provider, which holds a datacenter, which holds a zone; each layer labelled with what it adds - distance, a new way to fail" width="800" height="493"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The value of seeing it as a ladder is that one design can cover all four. Copies of a service are placed on the ladder; the network between them is described by the ladder; failures are cut at a step of the ladder. Part 1, &lt;em&gt;One Login For Every Stand&lt;/em&gt;, gave every local environment one way in. This part is the map of where the copies behind that door can live, and what each place costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zone
&lt;/h2&gt;

&lt;p&gt;A zone is the smallest domain that a cloud promises to keep independent. AWS describes an Availability Zone as one or more discrete data centers with separate and redundant power, networking and connectivity, far enough apart to avoid correlated failures and close enough for synchronous replication with single-digit millisecond latency (&lt;a href="https://docs.aws.amazon.com/whitepapers/latest/aws-fault-isolation-boundaries/availability-zones.html" rel="noopener noreferrer"&gt;AWS&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;For a system, a zone means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;latency&lt;/strong&gt; - near zero; nobody designs around it;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;loss of a zone&lt;/strong&gt; - the copies in it are gone; a quorum spread over three zones survives one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second line is the whole reason for three. Two copies of three is a majority; one of two is not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fafx5tmd04nk5sb5x3btr.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fafx5tmd04nk5sb5x3btr.gif" alt="An animation: three zone tiles each holding a node; one zone goes dark; the counter reads 2 of 3, quorum holds" width="799" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Datacenter and metro
&lt;/h2&gt;

&lt;p&gt;The next step is a whole site, or a metro of sites. Latency is small but no longer zero, and it is the first step where a timeout chosen on a laptop can be wrong in production. Synchronous replication is still possible inside a metro; it is simply slower.&lt;/p&gt;

&lt;p&gt;A datacenter lost is a zone lost, only bigger: everything in it at once, including the things that were assumed to be "elsewhere" because they were in another zone of the same site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provider
&lt;/h2&gt;

&lt;p&gt;A provider is a different kind of step. It is not further away so much as differently owned. Everything a provider runs - its API, its DNS, its network, its control plane - can fail together, and when it does, every site of that provider goes with it.&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%2F6xx4m3dyl2pl0ajqnl59.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%2F6xx4m3dyl2pl0ajqnl59.png" alt="Two halves: on the left the same provider - one API, one DNS, one control plane, all sites fail together; on the right a second provider - separate failure, but a second integration to own" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For a link between two places, the provider question has only two answers: the same provider or a different one. That is the whole axis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Continent
&lt;/h2&gt;

&lt;p&gt;The top step is the one physics decides. Between continents the round trip is many times larger than inside a region, and no configuration makes it smaller. Google's guidance says it plainly: communication within regions will always be cheaper and faster than communication across regions (&lt;a href="https://docs.cloud.google.com/compute/docs/regions-zones" rel="noopener noreferrer"&gt;Google Cloud&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;At this step synchronous replication stops being a design choice. The main risk becomes the split: a cut between two continents leaves two halves that both believe they are the system.&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%2Fywgtuka27vusfigoy0gm.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%2Fywgtuka27vusfigoy0gm.png" alt="A strip of three panels: a cut between two sides; two sides, each still running; the minority refuses writes, so there is never a second leader" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What a system feels
&lt;/h2&gt;

&lt;p&gt;Put the four steps side by side and the effects line up:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;th&gt;Loss on the link&lt;/th&gt;
&lt;th&gt;A split&lt;/th&gt;
&lt;th&gt;The whole level gone&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;zone&lt;/td&gt;
&lt;td&gt;near zero&lt;/td&gt;
&lt;td&gt;rare&lt;/td&gt;
&lt;td&gt;unlikely&lt;/td&gt;
&lt;td&gt;copies in it lost; a quorum of three zones holds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;datacenter / metro&lt;/td&gt;
&lt;td&gt;small&lt;/td&gt;
&lt;td&gt;rare&lt;/td&gt;
&lt;td&gt;possible&lt;/td&gt;
&lt;td&gt;everything in the site at once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;provider&lt;/td&gt;
&lt;td&gt;depends on placement&lt;/td&gt;
&lt;td&gt;depends on placement&lt;/td&gt;
&lt;td&gt;possible&lt;/td&gt;
&lt;td&gt;every site of that provider, its API and DNS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;continent&lt;/td&gt;
&lt;td&gt;many times larger&lt;/td&gt;
&lt;td&gt;more likely&lt;/td&gt;
&lt;td&gt;the main risk&lt;/td&gt;
&lt;td&gt;half the system on the far side&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%2F9pzct3nhmxfwgskpmz4s.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%2F9pzct3nhmxfwgskpmz4s.png" alt="A matrix: four rows - zone, datacenter, provider, continent - against four columns - latency, loss, split, level gone; cells shaded from calm to vermilion as the level grows" width="800" height="441"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The latency column reads as words, not numbers, on purpose. The order matters more than any single figure, and the order is the same in every cloud.&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%2Fwg6jgaxavh54drlk62bx.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%2Fwg6jgaxavh54drlk62bx.png" alt="Four columns rising left to right: zone near zero, metro small, continent larger, inter-continent many times larger" width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the clouds say
&lt;/h2&gt;

&lt;p&gt;Three providers, one picture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS&lt;/strong&gt; - Regions are isolated and independent from other Regions; this separation limits service failures to a single Region, and resources do not exist in another Region unless replicated (&lt;a href="https://docs.aws.amazon.com/whitepapers/latest/aws-fault-isolation-boundaries/regions.html" rel="noopener noreferrer"&gt;AWS&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google&lt;/strong&gt; - zones are designed to minimize correlated failures of power, cooling and networking; the recommendation is to deploy across multiple zones and multiple regions (&lt;a href="https://docs.cloud.google.com/compute/docs/regions-zones" rel="noopener noreferrer"&gt;Google Cloud&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure&lt;/strong&gt; - a zone is a logical group of physically separate datacenters, the target round trip between zones is under about two milliseconds, and zones do not protect against the loss of a whole region (&lt;a href="https://learn.microsoft.com/en-us/azure/reliability/availability-zones-overview" rel="noopener noreferrer"&gt;Azure&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&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%2Fvvgmuq3s1vsv3sfscfng.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%2Fvvgmuq3s1vsv3sfscfng.png" alt="Three quote tiles side by side: AWS, regions isolated, failure limited to one region; Google, spread across zones and regions; Azure, zones do not protect against a region outage" width="799" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of them says "zones are enough". All three say: know which step of the ladder each copy is on.&lt;/p&gt;

&lt;h2&gt;
  
  
  A ladder a tool can read
&lt;/h2&gt;

&lt;p&gt;For a test, the ladder has to become data. In our topology emulation, the kind of every link is derived from where its two ends are, in a fixed order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;intra-dc  &amp;lt;  intra-metro  &amp;lt;  intra-continent  &amp;lt;  inter-continent
provider:  same | cross            (continent ranks above provider)
&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%2Fl264lhj3ujisbdjprdwu.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%2Fl264lhj3ujisbdjprdwu.png" alt="A flow of four tiles left to right: intra-dc, intra-metro, intra-continent, inter-continent; a pill under them reads provider: same or cross" width="800" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Continent ranks above provider: two sites of different providers on one continent are closer than two sites of one provider on different continents. The provider is only ever "same" or "cross". That ordering is built; how it is used is part 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  On a laptop
&lt;/h2&gt;

&lt;p&gt;On a laptop each step of the ladder is a network: a subnet for a zone, a larger one for a site, a rule for every link between them. The failures become commands: take a zone down, isolate a site, cut a continent off. The runs of those scenarios are planned - the owner postponed them - so this part stops at the map.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do not claim
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Not that any of these failures has been run across the levels. They are planned.&lt;/li&gt;
&lt;li&gt;Not exact latencies for any level. The order is the point; the numbers belong to each cloud's documentation.&lt;/li&gt;
&lt;li&gt;Not that a laptop network behaves like a cloud network. It is an emulation, and part 3 says how close it got.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What of this is method
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One ladder, four steps.&lt;/strong&gt; Zone, site, provider and continent are one design, not four.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know the step of every copy.&lt;/strong&gt; A quorum is only as good as where its members are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the ladder data.&lt;/strong&gt; A test tool can only break what it can read.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Cross-anything on a laptop - Part 2.&lt;/strong&gt; Next: &lt;em&gt;A Planet On A Laptop&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you spread copies across zones&lt;/strong&gt;, do you know which site each zone is in? If &lt;strong&gt;you run on two providers&lt;/strong&gt;, what fails together that you thought would not? If &lt;strong&gt;you have lived through a continent split&lt;/strong&gt;, which side kept writing? Which step of the ladder does your system assume will never fail?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>devops</category>
      <category>kubernetes</category>
      <category>testing</category>
    </item>
    <item>
      <title>One Login For Every Stand</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Mon, 02 Nov 2026 06:39:01 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/one-login-for-every-stand-128a</link>
      <guid>https://dev.to/anton_brilliantov/one-login-for-every-stand-128a</guid>
      <description>&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%2F59glp9gpa0x7jp2shz0s.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%2F59glp9gpa0x7jp2shz0s.png" alt="One Login For Every Stand - the cover: many local environments fanning into one auth stand, four roles, one door" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  One Login For Every Stand
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;One self-hosted Supabase auth stand for every local environment: one door, four roles, a token audience that had to become a string, and the three answers the gateway gives.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer. These days I work on a platform: contracts, generated code, data paths and the tooling around them. 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;.&lt;/p&gt;

&lt;p&gt;The auth stand, its four roles, its webhooks and the gateway's answers have been checked by QA. How the monolith picks the stand up was established by reading its code; the switch between local and production auth is planned.&lt;/p&gt;

&lt;p&gt;This is part 1 of the series on testing cross-zone, cross-datacenter and cross-provider behaviour on a laptop. Maybe you already do it better, maybe you'd do it another way - this is how it is going, and where it stops.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why one login
&lt;/h2&gt;

&lt;p&gt;Before anything can be broken across zones, something has to log in. Every local environment needs users, tokens, roles and the webhooks that production's auth fires on every change. The easy answer is a copy of the auth stack inside every environment. That answer gives as many auth stacks as environments, each slightly different, and each one a place where a token is shaped a little unlike production.&lt;/p&gt;

&lt;p&gt;The answer we took is the opposite: one shared, self-hosted auth stand, and every local environment logs in against it. One place shapes tokens, one place holds the keys, one place fires the webhooks. When something about a token is wrong, it is wrong once and fixed once.&lt;/p&gt;

&lt;p&gt;The stand is built on the official Supabase self-hosting setup with docker compose (&lt;a href="https://supabase.com/docs/guides/self-hosting/docker" rel="noopener noreferrer"&gt;Supabase docs&lt;/a&gt;). Nothing in it is our own auth code. What is ours is the door, the keys, the roles and how environments are told apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  One door
&lt;/h2&gt;

&lt;p&gt;The stand has exactly one door, and it is Traefik. The API gateway of the Supabase stack - Kong, in the version we run - stays inside, with no published port.&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%2Fl8xjf4kllfsd3knlsyrx.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%2Fl8xjf4kllfsd3knlsyrx.png" alt="A flow: several local environments on the left, each a ghost tile; one arrow each into a Traefik tile, the only door; behind it the auth stand with Kong inside and no published port, and rest, storage and realtime behind Kong" width="799" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Kong cannot simply be dropped. Rest, storage and realtime are reached through it, and without Kong inside, the &lt;code&gt;sb_&lt;/code&gt; API keys are rejected. So the design keeps the stack whole and changes only what faces the outside: one router in front, everything else private.&lt;/p&gt;

&lt;p&gt;The stand now sits behind the shared edge router under a &lt;code&gt;*.localhost&lt;/code&gt; name, the same way every other local service will. QA checked it there: the JWKS endpoint answers 200, and a token is issued with 200 for each of the four roles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keys and four roles
&lt;/h2&gt;

&lt;p&gt;Tokens are signed with ES256 and carry a &lt;code&gt;kid&lt;/code&gt;. The key id is what lets a verifier pick the right public key from a JWKS without a shared secret, and what lets keys rotate without everyone being told at once.&lt;/p&gt;

&lt;p&gt;Roles come from a token hook. It issues four of them, each with the &lt;code&gt;user_role&lt;/code&gt; claim the services read:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;&lt;code&gt;user_role&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Checked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;root&lt;/td&gt;
&lt;td&gt;root&lt;/td&gt;
&lt;td&gt;token and refresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;admin&lt;/td&gt;
&lt;td&gt;admin&lt;/td&gt;
&lt;td&gt;token and refresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;specialist&lt;/td&gt;
&lt;td&gt;specialist&lt;/td&gt;
&lt;td&gt;token and refresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;token and refresh&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%2Fv5gxq84uqvbkkby0g07z.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%2Fv5gxq84uqvbkkby0g07z.png" alt="Four tiles, one per role - root, admin, specialist, user - each with its user_role claim and a tested pill; a key icon notes ES256 with kid" width="800" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All four were checked by QA end to end: a token issued, used, and refreshed.&lt;/p&gt;

&lt;h2&gt;
  
  
  An audience that had to be a string
&lt;/h2&gt;

&lt;p&gt;One fix in the stand is worth its own section, because it is the kind of thing that only shows up when two systems meet.&lt;/p&gt;

&lt;p&gt;The JWT standard allows &lt;code&gt;aud&lt;/code&gt; to be a string or an array of strings. A token from the stand arrived with &lt;code&gt;aud&lt;/code&gt; as an array. The monolith expected a string. In normal mode it cast the array to the literal text &lt;code&gt;Array&lt;/code&gt; and compared that; in debug mode it threw.&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%2Fdwb5c2axa4qaom99ev27.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%2Fdwb5c2axa4qaom99ev27.png" alt="Two halves: on the left, aud as an array, the monolith casts it to the text Array, refused; on the right, aud forced to one string, the monolith compares it, accepted" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The stand now forces the audience to a single string, and every check QA runs asserts it. The monolith also verifies tokens offline, against the exact issuer - another thing QA checked - so a token from the wrong issuer fails without a network call.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwfkp5yy72ccx4uyq7dyi.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwfkp5yy72ccx4uyq7dyi.gif" alt="An animation: a token travels from the auth stand to the monolith; with aud as an array the monolith tile turns red with the text Array; then with aud as a string it turns green; a pill reads checked on every run" width="800" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The lesson is not about Supabase. A local stand is where two parts of a system agree on a format for the first time outside production. Better there than in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhooks as in production
&lt;/h2&gt;

&lt;p&gt;Production's auth fires database webhooks on its tables, and services depend on them. A local stand without them would be a stand that logs in but never tells anyone.&lt;/p&gt;

&lt;p&gt;So the stand has database webhooks on all auth and public tables, as production does. Because one stand serves many environments, each webhook is routed to the environment it belongs to.&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%2F4uc4bg4vrjhkbgwcnek6.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%2F4uc4bg4vrjhkbgwcnek6.png" alt="A flow: auth and public tables into a webhook tile, then a router tile, then two environment tiles, each receiving only its own events" width="799" height="316"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the gateway answers
&lt;/h2&gt;

&lt;p&gt;The gateway is the first thing that reads a token. It now gives exactly three answers, each with a trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;200  token valid             trace_id=&amp;lt;32 hex&amp;gt;
401  token_missing           trace_id=&amp;lt;32 hex&amp;gt;
401  token_invalid           trace_id=&amp;lt;32 hex&amp;gt;
&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%2Fte7ns8w7b3imhra7erjy.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%2Fte7ns8w7b3imhra7erjy.png" alt="A dark log card with three lines: 200 token valid, 401 token_missing, 401 token_invalid, each with a trace id placeholder; the 401 token_invalid line highlighted" width="799" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;How the gateway gets there I know by the code, not from a measurement. It takes the auth key only as an inline JWKS, with no network fetch; it picks the issuer by the exact &lt;code&gt;iss&lt;/code&gt;; it maps the four roles with a hierarchy, root above admin above specialist above user. A token whose key is missing is a 401. A write needs one more thing: the user's identity chain in the cache.&lt;/p&gt;

&lt;p&gt;The two 401s are different on purpose. "No token" and "a token that does not verify" are different defects with different owners, and the machine reason says which one it is without anyone opening a log.&lt;/p&gt;

&lt;h2&gt;
  
  
  The monolith side
&lt;/h2&gt;

&lt;p&gt;This part I know from reading code, not from a run, and I say it that way.&lt;/p&gt;

&lt;p&gt;The monolith supports more than one auth integration. A second integration, &lt;code&gt;local&lt;/code&gt;, is picked up as a mounted configuration file - no code change. Its JWT secret is a JWKS rather than a shared secret, which is what makes ES256 with &lt;code&gt;kid&lt;/code&gt; work. The webhook address for that integration is a route plus a hashid of the integration's id.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# a second integration as a mounted file - established by reading code&lt;/span&gt;
&lt;span class="na"&gt;integration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
&lt;span class="na"&gt;jwt_secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;JWKS of the auth stand&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;webhook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;&amp;lt;route&amp;gt;/&amp;lt;hashid of the integration id&amp;gt;&lt;/span&gt;
&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%2Fyr8zb3uubac2n1e22yuq.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%2Fyr8zb3uubac2n1e22yuq.png" alt="A dark card with the second integration as a mounted file: integration local, the JWT secret as the stand's JWKS, the webhook as a route plus a hashid; a pill reads established by reading code" width="799" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is next
&lt;/h2&gt;

&lt;p&gt;Two things are not done:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a switch, &lt;code&gt;ENV_AUTH=local|production&lt;/code&gt;, so an environment chooses where it logs in with one variable - &lt;code&gt;planned&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;production-shaped names &lt;code&gt;&amp;lt;service&amp;gt;.&amp;lt;env&amp;gt;.localhost&lt;/code&gt; for the other services behind the one edge router, several environments at once - &lt;code&gt;in progress&lt;/code&gt;. The auth stand already lives under such a name.&lt;/li&gt;
&lt;/ul&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%2Fvheq5sjqsxoch59w3re1.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%2Fvheq5sjqsxoch59w3re1.png" alt="Three tiles: auth stand under a localhost name, tested; ENV_AUTH local or production, planned; service.env.localhost for the other services, in progress" width="800" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys a team
&lt;/h2&gt;

&lt;p&gt;One stand instead of one per environment: fewer copies, one place where token shape is decided, and defects of the kind the array audience was found before production. The cost is a shared dependency - if the stand is down, every local environment is logged out together. For a laptop that is a fair trade.&lt;/p&gt;

&lt;p&gt;With AI coding agents, the plumbing - a token hook, a router rule, a webhook route - is quick to write. Deciding that there is one stand, not many, is the part that is not typing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do not claim
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Not that the monolith side or the gateway's key handling was run. Both were established by reading code.&lt;/li&gt;
&lt;li&gt;Not that the switch between local and production auth exists. It is planned.&lt;/li&gt;
&lt;li&gt;Not that every service already has a &lt;code&gt;.localhost&lt;/code&gt; name. Only the auth stand does; the rest are in progress.&lt;/li&gt;
&lt;li&gt;Not that a shared stand fits every team. It is a shared dependency.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What of this is method
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One stand, not many.&lt;/strong&gt; A format decided once is a format that cannot drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One door.&lt;/strong&gt; Keep the vendor's stack whole; change only what faces outside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two kinds of 401.&lt;/strong&gt; A machine reason names the defect before anyone opens a log.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Cross-anything on a laptop - Part 1.&lt;/strong&gt; Next: &lt;em&gt;Four Failure Domains&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you run a copy of auth inside every environment&lt;/strong&gt;, how do you keep them alike? If &lt;strong&gt;you share one auth stand&lt;/strong&gt;, what happens to everyone when it is down? If &lt;strong&gt;you have been bitten by an array &lt;code&gt;aud&lt;/code&gt;&lt;/strong&gt;, where did it show up first? Which part of your auth would you rather find broken on a laptop?&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Anatomy of 32 Days</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Sun, 01 Nov 2026 06:00:16 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/anatomy-of-32-days-1p1j</link>
      <guid>https://dev.to/anton_brilliantov/anatomy-of-32-days-1p1j</guid>
      <description>&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%2Ff2my65qr5lh2n9a4uqt7.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%2Ff2my65qr5lh2n9a4uqt7.png" alt="Cover reading Anatomy of 32 Days, with 32 on a vermilion slab, the figure 24 of 26 answerable before the code, and four columns 32, 11, 9 and 7 for the days down to the ones with traced runs" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every number here was cheap to collect and late to arrive. That gap is the whole story.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. The seven parts before this one each took one angle on a single write path that left the monolith. This one is the ledger: the same 32 days read as one timeline, with every figure in one place and a link to the part that unpacks it. 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;.&lt;/p&gt;

&lt;p&gt;This is how I do it right now, with the price attached - maybe you already do it better, maybe you'd do it another way.&lt;/p&gt;




&lt;h2&gt;
  
  
  The path in one paragraph
&lt;/h2&gt;

&lt;p&gt;A user saves a set of matching rules; every domain under that account gets marked by them. Four participants: the &lt;strong&gt;Go API gateway&lt;/strong&gt; as the only door from outside, the &lt;strong&gt;rule-set-markup service&lt;/strong&gt; which takes the write and folds the cascade, the &lt;strong&gt;domain-rule-map service&lt;/strong&gt; which owns rule-set identity and recalculates the map, and PostgreSQL behind them - the monolith's database for the rule data, the identity service's own for identity.&lt;/p&gt;

&lt;p&gt;One user action, four processes, two databases. Accepting that as working took 32 days, and this is what was inside them.&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%2Fphu78aobkjznlsf9uozv.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%2Fphu78aobkjznlsf9uozv.png" alt="The series system map with every part lit - the UI, the Go API gateway, the rule-set-markup service, the domain-rule-map service, the acceptance repository and the shared gRPC contract layer - and only the PHP monolith dimmed" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What "32 days" actually contains
&lt;/h2&gt;

&lt;p&gt;The headline number is the least useful one in the set, so it is worth breaking before anything is built on it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  DAYS   32 ┤████████████████████████████████  the acceptance timeline
            │        ▼ −21  automated verdicts only: pass/fail per case, no numbers, no traces
         11 ┤███████████
            │        ▼ −2   two days with no verdict at all — the wrong thing was under test
          9 ┤█████████
            │        ▼ −2   days inside the detailed window with no runs
          7 ┤███████  ◄ days with detailed, traced runs
  ──────────┴──────────────────────────────────────────────────────
  MINUTES inside those 7 days — 985 total = 16 h 25 min
    day 1 ██████████████████ 178      day 5 ██████████████████ 179
    day 2 ███████████████    150      day 6 ███████            77
    day 3 ██████████         102      day 7 ████████           89
    day 4 █████████████████████ 210   ◄ one run alone = 120
&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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8tkwqln7rtbigj1ev25i.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8tkwqln7rtbigj1ev25i.gif" alt="Animated bars shrinking from 32 days of the acceptance timeline to 11 without the verdicts-only days, 9 without the days with no verdict, and 7 days with traced runs, with a note: 985 minutes = 16 h 25 min, 34 runs" width="800" height="488"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;34 runs&lt;/strong&gt; across the period, 28 of them numbered. Duration was recorded for 29 of the 34: mean 34 minutes, longest 2 hours, shortest 2 minutes. So the honest version of the headline is not "32 days of testing" - it is &lt;strong&gt;16 hours 25 minutes of measured work, spread over 7 days, inside a 32-day window&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I am starting here because every ratio later in this article has one of these denominators in it, and the wrong denominator turns a modest finding into a dramatic one for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three windows, three instruments
&lt;/h2&gt;

&lt;p&gt;The 32 days are not one measurement. They are three, taken with different instruments, and no line crosses between them honestly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  window 1   before day 0        no acceptance artefacts exist at all
                                 behaviour reconstructed from migrations, commits, docs
  window 2   day 0 … day 21      automated verdicts per case, per deploy tag
                                 pass / fail, and nothing else — no timings, no traces
  window 3   day 21 … day 32     traced runs: timings, state dumps, statement text
                                 the numbered defect registry starts on day 25
&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%2F219sos7tioor5lju8p35.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%2F219sos7tioor5lju8p35.png" alt="Three window tiles joined by dashed arrows - before day 0, no artefacts; day 0 to 21, verdicts only; day 21 to 32, traced runs - with a note: a comparison across two windows compares instruments, not systems" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which produces the reading rule I put on every chart in this material:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Comparing across those windows compares methods of observation, not states of the system.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two more caveats travel with it, and they are in every part of this series for the same reason: round-trip time to the environment sits at a &lt;strong&gt;steady 220-260 ms&lt;/strong&gt; regardless of endpoint, so it is never part of anyone's work; and &lt;strong&gt;a share is never published without its absolute value&lt;/strong&gt; - &lt;code&gt;440.0 ms · 61 %&lt;/code&gt;, never &lt;code&gt;61 %&lt;/code&gt; on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day one of the detailed window: three objects, everything passes
&lt;/h2&gt;

&lt;p&gt;For the first weeks the checks ran against a playground of &lt;strong&gt;three objects&lt;/strong&gt;. The runs were honest - real requests, real assertions, real data written and read back - and everything passed.&lt;/p&gt;

&lt;p&gt;Then a run went across everything the environment held:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  playground              3 objects        everything passes
  full sweep            276 objects        the active-set registry is EMPTY for 210 of them
  later sweeps          392 objects        392 of 392 covered; 129 map rows of 2 329
                                           carry neither rule set nor priority (5.5 %)
&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%2F8q311jgiju9hilnid5qt.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%2F8q311jgiju9hilnid5qt.png" alt="Two halves: the playground, 3 objects, everything passes; the full sweep, 210 of 276 with an empty registry; a note below reads later: 392 of 392, 129 of 2 329 rows bare" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing was fixed between the first two lines. The system was in the same state; what changed is that it could be &lt;strong&gt;asked&lt;/strong&gt; - the services had grown read endpoints that describe state: the input snapshot, the rule set in effect, the pointer to the active set, the stored map page by page.&lt;/p&gt;

&lt;p&gt;That is &lt;em&gt;Part 2 - A Service That Cannot Explain Itself Can Only Be Guessed At&lt;/em&gt;. It is the earliest thing in this timeline in the causal sense: nothing else here was findable before those four reads existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The argument about who was slow
&lt;/h2&gt;

&lt;p&gt;The next thing the traced window settled was an argument that had been running on opinions. A single write, end to end: &lt;strong&gt;121 spans, four services, 1 256.9 ms&lt;/strong&gt; on the client's stopwatch. The blame sat on the write service, because that is the address on the envelope.&lt;/p&gt;

&lt;p&gt;Read as nesting rather than as a sum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  client wait                                       1 256.9 ms   100 %
  ├── transport, client ↔ environment                  244.9      19.5 %   not ours
  └── four services at work                          1 012.0      80.5 %
      └── the suspected service, own zone                87.4       7.0 %
          ├── own work                                    1.4
          └── waiting on PostgreSQL                      86.0      98.4 % of its zone
&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%2Fk0ln283xpfh9n4ec8fxx.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%2Fk0ln283xpfh9n4ec8fxx.png" alt="Three number tiles: 1 256.9 ms on the client's stopwatch, 87.4 ms in the blamed service's zone, 1.4 ms of its own code, with a stamp reading 97.2 % of a rejection is not ours" width="800" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;87.4 ms of 1 256.9 - 7.0 %&lt;/strong&gt;, and 98.4 % of that is waiting on the database. The code itself accounts for 1.4 ms.&lt;/p&gt;

&lt;p&gt;The cheapest confirmation in the whole set came from a request that was &lt;strong&gt;rejected as invalid&lt;/strong&gt;: server work 6.6 ms, database 0.0 ms, client stopwatch 231.8 ms. Nothing computed, nothing stored, and &lt;strong&gt;97.2 % of the call still happened outside our code&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And when the shape of one read was fixed, the same three-build comparison showed why the unit matters: database round trips went &lt;strong&gt;92 to 7&lt;/strong&gt;, database time &lt;strong&gt;440.0 to 31.8 ms&lt;/strong&gt; - a factor of 14 - while the network never moved. By share alone the network went from a third of the call to 88 % of it and reads as a catastrophe. In milliseconds, everything we own fell and the rest stood still.&lt;/p&gt;

&lt;p&gt;Full breakdown, including the zone we still cannot measure: &lt;em&gt;Part 1 - Most of Your Latency Is Not Your Code&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The account that did not fit
&lt;/h2&gt;

&lt;p&gt;The same instrument answered a question nobody had asked before shipping: &lt;strong&gt;how much work is one caller allowed to order?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One object addressed directly is 121 spans and 53 database round trips, 11 of them repeats. One account addressed at the top, with &lt;strong&gt;366 children&lt;/strong&gt; below it, is &lt;strong&gt;10 822 spans&lt;/strong&gt; - roughly 30 spans per child, derived by dividing one measured total by one measured count.&lt;/p&gt;

&lt;p&gt;The deadline on that call is a constant 20 seconds. The work behind it is a line. On one observed run the caller was cut off at &lt;strong&gt;20.26 s&lt;/strong&gt; and the work carried on for another &lt;strong&gt;8.7 s&lt;/strong&gt;, committing successfully at &lt;strong&gt;28.96 s&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  0 s                                  20.26 s               28.96 s
  ├───────────────────────────────────────┤───────────────────┤
  │  work in progress                     │  8.7 s more work  │
                                          │
                          caller is told: the call failed
                                                              │
                                          database holds: the write landed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Around &lt;strong&gt;253 children&lt;/strong&gt; is where the endpoint stops fitting, and how many accounts sit above that line is a product fact nobody had written down. In aggregate it showed up as deadline failures on &lt;strong&gt;13 of 76 objects&lt;/strong&gt;, then &lt;strong&gt;27 of 76&lt;/strong&gt; after a change meant to help. The whole slope, and why optimisation moves the crossing point rather than removing it, is &lt;em&gt;Part 5 - The Client Decides How Much Work You Do&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpthw42wvmx08migcruqk.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpthw42wvmx08migcruqk.gif" alt="Animated waterfall of one call: the caller's bar stops with a warning, cut off at 20.26 s; the work bar runs on, committed at 28.96 s; an amber bar after the cut reads 8.7 s more; a note reads 366 children, 10 822 spans, a constant 20 s" width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The value nobody owned
&lt;/h2&gt;

&lt;p&gt;The longest-lived defect of the period had no code to blame. A rule is saved, the response is &lt;code&gt;200&lt;/code&gt;, the read shows the new set - and the engine keeps marking by the old one. Eight rounds thirty seconds apart, four minutes of the two answers disagreeing, no error anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Six iterations&lt;/strong&gt;, the record of the whole timeline. The analysis that explained it took an afternoon:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the value lives in &lt;strong&gt;four carriers across two databases&lt;/strong&gt;, and there is no foreign key between them - there cannot be, they belong to different services;&lt;/li&gt;
&lt;li&gt;the right to write it had moved &lt;strong&gt;seven times in 48 days&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;in one of those states, two days long, it belonged to &lt;strong&gt;nobody&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;and the state was produced by &lt;strong&gt;two commits, same day, two repositories, each correct on its own&lt;/strong&gt;, which together formed a circle: a freshly written set has no map rows, so it can never become active.&lt;/li&gt;
&lt;/ul&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%2Fhs7xg1768bsqqcff0gqk.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%2Fhs7xg1768bsqqcff0gqk.png" alt="Four number tiles for the value nobody owned: 6 iterations, the record; 4 carriers in 2 databases; 7 moves in 48 days; 2 days owned by nobody; with a stamp reading two correct commits, one circle" width="799" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No review inside either repository could have caught it: each diff was right, and the contradiction lived on the seam. &lt;em&gt;Part 3 - Nobody Owned the Value&lt;/em&gt; is that afternoon written out, including the detector that catches the class forever: two reads and an equality check, run on all 392 objects.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contract that could not say zero
&lt;/h2&gt;

&lt;p&gt;While that ran, a second class was producing numbers of its own. A priority that was stored, correct and impossible to present, because the strongest priority in this system is written as &lt;code&gt;0&lt;/code&gt; and on the wire &lt;code&gt;0&lt;/code&gt; looks exactly like a field nobody set. Both sides were locally correct: the producer omits zero values by the rules of the format, the consumer treats an absent field as unset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eight of the 26 numbered defects were defects of contract shape&lt;/strong&gt; - the largest single class in the registry, and not one of them was a computation error. The same field's own history: removed from the contract, put back about 45 days later, and pinned down with an explicit meaning for zero on day 91.&lt;/p&gt;

&lt;p&gt;What made it observable was not an assertion on a response - every response was correct throughout - but a count over stored state: map rows carrying no priority at all, &lt;strong&gt;27 to 41 to 0&lt;/strong&gt;.&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%2Ffcp3bjwvv0yhzyqdhie3.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%2Ffcp3bjwvv0yhzyqdhie3.png" alt="A dark JSON card: stored priority 0, on the wire an empty object - highlighted - the consumer reads priority as unset, bare rows 27 to 41 to 0, with a note: 8 of 26 were contract shape" width="799" height="309"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And its quieter twin: when two rules tie, which wins? Nobody had ever decided. The behaviour was deterministic - first match in array order - and it had held as a norm for &lt;strong&gt;17 months&lt;/strong&gt; as a property of how a loop was written. &lt;em&gt;Part 6 - Zero Is Not Absent, and Nobody Wrote the Order Down&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The baseline that moved
&lt;/h2&gt;

&lt;p&gt;The method for accepting the new write path was parity: run both implementations on the same input, compare field by field. That is a sound method exactly once - when the reference stands still.&lt;/p&gt;

&lt;p&gt;On one day the same input went through &lt;strong&gt;nine iterations&lt;/strong&gt;, and the size of the cascade the two sides produced went &lt;strong&gt;2, then 258, then 2, then 8 rows&lt;/strong&gt;. The reference was changing its own definition mid-day, and it was internally inconsistent too: one identifier came back as a number in one field and as a string in another. Three times out of nine the &lt;strong&gt;new&lt;/strong&gt; implementation turned out to be the correct one.&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%2Fgxtrr65nh88u9rhfretd.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%2Fgxtrr65nh88u9rhfretd.png" alt="Four columns for one input: 2 rows, 258 rows in red, 2 rows, 8 rows, with a stamp reading new side right 3 of 9" width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Underneath sat the same failure in three different disguises:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;code&gt;502&lt;/code&gt; returned after the row was committed;&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;200 completed&lt;/code&gt; on a row that had been silently deleted;&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;504&lt;/code&gt; at 20.26 s on a write that went through.&lt;/li&gt;
&lt;/ul&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%2F1q03eq4apaxkhc9tg2mi.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%2F1q03eq4apaxkhc9tg2mi.png" alt="A dark log card of three responses against the database: 502 with the row committed; 200 completed with the row silently deleted, highlighted; 504 at 20.26 s with the write gone through; a note reads two storages, no shared tx" width="800" height="286"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of them is a lie by the transport. With two storages and no shared transaction, a response code reports that the call arrived and its own transaction closed - and nothing about what is in the database. Then the parity work was erased in one line by a correct decision: the old write endpoint would be removed entirely. &lt;em&gt;Part 4 - Parity Against a Moving Baseline&lt;/em&gt; has the full accounting, including the one thing that was missing - the date the comparison would expire.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ledger
&lt;/h2&gt;

&lt;p&gt;Everything above, as counts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  26 numbered defects
     16   closed as real defects            (by subtraction from the four rows below)
      5   declared normal behaviour
      3   still open at the end of the period
      2   withdrawn as false

  39 defect-iterations between those 26 numbers      longest single defect: 6 iterations
   8 false alarms in total, of which one is a false PASS - a check that ran zero comparisons
&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%2Fer35e1upoa9wap076zpb.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%2Fer35e1upoa9wap076zpb.png" alt="A grid of 26 cells: 16 green closed as real, 5 teal declared normal, 3 amber still open, 2 red withdrawn as false, with the counts beside it and a stamp reading 1 false pass: 0 comparisons" width="800" height="344"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two rows are worth more than the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three still open at the end of the period&lt;/strong&gt;, and their wording is the giveaway: rule order is not provable; cursor paging says "more" and returns no token; an asynchronous rebuild lands in tens of seconds - or does not. None of them is "the code is wrong". All three are "the system cannot demonstrate the property being asked about".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One false PASS.&lt;/strong&gt; A probe sent a single value to an endpoint that requires a list, got a &lt;code&gt;422&lt;/code&gt;, took the empty decision list as agreement and printed &lt;code&gt;PASS 24, FAIL 3&lt;/code&gt;. Two builds were accepted on a line that had performed &lt;strong&gt;zero comparisons&lt;/strong&gt;. A false FAIL creates work. A false PASS removes the check and leaves the report looking identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a document would have caught
&lt;/h2&gt;

&lt;p&gt;This is the number I keep from the whole period, and the one I am least comfortable with.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   26 numbered defects
   ████████████████████████████████████████████  26
             │
             ▼  step 1 — the specification names WHO WRITES this value
   ██████████████████████████████                18  left
             │
             ▼  step 0 — endpoint and fields taken from the contract, not from memory
   ████████████████                              13  left
             │
             ▼  steps 2/3/4/6 — actual measurement against a live system
   ██                                             2  ◄ one conditional, one unobservable
&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%2Fspg86ydlpe6umnlidbww.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%2Fspg86ydlpe6umnlidbww.png" alt="Three number tiles: 24 of 26 answerable on paper, 92 %; 21 of 39 iterations die on one question; 2 need a live run; with a note: who is allowed to write this value?" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;24 of 26 - 92 % - would have been removed by acceptance written before the code&lt;/strong&gt;, and &lt;strong&gt;21 of 39 defect-iterations die on one question&lt;/strong&gt;: &lt;em&gt;who is allowed to write this value?&lt;/em&gt; That question is answered by reading a document. It is an estimate over the registry rather than an experiment, and I publish it as one - but even halved it says the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost, and the number I never took
&lt;/h2&gt;

&lt;p&gt;The bill is not "we wrote buggy code". It is the cost of finding out what was wanted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  requirements with two defensible readings            23
  reversals of decisions already implemented            7      in 5 of 6, the code matched
                                                               the specification of its day
  iterations spent finding out rather than fixing   ~13 of 27  (estimated over intervals)
  iterations of briefs written and discarded            15     within a day
  brief files accumulated in the repository            289     137 of them in 33 numbered sets
  write-ups of my own acceptance mistakes               28     5 of them repeated, 1–6 days apart
  ─────────────────────────────────────────────────────────
  days with a measurement of money or tokens             0
&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%2Fvcbs03mihj697ry89eqh.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%2Fvcbs03mihj697ry89eqh.png" alt="A torn paper receipt for 32 days of finding out: 23 ambiguous requirements, 7 reversals of decisions, about 13 of 27 iterations finding out, 15 brief iterations discarded, 289 brief files, 28 mistake write-ups; cost measured 0 days, with a stamp reading never priced" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The last row is the one I would fix first if I were starting again. Every duration in this article was recorded; not one day carries a figure for what the automated work cost in tokens or money. That means everything here is a claim about &lt;strong&gt;what the arrangement found&lt;/strong&gt;, and nothing here is a claim about whether it was worth it. Those are different claims and I can only make the first.&lt;/p&gt;

&lt;p&gt;The row above it is the second-cheapest lesson: 5 of 28 mistakes repeated within one to six days, every one of them already written up in prose. A rule that lives only in a postmortem is re-derived by whoever reads it next, which is to say never - and an automated executor never reads it at all. What stopped the repeats was moving each rule into the two places that are read on every run: the brief, as a numbered constraint, and the check, as an assertion that fails.&lt;/p&gt;

&lt;p&gt;The arrangement that produced most of the numbers above - one automated pass pointed at the output of another, told to instrument the zone with no spans and report back - is &lt;em&gt;Part 7 - One Executor Checking Another Executor's Work&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven readings, one per part
&lt;/h2&gt;

&lt;p&gt;If the timeline collapses to seven sentences, these are the seven:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A service is judged by its own span, not by the client's stopwatch.&lt;/strong&gt; 87.4 ms of 1 256.9 - and 97.2 % of a rejected call is not our code. -&amp;gt; &lt;em&gt;Part 1&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A service that cannot state what its answer should be can only be guessed at.&lt;/strong&gt; Three objects passed; 276 objects had 210 empty registries, and nothing had changed in between. -&amp;gt; &lt;em&gt;Part 2&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A value with no named owner does not have a bug - it has an unfinished analysis.&lt;/strong&gt; Four carriers, two databases, seven moves of the write right in 48 days, two of those days belonging to nobody. -&amp;gt; &lt;em&gt;Part 3&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A comparison is a test only while the reference stands still.&lt;/strong&gt; Nine parity runs in one day, a cascade of 2, 258, 2 and 8 rows for the same input, and a comparison with no expiry date. -&amp;gt; &lt;em&gt;Part 4&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A synchronous answer over work the caller sizes is a promise the caller gets to break for you.&lt;/strong&gt; A constant 20-second deadline against a cost that is linear in 366 children. -&amp;gt; &lt;em&gt;Part 5&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A field that cannot separate "absent" from "zero" is two facts sharing a slot.&lt;/strong&gt; Eight of 26 defects were contract shape; a tie-break rule held as a norm for 17 months because it lived in a loop. -&amp;gt; &lt;em&gt;Part 6&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A second automated pass earns its cost only if it can add a measurement the first one never had.&lt;/strong&gt; 92 database round trips on a call that needs 7, reported as &lt;code&gt;200 OK&lt;/code&gt; in every column. -&amp;gt; &lt;em&gt;Part 7&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the one that only appears when they are read together: &lt;strong&gt;the split did not create these defects, it made them expressible.&lt;/strong&gt; A single process can hold an unstated ownership rule, an unstated tie-break and an unstated capacity limit indefinitely, because there is no boundary at which the omission has to be declared. Two services cannot.&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%2Fk6i964hfdchz2rdelgso.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%2Fk6i964hfdchz2rdelgso.png" alt="A four-panel strip: what we did, 3 objects passed in the playground; what broke, 26 numbered defects, 39 iterations; why, 4 sentences never written; what now, 24 of 26 answerable on paper" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What none of this proves
&lt;/h2&gt;

&lt;p&gt;Being explicit, because a ledger invites conclusions it cannot support.&lt;/p&gt;

&lt;p&gt;It does not say microservices are worse than a monolith, or better. It does not say anything got faster after the split - the instruments changed inside the window, and the earlier stretch has no numbers to compare against. It does not price anything: no money, no person-hours, no token counts. It does not say anything about the reliability of anyone's production system; every figure here comes from a test environment.&lt;/p&gt;

&lt;p&gt;What it does support is narrower: &lt;strong&gt;on this path, in this period, 24 of 26 defects were answerable before anything ran, and the four questions that would have answered them fit on one page.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The multiplier line
&lt;/h2&gt;

&lt;p&gt;Nine parity runs in a day, a sweep of 392 objects per build, a trace folded into a table and diffed against yesterday's - all of that was cheap because it was automated, and none of it would have been affordable by hand. What automation never supplied was a single one of the sentences that were missing: who may write this value, what absent means for this field, how large a caller is allowed to be, which side of a comparison is entitled to be right. Fast iteration multiplies the definitions that exist in writing. Where a definition is missing, it multiplies the circling instead - and produces, very precisely and very confidently, 39 iterations of a description where four written sentences would have done.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Anatomy of a service split - Part 8.&lt;/strong&gt; That closes this set. The obvious hole in it is the last row of the bill: 985 minutes of recorded duration and not one day with a cost figure attached. The next thing I measured was exactly that - what the automated work itself bills, per session, in tokens and in money, before any of it produces a line of output.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me which of your defects you can honestly say a document would have caught. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your own ledger look like when you counted the iterations rather than the tickets? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where writing the requirement down first costs more than it returns. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>testing</category>
      <category>observability</category>
      <category>microservices</category>
    </item>
    <item>
      <title>One Executor Checking Another Executor's Work</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Wed, 28 Oct 2026 02:14:53 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/one-executor-checking-another-executors-work-1m4l</link>
      <guid>https://dev.to/anton_brilliantov/one-executor-checking-another-executors-work-1m4l</guid>
      <description>&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%2Fbvoee3iwoa5z77ak5isd.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%2Fbvoee3iwoa5z77ak5isd.png" alt="Cover reading One Executor Checking Another Executor's Work, with the word Another on a vermilion slab, the figure 92 round trips where 7 would do, and three columns 92, 7 and 7 for builds A, B and C, the first one red" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A second pass earns its cost only if it can add a measurement the first pass never had.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series is about one write path that left the monolith and what it took to accept it as working. This last part is about the arrangement that produced most of the numbers in the other six: one automated executor pointed at the output of another, told to instrument what was not instrumented, run the measurement and report what it saw. 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;.&lt;/p&gt;

&lt;p&gt;This is how I do it right now, with the price attached - maybe you already do it better, maybe you'd do it another way.&lt;/p&gt;




&lt;h2&gt;
  
  
  The path in one paragraph
&lt;/h2&gt;

&lt;p&gt;A user saves a set of matching rules; every domain under that account gets marked by them. Four participants: the &lt;strong&gt;Go API gateway&lt;/strong&gt; as the only door from outside, the &lt;strong&gt;rule-set-markup service&lt;/strong&gt; which takes the write and folds the cascade, the &lt;strong&gt;domain-rule-map service&lt;/strong&gt; which owns rule-set identity and recalculates the map, and PostgreSQL behind them - the monolith's database for the rule data, the identity service's own for identity.&lt;/p&gt;

&lt;p&gt;Work on that path ran in iterations: a brief describing what to change, an executor producing the change, a run against a live environment, a verdict. This part is about the second executor in that sentence - the one whose whole job is to look at what came out of the first, and whose findings are the only reason several of the numbers in this series exist.&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%2Fa5q5rdc1j9dg5mzfize9.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%2Fa5q5rdc1j9dg5mzfize9.png" alt="The series system map with the acceptance repository, the Go API gateway, the rule-set-markup service and the domain-rule-map service lit, the UI and the PHP monolith dimmed, and a note on the acceptance repository: spans carried into a neighbour" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the second pass actually is
&lt;/h2&gt;

&lt;p&gt;Not a review of the diff. A review of the diff is cheap and mostly useless here: every change in this period was locally correct in its own repository, and I have a whole part of this series about two individually correct commits producing one defect.&lt;/p&gt;

&lt;p&gt;The second pass is five steps, in this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  1  read what the other pass produced          the change, and the claim it makes
  2  find the zone that has no numbers          which span would have to exist to check that claim
  3  carry the instrumentation there            usually into a neighbouring service, not this one
  4  run the measurement on a live environment  same input, before and after, same day
  5  report two kinds of finding                "this is what it takes to run it at all"
                                                "this looks wrong - here is the number, take a look"
&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%2F11kfo76it24eqyfs96sx.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%2F11kfo76it24eqyfs96sx.png" alt="Five step tiles in a row - read what was produced, find the zone with no numbers, carry the spans over, run it on a live stand, report two kinds - with a red note under step 3: in scope, a neighbour's spans" width="800" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Step 3 is the one that makes the arrangement worth anything, and it is the one that gets skipped. A pass that only measures where measurement already exists will confirm whatever the first pass believed. The finding below existed because the executor was told, in the brief, that adding spans in a service it was not otherwise touching was &lt;strong&gt;in scope&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The zone with no spans
&lt;/h2&gt;

&lt;p&gt;The claim under check was ordinary: a read endpoint had been reworked and was, by the response, fine. Status &lt;code&gt;200&lt;/code&gt;, correct data, content assertions passing on every object in the sample.&lt;/p&gt;

&lt;p&gt;What did not exist was any number underneath that verdict. The service's own spans said "the call took this long"; nothing said what the call had been doing. So the second pass added, in the service that answers that read, the one field that turns a duration into a finding: &lt;strong&gt;the statement text on every database span&lt;/strong&gt;, with a repeat count.&lt;/p&gt;

&lt;p&gt;Then it ran the same request - two domains, one account - against three consecutive builds on the same day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  build                          A          B          C
  ─────────────────────────────────────────────────────────
  database round trips          92          7          7      ◄ the shape
  database time, ms          440.0       37.2       31.8      ◄ 14× less
  server root, ms            474.7       76.2       59.1      ◄ ours, 8× less
  client wall, ms            715.9      324.0      433.1
  network, ms                242.1      250.1      381.3      ░ not ours, never moved
  response code                200        200        200      ← unchanged throughout
&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%2Fcrvr6iwxlmpsnga35lss.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%2Fcrvr6iwxlmpsnga35lss.png" alt="A dark terminal card with three builds A, B and C: database round trips 92, 7, 7 highlighted as the shape; database time 440.0, 37.2, 31.8 ms; server root 474.7, 76.2, 59.1 ms; network 242.1, 250.1, 381.3 ms; response code 200, 200, 200" width="799" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqitkyiql9aerz65wuiox.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqitkyiql9aerz65wuiox.gif" alt="Animated bars of database round trips: build A grows to 92 in red, builds B and C fall to 7 in green, every row labelled response 200, and a stamp lands reading 200 in all three" width="800" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ninety-two round trips on a call that needs seven. Nothing about that is visible in a response code, a status page, an error rate or a duration alone. It is visible in exactly one place - the count and the text of the statements - and that place did not exist until a pass was sent to create it.&lt;/p&gt;

&lt;p&gt;Two things I take from that run, and keep:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The unit of the finding is round trips, not milliseconds.&lt;/strong&gt; 92 → 7 is a fact about the shape of the work. 440.0 → 31.8 ms is what the shape happened to cost on that day, on that data volume, on that machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A share without its absolute value is not publishable.&lt;/strong&gt; The network's share of the call went from 33.8 % to 88.0 % across those three builds and reads like a catastrophe; in milliseconds it never moved, and everything we own fell. I write &lt;code&gt;440.0 ms · 61 %&lt;/code&gt;, never &lt;code&gt;61 %&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two kinds of finding, and why the difference matters
&lt;/h2&gt;

&lt;p&gt;Everything the second pass reported fell into one of two boxes, and I ask for them separately now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"This is what it takes to run at all."&lt;/strong&gt; Missing configuration, a wrong assumption about a field, an endpoint that needs a list where a single value was sent. Mechanical, verifiable, and safe to act on immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"This looks wrong - here is the number, take a look."&lt;/strong&gt; 92 statements where 7 would do. A resolver called once per rule. A wait with no span around it. These are &lt;strong&gt;suspicions with evidence attached&lt;/strong&gt;, not verdicts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second box is the valuable one and it is the one that must not be auto-applied. A suspicion is a claim about intent - somebody meant this call to cost one round trip per request, not one per rule - and intent is not in the trace. What the pass supplies is the number that makes the conversation short. What it cannot supply is the decision that the number is wrong.&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%2F9zmmxl4r69k3uo328lgx.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%2F9zmmxl4r69k3uo328lgx.png" alt="Two tiles: what it takes to run, missing configuration or a list where one value was sent, stamped act on it; this looks wrong, 92 statements where 7 would do, a suspicion with a number, stamped decide first" width="800" height="379"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I have seen the failure mode of skipping that distinction, in this same period: a probe read a value from a listing endpoint that quietly ignored its filter, printed a confident finding, and produced two phantom defects - one of them filed as a comment on another team's ticket before being withdrawn. The executor was not sloppy. It reported what it measured, with the same formatting and the same confidence it uses when it is right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The instruction surface is where the cost sits
&lt;/h2&gt;

&lt;p&gt;The cheap part of this arrangement is the running. The expensive part is the writing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  brief files in the repository        289
  of them, in 33 numbered sets         137
  ─────────────────────────────────────────
  what a brief has to contain          the exact surface, the exact request shape,
                                       what counts as a normal empty, where artefacts go,
                                       and an explicit ban on concluding from one field
&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%2Fimsklmprnd6qhaes2t8p.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%2Fimsklmprnd6qhaes2t8p.png" alt="Three number tiles for the instruction surface: 289 brief files, 137 of them in sets, 33 numbered sets, with a stamp reading the run is disposable" width="800" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the honest bill for "the executor did it". Two hundred and eighty-nine files of instruction accumulated behind a period whose &lt;em&gt;code&lt;/em&gt; changes would fit in a much smaller number. None of it is boilerplate: a brief that does not say what a normal empty result looks like will get a normal empty result reported as a failure, and I have the reports to prove it.&lt;/p&gt;

&lt;p&gt;What it buys is repeatability. A brief that is complete enough to hand to an executor is, by construction, complete enough to hand to a person, to a pipeline, or to yourself in three weeks. The instruction surface is the durable artefact here; the run is disposable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defects do not die on the first pass
&lt;/h2&gt;

&lt;p&gt;The other thing a second pass changes is the arithmetic of a defect's life. Twenty-six numbered defects over the period, and &lt;strong&gt;39 defect-iterations&lt;/strong&gt; between them - a defect on average outlived more than one pass, and the worst of them outlived six:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  iterations   0    1    2    3    4    5    6
               ├────┼────┼────┼────┼────┼────┤
  D-01         ████████████████████████████▌  6   a rule written, the engine kept the old set
  D-04         ███████████████████████▌       5   map row carried no priority (+ a regression)
  D-11         ██████████████▌                3   priority 0 arrived as null on the wire
  false ✗      ██████████████▌                3   no defect at all - measured the wrong source
  D-14         █████████▌                     2   timeout on a third of the objects
  D-16         █████████▌                     2   map row did not name its set - came back later
  eight more   ████▌                          1   each
  ──────────── still open at the end of the period ────────────
  D-23         █████████▶                    ≥2   rule order not provable
  D-25         ████▶                         ≥1   cursor paging says "more", returns no token
  D-26         ▶                             ≥0   async rebuild lands in tens of seconds - or not
&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%2Fqxxejkunmdokd43qhiqi.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%2Fqxxejkunmdokd43qhiqi.png" alt="Animated bars of how many iterations each defect lived: D-01 6, D-04 5, D-11 3, the false defect from the wrong source 3 with a note - three iterations on no defect at all - D-14 2 and D-16 2" width="799" height="576"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The line worth staring at is the one labelled &lt;code&gt;false ✗&lt;/code&gt;: three iterations spent on something that was never a defect, because a probe read the wrong source and reported it with full confidence. Three iterations is the same cost as the third-worst real defect in the set.&lt;/p&gt;

&lt;p&gt;So the second pass is not a heroic single sweep that closes things. It is a loop that has to survive being wrong about its own findings, which is why every finding it reports carries the measurement that produced it rather than the conclusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  A rule written only into prose comes back
&lt;/h2&gt;

&lt;p&gt;I keep a write-up for every mistake I make in acceptance work. Over this period that is &lt;strong&gt;28 of them&lt;/strong&gt;, which is a number I am fine publishing because the interesting part is not the count.&lt;/p&gt;

&lt;p&gt;The interesting part is that &lt;strong&gt;5 of those mistakes repeated&lt;/strong&gt;, and the gap between the first occurrence and the repeat was &lt;strong&gt;one to six days&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reading a configuration's rule set from an endpoint that does not authoritatively answer it - came back after six days, four iterations burnt;&lt;/li&gt;
&lt;li&gt;writing a request from memory instead of from the contract - three separate times;&lt;/li&gt;
&lt;li&gt;writing to a production environment from a state that had been read wrongly - four days later, with 17 rules wiped and a four-minute gap in the data;&lt;/li&gt;
&lt;li&gt;misreading an auth &lt;code&gt;401&lt;/code&gt; as a defect - five days later, one ticket filed and withdrawn;&lt;/li&gt;
&lt;li&gt;overwriting a shared artefact with a blind write - the same day.&lt;/li&gt;
&lt;/ul&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%2Fh2uhonuirsgcwg9s4yrj.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%2Fh2uhonuirsgcwg9s4yrj.png" alt="Three number tiles: 28 write-ups of mistakes, 5 repeated anyway, 1-6 days until the repeat, with a stamp reading the write-up did nothing" width="800" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every one of those already had a write-up. The write-up did nothing. A rule that lives in prose is re-derived by whoever reads it next, which is to say never, and an executor never reads it at all.&lt;/p&gt;

&lt;p&gt;What actually stopped the repeats was moving the rule into the two places that are read on every run: &lt;strong&gt;the brief&lt;/strong&gt; (as a numbered constraint, with the ban stated as a ban) and &lt;strong&gt;the check&lt;/strong&gt; (as an assertion that fails, not a note that warns). That is the same movement as freezing requirement tests before code, applied to my own process rather than to the product.&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%2Ftv6s6dd8nbhiyhsnvifs.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%2Ftv6s6dd8nbhiyhsnvifs.png" alt="Three tiles joined by arrows: a dashed red write-up in prose, stamped read by nobody, then the brief with a numbered ban, then the check, an assertion that fails" width="800" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the loop, and the steps that stay manual
&lt;/h2&gt;

&lt;p&gt;Right now the handovers between passes are mine. I take the analysis, I decide it is worth a run, I hand it to the run, I read the report, I decide what it means. The obvious next step is to close that: one orchestrating pass that fans out analysis, instrumentation and measurement, and returns a report instead of returning to me three times on the way.&lt;/p&gt;

&lt;p&gt;Three steps I am deliberately not closing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;what "correct" means&lt;/strong&gt; - the decision that 92 round trips is wrong and 7 is right is a product decision, and the trace does not contain it;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;writing to a live environment&lt;/strong&gt; - a write from a wrongly-read state has already cost me 17 rules and four minutes of data; it stays behind a person on purpose;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;accepting a defect as a defect&lt;/strong&gt; - because the cheapest failure mode in the whole set is a confident, well-formatted, entirely fictional finding.&lt;/li&gt;
&lt;/ul&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%2F26d53tge0xraly8pn7jw.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%2F26d53tge0xraly8pn7jw.png" alt="Two columns: closes into a loop - dispatch analysis, add instrumentation, run the measurement, fold the comparison, repeat on the next build - against stays with a person - what correct means, writing to a live stand, accepting a defect" width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Calling those three "ritual" is fair, and I keep them anyway. Not because automation cannot perform them - it can perform all three - but because the cost of being wrong on them is asymmetric, and the cost of doing them by hand is a few minutes per iteration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I never measured
&lt;/h2&gt;

&lt;p&gt;The honest hole in this part, in one line: &lt;strong&gt;there is not a single day in this period with a measurement of what the automated work cost.&lt;/strong&gt; No token counts, no money, no per-iteration accounting. I have 985 minutes of run durations and I have zero currency.&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%2Fbhbrffucalmyowf5u6kp.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%2Fbhbrffucalmyowf5u6kp.png" alt="A torn paper receipt for mistakes in acceptance work: wrong source again, 4 iterations, came back; a false defect, 3 iterations, no defect; write from a bad read, 4 minutes, 17 rules; run durations 985 min, logged; money measured none, with a stamp reading not measured" width="800" height="459"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That means everything above is a statement about &lt;em&gt;what the arrangement found&lt;/em&gt;, and nothing above is a statement about whether it was worth it. Those are different claims and I can only make the first one. The second needs a measurement I did not take, and taking it costs nothing next to the thing being measured - which is exactly why not having it is embarrassing rather than defensible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multiplier line
&lt;/h2&gt;

&lt;p&gt;The second pass produced findings the first pass could not have produced, and it did it in an afternoon: the missing spans, the run, the comparison, the report. What it did not produce was the instruction that sent it into a service it was not otherwise touching, or the judgement that a correct &lt;code&gt;200&lt;/code&gt; response can still be a defect. Those came out of a brief - one of 289 - written by a person who had already been wrong enough times to know which zone had no numbers in it. Fast execution multiplies the definition of "done" that it is given; where that definition is a response code, it multiplies green.&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%2Fk5dqu1sidig9xb9lx1jp.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%2Fk5dqu1sidig9xb9lx1jp.png" alt="A four-panel strip: what we did, a 2nd pass, spans carried into a neighbour; what broke, 92 round trips behind a 200; why, no spans on the statements; what now, 7 round trips, same input" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Anatomy of a service split — Part 7.&lt;/strong&gt; That closes this set. The remaining piece is the whole ledger in one place: 32 days, 34 runs, 26 numbered defects and every figure from these seven parts, read end to end as one timeline rather than seven angles on it.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me how you decide which zone gets instrumented before a run. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your second pass find that the first one had reported as green? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where a second automated pass costs more than it returns. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>testing</category>
      <category>observability</category>
      <category>productivity</category>
      <category>architecture</category>
    </item>
    <item>
      <title>A Service That Cannot Explain Itself Can Only Be Guessed At</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Mon, 26 Oct 2026 21:53:44 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/a-service-that-cannot-explain-itself-can-only-be-guessed-at-a10</link>
      <guid>https://dev.to/anton_brilliantov/a-service-that-cannot-explain-itself-can-only-be-guessed-at-a10</guid>
      <description>&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%2F6gbnr7ospe1pjt1o54hu.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%2F6gbnr7ospe1pjt1o54hu.png" alt="Cover reading A Service That Cannot Explain Itself Can Only Be Guessed At, with the word Explain on a vermilion slab, the figure 210 of 276 had no active set, and two panels: 3 objects, all pass, against 210 of 276, empty" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If a service cannot state what its answer should be, the only way to check it is to guess.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series is about one write path that left the monolith and what it took to accept it as working. This part is about the thing that made checking it possible at all: read endpoints whose only job is to describe state. 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;.&lt;/p&gt;

&lt;p&gt;This is how I do it right now, with the price attached - maybe you already do it better, maybe you'd do it another way.&lt;/p&gt;




&lt;h2&gt;
  
  
  The path in one paragraph
&lt;/h2&gt;

&lt;p&gt;A user saves a set of matching rules; every domain under that account gets marked by them. Four participants: the &lt;strong&gt;Go API gateway&lt;/strong&gt; as the only door, the &lt;strong&gt;rule-set-markup service&lt;/strong&gt; which takes the write and folds the cascade, the &lt;strong&gt;domain-rule-map service&lt;/strong&gt; which owns rule-set identity and recalculates the map, and PostgreSQL behind them - the monolith's database for the rule data, the identity service's own for identity.&lt;/p&gt;

&lt;p&gt;We knew our input exactly: the rules the user sent. What nobody could state was the &lt;strong&gt;expected output&lt;/strong&gt;: given this input and this account, what should the resulting map contain, and how would we know the answer was complete rather than merely non-empty?&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%2Fvegvxwp4u7n30nyckwve.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%2Fvegvxwp4u7n30nyckwve.png" alt="The series system map with the acceptance repository, the Go API gateway, the rule-set-markup service and the domain-rule-map service lit, the UI and the PHP monolith dimmed, and a note on the acceptance repository: four reads, one sweep" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What a three-object test hides
&lt;/h2&gt;

&lt;p&gt;The first weeks of checking ran against a playground of &lt;strong&gt;three objects&lt;/strong&gt;. Everything passed. The runs were honest, the assertions were real, and the conclusion was worthless - three objects cannot show you anything that depends on the shape of the data.&lt;/p&gt;

&lt;p&gt;Then a run went across everything the environment held:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  playground              3 objects        everything passes
  full sweep            276 objects        active-set registry EMPTY for 210 of 276
  later sweeps          392 objects        392 of 392 covered, 129 map rows of 2 329
                                           carrying neither rule set nor priority (5.5 %)
&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%2Fkzzb8dka5kt0silodlrq.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%2Fkzzb8dka5kt0silodlrq.png" alt="Three bars of coverage: a playground of 3 objects, a full sweep of 276 objects with a note that the registry was empty for 210 of 276, and later sweeps with 392 of 392 covered" width="799" height="415"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyx1w3w0induojvqz2cuq.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyx1w3w0induojvqz2cuq.gif" alt="Animated grid of 276 cells filling red one by one up to 210 objects with an empty registry, next to the counters 210 empty registry and 276 objects swept" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing was fixed between the first two lines. The system was in exactly the same state; what changed is that it could now be &lt;strong&gt;asked&lt;/strong&gt;. 210 of 276 is not a defect that appeared - it is a defect that was already there, in production data, invisible because no endpoint would tell us.&lt;/p&gt;

&lt;h2&gt;
  
  
  The endpoints that made the difference
&lt;/h2&gt;

&lt;p&gt;Four reads, none of them user-facing features, all of them ordinary functionality the services already had internally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;the input snapshot for an account&lt;/strong&gt; - which domains are in scope at all, and how many.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the rule set in effect, with its rules&lt;/strong&gt; - what is supposed to be applied.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the pointer to the active set&lt;/strong&gt; - which set the engine believes is current.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the stored map, page by page&lt;/strong&gt; - what was actually written, per row.&lt;/li&gt;
&lt;/ul&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%2F7dn8qjmepdlb99y6tekg.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%2F7dn8qjmepdlb99y6tekg.png" alt="Four read tiles - input snapshot, rule set in effect, active pointer, stored map paged - each with an arrow down into one green tile: expected result, computed locally, then compared to the map and the engine" width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With those four, checking stops being an opinion. You read the snapshot and the rules, compute the expected result &lt;strong&gt;locally&lt;/strong&gt;, then compare it to the stored map and to what the engine answers live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  snapshot            8 693 domains in scope
  rule set               87 rules
  expected, computed     87 marked
  stored map             87 marked
  engine answer          87 marked
  ───────────────────────────────────
  mismatches              0            ← a verdict, not an impression
&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%2Fdj9b8n55ujbgp6tjn6z1.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%2Fdj9b8n55ujbgp6tjn6z1.png" alt="A dark terminal card for one account: snapshot 8 693 domains in scope, rule set 87 rules, expected 87 marked, stored map 87 marked, engine answer 87 marked, and the highlighted line mismatches 0, with a note: a verdict, not a guess" width="799" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the whole argument of this part. &lt;strong&gt;The expected result is not something a checker invents; it is something the system has to be able to state.&lt;/strong&gt; If the only endpoint is "do the work", then every verdict about it is a guess dressed as a test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways this goes wrong when the surface is missing
&lt;/h2&gt;

&lt;p&gt;Both of these are mine, both cost real days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A green result that never ran.&lt;/strong&gt; A probe sent a single-value field to an endpoint that requires a list of ids. The endpoint answered &lt;code&gt;422&lt;/code&gt;; the probe took the empty decision list as "nothing disagreed" and printed &lt;strong&gt;&lt;code&gt;PASS 24, FAIL 3&lt;/code&gt;&lt;/strong&gt;. Two builds were accepted on a check that had performed &lt;strong&gt;zero comparisons&lt;/strong&gt;. A false FAIL creates work; a false PASS silently removes the check.&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%2Fwrmkvf0tnamu7uc6la6u.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%2Fwrmkvf0tnamu7uc6la6u.png" alt="A dark log card of the probe: a single value sent where a list of ids is required, a 422 answer, an empty decision list, comparisons made 0, and the highlighted line PASS 24 FAIL 3, with a note: two builds accepted" width="800" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rule that came out of it is one line: &lt;strong&gt;an empty selection is a FAIL, not a PASS&lt;/strong&gt;, and every probe carries a counter of comparisons actually made, non-zero by construction.&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%2F9dnu1uxtb8mqcfbdqrtt.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%2F9dnu1uxtb8mqcfbdqrtt.png" alt="Two halves: on the dark side what the report said, PASS 24, FAIL 3, two builds accepted; on the red side what happened, 0 comparisons and a 422 answer; a stamp reads empty = fail" width="800" height="406"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The right-looking read of the wrong thing.&lt;/strong&gt; The set attached to a configuration was read from a listing endpoint that quietly ignores the filter it was given - a request for one account returned a hundred rows belonging to other accounts. The probe printed a confident, entirely fictional finding. Five days later the same misreading produced &lt;strong&gt;two phantom defects&lt;/strong&gt;, one of which was filed as a comment on another team's ticket before being withdrawn.&lt;/p&gt;

&lt;p&gt;That one costs a section in every spec since, in two halves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;positive definition&lt;/strong&gt; - which endpoint returns this value, by contract;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;negative definition&lt;/strong&gt; - which reads do &lt;em&gt;not&lt;/em&gt; prove it, and why.&lt;/li&gt;
&lt;/ul&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%2Fz509snqxxkye4qeyr2wr.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%2Fz509snqxxkye4qeyr2wr.png" alt="Two tiles: the positive definition, the endpoint that returns the value by contract, one object asked and its own value returned, stamped proves it; the negative definition, a listing that ignores the filter, one account asked and a hundred rows of others returned, stamped proves nothing" width="799" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  This is functionality, not scaffolding
&lt;/h2&gt;

&lt;p&gt;The tempting way to read all this is "add some debug endpoints". That framing is why they never get built.&lt;/p&gt;

&lt;p&gt;Every one of those four reads is &lt;strong&gt;capability the service already has&lt;/strong&gt;. Resolving the active pointer, listing rules, paging the map - it does all of it internally on every request. The only question is whether it is exposed. A service that keeps it inside is not simpler; it is a service whose behaviour can only be inferred.&lt;/p&gt;

&lt;p&gt;Where the endpoint lives is a detail, and the detail is smaller than it looks: it can sit behind the same gateway as everything else, under an operator role, under normal rights, or on a separate admin surface. What matters is that the capability is &lt;strong&gt;presentable&lt;/strong&gt;, and that it is treated as part of the product rather than as something added while chasing a bug. The same reads that let a checker compute an expectation are what an operator wants during an incident and what a support answer needs when someone asks why one account looks different.&lt;/p&gt;

&lt;p&gt;Two design notes that came out of using them in anger:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read endpoints must page and count honestly.&lt;/strong&gt; A page of five rows out of eighty-seven that says "there is more" while giving no cursor to continue with is not a diagnostic - it's another guess. That was its own defect and it is still open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Absence must be answerable.&lt;/strong&gt; An account with no data should return an empty, successful answer, not an error. Modelling "nothing here" as a failure means every sweep has to guess which failures are real - we had objects answering three different codes for one state.&lt;/li&gt;
&lt;/ul&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%2Fo7l7gesd97qmyy8yx54a.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%2Fo7l7gesd97qmyy8yx54a.png" alt="Four guarantees of a read endpoint: paging that continues, open, 5 of 87 rows and no cursor; counts that mean something, in place; absence as absence, open, one state answered with three codes; state per object, in place; a stamp reads two still open" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the reason to treat these as requirements rather than nice-to-haves: everything the sweep found came out of these reads, not out of running the feature.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fepc2tx5o2jjeltsm38ks.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fepc2tx5o2jjeltsm38ks.gif" alt="Animated number tiles appearing one by one: 210 of 276 with no active-set pointer, 129 of 2 329 rows without a set, 5.5 %, 3 to 392 objects under the sweep, 0 mismatches on one account, 2 phantom defects withdrawn" width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Today an executor, tomorrow the pipeline
&lt;/h2&gt;

&lt;p&gt;Right now the sweep is driven by an automated executor: it reads the four endpoints, computes the expectation, compares, and writes a report with the numbers and the trace contents attached. That is a stage, not a destination. The same four reads and the same comparison belong in a pipeline against a stand - a scheduled run, or one run per deploy of the services involved.&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%2Ffc3ms4j3rrlleqsunq5d.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%2Ffc3ms4j3rrlleqsunq5d.png" alt="The four read endpoints feeding three consumers: a person in an incident, an executor today, and a dashed pipeline tomorrow" width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Conceptually nothing changes between those columns; only the thing holding the clipboard does. Which is exactly why it is worth building the surface first and arguing about the runner later - the surface is the durable part, and it is useful to a human during an incident whether or not anything automated ever consumes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Being honest about the price, because there is one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Endpoints have to be maintained.&lt;/strong&gt; A read that describes internal state drifts with the internals; a stale describe-endpoint is worse than none, because it is believed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They widen the surface.&lt;/strong&gt; Rights, rate limits, and "who may see how many rows of whose data" all have to be answered - the same answers as for any other endpoint, but they must actually be given.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Computing the expectation locally is real work.&lt;/strong&gt; It means writing down the rule of the system in a second place, which is a second thing to keep true.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Against that: the sweep of 276 that found 210 empty registries took minutes once the reads existed. Before they existed, the same finding was unavailable at any price.&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%2Fk8uyx6fxp6jjzg2n8af6.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%2Fk8uyx6fxp6jjzg2n8af6.png" alt="A torn paper receipt for checks without a surface: probe on a 422, 2 builds, 0 compared; listing with no filter, five days, 2 phantoms; manual sweep, two hours, one pass; price 2 builds, with a stamp reading guessed" width="800" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The multiplier line
&lt;/h2&gt;

&lt;p&gt;An executor turned a two-hour manual sweep into a repeatable one, and made "run it again on the next build and print the difference" cost nothing. It could not invent the four reads, and it could not tell a genuine mismatch from a probe reading the wrong endpoint - it printed both with equal confidence. What made the automation worth anything was the surface underneath it and the definition of where each value legitimately comes from. Fast is only useful once correct is defined.&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%2F3hj5b1yhhb4lmcgxfser.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%2F3hj5b1yhhb4lmcgxfser.png" alt="A four-panel strip: what we did, 3 objects in the playground, all passed; what broke, 210 of 276 with an empty registry; why, no reads, nobody could state the answer; what now, 0 mismatches, read by read" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Anatomy of a service split — Part 2.&lt;/strong&gt; Next: the part before all of this - the analysis that was not done. One value with four homes and no owner, and how long it takes to find that out when nobody wrote down who is allowed to write it.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me what your services expose for checking their own state. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your first full sweep turn up that the sample never did? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where a describe-your-state endpoint costs more than it returns. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>testing</category>
      <category>observability</category>
      <category>microservices</category>
    </item>
    <item>
      <title>Most of Your Latency Is Not Your Code</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Sun, 25 Oct 2026 17:13:50 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/most-of-your-latency-is-not-your-code-5a06</link>
      <guid>https://dev.to/anton_brilliantov/most-of-your-latency-is-not-your-code-5a06</guid>
      <description>&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%2Fp1vd3thdq1ruy52evk2t.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%2Fp1vd3thdq1ruy52evk2t.png" alt="Cover reading Most of Your Latency Is Not Your Code, with the word Not on a vermilion slab, the figure 7 % was the blamed service, and a ring with a 7 % arc for the blamed service's share of a 1 256.9 ms call" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A service is judged by its own span, not by the client's stopwatch.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series is about one write path that left the monolith and what it took to accept it as working. This part is about latency: where it actually sits, and how long we argued about it before anyone measured. 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;.&lt;/p&gt;

&lt;p&gt;This is how I do it right now, with the price attached - maybe you already do it better, maybe you'd do it another way.&lt;/p&gt;




&lt;h2&gt;
  
  
  The path in one paragraph
&lt;/h2&gt;

&lt;p&gt;One feature: a user saves a set of matching rules, and every domain under that account gets marked according to them. Four participants. The &lt;strong&gt;Go API gateway&lt;/strong&gt; is the only door from outside. The &lt;strong&gt;rule-set-markup service&lt;/strong&gt; takes the write, folds the cascade and persists it. The &lt;strong&gt;domain-rule-map service&lt;/strong&gt; owns rule-set identity and does the recalculation, with a worker process behind a queue. Storage is PostgreSQL - the monolith's database for the rule data, the domain-rule-map service's own for identity.&lt;/p&gt;

&lt;p&gt;One user action, four processes, two databases. When it is slow, everyone in the chain has an opinion about whose fault it is, and none of the opinions cost anything to hold.&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%2Fwhozlpz2ku4mz757zdrq.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%2Fwhozlpz2ku4mz757zdrq.png" alt="The series system map with the UI, the Go API gateway, the rule-set-markup service and the domain-rule-map service lit, the PHP monolith and the acceptance repository dimmed, and a note on the domain-rule-map service: 775.6 ms recalculation" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The call everyone argued about
&lt;/h2&gt;

&lt;p&gt;A single write, traced end to end: &lt;strong&gt;121 spans, four services, 1 256.9 ms&lt;/strong&gt; as seen by the client.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F796olgjcwz55z7npuwoz.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F796olgjcwz55z7npuwoz.gif" alt="Animated trace waterfall of one write: the client bar 1 256.9 ms, the gateway 1 012.0 ms, the rule-set-markup service 943.0 ms, the domain-rule-map recalculation 775.6 ms and its worker 350.9 ms unroll one after another, then a note marks the blamed service's own zone, 87.4 ms, 7.0 %" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The blame went to the write service, because that is the one the request is addressed to. Here is the same call as nesting, which is the only shape that answers the question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client wait                                          1 256.9 ms   100 %
├── transport, client ↔ environment                     244.9      19.5 %   not ours
└── gateway root                                      1 012.0      80.5 %
    ├── auth, rate limit, identity, cache                 3.7
    ├── own work + 4 SQL + one catalog call              47.4
    └── batch write call                                950.9
        ├── hop, gateway → write service                  7.9              not ours
        └── rule-set-markup                              943.0
            ├── own work                                   1.4
            ├── validate + gather, 5 SQL                  32.3
            ├── identity calls → domain-rule-map          76.7
            ├── its own transaction, 10 SQL               54.4   (SQL 53.7 · work 0.7)
            └── recalculation → domain-rule-map          775.6
                └── worker: canonicalise                  350.9
                    ├── SELECT dictionary rows            129.6
                    └── INSERT dictionary rows            112.5
&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%2F20v5tkag5srtsxk2c3a0.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%2F20v5tkag5srtsxk2c3a0.png" alt="Three nested frames: client wait 1 256.9 ms holds four services at work 1 012.0 ms, which holds the blamed service's own zone 87.4 ms, split into own work 1.4 ms and PostgreSQL 86.0 ms, with a stamp reading 7.0 %" width="800" height="491"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The service under suspicion owns three rows of that tree: its own work, its validate/gather step and its transaction. &lt;strong&gt;87.4 ms out of 1 256.9 - 7.0 % of what the client waited.&lt;/strong&gt; And inside those 87.4 ms, &lt;strong&gt;98.4 % is waiting on PostgreSQL&lt;/strong&gt;; the code itself accounts for 1.4 ms.&lt;/p&gt;

&lt;p&gt;Two things worth saying out loud about that tree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never sum nested spans.&lt;/strong&gt; 943.0 already contains 775.6, which already contains 350.9. Adding them up is how a 1.2-second call becomes "three seconds of work".&lt;/li&gt;
&lt;li&gt;The whole call cost &lt;strong&gt;53 database round trips, 11 of them repeats of a query already made&lt;/strong&gt; in the same call. That count, not the milliseconds, is the actionable number - and I'll come back to it.&lt;/li&gt;
&lt;/ul&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%2F4pbystjq0ro4nmw48e88.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%2F4pbystjq0ro4nmw48e88.png" alt="A dark terminal card with the call as a tree - client wait 1 256.9 ms, transport 244.9, gateway root 1 012.0, batch write call 950.9, rule-set-markup 943.0, recalculation 775.6, worker 350.9 - with the rule-set-markup line highlighted and a note: 943.0 holds 775.6" width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The rejected request that settled the zone question
&lt;/h2&gt;

&lt;p&gt;Arguments about "the network is fine, it's your service" ended with the cheapest measurement in the whole set: a request that was &lt;strong&gt;rejected as invalid&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST … → 422 Unprocessable Entity
  server work    6.6 ms
  database       0.0 ms
  client wall  231.8 ms
  ─────────────────────
  225.2 ms, or 97.2 % of the call, is not the service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server parsed a body, failed validation and answered in 6.6 ms. The client saw 231.8 ms. Nothing was computed, nothing was stored, and &lt;strong&gt;97 % of the call still happened outside our code&lt;/strong&gt;.&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%2F38l9xa0qjy3w4xwy59ol.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%2F38l9xa0qjy3w4xwy59ol.png" alt="A dark log card of the rejected request: POST to 422 Unprocessable Entity, server work 6.6 ms, database 0.0 ms, client wall 231.8 ms, and the highlighted line 225.2 ms, 97.2 % of the call, is not the service, with a note: not ours" width="800" height="333"&gt;&lt;/a&gt;&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%2Fnce9xmolm302dr58n08i.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%2Fnce9xmolm302dr58n08i.png" alt="One dark bar for the rejected call with a narrow green sliver at its left end, labelled 225.2 ms outside our code, a note reading server work 6.6 ms, database 0.0 ms, and a stamp reading client wall 231.8 ms" width="799" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is not a defect and it is not fixable by us - the environment sits at a steady &lt;strong&gt;220-260 ms&lt;/strong&gt; regardless of endpoint or payload size, confirmed on a rejection and on a full run. It only becomes a lie when someone quotes the client's stopwatch as a service metric.&lt;/p&gt;

&lt;p&gt;So the reading rule for the whole series, written down after this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A service is judged by its root and server spans. &lt;code&gt;wall&lt;/code&gt; describes the trip, not the work.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Three builds, one input
&lt;/h2&gt;

&lt;p&gt;The same read request - two domains, one account - run three times on three consecutive builds of the matching service, same day:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;client wall:&lt;/strong&gt; 715.9 → 324.0 → 433.1 ms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;server root:&lt;/strong&gt; 474.7 → 76.2 → 59.1 ms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;database total:&lt;/strong&gt; 440.0 → 37.2 → 31.8 ms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;database queries:&lt;/strong&gt; 92 → 7 → 7&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;network:&lt;/strong&gt; 242.1 → 250.1 → 381.3 ms, i.e. a share of 33.8 % → 77.2 % → 88.0 %&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read the network share alone and the story is "it got dramatically worse: the network went from a third of the call to almost all of it". Read the row above it and the story is the opposite: &lt;strong&gt;the part we own fell by a factor of eight, and the part we don't own never moved.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  wall         715.9 ╲___324.0 ╱‾433.1     the trip bounced around
  server root  474.7 ╲______76.2 ╲_59.1    ◄ what we own — fell monotonically
  database     440.0 ╲______37.2 ╲_31.8    ◄ 14× drop
  queries         92 ╲_________7 ─── 7     ◄ the shape of the query is what got fixed
  network      242.1 ─────250.1 ╱‾381.3    ░ not ours — our fix did not move 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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8d6uryl6v237aw3vflky.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8d6uryl6v237aw3vflky.gif" alt="Animated columns for builds A, B and C in four panels: server root 474.7, 76.2, 59.1 ms; database 440.0, 37.2, 31.8 ms; queries 92, 7, 7; network 242.1, 250.1, 381.3 ms - the three we own fall while the network does not" width="800" height="573"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hence the second rule, which I now apply to every chart in this material: &lt;strong&gt;a share is never published without its absolute value.&lt;/strong&gt; &lt;code&gt;440.0 ms · 61 %&lt;/code&gt;, never &lt;code&gt;61 %&lt;/code&gt;. A percentage of a moving denominator is the easiest honest-looking way to say the wrong thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  What build A was actually doing
&lt;/h3&gt;

&lt;p&gt;Build A was not slow because the database was slow. It was slow because of the &lt;strong&gt;shape&lt;/strong&gt; of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one compile step held &lt;strong&gt;411.1 ms - 91 % of the server window&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;inside it, a per-rule resolver ran &lt;strong&gt;86 times&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;each of those read the rule-type catalog: &lt;strong&gt;88 identical &lt;code&gt;SELECT … ORDER BY id&lt;/code&gt; in one call&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The response was correct the whole time. Status &lt;code&gt;200&lt;/code&gt;, right data, PASS on content - and a class of defect that no response code can express. It was caught in one call for exactly one reason: &lt;strong&gt;the spans carried the query text&lt;/strong&gt;, so 88 identical statements were visible as 88 identical statements, not as "the database was busy".&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%2Fbwycpzege9pmm09195d0.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%2Fbwycpzege9pmm09195d0.png" alt="A dark JSON card of one span's fields - service, span, duration, status and statement - with the statement SELECT … ORDER BY id highlighted and a note: 88 identical in one call" width="800" height="380"&gt;&lt;/a&gt;&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%2Fnxc16rrse6gxtgkd25jb.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%2Fnxc16rrse6gxtgkd25jb.png" alt="Three number tiles for build A: 411.1 ms in one compile step, 91 %; 86 resolver calls; 88 identical statements; with a stamp reading status 200 the whole time" width="800" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix moved it to 7 queries. And with the database gone from the picture, the slowest remaining participant became the network - which is the correct end state, not a regression.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other direction: when the wait has no name
&lt;/h2&gt;

&lt;p&gt;The opposite case, from the write path, same period: recalculation calls started failing on a 20-second deadline - first on 13 of 76 objects, then, after a change meant to help, on &lt;strong&gt;27 of 76&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The instinct was to look at the write transaction. The transaction took &lt;strong&gt;14.8 ms&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The time was in two spans nobody had thought about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;queue wait: 64-84 s&lt;/strong&gt; - work sitting in a queue before anyone started it;&lt;/li&gt;
&lt;li&gt;one canonicalisation lookup: &lt;strong&gt;32.3 s&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&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%2Fj6ccbu4slh96y3ojexb6.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%2Fj6ccbu4slh96y3ojexb6.png" alt="Three bars on one scale: the own write transaction 14.8 ms is a dot, one canonical lookup 32.3 s fills a third, the queue wait 64-84 s fills the row, with a note: 20 s deadline, 13, then 27 of 76 failed" width="799" height="415"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Neither is "the service being slow". Both are the service &lt;strong&gt;waiting&lt;/strong&gt;, and the only reason they were nameable is that somebody had instrumented the wait before it became a problem. The four places a request sits still - acquiring a connection, waiting for a worker, handing work over, waiting for the answer - are almost never covered by a span, which is why they show up as an unexplained gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the traces still do not show
&lt;/h2&gt;

&lt;p&gt;Honest inventory, because a measurement set with unknown holes in it is worse than one with known holes.&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%2Fyg4gztaw2ragrxbaadcn.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%2Fyg4gztaw2ragrxbaadcn.png" alt="Five zone tiles of one call: transport 220-260 ms, hop 0.8-2.4 ms, a dashed red gRPC codec tile marked unknown, service work in its own spans, database per query; a stamp under the codec reads not measured" width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;transport, client ↔ environment&lt;/strong&gt; - measured: steady 220-260 ms, not ours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;hop between services&lt;/strong&gt; - measured as a gap: 0.8-2.4 ms per transition, 7.9 ms on the write call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the service's own work&lt;/strong&gt; - measured: its own spans, per service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;database&lt;/strong&gt; - measured: per query, with the statement text and a repeat count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gRPC encode / decode&lt;/strong&gt; - &lt;strong&gt;not measured&lt;/strong&gt;: unknown, probably small, but not a measured zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The gap is real: between-service time is derived from where the child span starts, so serialisation, deserialisation and whatever the client library does around them are folded into one number that nobody has split. On the numbers above it cannot be large. That is a bound, not a measurement, and I keep it in the list rather than pretending it is zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is an SLO conversation, not a performance one
&lt;/h2&gt;

&lt;p&gt;Once the zones are separated, the chain stops being a mystery and starts being arithmetic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  chain response ≈ the slowest participant + everything spent between them
&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%2Fx9477raro2jb7rf9wso8.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%2Fx9477raro2jb7rf9wso8.png" alt="Three number tiles for budgeting in round trips: 53 database round trips, 11 repeated queries, 92 to 7 queries from build A to B, with a note: chain is roughly the slowest participant plus every hop" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That reframes every argument we had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a service's target belongs to &lt;strong&gt;its own span&lt;/strong&gt;, because that is the only part its team can move;&lt;/li&gt;
&lt;li&gt;the door's target is the sum plus the hops, and it is the only number a user experiences;&lt;/li&gt;
&lt;li&gt;the environment gets an explicit line - 220-260 ms of it here - so it is never mistaken for someone's fault;&lt;/li&gt;
&lt;li&gt;and the sensible unit for a per-service budget is often &lt;strong&gt;not milliseconds but round trips&lt;/strong&gt;: 53 database calls with 11 repeats, or 92 queries where 7 will do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that needs new infrastructure. It needs the zones to be separable in the data you already collect - and one line in the report saying which zone each number belongs to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multiplier line
&lt;/h2&gt;

&lt;p&gt;An automated executor made this cheap: run, pull the trace, fold it into a table, run again on the next build, print the difference. What it did not do was decide what counts as ours. That distinction - our span, the environment, the database, the unmeasured gap - is a judgement, and it is the whole value of the exercise. Speed of measurement amplifies whoever set the definitions; it does not supply them.&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%2Feeeqb6dxlewtd5tlgtle.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%2Feeeqb6dxlewtd5tlgtle.png" alt="A four-panel strip: what we did, 1 256.9 ms pinned on the write service; what broke, 87.4 ms, its own zone, 7 % of it; why, 6.6 ms of work in a 231.8 ms rejection; what now, own span, judge a service by its root" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Anatomy of a service split — Part 1.&lt;/strong&gt; Next: the same run produced numbers nobody could have predicted from the code, because the services had endpoints that could describe their own state. That is the next part - what a service has to expose before anyone can check it.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me how you draw the line between your zone and the environment. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your unexplained gap turn out to be? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where per-zone budgets cost more than they return. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>performance</category>
      <category>observability</category>
      <category>architecture</category>
      <category>microservices</category>
    </item>
    <item>
      <title>Parity Against a Moving Baseline</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Sat, 24 Oct 2026 15:28:23 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/parity-against-a-moving-baseline-41f7</link>
      <guid>https://dev.to/anton_brilliantov/parity-against-a-moving-baseline-41f7</guid>
      <description>&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%2Foszaliu4nlk18niifm5u.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%2Foszaliu4nlk18niifm5u.png" alt="Cover reading Parity Against a Moving Baseline, with the word Parity on a vermilion slab, the figure 9 parity runs in one day, and four bars of cascade rows for the same input, 2, 258, 2 and 8, with the 258 bar red" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Comparing a new implementation against an old one is only a test while the old one stands still.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series is about one write path that left the monolith and what it took to accept it as working. This part is about checking a migration while both sides keep moving - and about response codes that describe the wire rather than the storage. 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;.&lt;/p&gt;

&lt;p&gt;This is how I do it right now, with the price attached - maybe you already do it better, maybe you'd do it another way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the boundary was drawn
&lt;/h2&gt;

&lt;p&gt;The migration moved &lt;em&gt;computation&lt;/em&gt; out of the monolith before it moved &lt;em&gt;data&lt;/em&gt;. For a stretch of about seven weeks that produced a split that looks reasonable in a diagram and is very hard to check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  the monolith                              the extracted service
  ├── works out the composition             ├── owns the identity of a rule set
  ├── asks the service for identity  ────►  ├── answers over the wire
  └── mirrors the row into its own DB       └── knows nothing about the mirror

                    no shared transaction between them
&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%2Fqo0ijuch1ntqiejepskp.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%2Fqo0ijuch1ntqiejepskp.png" alt="Two panels: the monolith works out the composition, asks for identity and mirrors the row; the extracted service owns the identity, answers over the wire and never sees the mirror; between them a dashed red line with a bolt reading no shared tx, and a stamp: 4 tasks, one shape, 18 edits" width="799" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One operation - &lt;em&gt;work out what the composition should be, and save it&lt;/em&gt; - now spans two processes and two databases with no shared transaction. Over 49 days that boundary produced four tasks of exactly one shape: &lt;strong&gt;the monolith computed the right thing and something else got stored&lt;/strong&gt;. In the two weeks right after ownership moved, the old system took &lt;strong&gt;18 more edits&lt;/strong&gt; on this subject alone.&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%2Flxpuqr7h74n96vh8rh63.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%2Flxpuqr7h74n96vh8rh63.png" alt="The series system map with the PHP monolith, the rule-set-markup service, the domain-rule-map service, the Go API gateway and the acceptance repository lit, and a red note on the monolith: the reference, being fixed" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The response code describes the wire
&lt;/h2&gt;

&lt;p&gt;The first thing a parity check leans on is the response. Three cases from this period, all of them real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;502&lt;/code&gt; after commit&lt;/strong&gt; - the row was written; the caller retried a completed write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;200 completed&lt;/code&gt;&lt;/strong&gt; - the row was silently deleted; the run counted it as success.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;504&lt;/code&gt; at 20.26 s&lt;/strong&gt; - the write went through, and the cascade kept working 8.7 s after the answer.&lt;/li&gt;
&lt;/ul&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%2F774mblcw1sylxr2ao52s.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%2F774mblcw1sylxr2ao52s.png" alt="Three rows from response code to storage: 502 after commit, the row was written; 200 completed, the row was deleted; 504 at 20.26 s, the write went through; a green note reads assert the read-back, not the response" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of these is a lie by the transport. A code honestly reports that the call arrived, the body parsed and the request's own transaction closed. With two storages and no shared transaction, that is &lt;strong&gt;all&lt;/strong&gt; it reports. So the rule that went into the acceptance instructions and has stayed there:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Assert on the persisted read-back, not on the write response. A green answer is not a green database.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The baseline was being fixed at the same time
&lt;/h2&gt;

&lt;p&gt;Now the harder problem. The new write path was accepted by comparing it against the old one - byte for byte, field for field. That is a sound method exactly once: when the reference is frozen.&lt;/p&gt;

&lt;p&gt;Ours was not. On one day the same input went through &lt;strong&gt;nine iterations&lt;/strong&gt;, and the size of the cascade the two sides produced went:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  run 1 ─────► 2 rows
  run 4 ─────► 258 rows        same input
  run 6 ─────► 2 rows
  run 9 ─────► 8 rows          settled — for that day
&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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzj8juqipvof99c7lxzer.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzj8juqipvof99c7lxzer.gif" alt="Animated bars of cascade rows for the same input across one day: run 1 at 2, run 4 at 258, run 6 at 2, run 9 at 8, with a note that the reference changed its definition and a stamp reading same input" width="800" height="488"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the diff itself, one object, both sides in the same run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;old&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;implementation&lt;/span&gt;&lt;span class="w"&gt;                        &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;implementation&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;                                             &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rule_set"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1806235194000891392&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nl"&gt;"rule_set"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1806235194000891392"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"scope_rule_set_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9412008365512340992"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"scope_rule_set_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9412008365512340992"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"priority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;                             &lt;/span&gt;&lt;span class="nl"&gt;"priority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;                                             &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&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%2Ffzvkjl7qlx27uowdgvd1.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%2Ffzvkjl7qlx27uowdgvd1.png" alt="A dark diff card for one object, old lines marked minus and new lines plus: the rule set id a number on one side and a string on the other, the scope id the same, priority null against 0; notes read a number here, a string there, and zero and absent" width="800" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two differences, two entirely different causes. The identifier being a number in one field and a string in another is the &lt;strong&gt;old side being inconsistent with itself&lt;/strong&gt;. The &lt;code&gt;null&lt;/code&gt; against &lt;code&gt;0&lt;/code&gt; is the contract: on the wire, zero and absent are the same thing, so a real value of zero cannot be told from no value at all.&lt;/p&gt;

&lt;p&gt;A machine can produce that diff in a second. What it cannot produce is the sentence &lt;em&gt;"the right-hand side is correct here and the left-hand side is correct there"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Every one of those runs was honest work. The swing came from the reference itself changing definition mid-day: what counts as a sibling in the cascade, whether inactive objects belong in it, what the scope of a client-level write is. And the reference was &lt;strong&gt;internally inconsistent&lt;/strong&gt; too: the same identifier came back as a number in one field of one response and as a string in another.&lt;/p&gt;

&lt;p&gt;Three times out of nine, the &lt;em&gt;new&lt;/em&gt; implementation turned out to be the correct one. Which is the real lesson of the day: when you name a reference, you are also declaring it correct - and that declaration needs to be true.&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%2Fyaeryskcm9gsdoui5jds.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%2Fyaeryskcm9gsdoui5jds.png" alt="A green number tile reading 3 of 9 runs where the new side was correct, next to the words a reference is also a claim that it is correct, stamped the reference was wrong" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A green suite that could not fail
&lt;/h2&gt;

&lt;p&gt;The same period had a quieter version of the same mistake. The write path had a switch selecting which implementation runs. Production ran the new one. The test environment's config file still pointed at the old one.&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%2F0vn5c20yq2m2e40x2fp5.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%2F0vn5c20yq2m2e40x2fp5.png" alt="Two halves: the test environment with engine equal to old, green every run, against production with engine equal to new, never touched by the suite, stamped proved nothing" width="800" height="406"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the suite was green, permanently, and green meant nothing: it exercised a code path production had stopped using. One flag, set differently in two places, for weeks. A switch that selects an implementation belongs in the test matrix, not in an environment file.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then the comparison stopped existing
&lt;/h2&gt;

&lt;p&gt;The end of the parity story is worth telling in full, because it is not a failure - it is a cost.&lt;/p&gt;

&lt;p&gt;Partway through, the owner decided the old write endpoint would be removed entirely: one door, one write path, no dual entry. Correct decision, and it simplified everything downstream. It also erased, in one line:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh4e8czv9yzhpm8dc9run.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh4e8czv9yzhpm8dc9run.gif" alt="Animated list of four erased items, each crossed out in turn: 9 parity iterations from one day, the cumulative contract diff field by field, every stopwatch number of the old path, the class the two disagree; a stamp reads erased and a note: missing, the date it takes effect" width="800" height="441"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;nine iterations of parity work from a single day;&lt;/li&gt;
&lt;li&gt;the cumulative field-by-field contract diff between the two answers;&lt;/li&gt;
&lt;li&gt;every timing measured on the old path;&lt;/li&gt;
&lt;li&gt;and the whole defect class &lt;em&gt;"the two implementations disagree"&lt;/em&gt;, which stopped being a defect class at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What was missing was not the decision. It was &lt;strong&gt;the date it would take effect, agreed in advance&lt;/strong&gt;. A comparison is worth its cost only while both sides are going to keep existing; the moment one is scheduled for removal, the parity work has a known expiry and can be scoped to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real bill: finding out what was wanted
&lt;/h2&gt;

&lt;p&gt;Add it up and the shape of the period is not "we wrote buggy code":&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%2Fplhtc51b0piutswoab8d.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%2Fplhtc51b0piutswoab8d.png" alt="Five number tiles of the real bill: 23 requirements with two readings, 7 reversals already built, 15 briefs discarded in a day, about 13 of 27 iterations spent finding out, and 5 of 6 reversals where the code matched its spec" width="799" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;23&lt;/strong&gt; requirements that had two defensible readings;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;7&lt;/strong&gt; reversals of decisions that were already implemented;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~13 of 27&lt;/strong&gt; iterations spent working out what was wanted rather than fixing code (an estimate over intervals, not a measurement);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;15&lt;/strong&gt; iterations of briefs written and discarded within a day;&lt;/li&gt;
&lt;li&gt;and in &lt;strong&gt;5 reversals out of 6&lt;/strong&gt;, the code was correct against the specification it was written against.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last number is the one I keep. The implementation was rarely the problem. The requirement moved, and nothing moved with it.&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%2F66qdmpz7ak6u5y6n5otk.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%2F66qdmpz7ak6u5y6n5otk.png" alt="A torn paper receipt for a reference that kept moving: 9 parity runs in one day, erased; 15 briefs in one day, discarded; about 13 of 27 iterations finding out the ask; code matched its spec in 5 of 6, stamped the spec moved" width="800" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What keeps them in step
&lt;/h2&gt;

&lt;p&gt;None of the remedy is exotic. It is a loop, run per iteration rather than per release:&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%2Fv6xtpsv5y1snl0ce138x.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%2Fv6xtpsv5y1snl0ce138x.png" alt="Four green tiles in a loop - freeze it in words, write the check first, run it on everything, re-sync spec, check and code - with a dashed return arrow and a note: and write when a comparison expires" width="799" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Freeze the requirement in words&lt;/strong&gt;, with the owner of each value named.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the check before the code&lt;/strong&gt;, in two halves: what proves the value, and which reads do &lt;em&gt;not&lt;/em&gt; prove it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run it against the live system&lt;/strong&gt;, on everything rather than on a sample.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-sync deliberately&lt;/strong&gt;: when the requirement moves, the check and the specification move in the same change, or the requirement hasn't actually moved.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The one addition the multi-system case forces: when a comparison is part of the method, &lt;strong&gt;write down when it expires&lt;/strong&gt;. Which side is going away, and on what date.&lt;/p&gt;

&lt;h2&gt;
  
  
  What automation settled, and what it handed back
&lt;/h2&gt;

&lt;p&gt;Nine iterations in a single day are only possible because the run itself is cheap - generated, executed and folded by an automated executor. Worth splitting precisely what that bought.&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%2Fsl0450dnw7zqg9mzggw2.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%2Fsl0450dnw7zqg9mzggw2.png" alt="Two tiles: mechanical and automated - the matrix per object, all 392 objects, a field-by-field diff, again on every build; a decision and still ours - which side is right, who owns the value, when it expires, broke or moved" width="800" height="379"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mechanical, and automated end to end:&lt;/strong&gt; building the request matrix per object, running all 392 rather than a sample, folding both answers into a field-by-field diff, repeating the whole pass on the next build, attaching the trace contents to every failure so the report outlives the retention window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A decision, and still ours:&lt;/strong&gt; which of the two sides is entitled to be right; who owns a value the two disagree about; when the comparison expires because one side is being removed; the observation that a switch was set differently in the test environment and in production; and the distinction that mattered most - &lt;em&gt;did the build break, or did the requirement move?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The last one is the whole reason this period cost what it did. To an executor, a requirement that changed overnight and a regression look identical: yesterday's expectation, today's mismatch. It will report both with the same confidence and the same formatting. Deciding between them is not a gap in the tooling - it is the job the tooling exists to make room for.&lt;/p&gt;

&lt;p&gt;So the honest framing of the multiplier: fast iteration does not settle what "correct" means, it multiplies whatever answer is currently written down. Nine cheap runs against a definition that changed by evening produce nine artefacts and no verdict. Freeze the definition, name the owner of each value, give the comparison an expiry date - and the same automation stops producing noise and starts producing evidence.&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%2Faq9vob22ideh1sdbfg87.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%2Faq9vob22ideh1sdbfg87.png" alt="A four-panel strip: what we did, 9 runs of parity against the old path in one day; what broke, 2 to 258 rows for the same input; why, the reference was being fixed; what now, an expiry written for every comparison" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Anatomy of a service split — Part 4.&lt;/strong&gt; Next: the cascade that does not fit inside a response - a constant deadline against a cost that grows with the number of children, and a write that succeeds after the caller has already been told it timed out.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me how you keep a reference frozen while it is still being changed. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your green suite turn out not to be testing? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where parity against an old implementation earns its cost. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>testing</category>
      <category>microservices</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The Client Decides How Much Work You Do</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Thu, 22 Oct 2026 14:50:57 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/the-client-decides-how-much-work-you-do-35h2</link>
      <guid>https://dev.to/anton_brilliantov/the-client-decides-how-much-work-you-do-35h2</guid>
      <description>&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%2Fkr2m0hui8wszj0nmk8le.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%2Fkr2m0hui8wszj0nmk8le.png" alt="Cover reading The Client Decides How Much Work You Do, with the word Client on a vermilion slab, the figure 366 children behind one call, and four bars for 3, 100, 253 and 366 children with the last one red" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A synchronous answer over work the caller sizes is not a contract. It is a promise the caller gets to break for you.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series is about one write path that left the monolith and what it took to accept it as working. This part is about the thing we never wrote down before shipping it: how much work one caller is allowed to ask for. 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;.&lt;/p&gt;

&lt;p&gt;This is how I do it right now, with the price attached - maybe you already do it better, maybe you'd do it another way.&lt;/p&gt;




&lt;h2&gt;
  
  
  The path in one paragraph
&lt;/h2&gt;

&lt;p&gt;A user saves a set of matching rules; every domain under that account gets marked by them. Four participants: the &lt;strong&gt;Go API gateway&lt;/strong&gt; as the only door, the &lt;strong&gt;rule-set-markup service&lt;/strong&gt; which takes the write and folds the cascade, the &lt;strong&gt;domain-rule-map service&lt;/strong&gt; which owns rule-set identity and recalculates the map, and PostgreSQL behind them - the monolith's database for the rule data, the identity service's own for identity.&lt;/p&gt;

&lt;p&gt;The shape that matters here is not the topology, it is one sentence about the work: &lt;strong&gt;one call from the user, and then a pass over every object below the one they addressed.&lt;/strong&gt; Save rules on a leaf and it is a small write. Save the same rules at the top of an account and it is the same call with a different amount of work behind it - decided entirely by how big that account happens to be.&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%2Fkqechppsw96zd3o3z2ns.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%2Fkqechppsw96zd3o3z2ns.png" alt="The series system map with the UI, the Go API gateway, the rule-set-markup service and the domain-rule-map service lit, and a red note on the call from the gateway into the rule-set-markup service: 366 children, one call" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three objects was the whole test bench
&lt;/h2&gt;

&lt;p&gt;For the first weeks the checks ran against a playground of &lt;strong&gt;three objects&lt;/strong&gt;. Everything passed, and the passes were honest: real requests, real assertions, real data written and read back.&lt;/p&gt;

&lt;p&gt;What three objects cannot show you is a &lt;em&gt;slope&lt;/em&gt;. Every run cost about the same, so cost looked like a constant - a property of the endpoint. It is not a property of the endpoint. It is a property of the caller.&lt;/p&gt;

&lt;p&gt;The slope only appeared when a run went across the whole environment: &lt;strong&gt;76 top-level objects, 392 configurations, roughly 2 300 map rows&lt;/strong&gt; - and, at the account level, one entity with &lt;strong&gt;366 children&lt;/strong&gt;.&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%2F5rtnwnt30lpm2kg65li5.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%2F5rtnwnt30lpm2kg65li5.png" alt="Four number tiles for the whole environment: 76 top-level objects, 392 configurations, about 2 300 map rows, 366 children in one account" width="800" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three against 366. The sample was &lt;strong&gt;1/122 of the largest legitimate case in the same environment&lt;/strong&gt;, and nothing in the sample said so.&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%2Feo9s449czifylv70rwzv.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%2Feo9s449czifylv70rwzv.png" alt="Two halves: the test bench, 3 objects with every check passed, against the largest account, 366 children below one call, stamped 1/122" width="800" height="406"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What one write actually costs
&lt;/h2&gt;

&lt;p&gt;Two writes from the same set of traced runs, drawn to the same scale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  one object addressed directly
    121 spans · 4 services · 53 database round trips (11 of them repeats)

  one account addressed at the top, 366 children below it
    10 822 spans
&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%2Fsyccrx92qimwzyand9r8.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%2Fsyccrx92qimwzyand9r8.png" alt="Two columns of traced spans: one object at 121, one account at 10 822, with a note: about 30 spans per child, derived as 10 822 divided by 366" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10 822 ÷ 366 ≈ 30 spans per child.&lt;/strong&gt; That number is derived, not measured directly - I divided one measured total by one measured count - and it is enough to answer the only question that mattered: &lt;em&gt;is the cost of this call flat, or does it track the caller's data?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It tracks the caller's data. Which means the operation has a slope, and the response has a ceiling, and nobody had ever written either of them down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round trips are the unit that scales
&lt;/h2&gt;

&lt;p&gt;The other measured number on that single-object write is the one I now put in the capacity budget instead of milliseconds: &lt;strong&gt;53 database round trips, 11 of them repeats of a query already made in the same call.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Milliseconds are a property of the day - of the machine, the cache, the load next door. Round trips are a property of the &lt;em&gt;code&lt;/em&gt;, and they are the thing that gets multiplied when the caller is bigger. Fifty-three round trips for one object is a number you can reason about before anything is deployed; "about a second" is not.&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%2Fpyc1e5adz71wgio92r28.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%2Fpyc1e5adz71wgio92r28.png" alt="Three number tiles: 53 round trips for one object, 11 of them repeats, and a 20 s budget that never grows" width="800" height="338"&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;  per one object            53 round trips · 11 of them repeats
  per account of 366        the same shape, multiplied
  what a repeat costs       nothing on the first call, everything on the thousandth
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the two useful sentences about this endpoint, both available without a stopwatch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;its cost per unit of caller data is a small number of round trips and about 30 spans;&lt;/li&gt;
&lt;li&gt;its budget is a fixed 20 seconds that does not grow with anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One of those scales with the customer. The other does not. That is the entire finding, and every measurement after it is decoration.&lt;/p&gt;

&lt;h2&gt;
  
  
  A constant against a line
&lt;/h2&gt;

&lt;p&gt;The deadline on that call is a &lt;strong&gt;constant&lt;/strong&gt;: 20 seconds, the same 20 seconds for a leaf with two children and for an account with 366. The work behind it is a &lt;strong&gt;line&lt;/strong&gt; through the origin.&lt;/p&gt;

&lt;p&gt;One observed run at the top of that account: the caller was cut off at &lt;strong&gt;20.26 s&lt;/strong&gt;, and the work carried on for another &lt;strong&gt;8.7 s&lt;/strong&gt; after the answer had already gone out - about &lt;strong&gt;28.96 s&lt;/strong&gt; of work behind a 20-second promise.&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%2Fzi0ljbywd9d7hsrv6iyu.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%2Fzi0ljbywd9d7hsrv6iyu.png" alt="A torn paper receipt for one account-level write: 366 children below, 10 822 spans, 28.96 s of work against a 20 s budget, over the deadline 8.7 s, stamped still written" width="800" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Divide again: 28.96 s over 366 children is roughly &lt;strong&gt;79 ms per child&lt;/strong&gt;. Same caveat - derived, one measured duration over one measured count, and it is a per-account average rather than a per-child guarantee. It still puts a number on the crossing point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   children     derived work         budget
        3   ▏            0.24 s      1.2 % of it
      100   ████▏         7.9 s      40 %
      253   ██████████▏  20.0 s      the last one that fits
      366   ██████████████▏ 28.96 s  145 % - over by 8.7 s
                         ▲ 20 s deadline
&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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1e5mwg084d3dhieyq6o0.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1e5mwg084d3dhieyq6o0.gif" alt="Animated bars of derived work against a dashed 20-second deadline: 3 children 0.24 s, 100 children 7.9 s, 253 children 20.0 s at the line, 366 children 28.96 s crossing it, with a note: over the 20 s deadline by 8.7 s" width="800" height="488"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Around 253 children is where this endpoint stops working&lt;/strong&gt;, and the number of accounts above that line is a product fact, not an engineering one. Nobody had asked for it. Part 1 of this series recorded what that looks like in aggregate: deadline failures on &lt;strong&gt;13 of 76 objects&lt;/strong&gt;, and then, after a change meant to help, on &lt;strong&gt;27 of 76&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The failure is not "the service is slow". A service that is uniformly 3x faster moves the crossing point from 253 to 759 children and fails on the next account that has 800. Optimisation moves the line. It does not change the fact that there is one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The answer that arrived before the work stopped
&lt;/h2&gt;

&lt;p&gt;The second half of that run is the part I would want to know before designing anything similar.&lt;/p&gt;

&lt;p&gt;At 20.26 s the caller got its failure. At 28.96 s the work finished - and it finished &lt;em&gt;successfully&lt;/em&gt;, in the database, exactly as if nothing had gone wrong. Which is what happened: nothing did go wrong. A deadline is a decision by the &lt;strong&gt;caller&lt;/strong&gt; to stop waiting. It is not a rollback.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  0 s                                  20.26 s               28.96 s
  ├───────────────────────────────────────┤───────────────────┤
  │  work in progress                     │  8.7 s more work  │
                                          │
                          caller is told: the call failed
                                                              │
                                          database holds: the write landed
&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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmp4gxslkxdjusbz470e6.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmp4gxslkxdjusbz470e6.gif" alt="Animated trace of one account-level write: the caller bar ends with a warning, failed at 20.26 s; the work bar runs on to 28.96 s; the database bar lands after it, 8.7 s later: written; a stamp reads not a rollback" width="800" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the response code and the state of the database disagree, &lt;strong&gt;legitimately&lt;/strong&gt;, and any check that treats the code as the verdict will be wrong in a way that repeats. Everything downstream inherits this: a retry on timeout is a second write, not a repair; an alert on 5xx counts calls that did their job; a parity comparison against a reference system reads a failure on one side and a row on the other and calls it a defect.&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%2Fc73ajekv71kcwd4j11te.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%2Fc73ajekv71kcwd4j11te.png" alt="A dark log card of one account-level write: at 20.26 s an error, call failed on the deadline; at 28.96 s, highlighted, the write committed; a red note reads assert both, separately" width="799" height="262"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three notes on reading any of these figures, because I would rather hand over the caveats than the confidence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The measurements come from three different observation windows with different instrumentation. Comparing across them compares &lt;em&gt;methods of observation&lt;/em&gt;, not states of the system.&lt;/li&gt;
&lt;li&gt;Round-trip time to the environment sits at a steady 220-260 ms regardless of endpoint. It is not part of the work above, and it is not ours.&lt;/li&gt;
&lt;li&gt;No share is quoted without its absolute value. &lt;code&gt;28.96 s over a 20 s budget&lt;/code&gt;, never "145 %" on its own - a percentage of a moving denominator is the easiest honest-looking way to say the wrong thing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule I now write into the acceptance notes before the endpoint exists:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A deadline bounds the wait, not the work. Response code and stored state are two separate measurements and must be asserted separately.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&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%2F0fei35w0kuzviltrxkdx.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%2F0fei35w0kuzviltrxkdx.png" alt="A dark quote card reading a deadline bounds the wait, not the work, stamped code is not state" width="800" height="352"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The questions that belong in the design, not the postmortem
&lt;/h2&gt;

&lt;p&gt;None of the above needed a trace to predict. It needed five answers, and none of them is an engineering answer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;What we had before the sweep&lt;/th&gt;
&lt;th&gt;What the sweep gave&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;How many children does a caller have?&lt;/td&gt;
&lt;td&gt;"a few" - three, in the test bench&lt;/td&gt;
&lt;td&gt;366 at the largest account, 76 top-level objects, 392 configurations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What does one child cost?&lt;/td&gt;
&lt;td&gt;unknown, never asked&lt;/td&gt;
&lt;td&gt;~ 30 spans, ~ 79 ms - derived&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where does the cost cross the deadline?&lt;/td&gt;
&lt;td&gt;no deadline written down&lt;/td&gt;
&lt;td&gt;~ 253 children, at a 20 s ceiling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What happens when the data grows?&lt;/td&gt;
&lt;td&gt;not considered&lt;/td&gt;
&lt;td&gt;the crossing point moves into the middle of the population&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What do we do for callers past the line?&lt;/td&gt;
&lt;td&gt;nothing defined&lt;/td&gt;
&lt;td&gt;still open - the reason for the next section&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%2Fgnuhju1ct6npw2mnfqcv.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%2Fgnuhju1ct6npw2mnfqcv.png" alt="Five capacity questions with the answer before and after the sweep: children per caller, three then 366; cost per child, never asked then about 30 spans and 79 ms; deadline crossing, not written then about 253 children; as data grows, not considered then the line moves in; past the line, nothing then still open" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is what "know your traffic" means concretely, and it is cheap: four of those five rows are one query against your own data plus one division. We paid for them in iterations instead, which is the expensive currency.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in how this gets accepted
&lt;/h2&gt;

&lt;p&gt;The practices below cost nothing to adopt and are the reason the same class of surprise does not repeat:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every run includes the largest caller in the environment&lt;/strong&gt;, not a convenient one. A sample that is 1/122 of the largest case tells you about the sample.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Response code and stored state are asserted separately.&lt;/strong&gt; Two checks, never one; the timeout case exists precisely because they can disagree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Round trips per unit of caller data are recorded per build&lt;/strong&gt;, next to the durations. A shape regression shows up there first and shows up nowhere else - the response stays &lt;code&gt;200&lt;/code&gt; the whole time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The population is part of the specification.&lt;/strong&gt; How many callers sit above the crossing point is a line in the requirements, alongside the deadline, before anyone writes a handler.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is tooling. It is four sentences written down before the code, which is the cheapest artefact in this entire story and the one we produced last.&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%2Fiyb6raw5lbkdaif716f6.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%2Fiyb6raw5lbkdaif716f6.png" alt="A four-panel strip: what we did, 3 objects on the bench, all green; what broke, 20.26 s, the caller cut off while the write ran; why, 366 children, cost tracks the caller; what now, the largest caller in every run" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this goes: change the shape, not the step
&lt;/h2&gt;

&lt;p&gt;The exit is not a faster cascade. It is a different &lt;strong&gt;shape&lt;/strong&gt; of operation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  compute on request                     compute ahead, select on request
  ──────────────────────                 ────────────────────────────────
  caller sets the size                   size is known before the request
  cost grows with the caller             cost is paid where it can be scheduled
  deadline is a constant                 the request is a selection over ready rows
  failure mode: cut off mid-write        failure mode: stale, and visibly so
&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%2Fvpx6rz8nr78gji8gmfd6.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%2Fvpx6rz8nr78gji8gmfd6.png" alt="Two tiles: compute on request, marked with a cross - the caller sets the size, the deadline is a constant, cut off mid-write; compute ahead and select, marked with a check - the size is known ahead, cost paid when scheduled, stale and visibly so" width="799" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the result is computed ahead of the request, a change of rules stops being a write that fans out and becomes &lt;strong&gt;a different selection over data that already exists&lt;/strong&gt;. The question "will we fit inside the deadline" is then answered by the form of the operation rather than by optimising a step inside it. The cost does not vanish - it moves to a place where it can be scheduled, measured and bounded, instead of a place where a user's account size decides it.&lt;/p&gt;

&lt;p&gt;I am not going to pretend that is free, or that it is done, or put a date on it. What I will say is that we arrived at it from the numbers above rather than from taste: an operation whose size is set by the caller and whose answer is synchronous has a population of callers it cannot serve, and you find out which ones by measuring your own data, not by tuning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multiplier line
&lt;/h2&gt;

&lt;p&gt;The full sweep that produced these numbers was cheap because it was automated: run every object, pull the trace, fold it into counts, run it again on the next build and print the difference. What automation could not do is say &lt;strong&gt;how large a legitimate caller is allowed to be&lt;/strong&gt; - that is a product answer, and it has to exist before the endpoint is designed, not after a run finds the ceiling. Fast measurement amplifies whoever wrote down the limits; where no limit was written, it produces a very precise description of a design that was never bounded.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Anatomy of a service split — Part 5.&lt;/strong&gt; Next: the contract itself - a value that exists, is stored and is correct, and still cannot be distinguished from "not set" once it is on the wire; and the ordering rule nobody ever specified, which held as a norm for 17 months because it was a property of an implementation.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me where you write down the largest caller your synchronous endpoints are allowed to serve. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your timeout turn out to have written anyway? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where precomputing costs more than it returns. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>performance</category>
      <category>scalability</category>
      <category>microservices</category>
    </item>
    <item>
      <title>Zero Is Not Absent, and Nobody Wrote the Order Down</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Wed, 21 Oct 2026 15:34:05 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/zero-is-not-absent-and-nobody-wrote-the-order-down-15oh</link>
      <guid>https://dev.to/anton_brilliantov/zero-is-not-absent-and-nobody-wrote-the-order-down-15oh</guid>
      <description>&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%2Fy57mabcgmlp2t8860wr3.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%2Fy57mabcgmlp2t8860wr3.png" alt="Cover reading Zero Is Not Absent, with the word Zero on a vermilion slab, the figure 8 of 26 defects were contract shape, and two panels: 0, the strongest rule, against 0 on the wire, nothing" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A field that cannot separate "absent" from "zero" is not one field. It is two facts sharing a slot.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series is about one write path that left the monolith and what it took to accept it as working. This part is about the contract: a value that exists, is stored and is correct and still cannot be shown - and a rule the system obeyed for a year and a half that nobody had ever written down. 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;.&lt;/p&gt;

&lt;p&gt;This is how I do it right now, with the price attached - maybe you already do it better, maybe you'd do it another way.&lt;/p&gt;




&lt;h2&gt;
  
  
  The path in one paragraph
&lt;/h2&gt;

&lt;p&gt;A user saves a set of matching rules; every domain under that account gets marked by them. Four participants: the &lt;strong&gt;Go API gateway&lt;/strong&gt; as the only door, the &lt;strong&gt;rule-set-markup service&lt;/strong&gt; which takes the write and folds the cascade, the &lt;strong&gt;domain-rule-map service&lt;/strong&gt; which owns rule-set identity and recalculates the map, and PostgreSQL behind them - the monolith's database for the rule data, the identity service's own for identity.&lt;/p&gt;

&lt;p&gt;Two of those rules can match the same domain. Something has to decide which one wins. That decision is a number on the rule - its &lt;strong&gt;priority&lt;/strong&gt; - and everything else in this article follows from the fact that this number was never fully described anywhere.&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%2Fqdjoripj3lsjkijfmw76.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%2Fqdjoripj3lsjkijfmw76.png" alt="The series system map with the Go API gateway, the rule-set-markup service, the domain-rule-map service and the shared gRPC contract layer lit, and a red note on the contract layer: 0 and not set, one absence" width="800" height="566"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The value was right the whole time
&lt;/h2&gt;

&lt;p&gt;The symptom was narrow and boring, which is why it survived so long. A rule is saved with a priority. The write is accepted. The row is in the database with the right number in it. Read the rule back and the number is there. Read the &lt;strong&gt;map row&lt;/strong&gt; the engine produced - the thing that says which rule marked this domain - and the priority is gone.&lt;/p&gt;

&lt;p&gt;Nothing was lost, nothing was corrupted, nothing was recomputed wrongly. The value simply could not be presented, because the highest priority in this system is written as &lt;code&gt;0&lt;/code&gt;, and on the wire &lt;code&gt;0&lt;/code&gt; is exactly what a field looks like when nobody set it at all.&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%2Fyxnzzhsb22qma68yf5h0.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%2Fyxnzzhsb22qma68yf5h0.png" alt="A dark JSON card: the row as stored carries priority 0, the same row on the wire carries only the rule, highlighted, with a red note: 0 carries no bytes" width="799" height="309"&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;  the field has three states                 the wire carries two
  ┌────────────────────────────┐             ┌────────────────────────────┐
  │ 0     the strongest rule   │  ────────►  │ nothing on the wire        │
  │ 7     a weaker rule        │  ────────►  │ 7                          │
  │ —     not set at all       │  ────────►  │ nothing on the wire        │
  └────────────────────────────┘             └────────────────────────────┘

  reader's rule: "no value means not set"  →  the strongest rule reads as unset
&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%2F1at16uk18g2cspk0jvee.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%2F1at16uk18g2cspk0jvee.png" alt="Three states the field must say - 7 a weaker rule, 0 the strongest rule, not set at all - with arrows to the two the wire carries: 7 stays 7, while 0 and not set both land on one red tile, nothing on the wire; a note reads: reader, no value means not set" width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is not exotic. It is the default behaviour of a wire format that omits zero values to save bytes, meeting a domain where zero is the most meaningful value there is. Both halves are reasonable on their own. Together they mean the strongest rule in the system is indistinguishable from a rule with no strength at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Both sides were locally correct
&lt;/h3&gt;

&lt;p&gt;The part worth sitting with is that neither end of this is a mistake anyone would flag in review.&lt;/p&gt;

&lt;p&gt;The producer follows the serialisation rules it was given: a numeric field holding its zero value carries no bytes, and that is the documented, intended behaviour of the format - it is how the wire stays small. The consumer follows the only rule available to it: a field that arrived with nothing in it was not provided, so fall back to the default. Read either repository on its own and you find a service behaving exactly as specified.&lt;/p&gt;

&lt;p&gt;The defect exists only in the sentence neither side owns: &lt;strong&gt;what does absence of this field mean for this particular value?&lt;/strong&gt; No reviewer is asked that question by a diff. It belongs to the contract, and the contract had never said.&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%2F7laexzn9a0x9mozainmw.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%2F7laexzn9a0x9mozainmw.png" alt="Two green tiles, each checked: the producer, a zero value carries no bytes; the consumer, nothing arrived, so use the default; both point into one red tile asking what absence means for this value, owned by the contract, and it never said" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The registry carried two entries for it: one where &lt;strong&gt;the map row carried no priority&lt;/strong&gt;, which took five iterations to close and came back once after being fixed, and one where &lt;strong&gt;priority &lt;code&gt;0&lt;/code&gt; arrived as absent on the wire&lt;/strong&gt;, which took three. In a period of 32 days and 26 numbered defects, that is a lot of attention for a value that was never wrong.&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%2Fmy8qcmqatq1i04e3d7ig.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%2Fmy8qcmqatq1i04e3d7ig.png" alt="Two number tiles from the registry: 5 iterations for the map row that carried no priority, 3 iterations for priority 0 arriving as absent, with a stamp reading came back once" width="800" height="381"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Eight of 26, and 91 days of the contract changing its mind
&lt;/h2&gt;

&lt;p&gt;That pair was not an accident of one field. Counting the whole registry, &lt;strong&gt;eight of the 26 numbered defects were defects of contract shape&lt;/strong&gt; - a field missing, a field ambiguous, a value that could not be told apart from its own absence, an identifier that was a string in one place and a number in another. It is the largest single class in the set. Nothing in it was a computation error.&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%2Fzzlg459qpmhq6xmvbr1r.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%2Fzzlg459qpmhq6xmvbr1r.png" alt="A grid of 26 numbered defects with 8 red cells marked with a cross as contract-shape defects, the large number 8 beside it, and a note: not one was a computation error" width="800" height="312"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same field's own history says why the class stays alive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  day 0                                day ~45                        day 91
    │                                     │                              │
    ●  priority removed from the contract ●  put back                    ●  meaning pinned down
       "the engine derives it"               "callers need it after all"    "0 is a value, and
                                                                             absent is a third state"
       ─────────────────────── 91 days of three different answers ───────────────────────
&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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcx99jd1qwpu0o7bzl7hp.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcx99jd1qwpu0o7bzl7hp.gif" alt="Animated timeline of one field across 91 days: day 0 removed, the engine derives it; day about 45 put back, callers need it; day 91 the meaning pinned, absent is a third state; a stamp lands reading 91 days, three answers" width="800" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each of those three moves was defensible when it was made. What nobody did until the third one was write down what the field &lt;em&gt;means&lt;/em&gt; when it is absent - and until that sentence exists, every consumer invents its own answer, and the answers disagree quietly.&lt;/p&gt;

&lt;p&gt;A field that leaves a contract and comes back three months later is worth treating as a signal in itself: it means the question "who needs this and why" was answered by removing it rather than by writing it down. The cheap version of that conversation is one sentence in the contract. The expensive version is two services, several runs and a class of defects that keeps producing new numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The count that made it visible
&lt;/h2&gt;

&lt;p&gt;The reason this ran for weeks is that nothing in the responses said anything was wrong. Status codes were fine, payloads validated, the data was correct in storage. So the check that finally made it observable was not an assertion about a response - it was a &lt;strong&gt;count over state&lt;/strong&gt;: how many map rows exist that carry no priority at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  map rows with no priority

  first measurement   ████████████████████████                27
  after the next run  ██████████████████████████████████████  41   ← growing
  after the fix       ▏                                        0
&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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1lo2iopio1f45h1tsrzq.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1lo2iopio1f45h1tsrzq.gif" alt="Animated bars of map rows with no priority: 27 at the first measurement, 41 after the next run, then 0 after the fix, with a note that the status codes were fine at all three and a stamp reading one SELECT" width="799" height="415"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two honest notes about those three numbers, because they are easy to over-read.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;27 → 41&lt;/code&gt; is not a measurement of a system getting worse at a steady rate; it is two counts taken while data kept being written. Second - and this holds for every number in this series - &lt;strong&gt;a share is never published without its absolute value.&lt;/strong&gt; Three windows of observation exist here with different instruments, and comparing across them compares the instruments, not the system.&lt;/p&gt;

&lt;p&gt;What matters is the zero at the end, and the fact that a single &lt;code&gt;SELECT&lt;/code&gt; produced the whole story. The class of defect that response codes cannot express is exactly the class that a count over stored state can.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half: how did this ever work in the monolith?
&lt;/h2&gt;

&lt;p&gt;While that was going on, a plainer question came up and stopped everyone: two rules match the same domain and have the same priority. Which one wins?&lt;/p&gt;

&lt;p&gt;Nobody knew. Not "nobody remembered" - nobody had ever decided. Reading the original specification produced nothing: it described what a rule is and what marking does, and said not one word about ties, order or precedence.&lt;/p&gt;

&lt;p&gt;The behaviour existed all the same, and it was completely deterministic: &lt;strong&gt;the first matching rule in the order the array happened to be in wins.&lt;/strong&gt; Not a rule anyone chose. A property of how a loop was written, promoted to a norm by nothing except the fact that it never broke.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  where the tie-break rule lived

  the written specification        ─  silent on order, silent on ties
  the interface contract           ─  no field expressing it
  the documentation                ─  not mentioned
  the code                         ●  "first match in array order wins"
                                      ▲
                                      the only copy, held as norm for 17 months
&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%2Ftzmkms56h6yzeuwkf65m.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%2Ftzmkms56h6yzeuwkf65m.png" alt="A dark terminal card listing where the tie-break rule lived: the specification silent on order, the contract with no field for it, the documentation not mentioning it, and the highlighted line, the code, first match wins, with a red note: the only copy, 17 months" width="799" height="309"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Seventeen months of production behaviour resting on a line of code nobody had ever been asked to agree with.&lt;/p&gt;

&lt;p&gt;This is what makes a migration expensive in a way that no estimate contains. Moving a feature out of a monolith is not "reimplement this specification elsewhere". It is, first, &lt;strong&gt;archaeology&lt;/strong&gt;: find out what the system actually did. Then, second, an argument nobody can settle from documents: was that intended, or is it a bug we have been shipping since it was written?&lt;/p&gt;

&lt;p&gt;Those are two separate bills, and only the first one gets estimated. Recovering the behaviour is finite work: read the loop, reproduce it, write down what it does. Deciding whether the recovered behaviour is a requirement is not finite, because there is no document that can settle it - only people, and only by making a decision that should have been made a year and a half earlier, now, under the schedule pressure of a migration.&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%2Fql1dppp325tulkdfge19.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%2Fql1dppp325tulkdfge19.png" alt="Two halves: estimated, recover the behaviour, finite, read the loop and write it down; not estimated, is it required, open, rule order is not provable" width="799" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The follow-up defect is still open at the end of the period, and its wording is the giveaway: &lt;strong&gt;rule order is not provable.&lt;/strong&gt; Not "wrong" - unprovable. You cannot demonstrate that a system obeys a rule that was never stated. Until the rule is written, the check can only assert that today's output equals yesterday's output, which is a description, not a requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirements read out of code are a liability, not a spec
&lt;/h2&gt;

&lt;p&gt;Both halves of this article are the same failure wearing different clothes.&lt;/p&gt;

&lt;p&gt;In the first, the requirement existed in someone's head - "priority 0 means strongest" - and never reached the contract, so the contract encoded a different, weaker requirement: "priority is a number that may be omitted".&lt;/p&gt;

&lt;p&gt;In the second, the requirement existed in the code - "first match in array order wins" - and never reached a document, so for 17 months there was no way to tell an implementation detail from a promise.&lt;/p&gt;

&lt;p&gt;Neither absence is visible from inside a running system. Code with an unstated rule behaves identically to code with a stated one, right up until somebody has to reimplement it somewhere else - and then the difference is the entire cost of the move.&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%2Ftpeqs2pd1ibfl8feyx3e.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%2Ftpeqs2pd1ibfl8feyx3e.png" alt="A four-panel strip: what we did, 0, the strongest priority stored right; what broke, 41 map rows with no priority; why, 0 equals absent, the wire omits zero; what now, 4 questions answered before code" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Across the whole period, &lt;strong&gt;23 places in the specification admitted two readings&lt;/strong&gt;. Each of those is a small coin toss between two implementations, and the toss is resolved late, by a run, by an argument, or by a defect - never for free.&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%2Frxsy47yecqr31g33siso.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%2Frxsy47yecqr31g33siso.png" alt="A plum number tile reading 23 places, two readings each, next to the words each one resolved late, by a run, an argument or a defect, stamped never for free" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in how this gets accepted
&lt;/h2&gt;

&lt;p&gt;The practice that came out of it is not "write more documentation". It is narrower and it is checkable: &lt;strong&gt;a new task starts by freezing the tests for its requirements, and only then the code gets written.&lt;/strong&gt; For a field crossing a service boundary, freezing it means answering four questions in writing before anyone implements a reader:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  1  presence     what does absent mean, and is it different from the zero value?
  2  ordering     when two candidates tie, what decides - and where is that written?
  3  authority    which service is allowed to write it, and which only reads it?
  4  proof        which stored count goes to zero when this is correct?
&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%2F6hgjx9u6iorux9wwrhwj.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%2F6hgjx9u6iorux9wwrhwj.png" alt="Four tiles of written answers per field: 1 presence, absent not equal to zero; 2 ordering, what decides a tie; 3 authority, who writes and who reads; 4 proof, which count goes to 0 - with a note on the fourth: would have caught it in an afternoon" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Question 4 is the one that would have caught this article's first half in an afternoon. "Priority is carried through correctly" is not testable as written; "zero map rows without a priority" is a &lt;code&gt;SELECT&lt;/code&gt;, it runs forever, and it was the thing that eventually made the defect visible anyway - just several weeks later than it needed to be.&lt;/p&gt;

&lt;p&gt;Question 1 is what turns a field description into a contract. "Priority: integer, optional" describes bytes. "Priority: integer; &lt;code&gt;0&lt;/code&gt; is the strongest; absent means the rule carries no priority and loses every tie" describes a decision, and a decision can be checked.&lt;/p&gt;

&lt;p&gt;Question 2 is the one that would have caught the second half, and it costs even less: someone types the sentence "ties are broken by X", the sentence turns out not to exist, and the archaeology happens on day one of the design instead of eighteen months later during a migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not prove
&lt;/h2&gt;

&lt;p&gt;The material behind this article covers 32 days of acceptance runs, and the instruments changed inside that window - the earlier stretch has verdicts without numbers, the later one has traces and counts. So none of this supports "the monolith was better" or "the split made it worse". It supports something narrower and, I think, more useful: &lt;strong&gt;the split did not create these defects; it made them expressible.&lt;/strong&gt; A single process can hold an unstated tie-break rule indefinitely, because there is no boundary at which the omission has to be declared. Two services cannot: the boundary forces every implicit fact to be either written down or lost.&lt;/p&gt;

&lt;p&gt;I also cannot put a money figure on any of it. Time and iteration counts were recorded, cost was not, and inventing it afterwards would be worse than leaving the column empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multiplier line
&lt;/h2&gt;

&lt;p&gt;Both defects were found and closed faster than they would have been by hand: an executor can re-run a full sweep on every build, pull the state counts, and print the difference without getting bored on the fortieth object. What it could not do is decide that &lt;code&gt;0&lt;/code&gt; means "strongest" rather than "unspecified", or that ties should be broken by explicit order rather than by whatever the array happened to hold. Those are product decisions, and they are exactly the sentences that were missing. Fast iteration multiplies the requirements that exist in writing; where a requirement exists only in code, it multiplies the archaeology instead.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Anatomy of a service split — Part 6.&lt;/strong&gt; Next: the last part of this set - what happened when one automated executor was pointed at the work of another, told to add the missing traces, run the measurement and report back on what it found.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me how you write down the difference between "zero" and "not set" in your contracts. If &lt;strong&gt;you have been through this&lt;/strong&gt;, which of your production rules turned out to live only in a loop? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where freezing requirement tests before code costs more than it returns. How is it solved on your side, and what broke there?&lt;/p&gt;

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