<?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: Siddharth Pandalai</title>
    <description>The latest articles on DEV Community by Siddharth Pandalai (@darkpandawarrior).</description>
    <link>https://dev.to/darkpandawarrior</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%2F4036798%2Fcbc9c397-ab12-4008-91ec-0deac4a666df.jpg</url>
      <title>DEV Community: Siddharth Pandalai</title>
      <link>https://dev.to/darkpandawarrior</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/darkpandawarrior"/>
    <language>en</language>
    <item>
      <title>The captain routes. The captain never rows.</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Mon, 05 Oct 2026 04:02:23 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/the-captain-routes-the-captain-never-rows-4o2p</link>
      <guid>https://dev.to/darkpandawarrior/the-captain-routes-the-captain-never-rows-4o2p</guid>
      <description>&lt;p&gt;Two runs died on rate limits in the same week.&lt;/p&gt;

&lt;p&gt;Not because the work was too big. Because the most expensive model in the fleet was doing the rowing.&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%2Ff65kh9c25jq3gqd1sppm.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%2Ff65kh9c25jq3gqd1sppm.png" alt="The Fleet, drawn as a specimen plate: a captain's boat riding higher than three smaller craft, dashed routing lines running down to each of them, and no oar anywhere on the captain. Labelled THE FLEET, AgentHarness." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong mental model
&lt;/h2&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%2Fnhsyz5fjaf9w4vv3daui.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%2Fnhsyz5fjaf9w4vv3daui.png" alt="The fix: One sentence, made mandatory" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Model tiers arrive looking like a quality ladder. A small one, a medium one, a large one. So the instinct is obvious: the work matters, use the best one you can afford.&lt;/p&gt;

&lt;p&gt;That instinct is what empties a budget, because it treats capability as the only axis and ignores volume completely. Capability is what you need for one hard decision. Volume is what you need for four hundred easy ones. Those are different problems and they want different answers.&lt;/p&gt;

&lt;p&gt;They are roles, not ranks.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mechanical work&lt;/strong&gt; stays a small-model job no matter how important the surrounding project is. A rename, a format, a lint sweep, an extraction. Importance does not transfer down into the difficulty of the individual step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bulk execution&lt;/strong&gt; is a mid-tier job, and this is where almost all real work actually lives. Twenty files, one pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestration and judgement&lt;/strong&gt; is where the largest model earns its price. Deciding what the work is. Splitting it. Ruling on the ambiguous case that the spec did not cover.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The failure has a name
&lt;/h2&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%2Fcjfb6v7u5vap8b5qst1y.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%2Fcjfb6v7u5vap8b5qst1y.png" alt="The routing rule: Four lines" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An orchestrator that starts doing the work itself is a captain picking up an oar. The ship stops steering at precisely the moment the volume shows up, which is precisely when steering was the thing you were paying for.&lt;/p&gt;

&lt;p&gt;I wired my own harness this way early on. Top tier as planner and executor, because the work mattered and I wanted the best output at every step. It read as thoroughness at the time.&lt;/p&gt;

&lt;p&gt;Two waves died on rate limits inside one week. The work was not unusually large. Every one of a few hundred mechanical steps had gone through the most expensive path available, and the budget was gone long before the actual judgement calls arrived. The runs did not fail at the hard part. They never reached the hard part.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix was one sentence, and its grammar mattered
&lt;/h2&gt;

&lt;p&gt;The fix was not a smaller model. It was making delegation &lt;strong&gt;mandatory wording&lt;/strong&gt; in the orchestrator's prompt rather than advisory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Delegate execution. Do not perform it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not "prefer to delegate". Not "you may delegate where appropriate". Those get read as permission, and permission loses to the more concrete instruction sitting next to it.&lt;/p&gt;

&lt;p&gt;A model told to plan and execute will execute. Executing is specific and immediate; delegating is abstract and deferred. When two instructions compete, the concrete one wins, and "helpful" resolves toward doing the visible thing now.&lt;/p&gt;

&lt;p&gt;This is the same failure as any under-specified interface. "You are the orchestrator" does not imply "and therefore you do not also do the work" to something built to be maximally useful. If a constraint matters, it has to be stated as a constraint, not implied by a role name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The routing rule
&lt;/h2&gt;

&lt;p&gt;The version I run now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pick the cheapest tier that covers the work.&lt;/strong&gt; Not the best available. The cheapest sufficient one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escalate exactly one rung&lt;/strong&gt;, and only on verified failure or genuine spec ambiguity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never escalate on volume.&lt;/strong&gt; Volume is the signal to go &lt;em&gt;down&lt;/em&gt; a tier and parallelise, not up. This is the one people get backwards, and it is the expensive one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the top tier's budget for deciding.&lt;/strong&gt; Its value is entirely in where it points. Every token it spends on execution is a token not spent on the call only it can make.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  This generalises past agents
&lt;/h2&gt;

&lt;p&gt;The shape is not new. It is the same reasoning behind not running your analytics job on the primary database, not putting a senior engineer on a mechanical refactor, and not calling a paid geocoding API in a loop when a local lookup table covers ninety percent of cases.&lt;/p&gt;

&lt;p&gt;The resource with the highest capability usually also has the lowest throughput and the highest cost per unit. Spending it on volume is how systems fall over, and it always feels like diligence while you are doing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&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%2Fde65zmtszj0bvbfpjpxd.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%2Fde65zmtszj0bvbfpjpxd.png" alt="The payload: The captain never rows" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Tiers are roles, not ranks. Pick the cheapest one that covers the work, escalate one rung at a time and only on verified failure, and never escalate because there is a lot of something.&lt;/p&gt;

&lt;p&gt;Then write the constraint into the prompt as a constraint. "The captain never rows" has to be said out loud, because a captain built to be helpful will absolutely pick up an oar, and it will look like enthusiasm right up until the ship stops.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 17 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Notes From The Loop&lt;/em&gt;* · Featuring: The Fleet*&lt;br&gt;
&lt;em&gt;← Previously in this series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/lessons/2026-08-29-the-borrowed-hand/article.md" rel="noopener noreferrer"&gt;Plausible is worse than wrong&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/notes-from-the-loop.md" rel="noopener noreferrer"&gt;Notes From The Loop&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aiassisteddevelopment</category>
      <category>orchestration</category>
      <category>costengineering</category>
      <category>tooling</category>
    </item>
    <item>
      <title>I audited my own migrations. It was not fine.</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Sat, 03 Oct 2026 04:38:54 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/i-audited-my-own-migrations-it-was-not-fine-f45</link>
      <guid>https://dev.to/darkpandawarrior/i-audited-my-own-migrations-it-was-not-fine-f45</guid>
      <description>&lt;p&gt;Forty-seven schema crossings in one app. Fifteen with a test behind them.&lt;/p&gt;

&lt;p&gt;I would have told you, confidently, that it was fine. I have been quoting the migration count as a strength.&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%2Fijwnum82e2kehqfcq2xl.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%2Fijwnum82e2kehqfcq2xl.png" alt="The Ferryman, drawn as a specimen plate: a tall faceless figure poling a flat punt across dark water, a single coin resting on the deck. Labelled THE FERRYMAN, Migration(23, 24)." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A migration is the only code that gets one attempt
&lt;/h2&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%2F1trcsjrxa3ru0xw0ka9c.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%2F1trcsjrxa3ru0xw0ka9c.png" alt="The line that matters: Assert the data, not the schema" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything else you ship has a second chance. A bad screen gets a hotfix. A wrong API call gets retried. A crash gets a patch release and an apologetic changelog entry.&lt;/p&gt;

&lt;p&gt;A migration runs once, on a device you cannot see, against data you cannot reproduce, at a moment you do not choose. If it is wrong, the user's data is not broken. It is gone. There is no second attempt because the schema has already moved, and the row you needed to read in order to recover is the row that did not survive.&lt;/p&gt;

&lt;p&gt;And yet it is routinely the least tested code in the codebase, because it looks trivial. One &lt;code&gt;ALTER TABLE&lt;/code&gt;. Ship it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit
&lt;/h2&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%2Fkejvognp9epis25fqz2b.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%2Fkejvognp9epis25fqz2b.png" alt="Sort by what it touches: Coverage is the wrong instrument" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I ran a count across Mileway this week:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;47&lt;/strong&gt; &lt;code&gt;Migration&lt;/code&gt; objects&lt;/li&gt;
&lt;li&gt;schema version &lt;strong&gt;48&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;8&lt;/strong&gt; instrumentation test files, covering &lt;strong&gt;15&lt;/strong&gt; version pairs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So roughly two thirds of the crossings have never once been executed against a real snapshot of the schema they claim to upgrade. They are trusted on read. Someone looked at the SQL, thought "yes, that looks right," and shipped it. Usually me.&lt;/p&gt;

&lt;p&gt;The number itself did not bother me as much as what happened when I sorted it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage is the wrong instrument
&lt;/h2&gt;

&lt;p&gt;Percentage tells you nothing here, because migrations are not uniformly dangerous. Adding a nullable column is close to free. Touching a constraint is not. So I sorted the untested ones by what they actually do:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Foreign key changes (5 to 6, 16 to 17).&lt;/strong&gt; SQLite cannot alter a constraint in place. The only way through is to create a new table with the new definition, copy every row across, drop the old one, and rename. That copy is a full data movement wearing the costume of a schema tweak. If a column order is off or a type coerces, rows land wrong and nothing throws.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A data-moving migration (32 to 33).&lt;/strong&gt; &lt;code&gt;INSERT INTO ... SELECT&lt;/code&gt;. This one does not change shape, it transforms content. A mismatched column order here loses no rows at all, which is exactly why it is the worst case: you get a full table of confidently wrong values and no error anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A drop (47 to 48).&lt;/strong&gt; Irreversible by construction. Nothing to say about this one except that it should have had a test first.&lt;/p&gt;

&lt;p&gt;Four migrations. That is the actual risk surface, out of thirty-five untested ones. Test those and you have bought most of the safety available for a fraction of the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to write the test, and they are not equal
&lt;/h2&gt;

&lt;p&gt;The standard path is &lt;code&gt;MigrationTestHelper&lt;/code&gt;, which needs exported schemas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;ksp&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"room.schemaLocation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"$projectDir/schemas"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;helper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDatabase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TEST_DB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;execSQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"INSERT INTO trips (id, distance_m) VALUES (1, 4200)"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;db&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;helper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMigrationsAndValidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TEST_DB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;MIGRATION_16_17&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second path is the one I am actually on, and I only rediscovered it reading my own tests. This project runs &lt;code&gt;exportSchema = false&lt;/code&gt;, which blocks &lt;code&gt;MigrationTestHelper&lt;/code&gt; entirely. So the existing tests build the old schema by hand and drive it through a real SQLite connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BundledSQLiteDriver&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execSQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CREATE TABLE media_library (...)"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// the v39 shape, written out&lt;/span&gt;
&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execSQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"INSERT INTO media_library VALUES (...)"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// then run MIGRATION_39_40 and assert&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works. It is a genuine test. But the two are not equivalent and the difference is worth being precise about, because it is the sort of thing you only notice when it bites.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MigrationTestHelper&lt;/code&gt; builds version 16 from &lt;strong&gt;the schema Room actually exported at version 16&lt;/strong&gt;. The hand-written version builds it from &lt;strong&gt;your memory of version 16&lt;/strong&gt;. If that memory is wrong, and after forty-odd crossings it eventually will be, the test constructs a table that never existed, migrates it successfully, and passes. Green the whole way, while production carries a shape your test has never seen.&lt;/p&gt;

&lt;p&gt;So: hand-seeding is a valid answer to "exportSchema is off". It is not a free one. You are trading a verified starting point for a remembered one, and the interest on that trade compounds with every version.&lt;/p&gt;

&lt;p&gt;If you can turn schema export on, turn it on. If you cannot, at least write the seed SQL by copying from the migration that created the table rather than from memory, and say so in a comment, which is what my better tests already do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assert the data, not the schema
&lt;/h2&gt;

&lt;p&gt;Whichever path you take, the important line is not the migration call. It is the query afterwards.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT distance_m FROM trips WHERE id = 1"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;assertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"the row did not survive the crossing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;moveToFirst&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;runMigrationsAndValidate&lt;/code&gt; proves the resulting schema matches what Room expects. It says nothing about whether your rows are still there or still correct. Only reading one back does that.&lt;/p&gt;

&lt;p&gt;For the data-moving ones, assert the count as well. If 1,200 rows go in, 1,200 come out, and if they do not you want a red test rather than a support ticket in four months.&lt;/p&gt;

&lt;p&gt;And never leave &lt;code&gt;fallbackToDestructiveMigration()&lt;/code&gt; in a shipping build. It converts "my migration has a bug" into "the user's data is deleted, silently, and the app looks fine". A development convenience that reads, in production, as a data-loss feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules, short version
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Export schemas if you can.&lt;/strong&gt; &lt;code&gt;MigrationTestHelper&lt;/code&gt; builds the old version from what Room actually exported. Hand-seeding builds it from what you remember, and after forty crossings those drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sort by what it touches, not by count.&lt;/strong&gt; Constraints, data movement, drops. Everything else is bookkeeping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert data, not just schema.&lt;/strong&gt; &lt;code&gt;runMigrationsAndValidate&lt;/code&gt; checks shape. Read a row back to check truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count rows through a data move.&lt;/strong&gt; In equals out, or the test fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No destructive fallback in release.&lt;/strong&gt; Ever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the test before the migration ships&lt;/strong&gt;, because after it ships you are no longer testing, you are doing forensics.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&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%2Fey4wwj76p966w4xsh1uu.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%2Fey4wwj76p966w4xsh1uu.png" alt="The payload: Pay the toll before you get on the boat" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I did not find a bug this week. I found thirty-five crossings I had never rehearsed, and four of them touch the things that actually eat data.&lt;/p&gt;

&lt;p&gt;The uncomfortable part is that the count was a number I had been proud of. Forty-seven migrations sounds like maturity. It is only maturity if you can say what happens when each one runs, and until this week I could not.&lt;/p&gt;

&lt;p&gt;The boat only rows one way. Pay the toll before you get on it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 16 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Crossing The Schema&lt;/em&gt;* · Featuring: The Ferryman*&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/crossing-the-schema.md" rel="noopener noreferrer"&gt;Crossing The Schema&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>room</category>
      <category>migrations</category>
      <category>testing</category>
    </item>
    <item>
      <title>Plausible is worse than wrong</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Thu, 01 Oct 2026 06:57:24 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/plausible-is-worse-than-wrong-2dl2</link>
      <guid>https://dev.to/darkpandawarrior/plausible-is-worse-than-wrong-2dl2</guid>
      <description>&lt;p&gt;The API returned 200. The article published. Every single tag was silently dropped, and nothing anywhere said so.&lt;/p&gt;

&lt;p&gt;I found it two days later, by accident, looking at something else.&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%2F3z5tdn9bgxg5qcy0iu12.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%2F3z5tdn9bgxg5qcy0iu12.png" alt="The Borrowed Hand, drawn as a specimen plate: an open palm offered through a seam in the frame, with a second hand clamped around its wrist. Labelled THE BORROWED HAND, LlmGateway, exhibit 08." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong code gets caught. Plausible code ships.
&lt;/h2&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%2Fb375zg21ehd76ejesaj7.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%2Fb375zg21ehd76ejesaj7.png" alt="The line: Reasonable. Also a no-op." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A junior engineer fails in a way you can see. The code looks off, the naming is strange, something in the shape of it makes you slow down and read twice. That instinct is most of what code review actually is.&lt;/p&gt;

&lt;p&gt;Borrowed work does not fail like that. Ask a model for something and it returns code that looks exactly right, because looking right is the thing it is best at. It compiles. It runs. It returns success. When your instructions run out, it does not stop and ask. It fills the gap with the most plausible thing, because being helpful is the only mode it has.&lt;/p&gt;

&lt;p&gt;So the defect never lands in the syntax, which is where review is looking. It lands in the seam between what you asked for and what you meant, and it survives review precisely because it reads well.&lt;/p&gt;

&lt;p&gt;Two from the same week, both in the tooling that publishes this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  One: the line that returned 200 and did nothing
&lt;/h2&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%2Fck60o23a99ivsgjkt9sh.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%2Fck60o23a99ivsgjkt9sh.png" alt="What I do now: Four habits" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The publisher pushes each article to the dev.to API. The line read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at it. Tags are a list. The API wants tags. Join them. I would have approved this in a pull request without a second thought, and so would you.&lt;/p&gt;

&lt;p&gt;That endpoint wants an array. Hand it a string and it does not reject the request, does not warn, does not fail. It returns &lt;code&gt;200 OK&lt;/code&gt;, publishes the article, and drops every tag on the floor. Four tags, every post, silently.&lt;/p&gt;

&lt;p&gt;There was no error to read. The publish log said published, because it was. The only way to catch it was to fetch the article back and look at what was actually on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;landed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;landed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`tags were rejected (sent &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That check is three lines and I did not write it until after the damage, because the call had already told me it worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two: the snippet that fit perfectly and taught the wrong thing
&lt;/h2&gt;

&lt;p&gt;This one is worse, and it is the one I keep thinking about.&lt;/p&gt;

&lt;p&gt;Part of the tooling picks a code snippet out of each article to render onto a slide. I specified it plainly: take the first code block that fits the slide.&lt;/p&gt;

&lt;p&gt;It did exactly that. Perfectly. Every time.&lt;/p&gt;

&lt;p&gt;On a post about the five second window you get to call &lt;code&gt;startForeground()&lt;/code&gt;, it selected a &lt;code&gt;WorkManager&lt;/code&gt; snippet. Real API, correct Kotlin, lifted from the right file, would compile. Also completely unrelated to the point the slide was making. That deck was one push away from teaching a few thousand people the wrong lesson inside a beautifully rendered panel.&lt;/p&gt;

&lt;p&gt;Nobody lied to me. I asked for a block that &lt;em&gt;fits&lt;/em&gt;. I meant a block that &lt;em&gt;demonstrates the claim&lt;/em&gt;. The gap between those two sentences is the entire bug, and it is invisible in the output, because the output looks great.&lt;/p&gt;

&lt;h2&gt;
  
  
  Specify the predicate, not the shape
&lt;/h2&gt;

&lt;p&gt;"A block that fits" is a shape. Shapes are easy to describe and easy to satisfy, which is exactly why they are dangerous: a shape can be satisfied perfectly by something useless.&lt;/p&gt;

&lt;p&gt;"A block that contains the API this slide is about" is a predicate. It can be checked. It can fail. Something that fails loudly is worth more than something that succeeds vaguely.&lt;/p&gt;

&lt;p&gt;The fix was one filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and a deliberate decision that when nothing matches, the slide falls back to plain text rather than showing code that argues for something else. Four posts now have no code slide. That is the correct outcome. Four honest gaps beat four confident wrong answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the outcome, not the call
&lt;/h2&gt;

&lt;p&gt;This generalises well past AI, and it is the part worth stealing.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;200&lt;/code&gt; means the server received your request. It does not mean the server did what you wanted. A successful &lt;code&gt;INSERT&lt;/code&gt; means a row exists, not that it holds what you think. A migration that does not throw is not a migration that preserved your data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the call succeeded&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isSuccessful&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// what you actually wanted to know&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;saved&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getProfile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;displayName&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"server accepted the update and ignored displayName"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will not do this everywhere, and you should not. Do it at the boundaries where a silent no-op is expensive: anything that publishes, anything that pays somebody, anything that migrates data, anything you cannot easily inspect afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review the seams, not the syntax
&lt;/h2&gt;

&lt;p&gt;The practical version, for when you are reviewing work you did not personally type:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the specification, not the diff.&lt;/strong&gt; The diff is the answer. The bug is usually in the question. Ask what was actually requested, and whether the code answers that or something adjacent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find where the instructions ran out.&lt;/strong&gt; Every generated block has a boundary past which nobody said anything specific. That is where the plausible filler lives. Go there first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distrust code that returns success.&lt;/strong&gt; Especially against an API you do not control. Confirm the effect happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the check mechanical.&lt;/strong&gt; An assertion, a linter rule, a test. Your attention is the one component in this system guaranteed to degrade, and it degrades fastest exactly when the output looks good.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&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%2Fyteybzlo1acull0gxewj.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%2Fyteybzlo1acull0gxewj.png" alt="The payload: Wrong code gets caught. Plausible code ships." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Borrowed work does not fail like bad work. It fails like confident work that answers a question slightly next to the one you asked.&lt;/p&gt;

&lt;p&gt;Specify the predicate, not the shape. Verify the outcome, not the call. Write the check down as something that runs, because the day you stop reading carefully is not announced in advance.&lt;/p&gt;

&lt;p&gt;It will do anything you can describe. The trouble is what happens where you stopped describing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 15 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Notes From The Loop&lt;/em&gt;* · Featuring: The Borrowed Hand*&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/notes-from-the-loop.md" rel="noopener noreferrer"&gt;Notes From The Loop&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aiassisteddevelopment</category>
      <category>codereview</category>
      <category>apidesign</category>
      <category>kotlin</category>
    </item>
    <item>
      <title>Your thresholds do not belong in constants</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Mon, 28 Sep 2026 07:21:01 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/your-thresholds-do-not-belong-in-constants-3loe</link>
      <guid>https://dev.to/darkpandawarrior/your-thresholds-do-not-belong-in-constants-3loe</guid>
      <description>&lt;p&gt;Every tuning change needed a code review, a release, and a week of store rollout. So we stopped tuning, which is the worst possible outcome.&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%2Fui4oul11o47x97h1rab4.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%2Fui4oul11o47x97h1rab4.png" alt="The Archivist, drawn as a specimen plate. You cannot backfill confidence you discarded. Labelled THE ARCHIVIST, Provenance." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Eighteen guesses in a const val
&lt;/h2&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%2F116w20zdxco0jb9c7mkk.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%2F116w20zdxco0jb9c7mkk.png" alt="The ladder: Make tuning cheap" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Our location pipeline accumulated roughly eighteen numbers that shaped its behaviour:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;speed band boundaries (walking, cycling, driving)&lt;/li&gt;
&lt;li&gt;a minimum displacement gate for each band&lt;/li&gt;
&lt;li&gt;a stationary speed and jitter threshold&lt;/li&gt;
&lt;li&gt;a rolling history window size and its movement threshold&lt;/li&gt;
&lt;li&gt;a hard teleport gate&lt;/li&gt;
&lt;li&gt;four time-gap tiers, each with a relaxed speed cap&lt;/li&gt;
&lt;li&gt;a maximum plausible gap distance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All &lt;code&gt;const val&lt;/code&gt;, scattered through the processor. Every one of them was a guess. Informed, tested, reasonable, and still a guess about how phones behave in the hands of thousands of drivers.&lt;/p&gt;

&lt;p&gt;The kind of thing you obviously want to revisit once real data arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost of revisiting
&lt;/h2&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%2Frzhh0ajffa7ti63be9gl.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%2Frzhh0ajffa7ti63be9gl.png" alt="The payload: Separate the algorithm from its parameters" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Except revisiting meant: edit code, open a PR, get review, cut a build, push to the store, wait for rollout. A week at best to move a number by 0.5.&lt;/p&gt;

&lt;p&gt;The predictable result is that nobody moved it. Threshold discussions became opinion versus opinion, because gathering evidence and shipping a correction cost more than the disagreement was worth. The numbers ossified and then got defended.&lt;/p&gt;

&lt;p&gt;Frozen numbers are not stable numbers. They are unexamined ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unglamorous refactor
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Serializable&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;AbnormalDetectionConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="c1"&gt;// speed-band jitter gates&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;walkingMaxMps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cyclingMaxMps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;7.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;walkingJitterM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cyclingJitterM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;drivingJitterM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;stationarySpeedMps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;stationaryJitterM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// movement-history window&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;speedHistorySize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;movementHistoryMps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// instant-teleport hard gate&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;spikeHardGateM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;5_000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// gap-recovery tiers&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gapMinSec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gap5mSec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gap1hSec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3_600L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gap6hSec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;21_600L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gapTier5mMps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;150.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gapTier1hMps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;100.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gapTier6hMps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;60.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gapMaxDistanceM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;10_000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;companion&lt;/span&gt; &lt;span class="k"&gt;object&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="cm"&gt;/** Exactly today's constants: the no-override baseline. */&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;DEFAULT&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AbnormalDetectionConfig&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Injected into the processor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LocationProcessor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;abnormalConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AbnormalDetectionConfig&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AbnormalDetectionConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire change. No new abstraction, no strategy pattern, no plugin system. One data class and a constructor parameter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two details that made it land
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The defaults are byte-identical to the old constants.&lt;/strong&gt; This makes the refactor provably behaviour neutral: every existing test passes untouched, and the diff is a pure move. That is the only kind of large refactor that gets approved without argument, and it is worth some care to achieve.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It stages.&lt;/strong&gt; Debug settings could override the config the same week. Later, server-driven config became a change to &lt;em&gt;where the object is constructed&lt;/em&gt;, not to the algorithm. The processor never learns where its numbers came from, which is exactly the property you want.&lt;/p&gt;

&lt;p&gt;Worth being clear about what this is not. It is not a feature-flag system, a rules engine, or remote code execution. It is a struct of numbers with sane defaults. The moment tuning parameters become a DSL, you have built a second product nobody asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line I would draw
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;const val&lt;/code&gt; is for something that genuinely cannot change: metres in a kilometre, the number of milliseconds in a second, a protocol constant.&lt;/p&gt;

&lt;p&gt;A threshold in a heuristic is a hypothesis about the world. Hypotheses must be cheap to revise, or they quietly turn into dogma defended by whoever picked them.&lt;/p&gt;

&lt;p&gt;If changing a number in your system requires a release, you will guess instead of measure. Fix the cost of changing it, and measuring becomes the easier path.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 14 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Chain Of Custody&lt;/em&gt;* · Featuring: The Archivist*&lt;br&gt;
&lt;em&gt;← Previously in this series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/lessons/2026-08-25-invariants-are-cheap/article.md" rel="noopener noreferrer"&gt;Invariants are cheap. Silent corruption is not.&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/chain-of-custody.md" rel="noopener noreferrer"&gt;Chain Of Custody&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>configuration</category>
      <category>kotlin</category>
      <category>refactoring</category>
    </item>
    <item>
      <title>Invariants are cheap. Silent corruption is not.</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Sat, 26 Sep 2026 04:01:31 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/invariants-are-cheap-silent-corruption-is-not-c9m</link>
      <guid>https://dev.to/darkpandawarrior/invariants-are-cheap-silent-corruption-is-not-c9m</guid>
      <description>&lt;p&gt;We had five numbers that were supposed to add up. Nothing in the codebase checked that they did.&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%2Fj7w7g8aiq0l15t99noqy.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%2Fj7w7g8aiq0l15t99noqy.png" alt="The Archivist, drawn as a specimen plate. You cannot backfill confidence you discarded. Labelled THE ARCHIVIST, Provenance."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The relationship nobody wrote down
&lt;/h2&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%2Fuwx81q0exuyh6fyi4yqh.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%2Fuwx81q0exuyh6fyi4yqh.png" alt="How to grade them: Errors and warnings"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A tracked journey produces five distance figures: &lt;code&gt;original&lt;/code&gt;, &lt;code&gt;cleaned&lt;/code&gt;, &lt;code&gt;mock&lt;/code&gt;, &lt;code&gt;abnormal&lt;/code&gt; and &lt;code&gt;spike&lt;/code&gt;. Anyone on the team could tell you how they relate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cleaned = original - (mock + abnormal)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With one wrinkle. &lt;code&gt;spike&lt;/code&gt; is not in that equation, because spike distance is never folded into &lt;code&gt;original&lt;/code&gt; in the first place, so subtracting it again would double count. It is tracked separately for reporting only.&lt;/p&gt;

&lt;p&gt;That wrinkle is the kind of thing that survives exactly as long as the person who wrote it. It looks like an oversight. A reasonable engineer refactoring this code in a year would "fix" it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the rule executable
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;DistanceValidator&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;TOLERANCE_METRES&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DistanceMetrics&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;ValidationResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;errors&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mutableListOf&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;warnings&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mutableListOf&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ValidationWarning&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;

        &lt;span class="c1"&gt;// negatives are always a bug&lt;/span&gt;
        &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"total"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"cleaned"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"mock"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="s"&gt;"abnormal"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;abnormal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"spike"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spike&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="nc"&gt;NegativeDistance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// CRITICAL: spike is excluded here. It is never folded into `total`,&lt;/span&gt;
        &lt;span class="c1"&gt;// so it isn't subtracted back out of `cleaned` either.&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;expectedCleaned&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mock&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;abnormal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;diff&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cleaned&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;expectedCleaned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;TOLERANCE_METRES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="nc"&gt;ComponentMismatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedCleaned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cleaned&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="nc"&gt;CleanedExceedsTotal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;// ...ratio warnings&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ValidationResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loud comment is deliberate. The invariant now enforces the wrinkle, and the comment explains it, so the refactor that would have broken it fails a test instead.&lt;/p&gt;

&lt;p&gt;The tolerance matters too. These are accumulated floating point sums, so exact equality would produce false failures. 0.1 metres is well below anything meaningful and well above accumulated error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two tiers, doing different jobs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Errors block submission.&lt;/strong&gt; Negative distances, component mismatch, cleaned exceeding total. These mean the code is wrong. The numbers contradict each other and nothing downstream should trust them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Warnings surface without blocking.&lt;/strong&gt; This tier is the one that earns its keep:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mock&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UnusualRatio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mock"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;abnormal&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UnusualRatio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"abnormal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spike&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spike&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UnusualRatio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"spike"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;odometer&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;odometer&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LargeDiscrepancy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"odometer-vs-gps"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are a fundamentally different kind of statement. The strict invariant catches code bugs. The ratios catch something internal consistency never can: a threshold being wrong.&lt;/p&gt;

&lt;p&gt;Every one of those five numbers can be perfectly consistent with the others while the filter quietly discards half of a genuine journey. The arithmetic is fine. The classification is not. If most of a real trip is landing in the abnormal bucket, our detection is wrong, not the driver, and only a ratio check will ever tell you.&lt;/p&gt;

&lt;p&gt;The odometer check goes further still. It compares against a genuinely independent measurement, so it can catch errors that no amount of self-consistency would reveal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is cheap
&lt;/h2&gt;

&lt;p&gt;The whole validator is around eighty lines of pure Kotlin. No Android dependency, no I/O, no mocking. It is a function from data to a result, which means it is trivially unit testable and runs in milliseconds before submission.&lt;/p&gt;

&lt;p&gt;Against that: distance figures that quietly drift wrong for weeks, discovered through a support queue, with no way to identify which journeys were affected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&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%2Fnwb70o96xa8952oez125.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%2Fnwb70o96xa8952oez125.png" alt="The payload: Invariants are cheap. Silent corruption is not."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every system has relationships it assumes and never asserts. Components summing to a total. A subset never exceeding its parent. Two independent measurements staying within range. A count matching between client and server.&lt;/p&gt;

&lt;p&gt;Those rules usually live in a comment, or a Slack message, or nowhere at all. Write them as code that runs. Errors for what must never happen, warnings for what is merely suspicious. Both before the data leaves.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 13 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Chain Of Custody&lt;/em&gt;* · Featuring: The Archivist*&lt;br&gt;
&lt;em&gt;← Previously in this series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/lessons/2026-08-18-never-silently-change-a-number/article.md" rel="noopener noreferrer"&gt;Never silently change a number someone gets paid on&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/chain-of-custody.md" rel="noopener noreferrer"&gt;Chain Of Custody&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dataintegrity</category>
      <category>validation</category>
      <category>architecture</category>
      <category>kotlin</category>
    </item>
    <item>
      <title>Every filter needs a documented exception</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Wed, 23 Sep 2026 04:01:24 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/every-filter-needs-a-documented-exception-2j3k</link>
      <guid>https://dev.to/darkpandawarrior/every-filter-needs-a-documented-exception-2j3k</guid>
      <description>&lt;p&gt;A filter with no exceptions has not met production yet. It has only met your test data.&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%2F9f3fo5c6lvpqftz2168v.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%2F9f3fo5c6lvpqftz2168v.png" alt="The Concussed Witness, drawn as a specimen plate. Confident. Cooperative. Often wrong. Labelled THE CONCUSSED WITNESS, LocationManager." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that was right nearly always
&lt;/h2&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%2Fztgom89i1yy2g17zf1pw.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%2Fztgom89i1yy2g17zf1pw.png" alt="The discipline: Every exception earns three things" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GPS reports an accuracy radius with every reading. Ours had a straightforward rule: anything worse than 50 metres is persisted, but does not count toward trusted distance.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;accuracyGated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accuracyM&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;MAX_ACCURACY_THRESHOLD_M&lt;/span&gt;   &lt;span class="c1"&gt;// 50.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is sound. A 200 metre radius means the phone is in a basement, an underground car park, or surrounded by reflective towers. Counting that as travelled distance produces nonsense.&lt;/p&gt;

&lt;p&gt;Then we hit the legitimate case: a vehicle parked for a long stop. Genuinely stationary. Decent fix. But occasionally reporting a slightly wider radius, and under the blanket rule those readings stopped contributing. The stop appeared as a hole in the journey.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two bad fixes and one good one
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Loosen the threshold.&lt;/strong&gt; Move 50 to 70 and the complaint disappears, along with the protection you added the rule for. This is not solving the edge case, it is deleting the rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Special-case it silently.&lt;/strong&gt; Add &lt;code&gt;|| fix.speedMps &amp;lt; 0.1&lt;/code&gt; somewhere in the condition, move on. It works today and becomes archaeology within a quarter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Name the exception.&lt;/strong&gt; What we actually did:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Stationary after recent movement with still-reasonable accuracy: the device is&lt;/span&gt;
&lt;span class="c1"&gt;// reliably placed, not drifting, so it may contribute despite the accuracy gate.&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;exceptionalStationary&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speedMps&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nc"&gt;EXCEPTIONAL_STATIONARY_SPEED_MPS&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;   &lt;span class="c1"&gt;// 0.1 m/s: genuinely not moving&lt;/span&gt;
        &lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accuracyM&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nc"&gt;EXCEPTIONAL_STATIONARY_ACCURACY_M&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="c1"&gt;// 20m: still a good fix&lt;/span&gt;
        &lt;span class="nf"&gt;hasMovementHistory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                                  &lt;span class="c1"&gt;// and it was moving recently&lt;/span&gt;

&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;accuracyGated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accuracyM&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;maxAccuracyThreshold&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;exceptionalStationary&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three conditions, all required, describing exactly one situation. The rule stays strict for everyone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the name is the important part
&lt;/h2&gt;

&lt;p&gt;The logic here is four lines. Any competent engineer would arrive at something similar. What makes it survive is that it is called &lt;code&gt;exceptionalStationary&lt;/code&gt; and carries a comment explaining the reasoning.&lt;/p&gt;

&lt;p&gt;An unnamed condition is indistinguishable from a bug, and it gets treated like one. Somebody tidying up in six months sees &lt;code&gt;speedMps &amp;lt;= 0.1f &amp;amp;&amp;amp; accuracyM &amp;lt; 20f &amp;amp;&amp;amp; hasMovementHistory()&lt;/code&gt; inline in a boolean, cannot work out why anyone would want that, and removes it. Tests pass, because nobody wrote a test for a case nobody could explain. The regression surfaces in support tickets a month later.&lt;/p&gt;

&lt;p&gt;A named exception with a comment and a test is a different object entirely. It says: this is deliberate, here is the situation, here is why the general rule is wrong for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other exception in the same pipeline
&lt;/h2&gt;

&lt;p&gt;There is a second one worth showing, because it demonstrates the discipline that keeps exceptions from metastasising. After a pause and resume, the user may genuinely have moved while tracking was off, so the teleport-spike gate is relaxed for a grace window.&lt;/p&gt;

&lt;p&gt;But the accuracy gate stays fully enforced during that window.&lt;/p&gt;

&lt;p&gt;Relaxing one rule must never silently relax the neighbours. An exception should be as narrow as the situation that justifies it, and no narrower than the code makes explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&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%2Fahjxfg3enh5c18pm7r44.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%2Fahjxfg3enh5c18pm7r44.png" alt="The payload: An undocumented special case looks exactly like a bug" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Write the rule. Then go looking for the legitimate case it breaks, because there is always one. When you find it, do not weaken the rule and do not smuggle in a condition. Give it a name, a comment with your reasoning, and a test.&lt;/p&gt;

&lt;p&gt;Undocumented special cases do not survive contact with a refactor. Documented ones do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 12 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Sensors Who Lie&lt;/em&gt;* · Featuring: The Concussed Witness*&lt;br&gt;
&lt;em&gt;← Previously in this series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/lessons/2026-08-13-accelerometer-outranks-gps/article.md" rel="noopener noreferrer"&gt;When two sensors disagree, rank them&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/sensors-who-lie.md" rel="noopener noreferrer"&gt;Sensors Who Lie&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>filtering</category>
      <category>edgecases</category>
      <category>codequality</category>
    </item>
    <item>
      <title>Never silently change a number someone gets paid on</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Sun, 20 Sep 2026 04:01:23 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/never-silently-change-a-number-someone-gets-paid-on-h4n</link>
      <guid>https://dev.to/darkpandawarrior/never-silently-change-a-number-someone-gets-paid-on-h4n</guid>
      <description>&lt;p&gt;Our algorithm decided a driver had travelled 4km less than they thought. It was probably right. Shipping that silently would still have been 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%2F1qb9xhsqxj5lavrccj5p.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%2F1qb9xhsqxj5lavrccj5p.png" alt="The Archivist, drawn as a specimen plate. You cannot backfill confidence you discarded. Labelled THE ARCHIVIST, Provenance." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When cleaning stops being technical
&lt;/h2&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%2Fdtr1nmeqs9es0orqk5ab.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%2Fdtr1nmeqs9es0orqk5ab.png" alt="Explicit degradation: Report the delta, not just the answer" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Mileage tracking ends in an expense claim. Someone drives, the app measures, and the measurement becomes money.&lt;/p&gt;

&lt;p&gt;Our pipeline removes several categories of distance: readings from mock location apps, physically implausible jumps, and teleport spikes over five kilometres in a single step. The resulting number is more accurate than the raw sum. That is not in dispute.&lt;/p&gt;

&lt;p&gt;The mistake is thinking accuracy is the whole job.&lt;/p&gt;

&lt;p&gt;A driver who believes they did 40km and sees 36km cannot distinguish between a careful algorithm and an employer quietly trimming expenses. From where they sit, both look identical: a smaller number, no explanation. Being right is invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Explicit degradation, in the UI
&lt;/h2&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%2Fjrvzhpwter9wokoxj7sp.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%2Fjrvzhpwter9wokoxj7sp.png" alt="What the user is owed: Four things, every time" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix is not better filtering. It is showing the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Show the original next to the cleaned figure.&lt;/strong&gt; Both numbers exist in the data model, so both can appear. The user sees that we measured 40km and are proposing 36km, rather than discovering 36km as if it were the only thing that ever happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Name what was removed.&lt;/strong&gt; Not "adjusted for accuracy" but the actual categories: abnormal distance, mock distance, spike distance. Categories are checkable. Vagueness is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let them overrule it.&lt;/strong&gt; The trip screen has a toggle for whether the abnormal segment is subtracted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;final&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;smartAnalysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;originalDistance&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;removeAbnormal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="p"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;smartAnalysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;abnormalDistance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one gets pushback in review. Why let a user override the algorithm? Two reasons. They were physically present and we were not, so on a genuinely ambiguous journey they hold information the pipeline does not. And if our threshold is wrong, this is the cheapest possible feedback channel. The alternative is not that they accept it. The alternative is that they escalate to their finance team, who escalate to us, three weeks later, with no data attached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not cry wolf.&lt;/strong&gt; In the irregularities view, an app-killed event is explicitly classified as not an irregularity, because the tracker recovers on its own. If every notice is alarming, users learn to dismiss all of them, and the disclosure you worked on becomes noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  This needs the data model to cooperate
&lt;/h2&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%2F5hn35rpw1k15cntsq43l.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%2F5hn35rpw1k15cntsq43l.png" alt="The payload: Correctness is table stakes. Transparency ships." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of this is possible if the pipeline throws away what it rejects. Showing original versus cleaned requires both numbers to survive. Naming the categories requires per-category accumulators. Letting the user toggle removal requires the removed distance to still be sitting there, addable back.&lt;/p&gt;

&lt;p&gt;Transparency is a data-modelling decision made months before it becomes a UI decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general case
&lt;/h2&gt;

&lt;p&gt;The pattern applies well beyond mileage. Fraud scores that block a transaction. Ranking systems that decide what a seller earns. Time trackers. Usage-based billing. Anything where a system's output becomes an input to somebody's money, time, or reputation.&lt;/p&gt;

&lt;p&gt;For those systems, correctness is table stakes. The actual product is: show the original, show the delta, show the reason, and let them push back.&lt;/p&gt;

&lt;p&gt;Being right in silence is just asking to be trusted. Showing the working is how you earn it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 11 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Chain Of Custody&lt;/em&gt;* · Featuring: The Archivist*&lt;br&gt;
&lt;em&gt;← Previously in this series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/lessons/2026-08-11-uncertainty-dies-in-data-model/article.md" rel="noopener noreferrer"&gt;Your data model is where uncertainty goes to die&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/chain-of-custody.md" rel="noopener noreferrer"&gt;Chain Of Custody&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productengineering</category>
      <category>trust</category>
      <category>ux</category>
      <category>dataintegrity</category>
    </item>
    <item>
      <title>When two sensors disagree, rank them</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Fri, 18 Sep 2026 06:44:33 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/when-two-sensors-disagree-rank-them-12ki</link>
      <guid>https://dev.to/darkpandawarrior/when-two-sensors-disagree-rank-them-12ki</guid>
      <description>&lt;p&gt;GPS said the car was moving. The accelerometer said the phone had not twitched in ten minutes.&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%2Fj140hnb1ypnvlbynw601.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%2Fj140hnb1ypnvlbynw601.png" alt="The Second Witness, drawn as a specimen plate. Cannot say where. Knows whether. Labelled THE SECOND WITNESS, Sensor.TYPE_ACCELEROMETER." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Asking a sensor to check itself
&lt;/h2&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%2Ffulkaomhbz429tf4aema.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%2Ffulkaomhbz429tf4aema.png" alt="The better question: Rank per question, not overall" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Stationary drift is the oldest problem in location tracking. A parked phone reports small movements in every direction. Sum the gaps and a car that never left the car park has quietly driven three kilometres.&lt;/p&gt;

&lt;p&gt;The obvious defence is to look at reported speed and ignore readings that claim you are not moving. It half works, because the drift sometimes carries a plausible speed with it. The fix looks internally consistent while being completely wrong.&lt;/p&gt;

&lt;p&gt;That is the structural problem. A single sensor can only be validated against itself, so every threshold you add is really a guess about your own input. You can spend months making that guess more elaborate without making it more correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second signal that fails differently
&lt;/h2&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%2Fg1vl0wesbd6ky4qruhkn.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%2Fg1vl0wesbd6ky4qruhkn.png" alt="The method: Corroborate, do not tune" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The accelerometer is a poor navigator. Integrate it for position and the error compounds within seconds. But ask it a narrower question, "did this device physically move", and it is far better than GPS, because its failure modes have nothing to do with satellites, tunnels, or reflections off a glass tower.&lt;/p&gt;

&lt;p&gt;Two sensors that fail differently can check each other. So the pipeline takes both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GpsFix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;motionStill&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// IMU says physically still&lt;/span&gt;
    &lt;span class="n"&gt;harshAccel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// hard accel or braking this fix&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;ProcessResult&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and when they disagree about movement, the IMU wins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// drop the sub-gate wander when GPS history shows no movement,&lt;/span&gt;
&lt;span class="c1"&gt;// OR the IMU says still, OR this coincides with a harsh accel/brake event&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;displacement&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;stationaryMicroJitter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="nf"&gt;hasMovementHistory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;motionStill&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;harshAccel&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment in our source is blunt about why: accelerometer stillness is authoritative.&lt;/p&gt;

&lt;p&gt;Harsh braking gets the same authority for a subtle reason. A hard stop can momentarily look like stillness to a speed gate, and the jitter it produces should not be counted as travelled distance either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rank per question, not overall
&lt;/h2&gt;

&lt;p&gt;The important nuance is that we did not decide the accelerometer is "better". It is useless for position. GPS remains the only thing that knows where you are.&lt;/p&gt;

&lt;p&gt;What we ranked was authority on one specific question:&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;Authority&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where are we?&lt;/td&gt;
&lt;td&gt;GPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Did we move at all?&lt;/td&gt;
&lt;td&gt;Accelerometer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How fast, over a distance?&lt;/td&gt;
&lt;td&gt;GPS, checked against implied speed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the environment trustworthy?&lt;/td&gt;
&lt;td&gt;Neither, the quality score&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A sensor is not good or bad. It is good at some questions and bad at others, and a fusion strategy is mostly a table like that one made explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheap version of the same idea
&lt;/h2&gt;

&lt;p&gt;You do not need an IMU to use this principle. At journey level we compare total GPS distance against the vehicle odometer reading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;discrepancyRatio&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;odometer&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;odometer&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;discrepancyRatio&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LargeDiscrepancy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"odometer-vs-gps"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;discrepancyRatio&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two completely independent measurements of one number. When they disagree by more than 30 percent, something is wrong and a human should look, rather than the system silently trusting whichever number it happened to compute.&lt;/p&gt;

&lt;p&gt;Most systems have a signal like this lying around unused. A client-side event count against a server-side one. A cache's view of a value against the source of truth. A model's confidence against observed outcomes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&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%2Flatrjzrtk8tiuosr3gtt.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%2Flatrjzrtk8tiuosr3gtt.png" alt="The payload: Correlation beats cleverness" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Do not build a smarter threshold on a lonely sensor. Find a second signal that fails differently, and give it authority over the specific question it is genuinely good at.&lt;/p&gt;

&lt;p&gt;Correlation beats cleverness.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 10 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Sensors Who Lie&lt;/em&gt;* · Featuring: The Concussed Witness, The Second Witness*&lt;br&gt;
&lt;em&gt;← Previously in this series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/lessons/2026-08-06-one-global-threshold/article.md" rel="noopener noreferrer"&gt;One global threshold is how you delete valid data&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/sensors-who-lie.md" rel="noopener noreferrer"&gt;Sensors Who Lie&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>sensors</category>
      <category>sensorfusion</category>
      <category>imu</category>
    </item>
    <item>
      <title>Your data model is where uncertainty goes to die</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Wed, 16 Sep 2026 05:08:31 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/your-data-model-is-where-uncertainty-goes-to-die-g6j</link>
      <guid>https://dev.to/darkpandawarrior/your-data-model-is-where-uncertainty-goes-to-die-g6j</guid>
      <description>&lt;p&gt;The sensor knew how much to trust itself. Our database column did not have room for that, so we threw it away on the way in.&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%2F5vdiykxbwetd0i9y2z6h.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%2F5vdiykxbwetd0i9y2z6h.png" alt="The Archivist, drawn as a specimen plate. You cannot backfill confidence you discarded. Labelled THE ARCHIVIST, Provenance." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything arrives with an error bar
&lt;/h2&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%2Fomkqoeghzturn7wvhz0f.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%2Fomkqoeghzturn7wvhz0f.png" alt="The schema: Design the row to carry doubt" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Android hands you a &lt;code&gt;Location&lt;/code&gt; object that is far richer than a point on a map. It has an accuracy radius in metres, the provider that produced it, a bearing, an altitude, a timestamp, sometimes a speed. The platform is telling you, on every reading, how much to believe it.&lt;/p&gt;

&lt;p&gt;This is not special to GPS. Models return confidences. Caches know their staleness. Upstream APIs know whether they served fresh data or a fallback. Almost every value in a real system arrives with a companion signal about its own reliability.&lt;/p&gt;

&lt;p&gt;And almost every schema drops it.&lt;/p&gt;

&lt;p&gt;Our first table was the obvious one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;LocationData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reasonable. Minimal. Exactly what the feature needed. It also silently discarded the accuracy radius on every write, which meant the discard was permanent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The questions you cannot answer later
&lt;/h2&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%2Fd92f95c5vupogmwi0tt3.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%2Fd92f95c5vupogmwi0tt3.png" alt="The columns: What every row owes you" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Within a few months, the real questions arrived:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Was this journey tracked well, or was the phone in a basement the whole time?&lt;/li&gt;
&lt;li&gt;Which readings were fused location and which were raw GPS?&lt;/li&gt;
&lt;li&gt;Was the device physically moving when this arrived, or just drifting?&lt;/li&gt;
&lt;li&gt;Why is this trip's distance different from the driver's odometer?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not one of those is an algorithm problem. They are all schema problems. The information existed at write time, in memory, for free, and we did not have a column for it.&lt;/p&gt;

&lt;p&gt;You cannot backfill confidence. Once the row is written without it, that uncertainty is gone for every future reader, forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The row that carries its papers
&lt;/h2&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%2Fcqgxz57c043yj13ch4og.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%2Fcqgxz57c043yj13ch4og.png" alt="The payload: You cannot backfill confidence you discarded" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What we persist now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;LocationData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="c1"&gt;// the reading&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;bearing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;altitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;locationTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// how much to trust it, and where it came from&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;accuracy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// what the device was doing&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gyroscopeX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gyroscopeY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gyroscopeZ&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;accelerometerX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;accelerometerY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;accelerometerZ&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;batteryPercentage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;deviceModel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;appVersionName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// what we already decided about it&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;isMock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;isAbnormal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;isPaused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;displacement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three groups, and the third is the one people skip. Storing our own verdict next to the evidence means a later reader can see both what we concluded and what we concluded it from. When a threshold turns out to be wrong, every affected row is identifiable rather than theoretical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confidence as a number, not a vibe
&lt;/h2&gt;

&lt;p&gt;Raw columns let you reconstruct the truth. They do not make it usable in a UI. So the environment collapses into a single score, 0 to 100:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Penalty&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Permission missing&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mock location&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Process killed&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restarted&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPS off&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Battery optimised&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Power saver&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;plus an accuracy tier (15m or better is free, worse than 75m costs 20) and a small bonus when the fix stream is stable. Now the app can say "this journey was tracked in poor conditions" instead of presenting a number with false confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Design the row to carry doubt. If a value can be wrong, the schema needs somewhere to record how wrong, where it came from, and what you already decided about it.&lt;/p&gt;

&lt;p&gt;Those columns are cheap. Add them before you need them, because the alternative is discovering at query time that the answer was thrown away at write time, months ago, by a schema that assumed confidence did not matter.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 9 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Chain Of Custody&lt;/em&gt;* · Featuring: The Archivist*&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/chain-of-custody.md" rel="noopener noreferrer"&gt;Chain Of Custody&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>datamodelling</category>
      <category>architecture</category>
      <category>provenance</category>
      <category>sensors</category>
    </item>
    <item>
      <title>One global threshold is how you delete valid data</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Mon, 14 Sep 2026 04:01:20 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/one-global-threshold-is-how-you-delete-valid-data-303h</link>
      <guid>https://dev.to/darkpandawarrior/one-global-threshold-is-how-you-delete-valid-data-303h</guid>
      <description>&lt;p&gt;Our GPS filter worked perfectly, right up until someone sat in Bangalore traffic.&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%2Fvgwyiz6s1qwxoctebtu7.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%2Fvgwyiz6s1qwxoctebtu7.png" alt="The Concussed Witness, drawn as a specimen plate. Confident. Cooperative. Often wrong. Labelled THE CONCUSSED WITNESS, LocationManager." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The phantom distance problem
&lt;/h2&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%2Fwy4q91lxheho8om3pg0g.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%2Fwy4q91lxheho8om3pg0g.png" alt="The claim you made: A constant is an assumption" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A parked phone does not sit still in the data. The reported position wanders a few metres in every direction, and if you naively sum the gaps between readings, a stationary car quietly accumulates kilometres. On a mileage app that becomes an expense claim, so it matters.&lt;/p&gt;

&lt;p&gt;The fix looks trivial. Ignore any step below some minimum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;displacement&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nc"&gt;MIN_DISPLACEMENT_M&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;   &lt;span class="c1"&gt;// 5.0, felt about right&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parked cars stop drifting. Tests pass. Ship.&lt;/p&gt;

&lt;p&gt;Then a driver crawls through traffic at walking pace for forty minutes. Every genuine step they take is under five metres. We deleted the whole journey and told them they had not moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constant was making a claim
&lt;/h2&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%2Feyk7qx388o8rhch4gbf9.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%2Feyk7qx388o8rhch4gbf9.png" alt="The fix: Thresholds take context" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MIN_DISPLACEMENT_M = 5.0&lt;/code&gt; looks like a number. It is really an assertion: five metres means the same thing whether you are parked, walking, cycling, on a motorway, or crawling in first gear. That assertion is false, and the filter had no way to know it.&lt;/p&gt;

&lt;p&gt;Every threshold worth keeping is a function of context. Ours ended up depending on three things.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Speed bands
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;minDisplacementForSpeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;speedMps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;speedMps&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;2.5&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;    &lt;span class="c1"&gt;// walking&lt;/span&gt;
    &lt;span class="n"&gt;speedMps&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;7.0&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;3.0&lt;/span&gt;    &lt;span class="c1"&gt;// cycling&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;           &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;5.0&lt;/span&gt;    &lt;span class="c1"&gt;// driving&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Faster travel expects and tolerates bigger steps between readings. Slower travel needs a tighter gate or you erase it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Time-gap tiers
&lt;/h3&gt;

&lt;p&gt;An implausible speed is only implausible relative to how long you were not looking. Under normal sampling, a 5km jump is a teleport. After a six hour gap it is a commute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;isAbnormal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;displacement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;impliedSpeed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtSec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dtSec&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;      &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;displacement&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5_000&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;impliedSpeed&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;70.0&lt;/span&gt;  &lt;span class="c1"&gt;// ~252 km/h&lt;/span&gt;
    &lt;span class="n"&gt;dtSec&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;    &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;impliedSpeed&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;150.0&lt;/span&gt;
    &lt;span class="n"&gt;dtSec&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3_600&lt;/span&gt;  &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;impliedSpeed&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;100.0&lt;/span&gt;
    &lt;span class="n"&gt;dtSec&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;21_600&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;impliedSpeed&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;60.0&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;            &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;displacement&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10_000&lt;/span&gt;   &lt;span class="c1"&gt;// stop testing speed entirely&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tunnels, killed processes, flights and genuine glitches all produce a jump. The gap is what tells them apart, so the gap has to be an input.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Recent history
&lt;/h3&gt;

&lt;p&gt;This is the one that actually rescued the traffic case. A small step is only treated as jitter when the recent window agrees that nothing is happening:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;hasMovementHistory&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;recentSpeedHistory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;recentSpeedHistory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;average&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;   &lt;span class="c1"&gt;// last 5 fixes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sustained slow movement is movement. Genuine stillness is stillness. A rolling window can tell them apart. A constant never could.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make them configurable while you are at it
&lt;/h2&gt;

&lt;p&gt;Once thresholds are context-dependent there are more of them, and you will get some wrong. Ours live in one serialisable object rather than scattered &lt;code&gt;const val&lt;/code&gt;s:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Serializable&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;AbnormalDetectionConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;walkingMaxMps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;walkingJitterM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cyclingJitterM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;drivingJitterM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;speedHistorySize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;movementHistoryMps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;spikeHardGateM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;5_000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ...gap tiers&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tuning becomes a config change rather than a release. The defaults reproduce the old hardcoded values exactly, so extracting them proved nothing changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&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%2Fpj7jyv78dfoytsntjsm5.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%2Fpj7jyv78dfoytsntjsm5.png" alt="The payload: A constant with no context is a bug on a delay" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you write a constant into a filter, ask what context you are assuming. If the answer is "all of them", you have not written a threshold. You have written a bug with a delay on it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 8 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Sensors Who Lie&lt;/em&gt;* · Featuring: The Concussed Witness*&lt;br&gt;
&lt;em&gt;← Previously in this series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/lessons/2026-08-04-filtered-never-deleted/article.md" rel="noopener noreferrer"&gt;Filtered should never mean deleted&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/sensors-who-lie.md" rel="noopener noreferrer"&gt;Sensors Who Lie&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>location</category>
      <category>thresholds</category>
      <category>heuristics</category>
    </item>
    <item>
      <title>Filtered should never mean deleted</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Sun, 06 Sep 2026 06:33:33 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/filtered-should-never-mean-deleted-5081</link>
      <guid>https://dev.to/darkpandawarrior/filtered-should-never-mean-deleted-5081</guid>
      <description>&lt;p&gt;We shipped a filter that threw away bad GPS readings. Months later somebody asked whether it was working, and I could not answer. The evidence was gone.&lt;/p&gt;

&lt;p&gt;That question changed how I build anything that rejects data.&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%2Fqazmdaxrkgqairkuc5tv.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%2Fqazmdaxrkgqairkuc5tv.png" alt="The Concussed Witness, drawn as a specimen plate. Confident. Cooperative. Often wrong. Labelled THE CONCUSSED WITNESS, LocationManager." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious version, and why it rots
&lt;/h2&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%2Fpd1irso4llzfzsw180mi.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%2Fpd1irso4llzfzsw180mi.png" alt="The shape that works: Classify. Do not discard." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Mileage tracking depends on trustworthy distance, and GPS lies constantly. So the first version of our cleanup did what everyone's first version does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isPlausible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;   &lt;span class="c1"&gt;// drop it, move on&lt;/span&gt;
&lt;span class="nf"&gt;accumulateDistance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean data comes out the other end. It feels responsible. It is also a trap, because that &lt;code&gt;return&lt;/code&gt; destroys the only record that could ever tell you whether the rejection was correct.&lt;/p&gt;

&lt;p&gt;Six months in, someone asked the reasonable question: is the filter right? I could not say how many readings we had dropped, on which journeys, or whether any of them had been a genuine drive through a tunnel rather than a glitch. We had built a thing that made a judgement call thousands of times a day and kept no record of any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persist, then classify
&lt;/h2&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%2Fn3mt7iqx6udyp0eb5v68.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%2Fn3mt7iqx6udyp0eb5v68.png" alt="The pattern: Keep the rejects" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rebuild flipped the default. Rejection stopped being a &lt;code&gt;return&lt;/code&gt; and became a label.&lt;/p&gt;

&lt;p&gt;Only two cases are still deleted, because they cannot physically be real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// impossible coordinates&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;90.0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mf"&gt;90.0&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lng&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;180.0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mf"&gt;180.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="c1"&gt;// impossible accuracy: too precise to be true, or useless&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accuracyM&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1f&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accuracyM&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;250f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire delete list. Everything else is persisted and sorted into named accumulators:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;originalDistanceM&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;displacement&lt;/span&gt;          &lt;span class="c1"&gt;// every metre we ever saw&lt;/span&gt;
&lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isMock&lt;/span&gt;  &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mockDistanceM&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;displacement&lt;/span&gt;
    &lt;span class="n"&gt;abnormal&lt;/span&gt;    &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;abnormalDistanceM&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;displacement&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isHardSpike&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;spikeDistanceM&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;displacement&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;accuracyGated&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* recorded, deliberately not counted */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cleanedDistanceM&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;displacement&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five numbers instead of one. The UI shows &lt;code&gt;cleaned&lt;/code&gt;. The rest live beside it.&lt;/p&gt;

&lt;p&gt;And the row itself keeps its provenance: accuracy, provider, bearing, altitude, the gyroscope and accelerometer snapshot, battery level, device model, plus &lt;code&gt;isMock&lt;/code&gt; and &lt;code&gt;isAbnormal&lt;/code&gt;. A filtered reading is still sitting there, labelled with exactly why it did not count.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that bought
&lt;/h2&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%2Ffhd81zfx9y076i5gsuv0.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%2Ffhd81zfx9y076i5gsuv0.png" alt="The payload: You cannot tune a filter you cannot audit" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disputes became answerable.&lt;/strong&gt; This is mileage that turns into an expense claim. When a driver says the distance is wrong, we pull the journey and show what was removed and why. Before, the honest answer was "the algorithm decided", which is not an answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thresholds became tunable.&lt;/strong&gt; We could replay real journeys and ask how much distance we were classifying as abnormal. Twice the data said our threshold was too aggressive on genuine motorway driving, and we moved it. That correction is impossible if the rejected steps are gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ratios became alarms.&lt;/strong&gt; Because we keep both numbers, we can assert on their relationship:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cleaned = total - (mock + abnormal)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and warn when abnormal exceeds half a journey, or when GPS distance and the vehicle odometer diverge by more than 30 percent. If most of a real trip is landing in the abnormal bucket, the threshold is wrong, not the driver.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general rule
&lt;/h2&gt;

&lt;p&gt;This is not a GPS lesson. Any system that rejects data has the same shape: fraud scoring, metrics pipelines, log sampling, feature stores, anything with a validation step. The moment you drop a record, you have made an irreversible claim about it and destroyed the ability to check.&lt;/p&gt;

&lt;p&gt;Keeping it is usually cheap. A boolean column and a second accumulator is nothing next to permanently losing the answer to "is this working?"&lt;/p&gt;

&lt;p&gt;So: filtered should never mean deleted. Filtered means "not counted, and here is the reason, written down, next to the thing itself."&lt;/p&gt;

&lt;p&gt;If your pipeline drops rows, find out where they go. "Nowhere" is an answer, just not a good one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 7 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Sensors Who Lie&lt;/em&gt;* · Featuring: The Concussed Witness*&lt;br&gt;
&lt;em&gt;← Previously in this series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/lessons/2026-07-19-mileway-dead-reckoning/article.md" rel="noopener noreferrer"&gt;Teaching a phone to disbelieve its own GPS&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/sensors-who-lie.md" rel="noopener noreferrer"&gt;Sensors Who Lie&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>datamodelling</category>
      <category>filtering</category>
      <category>observability</category>
    </item>
    <item>
      <title>collectAsState is quietly leaking your work</title>
      <dc:creator>Siddharth Pandalai</dc:creator>
      <pubDate>Fri, 04 Sep 2026 04:43:46 +0000</pubDate>
      <link>https://dev.to/darkpandawarrior/collectasstate-is-quietly-leaking-your-work-4ak0</link>
      <guid>https://dev.to/darkpandawarrior/collectasstate-is-quietly-leaking-your-work-4ak0</guid>
      <description>&lt;p&gt;Here is a line you have written a hundred times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;state&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collectAsState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks harmless. It is the standard way to turn a &lt;code&gt;Flow&lt;/code&gt; into Compose state. And on a screen with live data, it quietly keeps working long after the user has walked away.&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%2Feyxtsb0wytc3inkvbozt.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%2Feyxtsb0wytc3inkvbozt.png" alt="The Recomposer, drawn as a specimen plate. It redraws the room every time you blink. Labelled THE RECOMPOSER, @Composable." width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The screen nobody is watching
&lt;/h2&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%2F6yt3wlmslxvs53q1ufy3.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%2F6yt3wlmslxvs53q1ufy3.png" alt="The one word: collectAsState WithLifecycle" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;collectAsState&lt;/code&gt; starts collecting the flow and stops only when the composable leaves the composition. That sounds right until you remember what backgrounding an app does, which is almost nothing to the composition. Send the app to the background and the composable is still composed. So the collection keeps running. The flow keeps emitting. Your ViewModel keeps fetching, computing, and pushing new state to a screen that is not on screen.&lt;/p&gt;

&lt;p&gt;On a live price ticker or a location feed, that is real battery and mobile data spent in the dark. It is also a quiet source of bugs, because off-screen updates can pile up and then hit the user with a jump of stale changes the moment they return.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-word fix
&lt;/h2&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%2Fx7y0joj1q6f6dvvms6gk.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%2Fx7y0joj1q6f6dvvms6gk.png" alt="The rule: Make lifecycle the default" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;state&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collectAsStateWithLifecycle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;collectAsStateWithLifecycle&lt;/code&gt; ties the collection to the lifecycle owner. When the lifecycle drops below &lt;code&gt;STARTED&lt;/code&gt;, it stops collecting. When it comes back, it resumes. If your upstream is a &lt;code&gt;stateIn&lt;/code&gt; or &lt;code&gt;shareIn&lt;/code&gt; flow with &lt;code&gt;WhileSubscribed&lt;/code&gt;, the whole pipeline can now go cold while hidden and warm back up on return. Nobody pays for a screen that is not visible.&lt;/p&gt;

&lt;p&gt;It lives in &lt;code&gt;androidx.lifecycle.runtime.compose&lt;/code&gt;. Add the dependency, change the one call, and the leak is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make it the default
&lt;/h2&gt;

&lt;p&gt;Treat &lt;code&gt;collectAsStateWithLifecycle&lt;/code&gt; as the default and plain &lt;code&gt;collectAsState&lt;/code&gt; as the exception. The rare time you actually want collection to continue while the screen is hidden should be a deliberate, commented decision, not something you got by reaching for the shorter name out of habit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&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%2F6wa0pgr8nzp0sq7hie0m.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%2F6wa0pgr8nzp0sq7hie0m.png" alt="The payload: Lifecycle is not a thing you bolt on later" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Lifecycle awareness is not a nice-to-have you bolt on later. It is the line between work that stops when the user leaves and work that runs forever in the background. The Recomposer does not mind an empty room. It will happily keep redrawing it. Your job is to turn the lights off when everyone goes home.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;🌀 Iteration 6 of **The Loopdown&lt;/em&gt;&lt;em&gt;. field notes from an engineer who writes.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Series: **Ghosts In The Recomposition&lt;/em&gt;* · Featuring: The Recomposer*&lt;br&gt;
&lt;em&gt;← Previously in this series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/lessons/2026-07-24-lazycolumn-recomposition/article.md" rel="noopener noreferrer"&gt;Your LazyColumn recomposes on every scroll&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;📚 The full series: &lt;a href="https://github.com/darkpandawarrior/the-loopdown/blob/main/series/ghosts-in-the-recomposition.md" rel="noopener noreferrer"&gt;Ghosts In The Recomposition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the loop → &lt;a href="https://www.linkedin.com/in/siddharth-pandalai" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://dev.to/darkpandawarrior"&gt;dev.to&lt;/a&gt; · &lt;a href="https://github.com/darkpandawarrior/the-loopdown" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>jetpackcompose</category>
      <category>flow</category>
      <category>lifecycle</category>
    </item>
  </channel>
</rss>
