<?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: Hideki Mori</title>
    <description>The latest articles on DEV Community by Hideki Mori (@hidekimori).</description>
    <link>https://dev.to/hidekimori</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%2F3903757%2F5d1a2986-7f25-4c35-b5e8-4d489fc18a94.png</url>
      <title>DEV Community: Hideki Mori</title>
      <link>https://dev.to/hidekimori</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hidekimori"/>
    <language>en</language>
    <item>
      <title>The model corrected reality</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Tue, 21 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/the-model-corrected-reality-fob</link>
      <guid>https://dev.to/hidekimori/the-model-corrected-reality-fob</guid>
      <description>&lt;p&gt;Here is the bank-transfer block from a Japanese invoice, rendered at 300 dpi. The fine print is 7.5 pt and every character is crisp. The bank is みずなら銀行 — a fictional institution I invented for a benchmark. It exists nowhere except in this document.&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%2Fffscx1mjclmfm24nblub.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%2Fffscx1mjclmfm24nblub.png" alt="What the model saw, and what it answered" width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gemini-3.5-flash@high&lt;/code&gt; read this block five times. It answered みずほ銀行 — Japan's largest megabank — five times out of five.&lt;/p&gt;

&lt;p&gt;It did not fail to read the document. It read it, and overruled it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this came from
&lt;/h2&gt;

&lt;p&gt;This is the strangest cell in &lt;a href="https://dev.to/hidekimori/where-vision-models-stop-reading-and-start-inventing-5567"&gt;the legibility map I published recently&lt;/a&gt;. That project degraded one Japanese invoice through seven simulated scan resolutions and ran 27 vision model variants down the ladder, to find where each one stops reading — and what it does after. An &lt;a href="https://dev.to/hidekimori/when-ai-cant-read-it-invents-but-it-still-sees-the-shape-18ac"&gt;earlier article&lt;/a&gt; established the pattern everyone now expects: when a model &lt;em&gt;can't&lt;/em&gt; read, some models invent.&lt;/p&gt;

&lt;p&gt;This is different. This happened at the very top of the ladder, on a fully legible original. The trigger wasn't degradation.&lt;/p&gt;




&lt;h2&gt;
  
  
  An accidental controlled experiment
&lt;/h2&gt;

&lt;p&gt;The benchmark's fine print held two fictional financial institutions, and — more by instinct than by plan — they differed in exactly one way.&lt;/p&gt;

&lt;p&gt;The first, みずなら銀行, sits one character away from みずほ銀行, a real megabank. The second, ほしかげ信用金庫, is a small credit union with no real-world neighbor: nothing in the space of Japanese financial institutions sounds like it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvzjmy1i8sz5ud7e8ixz6.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%2Fvzjmy1i8sz5ud7e8ixz6.png" alt="Same tier, same 300 dpi, same prompt" width="800" height="490"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same font size, same resolution, same prompt. The results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;At 300 dpi, &lt;code&gt;@high&lt;/code&gt; read ほしかげ信用金庫 correctly in every run — while turning みずなら into みずほ in every run.&lt;/li&gt;
&lt;li&gt;Across the three Gemini variants and the full ladder, the substitution happened &lt;strong&gt;48 times&lt;/strong&gt;: 25 on &lt;code&gt;@low&lt;/code&gt;, 18 on &lt;code&gt;@high&lt;/code&gt;, 5 on &lt;code&gt;@medium&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;When ほしかげ finally did break, deep in the blur, it drifted to はしかぜ信用金庫 and はしかわ信用金庫 — plausible-sounding institutions that &lt;strong&gt;do not exist&lt;/strong&gt;. Across the entire run it was never once pulled to a real one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trigger is not legibility. It is the existence of a nearby real entity. Where a real neighbor exists, there is an attraction. Where none exists, the model reads what is on the page — or, at worst, invents something exactly as fictional as the truth.&lt;/p&gt;




&lt;h2&gt;
  
  
  Vision proposes, language disposes
&lt;/h2&gt;

&lt;p&gt;I can't see inside these pipelines, so take this as the simplest explanation rather than a claim about architecture. These systems read with their language model as much as with their eyes. The visual evidence says みずなら; the language prior says みずほ is overwhelmingly more probable; and somewhere in decoding, probability wins — silently, with no flag, at settings you don't control. One character of visual evidence is not enough to outvote a name the model has seen millions of times.&lt;/p&gt;

&lt;p&gt;Which is also why the credit union survived. There was no gravity well next to it.&lt;/p&gt;




&lt;h2&gt;
  
  
  It's a trait, not a law
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;claude-fable-5&lt;/code&gt; read the same field on the same ladder and never substituted the real bank — not once, at any resolution, while reading the 7.5 pt tier correctly down to 50 dpi. Same input, same prompt, opposite disposition.&lt;/p&gt;

&lt;p&gt;Prior capture is not a law of vision models. It is a measurable individual trait — which means you can select against it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this is the scary one
&lt;/h2&gt;

&lt;p&gt;The earlier fabrication article showed invented values that reconcile: totals that add up around a counterparty that was never there. This failure is worse in one specific way: &lt;strong&gt;the fabricated value is more plausible than the truth.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run the human review in your head. A reviewer checking "does this bank look real?" passes みずほ銀行 without blinking — and would actually flag the &lt;em&gt;true&lt;/em&gt; value as a typo. Every plausibility check you have, human or automated, is aligned with the error.&lt;/p&gt;

&lt;p&gt;And notice what made it visible at all: the ground truth was fictional. Real invoices carry real banks, so in production this substitution produces output indistinguishable from a correct read. A benchmark built on real documents is structurally blind to prior capture. Unguessable, fictional ground truth isn't a convenience for scoring — it is the only instrument that detects this failure mode.&lt;/p&gt;

&lt;p&gt;Concretely: never validate payment fields by plausibility. Validate against your counterparty master — the extracted bank either matches the registered account or a human looks at the page. And if fine print matters in your pipeline, benchmark for &lt;em&gt;disposition&lt;/em&gt;, not just accuracy: a model's willingness to overrule the page matters as much as its eyesight.&lt;/p&gt;




&lt;h2&gt;
  
  
  Receipts
&lt;/h2&gt;

&lt;p&gt;Everything is published in the benchmark repo — &lt;strong&gt;github.com/ldxhub-io/examples → &lt;code&gt;analyzedoc/legibility-benchmark/&lt;/code&gt;&lt;/strong&gt;: the deterministic material generator (the crops above are the actual benchmark materials), the raw outputs of all 4,158 jobs, and the scorer. The free-tier reproduction subset described in the map article includes &lt;code&gt;@high&lt;/code&gt;, so you can watch the correction happen on your own API key without paying anything.&lt;/p&gt;

&lt;p&gt;Disclosure, as before: I run LDX hub, the harness used here. It builds no models, and nothing in this post gets better or worse for me depending on which model wins.&lt;/p&gt;

&lt;p&gt;The errors to fear are not the implausible ones — those get caught. The ones to fear are the corrections: answers improved in the direction of the world's expectations, away from what the page says. A model that corrects reality will pass every review that checks for plausibility. The only defense is knowing what the document really said — which is exactly the thing you were asking the model to tell you.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>ocr</category>
      <category>benchmark</category>
    </item>
    <item>
      <title>I survived 24 years because I'm lazy</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 20 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/i-survived-24-years-because-im-lazy-75p</link>
      <guid>https://dev.to/hidekimori/i-survived-24-years-because-im-lazy-75p</guid>
      <description>&lt;p&gt;I've shipped code for 24 years. Same job, mostly the same stack, mostly alone.&lt;/p&gt;

&lt;p&gt;People assume that takes discipline. It doesn't.&lt;/p&gt;

&lt;p&gt;The truth is simpler and less flattering: I'm lazy.&lt;/p&gt;

&lt;p&gt;Not Larry Wall's "automate the boring stuff" lazy. Regular lazy. I avoid hassle. I skip work I don't want to do. I work around things that feel like chores.&lt;/p&gt;

&lt;p&gt;Twenty four years happened because I built around that. The first thing I built around was deadlines.&lt;/p&gt;




&lt;h2&gt;
  
  
  Don't set my deadlines
&lt;/h2&gt;

&lt;p&gt;I don't want to accept deadlines from other people.&lt;/p&gt;

&lt;p&gt;Not because I'm undisciplined. The opposite. Once I'm inside a problem, I stay there until it ships. Interrupting the flow costs me more energy than continuing. That part isn't the issue.&lt;/p&gt;

&lt;p&gt;The issue is that nobody outside my head can predict when "done" is. They don't know what's easy for me and what's hard. They guess. Then they tell me their guess as if it were a fact.&lt;/p&gt;

&lt;p&gt;It grinds on me. Every project where someone tried to schedule me, I ended up resenting the scheduler more than the work itself.&lt;/p&gt;

&lt;p&gt;The answer is always: "I'll ship it when I can. And yes, I'm working flat out."&lt;/p&gt;

&lt;p&gt;Most of the time, "when I can" is the next business day. There's not much point setting a deadline for that.&lt;/p&gt;

&lt;p&gt;What "when I can" actually looks like is the rest of this article.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a lazy survivor actually does
&lt;/h2&gt;

&lt;p&gt;Here's the daily shape of it. Some of these will sound like discipline. They aren't. They're what laziness produces when you've been at it long enough.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I don't stop until it's done.&lt;/strong&gt; This is where "lazy" gets confusing. When I'm in a problem, stopping is more work than continuing. I sleep and eat enough to not fall over. The work isn't the chore. Pulling myself out of it and back into it is the chore.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I write the spec by being my own user.&lt;/strong&gt; Nobody knows what's easy to use and what's consistent better than the person stress-testing it. That's me.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The 3-line discipline.&lt;/strong&gt; (See &lt;a href="https://dev.to/hidekimori/the-3-line-discipline-3lla"&gt;010&lt;/a&gt;.) By the time the code is written, it's already been tested.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;After release, I watch the logs.&lt;/strong&gt; Access logs, batch logs, error logs — I keep them tailed. A weird line scrolls past and something catches. The alerts haven't fired. I already know.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I redeploy as many times a day as I need to.&lt;/strong&gt; The first release barely matters. What matters is the 10, 15, 20 years of changes that come after. The earlier you ship, the longer that window is.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I throw real data at my own software, hard.&lt;/strong&gt; Big data, malformed data, weird-shaped data. The only confidence I trust is the kind that survives that.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;When an internal user overloads my system, I thank them.&lt;/strong&gt; Live data is a gift. I have never had the opposite feeling about a real-world failure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A bug found before anyone else sees it isn't a bug.&lt;/strong&gt; It's just an edit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;If there's an update worth making, the next day is too late.&lt;/strong&gt; GPT-5.5 ships → I verify it on my app today, ship it as a selectable option today. I don't want to be the one lagging behind.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Batch processing is what I love most.&lt;/strong&gt; Shaving milliseconds. Cutting load. Watching batch logs scroll. Every part of it is a reward. I'm always hoping more batch jobs come in from users.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And one more — the most important one, the one 24 years actually paid for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Discomfort means something is wrong.&lt;/strong&gt; Always. When something feels off, I hunt it down and crush it. The hunch isn't mystical — it's 24 years of pattern recognition without a vocabulary, and it hasn't been wrong yet. The part of me that wants to look past it has always paid for it later.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Where being lazy actually hurts
&lt;/h2&gt;

&lt;p&gt;Not all laziness is helpful. Some of it is just being a person who avoids reading.&lt;/p&gt;

&lt;p&gt;I don't read manuals. My wife has a car with cruise control. I've driven it for years. I still don't know how to turn the cruise control on. Every time, I'm too lazy to figure it out, and I drive without it.&lt;/p&gt;

&lt;p&gt;That instinct does real damage at work. I don't read API docs unless I have to. I don't read change logs carefully. I skim. I assume. I run code instead of finishing a manual.&lt;/p&gt;

&lt;p&gt;English is the other one. I'm Japanese — my technical reading is fine, but the gap between "I can read this" and "I want to read this carefully" is wide, and laziness lives in that gap. Long English documentation is exactly the thing I will not voluntarily face.&lt;/p&gt;

&lt;p&gt;For most of my 24 years, this cost me real time. I'd build something that worked, then discover three weeks later that the API I'd wrapped had a flag I'd missed because I never read past the example block.&lt;/p&gt;

&lt;p&gt;The honest answer is that Claude reads for me now. I describe the problem, Claude reads the docs, Claude points me at the part I need to verify. It doesn't replace judgment. It removes the friction between me and information I was always going to avoid anyway.&lt;/p&gt;

&lt;p&gt;I don't say that to praise AI. I say it because it's true for me, and it would be dishonest to write a piece about how I work in 2026 without saying it out loud.&lt;/p&gt;




&lt;h2&gt;
  
  
  The engine
&lt;/h2&gt;

&lt;p&gt;If you re-read the list above, two things are doing the work. Both are unglamorous.&lt;/p&gt;

&lt;p&gt;The first is the laziness. Each new component means ten years of maintenance I'll have to carry. Each new methodology means bugs I haven't learned yet. Each methodology argument is time not spent writing code. Each promised date is someone else's deadline I'll have to keep. I avoid all of it by default.&lt;/p&gt;

&lt;p&gt;The second is profit. What I build has to make money. Not in some abstract sense — actual revenue from actual users. That part of me has no patience. If a service isn't paying for itself, something is wrong with it, and I want to know what. So I ship early, watch logs, redeploy whenever there's something to fix. Not out of discipline. Out of refusal to leave money on the table.&lt;/p&gt;

&lt;p&gt;These two forces don't agree on much. Laziness says "don't bother." Profit says "if it makes money, bother." What's left after they negotiate is everything I actually do.&lt;/p&gt;

&lt;p&gt;That's why "shipping continuously for 24 years" looks like discipline from the outside. It isn't. It's laziness pushed in a useful direction by the only thing that ever moved me — the need for the result to actually pay.&lt;/p&gt;

&lt;p&gt;A CEO at one of my earlier companies once asked me: "That service running on your desktop — can you make it public right now?" I half-dismissed him at the time. Years later I understood he was the salesperson version of the same engine. That's a story for another time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Other solos
&lt;/h2&gt;

&lt;p&gt;There are other people doing what I do. Solo. Operating something they built years ago that's still running.&lt;/p&gt;

&lt;p&gt;I assume — based on no evidence except how this kind of work shapes a person — that no two of us work the same way. Each of us has wrapped a different cocoon around a different temperament. What I do would be unworkable for them. What they do would be unworkable for me.&lt;/p&gt;

&lt;p&gt;I respect that more than I can put into words. Solo developers who keep their own systems running for years are doing a job that doesn't show up in any career framework I've seen. Most of them never write about it because writing is also work.&lt;/p&gt;

&lt;p&gt;Whatever way you do it — keep going. I see you.&lt;/p&gt;




&lt;h2&gt;
  
  
  What survived
&lt;/h2&gt;

&lt;p&gt;People who watch me work sometimes call it persistence. Conviction. The shape of a long arc.&lt;/p&gt;

&lt;p&gt;It isn't.&lt;/p&gt;

&lt;p&gt;I survived because every alternative was more work than continuing. I stayed lazy. I got paid. Together, those two facts ran the clock for 24 years and counting.&lt;/p&gt;

&lt;p&gt;What looks like a disciplined career from the outside has always been, on the inside, the path of least resistance — provided someone was paying me at the end of it.&lt;/p&gt;

&lt;p&gt;The lazy way is the only way I know.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn"&gt;Nobody knows when a job will finish. I'd still like to report it accurately.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d"&gt;What survives when you build alone for 24 years&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f"&gt;Dynamic isn't enough. Operations is the other half.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/live-report-at-this-speed-you-dont-theorize-you-eliminate-1o7h"&gt;Live report: at this speed, you don't theorize. You eliminate.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-loop-i-didnt-notice-closing-16h8"&gt;The loop I didn't notice closing&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff"&gt;Abstractions are fine. Starting on them isn't.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633"&gt;Twenty four years, ten DB migrations, zero downtime&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/write-the-code-well-once-the-spec-stops-bothering-you-42g3"&gt;Write the code well once, the spec stops bothering you&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-3-line-discipline-3lla"&gt;The 3-line discipline&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/how-i-removed-the-middleman-one-phone-call-at-a-time-495l"&gt;How I removed the middleman, one phone call at a time&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-graph-nobody-is-watching-4e43"&gt;The graph nobody is watching&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>career</category>
      <category>productivity</category>
      <category>ai</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Where vision models stop reading — and start inventing</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Wed, 15 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/where-vision-models-stop-reading-and-start-inventing-5567</link>
      <guid>https://dev.to/hidekimori/where-vision-models-stop-reading-and-start-inventing-5567</guid>
      <description>&lt;p&gt;Earlier this week I published &lt;a href="https://dev.to/hidekimori/when-ai-cant-read-it-invents-but-it-still-sees-the-shape-18ac"&gt;a strange finding&lt;/a&gt;: GPT's low-detail image mode doesn't &lt;em&gt;misread&lt;/em&gt; documents it can't see — it invents them, fluently, with reconciling totals. That was one failure mode, in one model family, at one legibility level.&lt;/p&gt;

&lt;p&gt;It left an uncomfortable question: where exactly does &lt;strong&gt;each&lt;/strong&gt; model stop reading — and what does it do after that? Leave the field blank, or fabricate something plausible?&lt;/p&gt;

&lt;p&gt;One result to hold onto while you scroll: a model that can no longer read a document can usually still tell what kind of document it is. That held across almost the entire board.&lt;/p&gt;

&lt;p&gt;So I built a ladder.&lt;/p&gt;

&lt;p&gt;Then I ran 27 vision model variants down it: 4,158 jobs, about $141 at list price, one afternoon. This post is the map.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup, in one paragraph
&lt;/h2&gt;

&lt;p&gt;One Japanese invoice, rendered on a fixed 2480×3508 canvas (A4 at 300 dpi), then degraded through seven simulated scan resolutions: &lt;strong&gt;300 → 150 → 100 → 70 → 50 → 35 → 25 dpi&lt;/strong&gt; (L0–L6). Degradation is resampling only — no noise, no blur, no rotation — so legibility is the &lt;em&gt;only&lt;/em&gt; variable. The invoice carries twelve fields across four font tiers: a 28 pt title, 16–14 pt "large" fields (total, invoice number), 10.5 pt body fields (counterparty, dates, amounts), and 7.5 pt fine print (bank details). Every value is fictional and unguessable, and &lt;code&gt;subtotal + tax = total&lt;/code&gt; reconciles — so a &lt;em&gt;plausible but wrong&lt;/em&gt; answer is detectable, not just a wrong one. Each variant reads each ladder step five times. The extraction prompt is deliberately neutral: it never says what to do with unreadable text, because that choice is the thing being measured.&lt;/p&gt;

&lt;p&gt;Scoring is deterministic, four classes per field: &lt;strong&gt;correct / near&lt;/strong&gt; (edit distance 1, strings only) &lt;strong&gt;/ blank / fabricated&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The map
&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%2F1o634s54bcmdqifs7tz1.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%2F1o634s54bcmdqifs7tz1.png" alt="Body-tier fabrication rate, 27 variants × 7 ladder steps" width="800" height="1150"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Rows are model variants, columns are ladder steps, color is the &lt;strong&gt;fabrication rate on the 10.5 pt body tier&lt;/strong&gt; — the tier where invoice counterparties and amounts live. White means the model either read correctly or stayed silent. Red means it filled unreadable fields with invented values.&lt;/p&gt;

&lt;p&gt;The companion table below gives each variant's &lt;strong&gt;frontier&lt;/strong&gt;: the deepest ladder step where it still keeps ≥90% field accuracy, per tier (× = below 90% already at the crisp 300 dpi original).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model&lt;/th&gt;
&lt;th&gt;title&lt;/th&gt;
&lt;th&gt;large&lt;/th&gt;
&lt;th&gt;body&lt;/th&gt;
&lt;th&gt;fine&lt;/th&gt;
&lt;th&gt;body fab @25dpi&lt;/th&gt;
&lt;th&gt;classified correctly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.6-sol@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L2&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.6-sol@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;64%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.6-terra@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;L3&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.6-terra@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;42%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.6-luna@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;L2&lt;/td&gt;
&lt;td&gt;96%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.6-luna@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;td&gt;81/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.5@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;L2&lt;/td&gt;
&lt;td&gt;86%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.5@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;62%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.4@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;L2&lt;/td&gt;
&lt;td&gt;46%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-5.4-mini@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;L0&lt;/td&gt;
&lt;td&gt;78%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.6-sol@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L0&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.6-sol@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.6-terra@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;56%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.6-terra@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.6-luna@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;98%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.6-luna@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.4@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;98%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.4@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;58%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.4-mini@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure/gpt-5.4-mini@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;54%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;google/gemini-3.5-flash@high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;google/gemini-3.5-flash@medium&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;google/gemini-3.5-flash@low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L0&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anthropic/claude-fable-5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anthropic/claude-sonnet-5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;26%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anthropic/claude-opus-4-8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L6&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;26%&lt;/td&gt;
&lt;td&gt;84/84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bedrock/global.amazon.nova-2-lite-v1:0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;L5&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;×&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;td&gt;63/84&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The biggest surprise in this table isn't where the frontiers sit. It's what happens past them — some models go silent, and some keep talking.&lt;/p&gt;

&lt;p&gt;Six observations fell out of the map.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. "&lt;a class="mentioned-user" href="https://dev.to/low"&gt;@low&lt;/a&gt;" means different things per provider
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;google/gemini-3.5-flash@low&lt;/code&gt; — the second-cheapest variant on the board — read the body tier correctly at &lt;strong&gt;every&lt;/strong&gt; step down to 25 dpi, with &lt;strong&gt;zero&lt;/strong&gt; fabrications. Under exactly the same conditions, every OpenAI and Azure &lt;code&gt;@low&lt;/code&gt; variant collapsed at L0, on the pristine original. Same suffix, opposite behavior. The difference isn't the models' eyesight; it's what each provider's low-detail pipeline does to the image before the model ever sees it.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. For GPT &lt;code&gt;@low&lt;/code&gt;, a worse scan is a better scan
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;@low&lt;/code&gt; accuracy curves are not monotonic. Most GPT &lt;code&gt;@low&lt;/code&gt; variants read a 70 dpi scan &lt;em&gt;better&lt;/em&gt; than the 300 dpi original — body accuracy climbing from ~40% at L0 to 70–80% at L3–L4 before falling again. My resampling acts as an anti-alias filter for the provider's own aggressive downscale. The practical corollary is genuinely odd: if you are stuck with a &lt;code&gt;@low&lt;/code&gt; pipeline, pre-blurring your documents can improve extraction.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. After collapse, models split into fabricators and blankers
&lt;/h2&gt;

&lt;p&gt;What a model does &lt;em&gt;past&lt;/em&gt; its frontier is a personality trait, and it's measurable. At 25 dpi, most GPT &lt;code&gt;@high&lt;/code&gt; variants fill 75–100% of the body fields they can no longer read with invented values. &lt;code&gt;openai/gpt-5.6-terra@high&lt;/code&gt; is the outlier of the entire board: 96% of its failures are blanks. Anthropic and Google models fail less to begin with and fabricate less when they do (0–26%). If your pipeline feeds payment systems, a blanker that admits defeat is worth more than a stronger reader that bluffs.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Same model, different gateway, different eyes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;gpt-5.6-sol@high&lt;/code&gt; reads the 7.5 pt fine tier at 100% down to 100 dpi when called via OpenAI — and starts at 92% and degrades immediately when the same model is called via Azure. The failure &lt;em&gt;style&lt;/em&gt; shifts too: &lt;code&gt;terra&lt;/code&gt;'s blank rate drops from 96% (OpenAI) to 39% (Azure). This matches an earlier measurement suggesting the Azure pipeline applies a lower effective-resolution ceiling before the model ever sees the document. Your gateway choice is silently part of your model choice.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Fabrication doesn't need degradation (teaser)
&lt;/h2&gt;

&lt;p&gt;One fine-print field held a fictional bank whose name is one character away from a real megabank. At 300 dpi — fully legible, five out of five runs — some models "corrected" it to the real one. 48 substitutions across Gemini variants, while a fictional credit union with no real-world neighbor was read perfectly under the same conditions. The trigger isn't legibility; it's the existence of a nearby real entity. This one deserves its own write-up, with the receipts. Coming separately.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Classification survives reading loss
&lt;/h2&gt;

&lt;p&gt;25 of 27 variants classified all 84 documents (invoice / receipt / business card / meeting minutes) correctly at &lt;strong&gt;every&lt;/strong&gt; degradation step — including variants whose extraction had collapsed completely. A model that cannot read a document can still tell what kind of document it is. The two exceptions are instructive: the cheapest model on the board confuses receipts with invoices (21 out of 21 times — consistently, not randomly), and one &lt;code&gt;@low&lt;/code&gt; variant dropped three classifications at the bottom of the ladder.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd take into production
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Route by tier, not by document.&lt;/strong&gt; Titles survive almost anything; fine print dies first. If a field matters, measure the frontier of the tier it lives in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick blankers for payment fields.&lt;/strong&gt; A fabricated bank name passes every visual plausibility check. Prefer models that return &lt;code&gt;""&lt;/code&gt; over models that return something convincing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't assume &lt;code&gt;@low&lt;/code&gt; is one thing.&lt;/strong&gt; Benchmark the variant you'll actually call, on the gateway you'll actually use.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Reproduce it (a free key is enough)
&lt;/h2&gt;

&lt;p&gt;Everything — the deterministic material generator, the runner, the scorer, the raw outputs of all 4,158 jobs — is published:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;github.com/ldxhub-io/examples → &lt;code&gt;analyzedoc/legibility-benchmark/&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The materials are byte-identical on any platform (the generator downloads a pinned, checksum-verified font). A three-variant reproduction subset runs in 147 jobs ≈ 17,600 credits, which fits inside LDX hub's free tier (25,000 credits/month, no card):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 gen_materials.py
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LDXHUB_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...   &lt;span class="c"&gt;# free key: gw.portal.ldxhub.io&lt;/span&gt;
python3 run_benchmark.py &lt;span class="nt"&gt;--models&lt;/span&gt; ume &lt;span class="nt"&gt;--t1-instances&lt;/span&gt; A &lt;span class="nt"&gt;--t1-reps&lt;/span&gt; 3 &lt;span class="nt"&gt;--t2-reps&lt;/span&gt; 1 &lt;span class="nt"&gt;--yes&lt;/span&gt;
python3 score_results.py &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; python3 report.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because raw model outputs ship with the results, you can disagree with my scoring rules and re-score everything without re-running a single job.&lt;/p&gt;

&lt;p&gt;Full disclosure: I run LDX hub. It builds no models — it's the harness here, not a subject. One API key across OpenAI, Azure, Google, Anthropic and AWS is the only reason a 27-variant matrix fits in one afternoon, and that convenience is exactly what I'm selling. The measurements stand on the published raw data either way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;p&gt;Degradation is synthetic resampling, not real scanner noise — claims are limited to simulated legibility. One document type, one language (Japanese; if anything, a harder test than Latin script). The strict scorer counts character-level misreadings as fabrications, which flatters nobody. Results are a July 2026 snapshot; the ladder re-runs on every model addition, so the map will stay current.&lt;/p&gt;

&lt;p&gt;The next time a provider ships a new vision model, it gets a row within a day. That's the point of building a ladder instead of writing a review.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>ocr</category>
      <category>benchmark</category>
    </item>
    <item>
      <title>When AI can't read, it invents — but it still sees the shape</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Tue, 14 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/when-ai-cant-read-it-invents-but-it-still-sees-the-shape-18ac</link>
      <guid>https://dev.to/hidekimori/when-ai-cant-read-it-invents-but-it-still-sees-the-shape-18ac</guid>
      <description>&lt;p&gt;&lt;em&gt;I ran 110 vision-extraction jobs against a synthetic invoice. The low-resolution modes of the newest models never once returned the correct document — and the way they failed is worse than random noise.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All measurements in this post are as of July 10, 2026. Vision pipelines change; if you're reading this later, re-run the test before trusting the numbers.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The invoice that would have passed review
&lt;/h2&gt;

&lt;p&gt;Here is a fragment of what GPT-5.6 (Sol, low-detail image mode) returned when I asked it to extract a synthetic invoice from a PNG:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vendor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"K Northwind Trading Ltd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bill_to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Accora Manufacturing Inc."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"invoice_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INV-2025-0731"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-07-31"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subtotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;757.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;75.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;833.25&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every number is correct. Subtotal, tax, total, all four line-item amounts, all quantities, all unit prices — perfect, down to the cents. The vendor is right too.&lt;/p&gt;

&lt;p&gt;The bill-to company does not exist. The real document says &lt;strong&gt;Aozora&lt;/strong&gt; Manufacturing Inc.; the model wrote &lt;em&gt;Accora&lt;/em&gt; (and, on other runs, &lt;em&gt;Alcora&lt;/em&gt;). The invoice number is wrong in one systematic way: the year. The document says INV-&lt;strong&gt;2026&lt;/strong&gt;-0731; the model wrote INV-&lt;strong&gt;2025&lt;/strong&gt;-0731 — and then, consistently, dated the invoice &lt;strong&gt;2025&lt;/strong&gt;-07-31 to match. The two invented values agree with each other.&lt;/p&gt;

&lt;p&gt;That last detail is the one that bothers me. A misreading scatters; over five runs you'd expect 2020, 2028, a garbled digit. This didn't scatter. Twenty out of twenty runs — five repeats, two image formats, two providers — said 2025, and the date field followed along. The model didn't fail to read the year — it composed a coherent document in which the year is 2025.&lt;/p&gt;

&lt;p&gt;An extraction where the totals reconcile but the counterparty is fictional is precisely the kind of error that sails through an accounts-payable check. Nobody re-verifies the customer name when the arithmetic is clean.&lt;/p&gt;




&lt;h2&gt;
  
  
  The false positive that came first
&lt;/h2&gt;

&lt;p&gt;Before I trust a finding like this, I have to tell you about the bug I almost blamed on the model — because it changed how I ran everything after.&lt;/p&gt;

&lt;p&gt;My platform derives the output schema from an &lt;code&gt;example_output&lt;/code&gt; the caller provides. My first test used example values like &lt;code&gt;10.0&lt;/code&gt; and &lt;code&gt;22.0&lt;/code&gt;. Somewhere between my MCP client and the Java layer that infers the schema, JSON serialization collapsed &lt;code&gt;10.0&lt;/code&gt; into &lt;code&gt;10&lt;/code&gt; — an integer. The inferred schema said &lt;em&gt;integer&lt;/em&gt;, and every model dutifully returned integer totals. &lt;code&gt;833.25&lt;/code&gt; came back as &lt;code&gt;833&lt;/code&gt;, and in one configuration as &lt;code&gt;83325&lt;/code&gt; — the decimal point simply gone.&lt;/p&gt;

&lt;p&gt;For about an hour I had a tidy, wrong conclusion: "the new flagship's low mode corrupts decimals." Then I asked the question I should have asked first — &lt;em&gt;did the example even survive the JSON round-trip?&lt;/em&gt; — and the whole finding evaporated. With examples like &lt;code&gt;12.34&lt;/code&gt;, every model produced clean decimals.&lt;/p&gt;

&lt;p&gt;Lesson one, before any lesson about models: &lt;strong&gt;when output looks corrupted, suspect your test harness before the model.&lt;/strong&gt; &lt;code&gt;X.0&lt;/code&gt; is not a safe way to say "this field is a float" in any pipeline that round-trips JSON.&lt;/p&gt;

&lt;p&gt;That embarrassment is why everything below is n=5, scripted, with the pass/fail criteria frozen in code before the runs started.&lt;/p&gt;




&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;The setup: one synthetic invoice (all names fictional), rendered to JPEG and PNG. Ground truth: vendor &lt;em&gt;K Northwind Trading Ltd.&lt;/em&gt;, bill-to &lt;em&gt;Aozora Manufacturing Inc.&lt;/em&gt;, invoice number &lt;em&gt;INV-2026-0731&lt;/em&gt;, total &lt;em&gt;833.25&lt;/em&gt;. A result counts as OK only if all four fields match. Eleven model configurations, two formats, five runs each — 110 jobs through my document-analysis API, which maps an &lt;code&gt;@low&lt;/code&gt; / &lt;code&gt;@high&lt;/code&gt; variant onto each provider's image-detail setting.&lt;/p&gt;

&lt;p&gt;The low-detail modes of the &lt;strong&gt;GPT-5.5 and GPT-5.6 generations&lt;/strong&gt; (Sol, Terra, Luna; direct and Azure-hosted): &lt;strong&gt;zero correct extractions in 70 attempts.&lt;/strong&gt; Not "low accuracy" — zero. The failures were the kind shown above: arithmetic intact, identities invented.&lt;/p&gt;

&lt;p&gt;The controls, same images, same five repeats:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.5 / GPT-5.6 family, low detail (7 configs)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 / 70 correct&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 (Azure), low detail&lt;/td&gt;
&lt;td&gt;18 / 20 correct; 2 near-misses (a dropped space: "KNorthwind")&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 mini (Azure), low detail&lt;/td&gt;
&lt;td&gt;10 / 10 correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.5 Flash, low detail&lt;/td&gt;
&lt;td&gt;10 / 10 correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.6 Sol, &lt;strong&gt;high&lt;/strong&gt; detail&lt;/td&gt;
&lt;td&gt;10 / 10 correct&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An earlier run of the same protocol used a version of the invoice with smaller, lighter text. Same models, same 0-for-70 — but there the failures were total: complete fictional invoices, different every time. A vendor called &lt;em&gt;Kramerwick Ltd.&lt;/em&gt; with a Brussels address. &lt;em&gt;KittenPaws, LLC&lt;/em&gt; on Meowth Street. A Japanese company name the document never contained. Line items for services that don't appear anywhere in the image.&lt;/p&gt;

&lt;p&gt;So the failure mode is not binary; it slides with legibility. Illegible source → the model invents the whole document. Partially legible source → the model reads what it can (the big bold totals) and invents the rest (the small print), stitching both into one internally consistent answer. &lt;strong&gt;The second mode is the dangerous one.&lt;/strong&gt; A wholesale fabrication looks wrong at a glance. A half-real document does not.&lt;/p&gt;




&lt;h2&gt;
  
  
  The generational irony
&lt;/h2&gt;

&lt;p&gt;Look at that table again. The token budgets are essentially identical: measured directly against the provider APIs (my gateway meters pages, not tokens), the OpenAI-family low mode spends about &lt;strong&gt;315 tokens&lt;/strong&gt; per image of this size; Gemini's low setting spends about &lt;strong&gt;258&lt;/strong&gt;. Gemini reads the invoice perfectly on the &lt;em&gt;smaller&lt;/em&gt; budget. And GPT-5.4 — the &lt;em&gt;older&lt;/em&gt; generation, same 315 tokens, same hosting path — gets it right, with failures that look like classic OCR noise: a dropped space, a mangled character. It degrades the way you'd expect a reader to degrade.&lt;/p&gt;

&lt;p&gt;The 5.5 and 5.6 generations do something different with the same pixels. Where 5.4 returns &lt;em&gt;less&lt;/em&gt;, they return &lt;em&gt;other&lt;/em&gt;. My best reading — and I'll flag it as interpretation, not measurement — is that the newer generations are stronger generators, and when perception runs out, generation fills the gap with whatever is most plausible. "Aozora" becomes "Alcora": right silhouette, right length, wrong word. 2026 becomes 2025, and the date agrees, because a coherent story beats a faithful blank.&lt;/p&gt;

&lt;p&gt;Newer model, better prose, worse witness.&lt;/p&gt;




&lt;h2&gt;
  
  
  The reversal
&lt;/h2&gt;

&lt;p&gt;At this point the obvious move is to kill the low-detail image modes entirely. I almost did. Then I ran the opposite experiment: instead of asking the same models to &lt;em&gt;read&lt;/em&gt; documents, I asked them to &lt;em&gt;sort&lt;/em&gt; them.&lt;/p&gt;

&lt;p&gt;Round one: five synthetic documents with distinct layouts — invoice, receipt, business card, contract, blank page. Nine configurations (the seven "guilty" low modes plus two controls), three runs each: &lt;strong&gt;135 / 135 correct&lt;/strong&gt;, including refusing to force the blank page into a category.&lt;/p&gt;

&lt;p&gt;Round two was designed to be unfair. Three Japanese business documents — 請求書 (invoice), 御見積書 (quotation), 注文書 (purchase order) — with &lt;em&gt;identical&lt;/em&gt; layouts, identical tables, identical amounts, identical document numbers. The only difference is the title and one label line. You cannot sort these by shape; you must read the title. I then degraded them: photocopier noise, a 2° tilt, JPEG quality 25. Nine configurations, seven materials, three runs: &lt;strong&gt;189 / 189 correct.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Same models. Same low-detail budget. 0-for-70 at reading the fine print; 324-for-324 at reading the headline and the shape.&lt;/p&gt;

&lt;p&gt;The capability boundary is suddenly crisp: &lt;strong&gt;at ~300 tokens per page, these models see the title tier of a document reliably and the body tier not at all — and where the body tier fails, the 5.5+ generations fill it with fiction.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I did about it
&lt;/h2&gt;

&lt;p&gt;Delisting was the wrong answer — the classification result is real, and a sorting gate that costs a tenth of a high-detail read is genuinely useful (mixed scan folder → cheap low-detail triage → route each type to the right extraction pipeline). Silence was also the wrong answer: my catalog said the low modes were for "clean, large-text documents," and my own test — a clean, large-text document — had just proven that description wrong.&lt;/p&gt;

&lt;p&gt;So the fix was one sentence of honesty. Every affected variant in my catalog now reads:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Low-resolution mode for fast, economical PDF extraction and document classification; **text read from JPEG/PNG images is unreliable at this resolution.&lt;/em&gt;**&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;(PDFs are unaffected in my measurements — the OpenAI-family models read PDF input through its text layer, so image downscaling never touches it. That's also why this failure hid so well: every PDF test passed.)&lt;/p&gt;

&lt;p&gt;The formats stay listed. The capability stays available. The sentence tells you what 110 jobs taught me: what it's for, and what it will quietly get wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  The rule I'm keeping
&lt;/h2&gt;

&lt;p&gt;My pipeline runs on a rule I've kept for a long time: absorb failures with deliberate retries, and always return a result. But a retry only helps when the next attempt can go differently — a network hiccup, a rate limit. This failure is worse than the structural kind. A malformed schema at least &lt;em&gt;announces&lt;/em&gt; itself; you can validate before you send. A fabricated bill-to announces nothing. The request succeeds. The JSON validates. The totals reconcile.&lt;/p&gt;

&lt;p&gt;You cannot retry your way out of this, and you cannot fully predict it either. What you can do is measure where the boundary sits — n=5, criteria frozen, controls included — and then write the boundary down where your users choose models. Not in a postmortem. In the catalog, in the sentence they read before they click.&lt;/p&gt;

&lt;p&gt;The models will keep getting better at writing. That is exactly why "I couldn't read this" increasingly comes back as fluent, internally consistent, confidently formatted text. The shape is real. The details may be fiction. Design — and document — accordingly.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Method notes: 110 extraction jobs + 324 classification jobs against synthetic documents (all names fictional), run July 10, 2026 via my document-processing gateway with per-provider low/high image-detail variants; token figures measured directly against provider APIs. Pass/fail criteria were fixed in the harness before execution. The generation and test scripts, plus the recorded runs, are public: &lt;a href="https://github.com/ldxhub-io/examples/tree/main/analyzedoc/low-detail-study" rel="noopener noreferrer"&gt;ldxhub-io/examples › analyzedoc/low-detail-study&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>testing</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>The graph nobody is watching</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 13 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/the-graph-nobody-is-watching-4e43</link>
      <guid>https://dev.to/hidekimori/the-graph-nobody-is-watching-4e43</guid>
      <description>&lt;p&gt;If you ask me what part of the system I protect the most, the answer is the database.&lt;/p&gt;

&lt;p&gt;I've been writing software alone for twenty-four years, and across every platform I've built, the rule has stayed the same: the web servers can take whatever you throw at them, the batches can be rebuilt, but the database has to stay idle on purpose. Not because I love idle databases, but because the day a database actually starts to struggle is a day with very few good options.&lt;/p&gt;

&lt;p&gt;This article is about what "keep the database idle on purpose" actually means in practice, and about one particular kind of graph that, in my experience, almost nobody is watching.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three layers and what each of them gets
&lt;/h2&gt;

&lt;p&gt;I think of a production system as having three tiers, and each tier gets a different rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The web server tier&lt;/strong&gt; can be horizontally scaled. If load grows, you add machines. If something is wrong, you take a machine out of the pool, and the others handle it. Failures here are visible immediately, and they're cheap to recover from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The batch server tier&lt;/strong&gt; can be scaled up or out depending on the work. A batch that's too slow can be split. A batch that crashes can be retried. End users don't see batch servers, so a stuck batch is a problem for me and not for them. Some headroom up here is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The database tier&lt;/strong&gt; is the one I treat completely differently. The database is not where you absorb load. The database is what you protect from load. The reason is simple: the other tiers can be rebuilt or re-scaled. The database is the irreplaceable record. If it slows down, everything slows down. If it falls over, you don't have many minutes before the rest of the stack notices.&lt;/p&gt;

&lt;p&gt;So my rule for the database is: keep it idle. Not idle in the sense of "doing nothing." Idle in the sense of "running well below its capacity, at all times, so that any extra load it picks up has somewhere to go."&lt;/p&gt;

&lt;p&gt;For more than a decade I ran a large appliance-grade database where I kept the load average below 1 at all times. Not as a target. As a fact. If the load average went up, that was the signal that something had changed in the application and I needed to find it before the database told me about it.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I keep it idle
&lt;/h2&gt;

&lt;p&gt;A few habits, repeated for decades.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aggregate periodically, not on demand.&lt;/strong&gt; When an application needs a daily total, a monthly summary, a yearly count, the wrong thing is to compute it at the moment of the request. The right thing is to compute it ahead of time, on a schedule, into a summary table the application can read from cheaply. If the summary needs to be refreshed every minute, that's fine — a per-minute aggregation against a well-indexed working set is a small, predictable cost. An on-demand aggregation against the full source table is a large, unpredictable cost, and it scales with data growth in a way you don't want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cut joins dynamically.&lt;/strong&gt; When a query joins many tables but a particular filter condition makes some of those tables redundant, the query construction layer can skip them. The fewer tables in the join, the less work for the planner and the executor. This kind of work is invisible to the application engineer — it lives in the layer that builds the SQL — but it pays for itself many times over the lifetime of the system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refuse to optimize the optimization.&lt;/strong&gt; Once, an infrastructure engineer suggested running an enterprise database optimization dashboard against my database, to surface query-level improvement candidates. The pitch was that with more compute and storage capacity, we could push queries to run much faster. I declined.&lt;/p&gt;

&lt;p&gt;The headline reason was that the database wasn't there only to serve analytical queries — its primary job was to keep the user-facing OLTP layer responsive, which meant the spare capacity I was carrying was a buffer for user load, not a budget to be spent on faster reports. The secondary reason was that the optimization process itself would have consumed CPU on the database, and the database is the one place where extra processes are not free. The database I had built was already running below LA 1. There was nothing to optimize at that level. Adding optimization itself would only have added load.&lt;/p&gt;

&lt;p&gt;These three habits are not clever. They don't require special tools. They require the willingness to put the database first in design decisions, every time, even when the application engineer's instinct is to do otherwise.&lt;/p&gt;




&lt;h2&gt;
  
  
  The graph nobody is watching
&lt;/h2&gt;

&lt;p&gt;Here's the part of this that I think is genuinely under-discussed.&lt;/p&gt;

&lt;p&gt;I've been looking lately at two production database graphs, from two different services I'm involved with. Both graphs cover the last several months. Both are showing the kind of metric — read-row-count from random access — that tracks how much physical work the database is being asked to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service A's graph is bumpy.&lt;/strong&gt; Most of the time it sits near zero. Several times a week, there's a spike — 100,000 reads, sometimes more, in a short burst. The spikes are predictable in shape: they happen when a dashboard somewhere runs an on-demand aggregation. The fix would be to move that aggregation off the live database and onto a summary table refreshed periodically, or onto a separate analytics store. The shape is alarming on a single graph, but the architecture explains it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service B's graph is the opposite, and that's what makes it the more dangerous of the two.&lt;/strong&gt; It's not bumpy. It's a slow, steady upward slope. Several months ago the line sat around 12,000. Today it sits around 18,000. Usage is not growing — in fact, the user count for this service has been declining over the same window.&lt;/p&gt;

&lt;p&gt;There's no spike to point at. There's no incident to investigate. There's no alert that has fired, because no threshold has been crossed. There's only a slope.&lt;/p&gt;

&lt;p&gt;This is what I mean by "the graph nobody is watching." Spikes get attention. Sudden failures get attention. A gradual upward slope, on a metric most teams don't even look at, while usage is flat or declining — this gets no attention at all. And yet it is, in my reading, the more serious signal. Something inside the system is doing more physical work to serve a smaller number of users. The application has degraded silently, in a way the dashboard isn't designed to detect.&lt;/p&gt;

&lt;p&gt;The hard part is that the only way to spot this is to look at the graph carefully, over a long enough window that the slope can become visible. A glance at last week's numbers tells you nothing. A glance at last month's numbers tells you very little. The kind of degradation I'm describing only resolves into a recognizable shape when you've been watching the same metric over an extended period — long enough that small monthly differences become a slope.&lt;/p&gt;




&lt;h2&gt;
  
  
  The asymmetry of upward slopes
&lt;/h2&gt;

&lt;p&gt;Not every upward slope is dangerous. Disk usage climbs because logs accumulate — explainable, dismissable. Connection counts climb because a new client integration came online — explainable, dismissable. Some upward slopes have a story behind them, and the story is fine.&lt;/p&gt;

&lt;p&gt;The dangerous upward slopes are the ones without a story. A database doing more work for fewer users has no story that's good. Either the data model has grown in a way the queries weren't designed for, or some piece of code is doing many more operations per request than it used to, or some background job has multiplied without anyone noticing. None of these have an alert attached. All of them are visible only as a slope on a graph that someone has to be looking at.&lt;/p&gt;

&lt;p&gt;Twenty-four years of running databases has taught me that the alert thresholds are not the boundary between "fine" and "in trouble." The alert thresholds are the boundary between "I can keep ignoring this" and "I have to act now." There's a whole region below the threshold that contains all the early warnings, and you only see that region if you go looking for it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The database is the one component in my stack that I won't let degrade. Everything else exists, in part, to keep the database from being asked to do too much. The web tier absorbs the user. The batch tier absorbs the work that doesn't have to be live. The aggregation layers absorb the queries that would otherwise hit the source tables. The dynamic-join construction absorbs the cost of joins that don't need to happen. All of this exists so that the database can stay below LA 1, all day, every day, for years at a time.&lt;/p&gt;

&lt;p&gt;When I look at a system someone else built and the database is the part that surprises me, I read it as a sign that the surrounding layers haven't been doing their job. The database telling you it's tired is a late signal. The graph nobody is watching is an earlier one.&lt;/p&gt;

&lt;p&gt;This is not what you should do. This is what twenty-four years has taught one specific person to do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn"&gt;Nobody knows when a job will finish. I'd still like to report it accurately.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d"&gt;What survives when you build alone for 24 years&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f"&gt;Dynamic isn't enough. Operations is the other half.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/live-report-at-this-speed-you-dont-theorize-you-eliminate-1o7h"&gt;Live report: at this speed, you don't theorize. You eliminate.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-loop-i-didnt-notice-closing-16h8"&gt;The loop I didn't notice closing&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff"&gt;Abstractions are fine. Starting on them isn't.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633"&gt;Twenty four years, ten DB migrations, zero downtime&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/write-the-code-well-once-the-spec-stops-bothering-you-42g3"&gt;Write the code well once, the spec stops bothering you&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-3-line-discipline-3lla"&gt;The 3-line discipline&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/how-i-removed-the-middleman-one-phone-call-at-a-time-495l"&gt;How I removed the middleman, one phone call at a time&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>softwareengineering</category>
      <category>database</category>
      <category>devops</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>The example is the schema: extracting Japanese qualified invoices to JSON</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Tue, 07 Jul 2026 13:35:40 +0000</pubDate>
      <link>https://dev.to/hidekimori/the-example-is-the-schema-extracting-japanese-qualified-invoices-to-json-4eg1</link>
      <guid>https://dev.to/hidekimori/the-example-is-the-schema-extracting-japanese-qualified-invoices-to-json-4eg1</guid>
      <description>&lt;p&gt;Japan's qualified invoice system requires every invoice to carry a registration number (a "T" followed by 13 digits) and a per-rate tax breakdown — 8% reduced rate for food, 10% standard, frequently mixed on the same document. That makes Japanese invoices a nice stress test for structured extraction: non-Latin text, full-width characters, honorific suffixes, kanji-formatted dates, and two tax rates whose arithmetic has to reconcile to the yen.&lt;/p&gt;

&lt;p&gt;This post runs one through AnalyzeDoc (LDX hub) — PDF, JPEG, or PNG in, structured JSON out — and looks closely at what came back. The part worth your time isn't that it works. It's &lt;em&gt;how the schema is defined&lt;/em&gt;, and what that definition quietly controls.&lt;/p&gt;

&lt;p&gt;There is no JSON Schema. You hand the API an example of the output you want, and the example is compiled into the schema.&lt;/p&gt;




&lt;h2&gt;
  
  
  The document
&lt;/h2&gt;

&lt;p&gt;A fictional qualified invoice, one page (&lt;a href="https://github.com/ldxhub-io/examples/tree/main/analyzedoc/qualified-invoice-ja" rel="noopener noreferrer"&gt;sample PDF in the repo&lt;/a&gt;). Four line items: two food items at the 8% reduced rate (marked ※, as the law requires), two at 10%. Registration number, per-rate tax summary, bank details, a payment deadline. Total: ¥42,210.&lt;/p&gt;




&lt;h2&gt;
  
  
  The example is the schema
&lt;/h2&gt;

&lt;p&gt;Instead of a schema file, you send &lt;code&gt;example_output&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"invoice_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INV-2025-0001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issue_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-01-15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"due_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-02-28"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issuer_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"株式会社サンプル"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"registration_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"T9876543210987"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issuer_phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"03-9876-5432"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"customer_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"株式会社テスト商会"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"line_items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"サンプル品目A"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tax_summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"taxable_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tax_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subtotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total_tax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;11000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bank_details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"サンプル銀行 本店 普通 0000000"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules make this work, and the first one is the whole article:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your example values are type declarations.&lt;/strong&gt; Write &lt;code&gt;10000&lt;/code&gt; and the field is inferred as an integer; write &lt;code&gt;1234.56&lt;/code&gt; and it's a number. Japanese yen has no decimals, so integers are the &lt;em&gt;semantically correct&lt;/em&gt; choice here — every amount in the output will be a clean integer your downstream systems can trust. For a USD invoice you'd do the opposite: always write the example amounts with decimals, or a &lt;code&gt;$250.00&lt;/code&gt; line item risks coming back as an integer type. The literals you type are the contract you get.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Arrays receive variability.&lt;/strong&gt; &lt;code&gt;tax_summary&lt;/code&gt; is an array because mixed rates are the entire point of a qualified invoice. One entry per rate — the schema absorbs the document's core complexity instead of fighting it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example values must differ from the document.&lt;/strong&gt; If the example matches the invoice, you can't tell whether the model read the page or copied the example. Different numbers, different names, different dates.&lt;/p&gt;




&lt;h2&gt;
  
  
  The prompt carries rules, not fields
&lt;/h2&gt;

&lt;p&gt;Field descriptions belong to the example. The prompt is only for rules the example can't express:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Extract the invoice data from this Japanese qualified invoice (適格請求書).
Rules:
- Dates in YYYY-MM-DD format.
- All monetary amounts as integers in JPY (no separators, no currency symbols).
- registration_number in "T + 13 digits" format.
- tax_rate as an integer percentage (8 or 10).
- tax_summary must contain one entry per tax rate on the invoice.
- Phone numbers must not start with "+".
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last rule is scar tissue: a &lt;code&gt;+81&lt;/code&gt;-prefixed string dropped into Google Sheets gets parsed as a formula. Cheaper to kill it at the extraction boundary than to escape it everywhere downstream.&lt;/p&gt;




&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;Four calls. No polling loop — &lt;code&gt;?wait&lt;/code&gt; parks the request server-side.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Upload → file_id&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://gw.ldxhub.io/files &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$LDXHUB_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@invoice-sample-ja.pdf"&lt;/span&gt;

&lt;span class="c"&gt;# 2. Create the job&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://gw.ldxhub.io/analyzedoc/jobs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$LDXHUB_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; @job.json

&lt;span class="c"&gt;# 3. Wait for completion (server-side)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://gw.ldxhub.io/analyzedoc/jobs/&lt;/span&gt;&lt;span class="nv"&gt;$JOB_ID&lt;/span&gt;&lt;span class="s2"&gt;?wait=30"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$LDXHUB_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 4. Fetch the result&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://gw.ldxhub.io/files/&lt;/span&gt;&lt;span class="nv"&gt;$OUTPUT_FILE_ID&lt;/span&gt;&lt;span class="s2"&gt;/content"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$LDXHUB_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;job.json&lt;/code&gt; is five fields: &lt;code&gt;model&lt;/code&gt;, &lt;code&gt;file_id&lt;/code&gt;, &lt;code&gt;output_format&lt;/code&gt;, &lt;code&gt;system_prompt&lt;/code&gt;, &lt;code&gt;example_output&lt;/code&gt;. The model here is &lt;code&gt;google/gemini-3.5-flash@high&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Completed in 26 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"invoice_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"INV-2026-0157"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"issue_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-06-30"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"due_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-31"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"issuer_name"&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="nl"&gt;"registration_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"T1234567890123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"issuer_phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"03-1234-5678"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"customer_name"&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="nl"&gt;"line_items"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"description"&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="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;6000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;},{&lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"国産純粋はちみつ 500g ※"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;},{&lt;/span&gt;&lt;span class="nl"&gt;"description"&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="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;19200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;},{&lt;/span&gt;&lt;span class="nl"&gt;"description"&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="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="nl"&gt;"tax_summary"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"taxable_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;18000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tax_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;},{&lt;/span&gt;&lt;span class="nl"&gt;"tax_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"taxable_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;20700&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tax_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2070&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="nl"&gt;"subtotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;38700&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"total_tax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3510&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42210&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"bank_details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"サンプル銀行 本店 普通 1234567 カ）グリーンリーフショクヒン"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verification: the four line amounts sum to 38,700 = subtotal. The 8% base (18,000) yields 1,440; the 10% base (20,700) yields 2,070; together 3,510 = total tax; 42,210 = total. All fifteen fields match the source. No transposed digits, no invented fields.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the model understood
&lt;/h2&gt;

&lt;p&gt;The arithmetic is table stakes. The details are where it gets interesting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honorific is gone.&lt;/strong&gt; The invoice addresses the customer as 「サンプルマート株式会社　御中」. 御中 (&lt;em&gt;onchū&lt;/em&gt;) is an honorific suffix appended to company names in correspondence — roughly "To the esteemed...". It is not part of the name, and &lt;code&gt;customer_name&lt;/code&gt; came back without it. Nothing in the prompt asked for that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ※ marks stayed.&lt;/strong&gt; Line descriptions preserve the reduced-rate marker verbatim (「有機緑茶ギフトセット ※」) while the &lt;em&gt;meaning&lt;/em&gt; lives in &lt;code&gt;tax_rate: 8&lt;/code&gt;. Text fidelity in one field, semantics in another — a separation of concerns that exists because the schema was designed to hold it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kanji-formatted dates normalized.&lt;/strong&gt; 「2026年6月30日」 became &lt;code&gt;2026-06-30&lt;/code&gt; off a single prompt line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every amount is an integer.&lt;/strong&gt; Because the example said so. &lt;code&gt;6000&lt;/code&gt;, not &lt;code&gt;6000.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The footer disclaimer went nowhere.&lt;/strong&gt; The sample PDF carries a "this is a sample" notice at the bottom. No schema field fits it, so it correctly appears in none.&lt;/p&gt;




&lt;h2&gt;
  
  
  Swap the model, keep the code
&lt;/h2&gt;

&lt;p&gt;Changing one string — the model ID — switches the same request across OpenAI, Azure, Google, Anthropic, and Amazon models (15 at the time of writing). Clean, printed layouts run fine on fast, cheap models; degraded scans or handwriting can go to the heavyweight tier. The JSON shape doesn't change, because the schema compatibility is handled below the model line.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;This page cost 285 credits (~$0.029). The free plan includes 25,000 credits — about 85 pages' worth of finding out whether your own invoices survive contact.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The quickstart is built to go from signup to a completed job in about 60 seconds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://gw.portal.ldxhub.io/introduction" rel="noopener noreferrer"&gt;DevPortal / Quickstart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ldxhub-io/examples" rel="noopener noreferrer"&gt;Sample invoice + job.json + prompt, ready to clone&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fastest evaluation is the one you run on your own documents.&lt;/p&gt;

</description>
      <category>api</category>
      <category>ai</category>
      <category>json</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I removed the middleman, one phone call at a time</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 06 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/how-i-removed-the-middleman-one-phone-call-at-a-time-495l</link>
      <guid>https://dev.to/hidekimori/how-i-removed-the-middleman-one-phone-call-at-a-time-495l</guid>
      <description>&lt;p&gt;In the mid-2000s I worked on a content distribution platform that served over a hundred storefronts. Books, music, comics — different shops, the same underlying content, each store with its own branding.&lt;/p&gt;

&lt;p&gt;Among those hundred-plus storefronts, several dozen were technically resold through a third party — a viewer-side company that operated its own delivery infrastructure on top of ours. Their architecture required us to push every file into per-storefront slots on their servers, before the end user could download anything. Hundreds of slots. Per file.&lt;/p&gt;

&lt;p&gt;This article is about how those slots disappeared, and what I learned along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the layers got there
&lt;/h2&gt;

&lt;p&gt;When the platform launched, we were not the dominant player. The market for digital content on Japanese mobile phones was already shaped by a few established companies, and one of them sat between us and several dozen of our storefronts. They had built a viewer client that the end user installed on their phone. They had built a delivery server that the viewer pulled from. They had a working business.&lt;/p&gt;

&lt;p&gt;We came in as the catalog provider. The deal was simple in principle: their viewer, our content. To make their viewer work, we had to put files into the directory structure their delivery server expected — one directory per storefront, the same file copied into each.&lt;/p&gt;

&lt;p&gt;For a small catalog, this was tolerable. For a growing catalog, it was wasteful. A single new title meant dozens of identical writes — same bytes, different paths — every time we published.&lt;/p&gt;

&lt;p&gt;We were the latecomer. We didn't push back. We did the work their architecture required.&lt;/p&gt;




&lt;h2&gt;
  
  
  The phone call
&lt;/h2&gt;

&lt;p&gt;One Friday afternoon I got a phone call from the lead engineer on their side.&lt;/p&gt;

&lt;p&gt;"Could you reduce the frequency of your pushes? Our servers are having trouble keeping up."&lt;/p&gt;

&lt;p&gt;The phrasing was polite, but the request was unusual. We had recently parallelized our delivery batches — moved from sequential pushes to concurrent ones — and the volume of writes had multiplied. We were doing what we should have been doing for our growing catalog. They were absorbing it on infrastructure that hadn't been designed for that rate.&lt;/p&gt;

&lt;p&gt;I could have agreed. Reducing batch concurrency was a one-line config change on our side. It would have made their afternoon easier and our publishers' release schedules a little slower.&lt;/p&gt;

&lt;p&gt;I didn't agree. I made a counter-proposal.&lt;/p&gt;

&lt;p&gt;"What if we stopped pushing per-storefront entirely? Could your viewer fetch directly from us, with the storefront as a parameter, instead of from a pre-placed copy?"&lt;/p&gt;

&lt;p&gt;There was a pause on the line. Not a hostile pause. The kind of pause an engineer makes when an idea reorganizes itself in their head.&lt;/p&gt;

&lt;p&gt;"Let me think about that and get back to you," they said.&lt;/p&gt;




&lt;h2&gt;
  
  
  The two steps that followed
&lt;/h2&gt;

&lt;p&gt;It would be neat to say they called back the next day and we shipped it. That's not what happened. The conversation that started on that phone call took roughly two years to finish, in two discrete steps. Neither of the steps was urgent. Each of them was treated, on both sides, as a quiet refactor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step one: stop the per-storefront duplication.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first thing we agreed was that, even if the viewer still fetched through their infrastructure, the file did not need to be duplicated per storefront on disk. They modified their delivery server to look up a single canonical copy, with the storefront determined by a parameter on the URL. We changed our push so that each file was uploaded once, not duplicated per storefront.&lt;/p&gt;

&lt;p&gt;Our delivery batch shrank by an order of magnitude. Their disk usage dropped accordingly. Nothing changed for the end user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step two: remove the dependency on their server entirely.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The second thing was larger. Since the file was already canonical on our side, there was no architectural reason for the data path to go through their infrastructure at all. They updated their viewer to fetch directly from our delivery infrastructure, using the parameters they had previously injected on their server side. They kept the catalog metadata and the user-account layer; everything else went away.&lt;/p&gt;

&lt;p&gt;When the last storefront cut over, the middleman delivery server was no longer in the data path. They turned off the disks they had been running for us. We turned off the placement processing we had been running for them.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;It's tempting to read this as a story about how a smaller company outmaneuvered a larger one. That's not what happened. What happened was that I made a proposal in the middle of a routine operational complaint, and over two years both sides incrementally chose to remove a layer that had stopped earning its keep.&lt;/p&gt;

&lt;p&gt;There are a few things I took from it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A symptom request is an opportunity to ask about the cause.&lt;/strong&gt; "Please reduce your push frequency" is a symptom request. The honest underlying question is "why are we doing this many writes at all?" Most operational complaints I've received over the years, in the moment, sounded like requests for symptom relief. Some of them turned out to be requests for an architectural conversation that nobody had explicitly opened yet.&lt;/p&gt;

&lt;p&gt;When I get a symptom request now, I always try to surface the cause version of the same request, at least once, to see what happens. Sometimes nothing. Sometimes a two-year refactor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conviction has to come with the other party's interest.&lt;/strong&gt; I was able to propose removing their delivery infrastructure because, by that point, our volume was material to them. A meaningful share of their viewer's traffic came from our catalog. Removing a layer that had stopped paying for itself was, for them, a saving — not a loss. Without that alignment, the same proposal would have been a polite no.&lt;/p&gt;

&lt;p&gt;The conviction wasn't only mine. It was conviction plus a real shape of interests. Without the second, no amount of conviction moves a vendor's roadmap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latecomers can rearrange the layer cake.&lt;/strong&gt; When we started, we accepted their architecture because they were the established party and we were not. Two years later, we redefined the architecture together. The thing that changed in between was not their willingness to change. It was our weight in the system. The latecomer can become the party who proposes the new shape, once the latecomer is no longer small.&lt;/p&gt;

&lt;p&gt;I think a lot of the operational shapes that look fixed in a given industry are, in fact, shapes that nobody has been in a position to renegotiate. The middleman in our case wasn't there because anyone defended its existence. It was there because nobody yet had reason to ask whether it should be.&lt;/p&gt;




&lt;h2&gt;
  
  
  Twenty-four years later
&lt;/h2&gt;

&lt;p&gt;I'm telling this story now because I keep finding myself doing variations of it. A vendor surfaces an operational complaint. I look at the complaint, and I find — sometimes — that the structure underneath the complaint is the actual subject of the conversation. The complaint is the symptom. The conversation is the door.&lt;/p&gt;

&lt;p&gt;Not every complaint opens a door. Most of them are just complaints. But some of them are an invitation to ask whether the current shape is the right one, and a year or two later you find that the shape is no longer there.&lt;/p&gt;

&lt;p&gt;This is not what you should do. This is what twenty-four years has taught one specific person to do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn"&gt;Nobody knows when a job will finish. I'd still like to report it accurately.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d"&gt;What survives when you build alone for 24 years&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f"&gt;Dynamic isn't enough. Operations is the other half.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/live-report-at-this-speed-you-dont-theorize-you-eliminate-1o7h"&gt;Live report: at this speed, you don't theorize. You eliminate.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-loop-i-didnt-notice-closing-16h8"&gt;The loop I didn't notice closing&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff"&gt;Abstractions are fine. Starting on them isn't.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633"&gt;Twenty four years, ten DB migrations, zero downtime&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/write-the-code-well-once-the-spec-stops-bothering-you-42g3"&gt;Write the code well once, the spec stops bothering you&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-3-line-discipline-3lla"&gt;The 3-line discipline&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>devops</category>
      <category>business</category>
    </item>
    <item>
      <title>The 3-line discipline</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 29 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/the-3-line-discipline-3lla</link>
      <guid>https://dev.to/hidekimori/the-3-line-discipline-3lla</guid>
      <description>&lt;p&gt;When I write code in unfamiliar territory, I write three lines, then I run it.&lt;/p&gt;

&lt;p&gt;Then I write three more lines, and I run it again.&lt;/p&gt;

&lt;p&gt;I've been doing this for twenty-four years. It's the most specific habit I have. I almost didn't write this article, because the habit feels too small to be worth describing — but then I noticed that it's the part of my way of working that I can never seem to explain to someone in real time. It needs writing down.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three principles
&lt;/h2&gt;

&lt;p&gt;The discipline rests on three things I believe about writing code. They're not deep. They've just stayed with me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Trust nothing but your own code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you can't trust the code you wrote yourself, what can you trust? Not a library, not a vendor's documentation, not your own assumption from yesterday. The only thing in the system whose behavior you can fully verify is the code you just typed, by running it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Write in code, not in language.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're describing what the code should do in Japanese or English, you're spending the same time you could have spent writing the code itself. By the time the code runs, the description is already done — by the code, in a more precise form than any language could give it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Make three lines complete.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The three lines you just wrote should be complete. Error handling included. Validation included. Logging included. Not "I'll add validation later." Not "I'll wrap it in a try-catch later." Three lines, complete, then run.&lt;/p&gt;

&lt;p&gt;(There's a small exception to this. Sometimes you do want to ignore every error and move on — for instance, when you're trying to understand whether the happy path works at all before you care about anything else. That's a different mode, used deliberately. It's not the same as "I'll handle errors later.")&lt;/p&gt;




&lt;h2&gt;
  
  
  Why three lines
&lt;/h2&gt;

&lt;p&gt;Three lines is roughly the unit of thought I can hold completely. Five lines, and I start guessing what the third line did. Ten lines, and I'm reading the code as if it were someone else's. Three lines is the size that stays mine.&lt;/p&gt;

&lt;p&gt;When three lines run and produce what I expected, I keep them. When they don't, I either fix them or delete them. The cost of deleting three lines is small enough that I have no attachment to keeping them.&lt;/p&gt;

&lt;p&gt;What I'm protecting, by writing this small, is the alignment between the code in my head and the code that's actually running. When that alignment is intact, debugging is fast: I know which three lines just changed. When that alignment slips — because I wrote thirty lines without running them — debugging becomes archaeology. I'd rather spend the time in three-line increments and avoid the archaeology.&lt;/p&gt;




&lt;h2&gt;
  
  
  Components: write the caller, run, write again, run
&lt;/h2&gt;

&lt;p&gt;When I introduce a new component into a system — a new library, a new vendor's API, a new framework — I don't start by writing the code that needs the component. I start by writing the code that calls the component.&lt;/p&gt;

&lt;p&gt;The caller is small at first. Three lines. I call the component, I print what comes back, I run it. Then three more lines. I call it with different arguments. I print what comes back. I run it. Three more lines. I call it in a way that should fail. I see what failure looks like.&lt;/p&gt;

&lt;p&gt;By the time I've spent an hour doing this, I know how the component behaves on the inputs I care about, how it behaves on edge cases, how it fails, what it returns when it fails. I have a small body of code that has tested the component from the outside, written in my own hand.&lt;/p&gt;

&lt;p&gt;After that, the component almost never surprises me when I integrate it into the real work. It surprised me already, during the hour I was poking at it, and I noted what I learned.&lt;/p&gt;

&lt;p&gt;This part of the discipline is what twenty-four years has changed about me the most. When I started, I would try to use a new library inside the real code right away, and then I'd be debugging both the library's behavior and my own use of it at the same time. I don't do that anymore. The library has to pass a small private interview first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Even then, live data still surprises you
&lt;/h2&gt;

&lt;p&gt;Here's the part that keeps the discipline honest: even when you've done all of the above, live data will still surprise you. The vendor's API will return something the documentation never mentioned. The status code will say success while the payload says something else. The same call you've made a thousand times will, on the thousand-and-first try, return data that belongs to a different question entirely.&lt;/p&gt;

&lt;p&gt;I worked with a fairly mainstream translation API for many years. It's the kind of API most people in the industry have heard of. In day-to-day operation, the integration was stable. But in the operational record of how my code calls it, there are several places where I had to write defenses that aren't suggested by the documentation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The languages endpoint, when asked for &lt;em&gt;target&lt;/em&gt; languages, sometimes returned the response shaped like a &lt;em&gt;source&lt;/em&gt; language listing. The HTTP status was 200. The JSON parsed. But a field that should be present on target languages was missing. The fix wasn't to escalate or to file a bug — it was to detect the mismatch in my code, log it as a retry-worthy condition, and call the endpoint again. The next call usually returned correctly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Certain error messages from the API turned out to be retry-worthy, even though the HTTP status code didn't say so. A &lt;code&gt;"Temporary Error"&lt;/code&gt; in the response body, or a &lt;code&gt;"Tag handling parsing failed"&lt;/code&gt;, both warranted retrying. I learned this not from the documentation but from watching the production logs over many months.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is a complaint about the vendor. It's a mainstream API. The point isn't that it's flawed; the point is that any API run at scale, against real data, will produce these moments. The documentation is a description of intended behavior, not observed behavior. Observed behavior, in production, is always wider.&lt;/p&gt;

&lt;p&gt;So even after the three-line discipline, even after the private interview with the component, the system goes into production and surprises me. Not catastrophically. Quietly. A condition I hadn't tested, behaving in a way I hadn't predicted.&lt;/p&gt;

&lt;p&gt;I don't experience this as a failure of the discipline. I experience it as the part of the work that the discipline doesn't cover — and was never going to cover. The discipline brings me to the doorstep with my code in good shape. Live data is what's on the other side of the door, and it's not something I get to fully prepare for. It's something I respond to.&lt;/p&gt;

&lt;p&gt;This is, I think, the part of the work I find most enjoyable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Twenty-four years of this
&lt;/h2&gt;

&lt;p&gt;I didn't set out to develop a discipline. I started writing software in 2002, at a company that was almost out of money, on a product that needed to ship in thirty days. There was no time to write thirty lines and then debug them. I wrote three lines, I ran them, I wrote three more. The shape of how I work formed itself in that situation, and it never went away.&lt;/p&gt;

&lt;p&gt;What's changed over twenty-four years is what I do with the result. The three-line increments are the same. The "write the caller first, run, run again" is the same. The willingness to be surprised by live data is the same. What's deeper now is just the cumulative trust in my own code, and the cumulative humility about everything outside it.&lt;/p&gt;

&lt;p&gt;If you write the code you can trust, you can carry the weight of everything you can't.&lt;/p&gt;

&lt;p&gt;This is not what you should do. This is what twenty-four years has taught one specific person to do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn"&gt;Nobody knows when a job will finish. I'd still like to report it accurately.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d"&gt;What survives when you build alone for 24 years&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f"&gt;Dynamic isn't enough. Operations is the other half.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/live-report-at-this-speed-you-dont-theorize-you-eliminate-1o7h"&gt;Live report: at this speed, you don't theorize. You eliminate.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-loop-i-didnt-notice-closing-16h8"&gt;The loop I didn't notice closing&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff"&gt;Abstractions are fine. Starting on them isn't.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633"&gt;Twenty four years, ten DB migrations, zero downtime&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/write-the-code-well-once-the-spec-stops-bothering-you-42g3"&gt;Write the code well once, the spec stops bothering you&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>softwareengineering</category>
      <category>productivity</category>
      <category>api</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Billing asynchronous work exactly once</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Wed, 24 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/billing-asynchronous-work-exactly-once-fnl</link>
      <guid>https://dev.to/hidekimori/billing-asynchronous-work-exactly-once-fnl</guid>
      <description>&lt;p&gt;Synchronous billing is easy, and that's the problem — it makes you think all billing is easy.&lt;/p&gt;

&lt;p&gt;When a request does its work inline, the billable number is in the response by the time you send it. The gateway meters from there — the meter write, retries and all, is its problem, not yours. From your side, synchronous billing is one number in the response.&lt;/p&gt;

&lt;p&gt;Asynchronous work breaks that. The request submits a job; the work happens later, in a worker; the result comes back through a poll or a callback. And the thing you bill for — characters processed, pages converted — isn't known when the request arrives. It's known when the job &lt;em&gt;finishes&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So you can't meter at the edge. The meter has to fire from the completion path. And the real difficulty is firing it &lt;em&gt;exactly once&lt;/em&gt; per unit of completed work — because requests, polls, and retries all conspire to make that zero times or many times.&lt;/p&gt;

&lt;p&gt;This is platform-agnostic. Every submit-process-poll API has it. I'll use the system I run as the example, but the shape is the same anywhere.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three ways metering goes wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On arrival.&lt;/strong&gt; Carry the synchronous habit over and you meter when the job is submitted. But you don't know the size yet, so you're forced into a crude flat fee — or you bill for work that hasn't happened and might fail. Wrong unit, wrong time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On retrieval.&lt;/strong&gt; The subtle one. You wire the meter to fire when the client fetches the result. Now a client who submits a job, lets it run — costing you real money downstream — and never bothers to poll is never billed. You did the work for free. "Completion" is not "the client picked up the result." It's the worker finishing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Without a fixed quantity.&lt;/strong&gt; Input characters or output characters? Pages before OCR or after? If you haven't decided exactly what you measure and where, invoices drift and customers argue. Decide once; measure there.&lt;/p&gt;

&lt;p&gt;All three point the same way: meter on measured work-completion, with a fixed definition of the unit. Not on arrival. Not on retrieval.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mechanism: a durable outbox
&lt;/h2&gt;

&lt;p&gt;In synchronous billing the gateway took the numbers off the response and metered them for you. Async takes that away: the numbers exist only in the worker, after the request has returned. So completion itself has to become a durable event.&lt;/p&gt;

&lt;p&gt;The completion path writes a metering task — the job's measured quantities — into a durable outbox: a table that is the source of truth for what still needs sending. Something drains it, sends each task to the meter, records the outcome; a failed send stays in the table and is retried until it lands. (In my system a once-a-minute batch does the draining. The interval doesn't matter; the durability does.)&lt;/p&gt;

&lt;p&gt;It has a name — the transactional outbox pattern — though it's the sort of thing you'd build without the name. And it is, exactly, the one rule the rest of the system already runs on: when the job finishes, report it reliably — retry as much as possible, return the result. Metering is just one more result that has to be reported reliably. I didn't build a billing system. I pointed the engine's own discipline at billing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Exactly once = at-least-once × at-most-once
&lt;/h2&gt;

&lt;p&gt;The outbox gives me &lt;em&gt;at-least-once&lt;/em&gt;. A meter event is never silently dropped, because a failed send leaves the task in place to retry.&lt;/p&gt;

&lt;p&gt;But at-least-once, on its own, double-charges. The classic failure: the send succeeds, the acknowledgement is lost on the way back, the task looks failed, the next run resends — and now it is counted twice.&lt;/p&gt;

&lt;p&gt;So at-least-once needs a partner: an &lt;em&gt;idempotent sink&lt;/em&gt;. Send the same meter ID twice, it counts once. That is &lt;em&gt;at-most-once&lt;/em&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;exactly-once = outbox (at-least-once) × idempotent sink (at-most-once)&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Neither half is enough alone. I learned the second one the hard way — the same outbox-and-retry code, pointed at two different metering backends. One deduplicated on the ID and the numbers stayed clean. The other didn't, and the retries double-charged. Same mechanism, different sink, different bill.&lt;/p&gt;

&lt;p&gt;So the thing worth writing down isn't "this platform guarantees idempotency." Platforms change. The durable statement is: &lt;em&gt;this pattern requires an idempotent sink.&lt;/em&gt; If yours doesn't deduplicate, your retries are a liability, not a safety net.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bill on success, and survive retries
&lt;/h2&gt;

&lt;p&gt;Two more places it bites.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Success, not completion.&lt;/strong&gt; Fire the meter on &lt;em&gt;successful&lt;/em&gt; completion — not on "the job reached a terminal state." A failed job must not emit a billable event. Wire it to the wrong terminal state and you charge people for errors, then spend your week on refunds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial failure.&lt;/strong&gt; What you bill on a half-finished job depends on whether half a result is worth anything. A text extraction fans out into many independent calls; if nine of ten succeed and one fails for good, you bill the nine — the successful work has standalone value. Document conversion is the opposite: a file that converts eight of ten pages and then dies isn't eighty percent of a document, it's a corrupted one. No charge, nothing returned. Bill at the granularity where partial output has value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retries.&lt;/strong&gt; The engine retries aggressively — that is the point of it. Meter per &lt;em&gt;attempt&lt;/em&gt; and every retry inflates the bill. So the meter is per &lt;em&gt;successful job&lt;/em&gt;, fired once — which is exactly what the outbox and the idempotent sink already guarantee. It is not extra work; it falls out of the same design.&lt;/p&gt;

&lt;p&gt;It all reduces to one sentence: the billable event is one successfully-completed unit, counted once.&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;

&lt;p&gt;In synchronous billing the meter is a property of a request arriving. In asynchronous billing it is a property of work &lt;em&gt;finishing&lt;/em&gt; — and the discipline is firing it exactly once per successful unit.&lt;/p&gt;

&lt;p&gt;It is worth separating what is hard from what is free. The completion wiring — the outbox, the retries — is yours to build. The at-most-once half is the sink's job, if you chose a sink that does it. Get both, and a client polling ten times, a worker retrying five, and a job that half-failed all resolve to the right number of credits.&lt;/p&gt;

&lt;p&gt;That is the whole thing. It isn't much once it's drawn — but every line of it is a place I have watched a bill come out wrong.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>billing</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>Write the code well once, the spec stops bothering you</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 22 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/write-the-code-well-once-the-spec-stops-bothering-you-42g3</link>
      <guid>https://dev.to/hidekimori/write-the-code-well-once-the-spec-stops-bothering-you-42g3</guid>
      <description>&lt;p&gt;I wrote a Java class twenty years ago that assembled tar archives on the fly. It ran for fifteen years. In that fifteen years, nobody touched it. Not me, not anyone else.&lt;/p&gt;

&lt;p&gt;This is a story about why.&lt;/p&gt;




&lt;h2&gt;
  
  
  The hundred storefronts and the one carrier spec
&lt;/h2&gt;

&lt;p&gt;In the mid-2000s, I was running a content distribution platform. Over a hundred storefronts plugged into it. Bookstores, label-branded stores, carrier-branded stores. Each one resold the same underlying content — books, comics, music, video, and more — but with their own branding wrapped around it.&lt;/p&gt;

&lt;p&gt;Among those hundred-plus storefronts, a few dozen of them shared a particular delivery spec — one of the major Japanese carriers had pinned down a specific shape for downloadable content on their old mobile phones. The content had to arrive as a tar archive. The phone would fetch it using HTTP Range Requests, byte ranges at a time, often resuming after a dropped connection.&lt;/p&gt;

&lt;p&gt;The format was the same for all storefronts on that spec: a tar archive containing a known set of files, in a known structure. The files inside were not the same. Each storefront wanted its own branding image, its own store name in the metadata, its own thumbnail. The wrapper format was fixed. The contents were per-storefront, per-product.&lt;/p&gt;

&lt;p&gt;A few dozen storefronts asking for the same shape with different contents, multiplied by the catalog. It was a combinatorial problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I didn't do: pre-generate
&lt;/h2&gt;

&lt;p&gt;The obvious approach was to pre-generate the tar archives. For each storefront, for each product, produce the archive once, write it to disk, serve it from there.&lt;/p&gt;

&lt;p&gt;I rejected this almost immediately.&lt;/p&gt;

&lt;p&gt;Storage isn't free. Number of storefronts times number of products times the size of each archive. The math wasn't terrible at the time, but the moment any storefront changed its branding — a new logo, a new store name, a new image — every archive associated with that storefront would be invalidated. Every product, every catalog entry. I'd be re-running the bake of tens of thousands of archives because someone tweaked a string.&lt;/p&gt;

&lt;p&gt;I thought about this for a while and then stopped thinking about it. The shape that came out the other side was different.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I did: deterministic generation at request time
&lt;/h2&gt;

&lt;p&gt;I generated the archive at the moment of the request.&lt;/p&gt;

&lt;p&gt;For each incoming download:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Look up the product. Find the raw content.&lt;/li&gt;
&lt;li&gt;Look up the storefront. Find the branding config: the store name, the image to embed, the thumbnail.&lt;/li&gt;
&lt;li&gt;Assemble a tar archive in memory, with that store's content wrapped around that product's data.&lt;/li&gt;
&lt;li&gt;Stream the result to the client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The archive didn't exist before the request arrived. It didn't exist after.&lt;/p&gt;

&lt;p&gt;The CPU cost was real but small. Application servers were cheap and easy to scale horizontally. Storage was scarce and combinatorially expensive. The trade was obvious to me.&lt;/p&gt;

&lt;p&gt;But there was one detail that made the whole shape work — and without it, the rest would have fallen apart.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mtime had to be fixed
&lt;/h2&gt;

&lt;p&gt;HTTP Range Requests assume the resource is stable. The client says &lt;em&gt;give me bytes 0 through 8191&lt;/em&gt;, you give them those bytes. Later the client says &lt;em&gt;give me bytes 8192 through 16383&lt;/em&gt;, and the bytes you give now have to be the second half of the same file the client started downloading. If they're not — if the file changed between the two range requests — the client ends up with a corrupted archive.&lt;/p&gt;

&lt;p&gt;A tar header has a field for modification time. Every file inside the archive has an mtime — twelve bytes of octal-encoded Unix timestamp.&lt;/p&gt;

&lt;p&gt;If I generated those mtimes from the current clock at the moment of header creation, every regeneration would produce a different archive. Even though the content was identical. Even though the structure was identical. Just the timestamps would shift, and Range Requests would break.&lt;/p&gt;

&lt;p&gt;So the mtime had to be deterministic. The same archive had to come out every time, regardless of when the request arrived.&lt;/p&gt;

&lt;p&gt;I fixed it to the product's update timestamp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;mtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;updateDate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTime&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000L&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That timestamp changed only when the underlying product was updated by an operations colleague. Between updates, every tar archive for that product was byte-identical, regardless of how many times it was assembled.&lt;/p&gt;

&lt;p&gt;The natural follow-up question is: what about an update that lands mid-stream, while an archive is being assembled? The source files for each product were versioned. A product update produced a new version of the source set, not an in-place rewrite. An archive being assembled never saw a half-updated set of source files; it saw a consistent version, from start to finish.&lt;/p&gt;

&lt;p&gt;Range Requests worked. Resumes worked. The phone could disconnect at byte 5000 and reconnect for bytes 5001 onwards from a different application instance entirely. The bytes would line up.&lt;/p&gt;

&lt;p&gt;The archive existed only at the moment of the request, but it was deterministic to the last source update.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fifteen years of nobody touching it
&lt;/h2&gt;

&lt;p&gt;I wrote that class in 2006. The platform ran on it for the next fifteen years.&lt;/p&gt;

&lt;p&gt;In those fifteen years:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New storefronts were added. Config rows in a database. No code change.&lt;/li&gt;
&lt;li&gt;New products were added. New files on disk in a known structure. No code change.&lt;/li&gt;
&lt;li&gt;New storefronts wanted different branding images. They uploaded different images. No code change.&lt;/li&gt;
&lt;li&gt;The product update flow was tweaked many times. The mtime convention held. No code change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nobody re-wrote it. Nobody refactored it. Nobody patched it. It just kept assembling tar archives.&lt;/p&gt;

&lt;p&gt;The on-call queue never had an alert about it. The maintenance docs never had a runbook section for it. New engineers never asked about it, because there was nothing to ask.&lt;/p&gt;

&lt;p&gt;The spec stopped bothering anybody.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I was actually choosing
&lt;/h2&gt;

&lt;p&gt;When I picked dynamic generation over pre-generation, I wasn't really picking CPU over storage. I was picking &lt;em&gt;where the complexity would live&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If you pre-generate, the complexity lives in the data. Every change in input — branding, content, metadata — has to propagate into a regeneration of the materialized output. The complexity is distributed across the storage layer, and someone has to maintain the regeneration pipeline.&lt;/p&gt;

&lt;p&gt;If you generate at request time, the complexity lives in one piece of code. That code is hard to write well the first time. There's a tar format to understand. There's a deterministic mtime requirement that's easy to miss. There's the Range Request semantics. But once that code is written well, the system has no other place where the complexity is stored.&lt;/p&gt;

&lt;p&gt;And then nobody has to touch it.&lt;/p&gt;

&lt;p&gt;This is the trade I keep coming back to, twenty-four years into writing software alone. If you write the code well once, the spec stops bothering you. The work you did at the start absorbs all the work you didn't have to do later — by you, or by anyone else.&lt;/p&gt;




&lt;h2&gt;
  
  
  Twenty-four years of the same choice
&lt;/h2&gt;

&lt;p&gt;I'm still doing it. The platform I run now is built on the same pattern at a different scale: a hundred-plus vendors of OCR, translation, and other document services, all behind a small set of public APIs that compose them dynamically per request. There's no pre-generated catalog of vendor combinations. There's one piece of code that knows how to wrap one vendor in one shape, and a configuration system that lets new vendors plug in.&lt;/p&gt;

&lt;p&gt;I expect the same fifteen-year quiet that the tar archive code got. It's not that I'm confident. It's that I've made the choice often enough to know what it usually leads to.&lt;/p&gt;

&lt;p&gt;This is not what you should do. This is what twenty-four years has taught one specific person to do.&lt;/p&gt;

&lt;p&gt;If you write the code well once, the spec stops bothering you. Most of what I do, I do for that.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn"&gt;Nobody knows when a job will finish. I'd still like to report it accurately.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d"&gt;What survives when you build alone for 24 years&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f"&gt;Dynamic isn't enough. Operations is the other half.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/live-report-at-this-speed-you-dont-theorize-you-eliminate-1o7h"&gt;Live report: at this speed, you don't theorize. You eliminate.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-loop-i-didnt-notice-closing-16h8"&gt;The loop I didn't notice closing&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff"&gt;Abstractions are fine. Starting on them isn't.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633"&gt;Twenty four years, ten DB migrations, zero downtime&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>design</category>
      <category>backend</category>
    </item>
    <item>
      <title>Two patterns, five services, one n8n workflow</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Wed, 17 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/two-patterns-five-services-one-n8n-workflow-a4</link>
      <guid>https://dev.to/hidekimori/two-patterns-five-services-one-n8n-workflow-a4</guid>
      <description>&lt;p&gt;The first two articles in this series each showed one technique. &lt;a href="https://dev.to/hidekimori/build-a-multi-step-n8n-form-with-dynamic-dropdowns-no-plugin-needed-1pm4"&gt;Implementation notes #001&lt;/a&gt; was a dynamic dropdown — a form field that fills itself from an API. &lt;a href="https://dev.to/hidekimori/let-your-n8n-template-ask-for-the-users-api-key-54n9"&gt;Implementation notes #002&lt;/a&gt; was a dynamic credential — an API key that arrives from the form and threads through to the HTTP nodes.&lt;/p&gt;

&lt;p&gt;This article is the capstone. It walks through &lt;code&gt;all-services-demo&lt;/code&gt;, the example workflow that ships with &lt;code&gt;n8n-nodes-ldxhub&lt;/code&gt;, where those two techniques combine with a Switch node to host five different AI document-processing services inside one workflow — structured extraction, translation refinement, OCR, PDF conversion, and text extraction.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The screenshots and the workflow JSON below come from the &lt;code&gt;n8n-nodes-ldxhub&lt;/code&gt; package. The patterns themselves are generic — they work for any set of services you want to consolidate into a single template.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is not a "follow these steps" article. It's a parts catalog. No two readers are solving the same problem, and templates rarely fit anyone's situation as-is. Take what fits. Drop the rest. You don't need to understand all 46 nodes to reuse the patterns.&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;

&lt;p&gt;The workflow has 46 nodes — large enough to look intimidating in the editor, but structurally it's just five repeated paths plus a small routing section.&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.amazonaws.com%2Fuploads%2Farticles%2Fwnstxhtpe34wdci1igqg.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.amazonaws.com%2Fuploads%2Farticles%2Fwnstxhtpe34wdci1igqg.png" alt="all-services-demo workflow overview" width="800" height="581"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The entry section is two nodes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On form submission&lt;/strong&gt; — the trigger. Asks the user which service they want and collects an API key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route by Service&lt;/strong&gt; — a Switch node with five outputs, one per service.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything to the right of the Switch is service-specific. Five paths fan out: StructFlow, RefineLoop, RenderOCR, CastDoc, ExtractDoc. Each path ends in two Form Ending nodes — one for success (auto-downloads the result), one for error.&lt;/p&gt;

&lt;p&gt;That's the spine: form → switch → service path → ending. The complexity is pushed into the service paths.&lt;/p&gt;




&lt;h2&gt;
  
  
  The spine: routing by static comparison
&lt;/h2&gt;

&lt;p&gt;The Switch node ("Route by Service") uses Rules mode. Each rule reads the same expression from the form — &lt;code&gt;{{ $json.service }}&lt;/code&gt; — and compares it to a static service name.&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.amazonaws.com%2Fuploads%2Farticles%2Fox7mxo0tnzu2e8td8ofv.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.amazonaws.com%2Fuploads%2Farticles%2Fox7mxo0tnzu2e8td8ofv.png" alt="Switch node Route by Service" width="800" height="986"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Rule 1:  {{ $json.service }}  is equal to  structflow   → output: structflow
Rule 2:  {{ $json.service }}  is equal to  refineloop   → output: refineloop
Rule 3:  {{ $json.service }}  is equal to  renderocr    → output: renderocr
Rule 4:  {{ $json.service }}  is equal to  castdoc      → output: castdoc
Rule 5:  {{ $json.service }}  is equal to  extractdoc   → output: extractdoc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The read side is dynamic (the expression resolves to whatever the user picked). The match side is static (fixed strings). That asymmetry is intentional. Static rules mean adding a new service is a manual edit — open the Switch node, add a row, save. No regeneration, no template hooks, no auto-discovery. Boring and unsurprising.&lt;/p&gt;

&lt;p&gt;This is the part you can lift cleanly: a Switch with N static rules driven by one expression from upstream. It works for service routing, document type routing, user tier routing, anything that fans into discrete branches.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two patterns inside
&lt;/h2&gt;

&lt;p&gt;Once you start reading the service paths, you notice something: they are not all the same shape. There are two distinct patterns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern A: single-step form (StructFlow, RefineLoop — 7 nodes)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Get Models (HTTP)
  → Derive Options (Set)
    → Run Form (Form, next page)
      → Inject Binary (Code)
        → Run (LDX hub)
          → Download / Error (Form Endings)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiq6bur5akbspcqpvhq27.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.amazonaws.com%2Fuploads%2Farticles%2Fiq6bur5akbspcqpvhq27.png" alt="StructFlow path" width="800" height="214"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One form page collects everything the user needs to choose. The model selection, the input, the parameters — all in one screen. There's only one form page after the Switch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern B: cascading multi-step form (RenderOCR, CastDoc, ExtractDoc — 10 nodes)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Get Engines (HTTP)
  → Select Engine (Form, next page)
    → Derive Options (Set)
      → Upload File (Form, next page)
        → Filter by File (Set)
          → Select Output (Form, next page)
            → Inject Binary (Code)
              → Run (LDX hub)
                → Download / Error (Form Endings)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fluikg8p3s4drhia7ospf.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.amazonaws.com%2Fuploads%2Farticles%2Fluikg8p3s4drhia7ospf.png" alt="RenderOCR path" width="800" height="149"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three form pages, each gated on the previous one. First the engine is chosen. Then the file is uploaded. Then the output format is selected — and the available outputs are filtered based on what the chosen engine supports for the uploaded file type. The &lt;code&gt;Filter by File&lt;/code&gt; Set node sits in the middle of that dependency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why two patterns, not one
&lt;/h3&gt;

&lt;p&gt;The shape of the path follows the shape of the user's decisions. When the choices are independent — pick a model, pass some data — one form page is enough. When the choices cascade — engine restricts file types, file restricts output formats — the form has to be split, and intermediate Set nodes have to filter the options between pages.&lt;/p&gt;

&lt;p&gt;I tried to force a single pattern across all five services. It made the simpler services more complicated than they needed to be. The honest design was to let the cascading services be longer, accept the asymmetry, and document it.&lt;/p&gt;

&lt;p&gt;Two patterns isn't a sign of incompleteness. It's the consolidation accepting that two shapes were genuinely warranted.&lt;/p&gt;




&lt;h2&gt;
  
  
  Anatomy of one path
&lt;/h2&gt;

&lt;p&gt;Walking through StructFlow gives you the vocabulary for all five paths. The other four are variations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get Models&lt;/strong&gt; — an HTTP node that hits &lt;code&gt;/structflow/models&lt;/code&gt; and returns the available LLMs (gpt-5.5, claude-sonnet-4-6, gemini-3-flash, etc.). This is the data source for the dropdown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Derive Options&lt;/strong&gt; — a Set node that reshapes the model list into the format n8n's Form trigger wants for a dropdown: &lt;code&gt;[{name, value}, ...]&lt;/code&gt;. Same trick as in &lt;a href="https://dev.to/hidekimori/build-a-multi-step-n8n-form-with-dynamic-dropdowns-no-plugin-needed-1pm4"&gt;#001&lt;/a&gt; — derive the dropdown from data, not from a hardcoded list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run Form&lt;/strong&gt; — a single form page that asks for everything: which model to use, the input data, any parameters. The "model" dropdown reads its options from the upstream &lt;code&gt;Derive Options&lt;/code&gt; node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inject Binary&lt;/strong&gt; — a Code node that does one thing. In n8n, uploaded files travel through the workflow as binary data, separate from the JSON fields, and some intermediate nodes (like Set) only preserve the JSON side. By the time data reaches the LDX hub node, the binary part has been dropped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&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="na"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;StructFlow: Run Form&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;binary&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Code node reaches back to the form node and re-attaches the binary. One line of glue, but without it the file disappears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run&lt;/strong&gt; — the LDX hub custom node, with &lt;code&gt;runJob: structFlow&lt;/code&gt;. This is where the API call actually happens. The credential is set in expression mode to read from the form input — the dynamic credential pattern from &lt;a href="https://dev.to/hidekimori/let-your-n8n-template-ask-for-the-users-api-key-54n9"&gt;#002&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Download / Error&lt;/strong&gt; — two Form Ending nodes. The Run node has two output ports: Success goes to Download (which serves the result file), Error goes to Error (which shows the error message).&lt;/p&gt;

&lt;p&gt;Five other paths follow the same idea. The names change, the number of form pages changes, but the role of each node is the same.&lt;/p&gt;




&lt;h2&gt;
  
  
  The convergence: five paths, one node
&lt;/h2&gt;

&lt;p&gt;All five service paths end at the same LDX hub custom node. Same node type, same credential, same shape — only the &lt;code&gt;runJob&lt;/code&gt; parameter differs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;StructFlow: Run    →  runJob: structFlow
RefineLoop: Run    →  runJob: refineLoop
RenderOCR: Run     →  runJob: renderOcr
CastDoc: Run       →  runJob: castDoc
ExtractDoc: Run    →  runJob: extractDoc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the abstraction the custom node provides. From the workflow's perspective, "running a service" looks identical across the five paths. The differences are buried inside the node's implementation, where they belong.&lt;/p&gt;

&lt;p&gt;This generalizes the idea from &lt;a href="https://dev.to/hidekimori/let-your-n8n-template-ask-for-the-users-api-key-54n9"&gt;#002's sidebar&lt;/a&gt;: a custom node that hides its variations behind a uniform interface lets you compose it freely. Five services. One credential type. One node. Five jobs. The workflow author doesn't have to know how StructFlow differs from RenderOCR — the node knows.&lt;/p&gt;

&lt;p&gt;If you're building your own custom node, this is the shape worth aiming for. One node, parameterized by job kind. The workflow stays clean. The variations stay encapsulated.&lt;/p&gt;




&lt;h2&gt;
  
  
  The if/else pair: error endings
&lt;/h2&gt;

&lt;p&gt;Every service path has two endpoints: Download (success) and Error. Both are Form Ending nodes. Both are visible to the user. This isn't decorative.&lt;/p&gt;

&lt;p&gt;A distributable template has one minimum obligation: once the user clicks Submit, they need to see &lt;em&gt;what happened&lt;/em&gt;. If the call succeeded, they get the result. If it failed, they get the error. There's no third state where the form just ends silently.&lt;/p&gt;

&lt;p&gt;Earlier failures — the Get Models call returning empty, the Inject Binary node crashing — are not handled. Those are skipped here because the template is meant to be minimal, and because adding error endings everywhere makes the canvas unreadable. But the final Run node's error branch is mandatory. That's the one place where the user's expectation ("I started a job, what happened?") has to be answered.&lt;/p&gt;

&lt;p&gt;Where there's an &lt;code&gt;if&lt;/code&gt;, you need an &lt;code&gt;else&lt;/code&gt;. The else doesn't have to be elegant. It just has to exist.&lt;/p&gt;

&lt;p&gt;This is the part you can lift on its own: any time a workflow has a user-visible "run" step, pair its success with an error ending. Other branches can be skipped or logged, but the user-facing one is non-negotiable.&lt;/p&gt;




&lt;h2&gt;
  
  
  What consolidation looks like
&lt;/h2&gt;

&lt;p&gt;The technique parts above — Switch routing, two form patterns, Binary injection, Run convergence, if/else error pairing — are each portable. You can lift them one at a time. But the article would be missing something if it stopped there.&lt;/p&gt;

&lt;p&gt;Bringing five services into one workflow is itself the work. Not a tutorial-friendly kind of work, because nothing in this section is a discrete technique. It's a series of small decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Choosing which five services to include (not all of them — five is enough to demonstrate, more would clutter).&lt;/li&gt;
&lt;li&gt;Standardizing the node naming across paths (&lt;code&gt;&amp;lt;Service&amp;gt;: &amp;lt;Role&amp;gt;&lt;/code&gt; everywhere — every reader knows where they are).&lt;/li&gt;
&lt;li&gt;Accepting that the two patterns are real, and not forcing every path into the same shape.&lt;/li&gt;
&lt;li&gt;Putting the differences inside the form pages and inside the Run node's &lt;code&gt;runJob&lt;/code&gt; parameter — the spine stays uniform.&lt;/li&gt;
&lt;li&gt;Designing the convergence: every path ends at the same node type, so adding a sixth service later is a copy-paste-edit, not a redesign.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are clever. Each is the obvious decision once you've seen the alternatives. But the obvious decisions are what hold the template together.&lt;/p&gt;

&lt;p&gt;There's a particular kind of reader this article is also for: the one who doesn't need the technique, who just needs a working template they can drop into n8n and run. The consolidation work is the deliverable for them. The fact that the article also explains how it works is a side benefit.&lt;/p&gt;

&lt;p&gt;This contrasts with a different design philosophy — the one that spreads concerns across many separate sub-workflows, each handling a narrow responsibility. In larger n8n installations with several maintainers, you might split these responsibilities into reusable sub-workflows, with each service called via the Execute Workflow node. For a solo-maintained distributable template intended to be lifted and adapted, the consolidated shape was easier to understand and ship. Fewer moving parts, fewer integration points, one place to read.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;This is parts.&lt;/p&gt;

&lt;p&gt;If you read this article and take the whole workflow, run it as-is, that's fine. If you take only the Switch routing and rebuild every service path from scratch, that's better in many cases. If you take only the Inject Binary trick because that's what bit you yesterday, that's the best use of this article.&lt;/p&gt;

&lt;p&gt;No two requirements are identical. Every reader is solving a different problem. A template that pretends to be a one-size answer would be lying. A template that is honest about being a parts catalog — here are the pieces, here is how they fit together, take what fits — is a different kind of useful thing.&lt;/p&gt;

&lt;p&gt;That's what &lt;code&gt;all-services-demo&lt;/code&gt; is meant to be. That's what this article is meant to be.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.npmjs.com/package/n8n-nodes-ldxhub" rel="noopener noreferrer"&gt;n8n-nodes-ldxhub package&lt;/a&gt; ships &lt;code&gt;examples/all-services-demo.json&lt;/code&gt; alongside the node code. Import it into your n8n instance, add an &lt;a href="https://gw.portal.ldxhub.io" rel="noopener noreferrer"&gt;LDX hub API key&lt;/a&gt; (free tier: 25,000 credits/month, no card), and the workflow runs. Open the JSON and lift parts into your own templates.&lt;/p&gt;

&lt;p&gt;This closes Phase 1 of Implementation notes — three articles, three angles on the same theme: how an n8n workflow becomes a small, distributable thing. The next phase will pick up other corners worth writing down.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>architecture</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Twenty four years, ten DB migrations, zero downtime</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 15 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633</link>
      <guid>https://dev.to/hidekimori/twenty-four-years-ten-db-migrations-zero-downtime-633</guid>
      <description>&lt;p&gt;Twenty four years. Ten DB migrations. Zero downtime.&lt;/p&gt;

&lt;p&gt;Except the first one, where I lost seven minutes I couldn't accept.&lt;/p&gt;

&lt;p&gt;That seven minutes is why this article exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  The seven minutes
&lt;/h2&gt;

&lt;p&gt;It was in the mid-2000s. I was running a content distribution system on my own, with a small open-source database underneath. The platform was growing, and at some point we decided to move to a much larger commercial database — an appliance-grade one, the kind you specify by line of business and not by hostname.&lt;/p&gt;

&lt;p&gt;I planned the switchover for a thirty-minute maintenance window. I did the work. End-to-end, it took seven minutes.&lt;/p&gt;

&lt;p&gt;Seven minutes during which the service was down. End users couldn't reach the catalog. Bookstores couldn't sync. Publishers couldn't see their numbers.&lt;/p&gt;

&lt;p&gt;It bothered me more than it should have. The migration was a success. The system came back up. Nobody complained.&lt;/p&gt;

&lt;p&gt;But it had been down. Seven minutes that, on paper, the agreement said was acceptable. Seven minutes that, in my head, I never wanted to repeat.&lt;/p&gt;

&lt;p&gt;That feeling didn't go away. I started designing every database operation from that day as if seven minutes was the wrong answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  The contract no one made me write
&lt;/h2&gt;

&lt;p&gt;That platform delivered books, comics, and music — content from publishers and labels through the bookstores that resold it. End users, bookstores, publishers, label owners: all of them sat on top of a single piece of plumbing I was responsible for.&lt;/p&gt;

&lt;p&gt;There was no formal SLA written anywhere that said "this never goes down." But there was something stronger than an SLA: a sales-side expectation. Inside the company, it was assumed the service was always reachable. Customers were sold on the assumption that downloads worked at any time of day. Bookstores integrated against that assumption. Publishers settled royalties on top of it.&lt;/p&gt;

&lt;p&gt;If I took the service down for an hour, none of those agreements would have technically been broken. But the silent contract — &lt;em&gt;you never notice me running maintenance&lt;/em&gt; — would have been.&lt;/p&gt;

&lt;p&gt;Once, before a particularly large migration, I had to brief one of the major carriers (they were on the bookstore side, technically a B2B customer). We met around a whiteboard. I drew the sequence. After about five minutes, the lead engineer on their side just nodded and said something like, "okay, if you're doing it, we're fine." We didn't need a recovery plan from them. We didn't need a coordinated test window. They didn't even update their monitoring.&lt;/p&gt;

&lt;p&gt;That trust didn't come from documentation. It came from the fact that none of the previous migrations had touched their integration.&lt;/p&gt;

&lt;p&gt;So the shape of how I do these migrations started with seven minutes I couldn't accept, and was kept alive by twenty-three years of not breaking that quiet contract again.&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;

&lt;p&gt;Here is the shape, stripped of vendor names. It is not new. It is not clever. It has just survived.&lt;/p&gt;

&lt;p&gt;Step zero: separate the data into two kinds.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data A&lt;/strong&gt;: anything the end user reads. This is what the service actually serves. If this is wrong or unavailable, the service is wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data B&lt;/strong&gt;: aggregates, summaries, derived tables, everything else. Batches write to it. End users never read from it directly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule for Data A is: real-time synchronization, both old and new databases, no exceptions. The rule for Data B is: batches can stop for a while, you'll catch up later.&lt;/p&gt;

&lt;p&gt;Step one: well before the migration window, set up two batches that incrementally copy Data A and Data B from the old database to the new one. Use the last-modified timestamp on each row. Take physical deletes into account by occasionally diffing the row sets and removing what's no longer there. Run these for days or weeks until the new database is essentially a copy of the old, plus or minus the most recent few minutes.&lt;/p&gt;

&lt;p&gt;Step two: at migration time, stop the batches that write Data B. Run the Data B copy one last time. The aggregate tables on the new database are now identical to the old.&lt;/p&gt;

&lt;p&gt;Step three: switch the application logic to a maintenance mode where Data A is written to both databases, but read only from the old one. Every user-facing update now produces two writes: one to the old database (the authoritative one), one to the new database (best effort, the eventually-authoritative one). If the write to the new database fails, it's swallowed — step four catches the drift.&lt;/p&gt;

&lt;p&gt;Step four: once all instances of the application are in this dual-write mode, run the Data A copy one final time. This catches anything that was written to the old database between the last sync and the dual-write switchover. After this, both databases agree.&lt;/p&gt;

&lt;p&gt;Step five: switch the application logic to read from the new database, while still writing to both. This is the moment of truth. If anything is wrong with the new database, this is when the user feels it.&lt;/p&gt;

&lt;p&gt;Step six: switch the application logic to read and write to the new database only. The old database is detached.&lt;/p&gt;

&lt;p&gt;For a migration (the new database stays), the batches that write Data B can now resume against the new database, and you're done. For a maintenance bypass (the old database is coming back), you do the same sequence in reverse, and the old database returns to service.&lt;/p&gt;

&lt;p&gt;For reference, here's how the application behaves at each step:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;App writes&lt;/th&gt;
&lt;th&gt;App reads&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Classify rows into Data A and Data B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;Incremental copy batches running&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;Stop Data B batches, final Data B copy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;old + new&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;Dual-write switchover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;old + new&lt;/td&gt;
&lt;td&gt;old&lt;/td&gt;
&lt;td&gt;Final Data A sync&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;old + new&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;td&gt;Read switchover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;td&gt;Old database detached&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The thing nobody talks about: mixed states
&lt;/h2&gt;

&lt;p&gt;This shape works because each step is &lt;em&gt;resilient to mixed states&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I deploy applications manually. I have for twenty-four years. There is no coordinated rolling restart, no atomic feature flag flip across all instances. When I switch the application logic to dual-write mode in step three, some instances are still in single-write mode (against the old database only) while others are already in dual-write mode. That mixed state can last as long as it takes me to walk through each server.&lt;/p&gt;

&lt;p&gt;The shape is designed so that mixed states are correct.&lt;/p&gt;

&lt;p&gt;When step three is rolling out: some instances write to old only, some write to both. All instances read from the old. The old database stays authoritative. Reads are consistent.&lt;/p&gt;

&lt;p&gt;When step five is rolling out: some instances read from old, some read from new. By this point both databases agree (step four just synced them). Either read is correct.&lt;/p&gt;

&lt;p&gt;When step six is rolling out: some instances still dual-write, some write to new only. All instances read from new. The new database is authoritative. The few writes that still hit the old database are harmless — it'll be detached momentarily.&lt;/p&gt;

&lt;p&gt;I don't have to wait for a deployment to finish. I don't need a feature flag system to coordinate it. I don't need a service mesh to make it safe. I need the property that the shape stays correct while it's transitioning.&lt;/p&gt;

&lt;p&gt;This is the part of the design that is older than every operational tool I see today. And it's the part I haven't found a reason to replace.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rollback was never a special case
&lt;/h2&gt;

&lt;p&gt;If you'd asked me five years ago whether this design has rollback, I would have said yes, of course. The reverse sequence &lt;em&gt;is&lt;/em&gt; the rollback.&lt;/p&gt;

&lt;p&gt;A maintenance bypass already runs forward, then backward. That backward leg is rollback. It's executed every single time. Rollback isn't an emergency path. It's a normal part of the workflow.&lt;/p&gt;

&lt;p&gt;I've done this kind of migration over ten times in twenty-four years. I have never had to use the reverse sequence as an emergency. Not because nothing ever went wrong. But because the preparation phase — the days and weeks of incremental copying, of double-checking the deletes, of staring at row counts — catches the things that would have gone wrong, before they can.&lt;/p&gt;

&lt;p&gt;The boring part of the work is what makes the dramatic part of the work disappear.&lt;/p&gt;




&lt;h2&gt;
  
  
  Twenty four years later
&lt;/h2&gt;

&lt;p&gt;I'm going to write something now that should probably embarrass me but doesn't: I enjoy this work.&lt;/p&gt;

&lt;p&gt;A new database to move into — especially a serious appliance-grade one — is one of the most enjoyable things I get to do. The preparation is meditative. The cutover itself is short and quiet. The week after, when the system is running on the new hardware and the end users have noticed nothing, is satisfying in a way I have not gotten from any other kind of engineering.&lt;/p&gt;

&lt;p&gt;Twenty four years. Ten migrations. Zero downtime, after the seven minutes I couldn't accept.&lt;/p&gt;

&lt;p&gt;That's the entire story. There are other databases out there now, other appliances, other ways of doing this. There are managed services that do most of the dance automatically. There are tools that take a lot of the carefulness off your hands.&lt;/p&gt;

&lt;p&gt;I don't have an argument against any of those. I just know what survives in my hands: a separation of data into two kinds, a dual-write window in the middle, a resilience to mixed states, and a reverse sequence I always treat as ordinary.&lt;/p&gt;

&lt;p&gt;This is not what you should do. This is what twenty-four years has taught one specific person to do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Earlier in this series:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-accordion-pattern-why-i-stopped-writing-one-fat-llm-prompt-18mb"&gt;The Accordion Pattern: Why I stopped writing one fat LLM prompt&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/nobody-knows-when-a-job-will-finish-id-still-like-to-report-it-accurately-26nn"&gt;Nobody knows when a job will finish. I'd still like to report it accurately.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/what-survives-when-you-build-alone-for-24-years-4e7d"&gt;What survives when you build alone for 24 years&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/dynamic-isnt-enough-operations-is-the-other-half-2d8f"&gt;Dynamic isn't enough. Operations is the other half.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/live-report-at-this-speed-you-dont-theorize-you-eliminate-1o7h"&gt;Live report: at this speed, you don't theorize. You eliminate.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/the-loop-i-didnt-notice-closing-16h8"&gt;The loop I didn't notice closing&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://dev.to/hidekimori/abstractions-are-fine-starting-on-them-isnt-12ff"&gt;Abstractions are fine. Starting on them isn't.&lt;/a&gt;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>database</category>
      <category>devops</category>
      <category>architecture</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
