<rss xmlns:source="http://source.scripting.com/" version="2.0">
  <channel>
    <title>LLBBL Blog</title>
    <link>https://llbbl.blog/</link>
    <description></description>
    
    <language>en</language>
    
    <lastBuildDate>Mon, 24 Aug 2026 10:00:00 -0500</lastBuildDate>
    <item>
      <title>JPEG Quality 80 Is Not a Setting</title>
      <link>https://llbbl.blog/2026/08/24/jpeg-quality-is-not-a.html</link>
      <pubDate>Mon, 24 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/24/jpeg-quality-is-not-a.html</guid>
      <description>&lt;p&gt;MP4&amp;rsquo;s boxes are exact about everything: every byte accounted for, every offset written down. JPEG is exact about its structure too, and then hands you one control that has no defined meaning at all.&lt;/p&gt;
&lt;p&gt;It destroys things. That is the entire point, and it will not tell you how much.&lt;/p&gt;
&lt;p&gt;Here is one 512x512 PNG, encoded to JPEG four times, on the same machine, with every encoder set to quality 80.&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;q80-cjpeg.jpg      42595 bytes    (libjpeg-turbo 3.2.0)
q80-pillow.jpg     42595 bytes    (Pillow 12.3.0)
q80-sips.jpg       67865 bytes    (macOS sips)
q80-ffmpeg.jpg     13847 bytes    (ffmpeg -q:v 80)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Same input. Same number typed into the same-named parameter. A 5x spread in output size.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-number-is-an-index-not-a-measurement&#34;&gt;The Number Is an Index, Not a Measurement&lt;/h2&gt;
&lt;p&gt;There is no quality field in a JPEG file. Nothing in ISO/IEC 10918-1 defines a scale from 0 to 100. What the file actually carries is a &lt;strong&gt;quantization table&lt;/strong&gt;: 64 integers that every DCT coefficient in an 8x8 block gets divided by before rounding. Bigger divisors, more coefficients rounded to zero, smaller file, more damage.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;Quality 80&amp;rdquo; is just a name your encoder gives to one particular table. Here is the first row of the luminance table each of those four files chose:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;cjpeg    6   4   4   6  10  16  20  24
pillow   6   4   4   6  10  16  20  24
sips     2   2   2   3   4   5   7   8
ffmpeg   8  62  73  85 100 104 112 131
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;cjpeg and Pillow are identical because Pillow links libjpeg and inherits its table. Both are the standard Annex K example table scaled by a linear formula: at quality 80 the scale factor is 40%, and 16 x 0.40 rounds to 6, 11 x 0.40 rounds to 4, and so on down the row.&lt;/p&gt;
&lt;p&gt;macOS &lt;code&gt;sips&lt;/code&gt; divides by 2 where libjpeg divides by 6. Its quality 80 lands somewhere around libjpeg&amp;rsquo;s 93, and it is not the standard table scaled differently, it is a different table. Apple picked their own numbers.&lt;/p&gt;
&lt;p&gt;ffmpeg is the funny one. Its &lt;code&gt;-q:v&lt;/code&gt; for MJPEG runs 2 to 31, where &lt;strong&gt;lower&lt;/strong&gt; is better. So &lt;code&gt;-q:v 80&lt;/code&gt; gets clamped to 31, the worst setting it has:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ ffmpeg -i source.png -q:v 31 f31.jpg
$ cmp f31.jpg q80-ffmpeg.jpg
$
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Byte-identical. Asking ffmpeg for 80 asks it for the ugliest image it knows how to make, and it does not warn you, because 80 is a perfectly valid thing to say to a parameter that happens to top out at 31.&lt;/p&gt;
&lt;p&gt;None of these encoders is wrong. The specification never told them what 80 means.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-damage-is-mostly-not-where-you-think&#34;&gt;The Damage Is Mostly Not Where You Think&lt;/h2&gt;
&lt;p&gt;The DCT-and-quantize step gets all the attention. It is not usually the thing wrecking your image.&lt;/p&gt;
&lt;p&gt;Before any of that happens, the encoder converts RGB to YCbCr and then, by default, throws away three quarters of the color information. 4:2:0 subsampling averages the two chroma channels over 2x2 pixel blocks. Human vision is much less sensitive to color detail than to brightness detail, so most of the time you cannot see it.&lt;/p&gt;
&lt;p&gt;Most of the time. Here is a 400x120 image, pure red on pure blue, encoded at &lt;strong&gt;quality 95&lt;/strong&gt; both ways:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;                    size      max channel error   mean error
4:4:4 (no subsampling)   19688 bytes          20         0.59
4:2:0 (default)          10782 bytes         232        14.70
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Quality 95 is a setting people reach for when they want the image to be basically untouched. At 4:2:0 a single channel is off by 232 out of 255. The red and blue have the same luminance, so the entire edge between them lives in chroma, and chroma is the part that got averaged away.&lt;/p&gt;
&lt;p&gt;This is why red text on a colored background looks like it was scanned by a fax machine, why UI screenshots with colored syntax highlighting come out muddy, and why a logo saved as JPEG at &amp;ldquo;high quality&amp;rdquo; still has a smeared halo. Turning subsampling off costs about 80% more bytes here and takes the error from 232 to 20.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cjpeg -sample 1x1&lt;/code&gt; does it. In Pillow it is &lt;code&gt;subsampling=0&lt;/code&gt;. Almost no tool exposes it in a GUI, and almost every default is 4:2:0.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;generation-loss-converges&#34;&gt;Generation Loss Converges&lt;/h2&gt;
&lt;p&gt;The folk wisdom is that re-saving a JPEG degrades it a little more each time, forever, until it turns to soup. I re-encoded the same image 50 times at quality 85, decoding and re-encoding each round, and measured the drift from the original:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;gen   size(bytes)   mean err   max err
  1        48911       3.51        194
  2        49532       4.11        188
  5        49351       5.21        197
 10        49299       6.17        199
 25        49249       7.20        190
 50        49189       7.54        190
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Most of the loss happens on save one. Generation two adds about half a point. Generations 25 through 50 add a third of a point between them, and the maximum error never moves at all.&lt;/p&gt;
&lt;p&gt;It converges because quantization is idempotent once you land on the grid. Decode a coefficient that was rounded to 6 times its divisor, transform it back, and it quantizes to the same bucket. The image reaches a fixed point that survives re-encoding. What breaks this is changing anything: a different quality, a different subsampling mode, a crop that shifts the 8x8 block boundaries, or a rotation that resamples. Then you land on a new grid and pay the first-generation cost again.&lt;/p&gt;
&lt;p&gt;Which is the actual reason &lt;code&gt;jpegtran&lt;/code&gt; exists:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ jpegtran -rotate 180 -outfile r1.jpg original.jpg
$ jpegtran -rotate 180 -outfile r2.jpg r1.jpg
$ cmp original.jpg r2.jpg
$
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Two 180-degree rotations, byte-identical to the input. &lt;code&gt;jpegtran&lt;/code&gt; permutes the already-quantized coefficient blocks without ever decoding to pixels, so there is nothing to re-quantize. Rotating, flipping, and cropping on 8-pixel boundaries are all lossless if you use the right tool. Almost nobody does.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-the-file-looks-like&#34;&gt;What the File Looks Like&lt;/h2&gt;
&lt;p&gt;Worth thirty seconds, because the structure is unusually clean. Every marker is &lt;code&gt;0xFF&lt;/code&gt; followed by a type byte, and every marker except the two bare ones carries a 2-byte big-endian length:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ xxd -g 1 -l 32 q80-cjpeg.jpg
00000000: ff d8 ff e0 00 10 4a 46 49 46 00 01 01 00 00 01  ......JFIF......
00000010: 00 01 00 00 ff db 00 43 00 06 04 05 06 05 04 06  .......C........
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;ffd8&lt;/code&gt; is Start of Image. &lt;code&gt;ffe0&lt;/code&gt; is APP0, length &lt;code&gt;0x0010&lt;/code&gt;, containing the literal string &lt;code&gt;JFIF\0&lt;/code&gt;. Then at offset 20, &lt;code&gt;ffdb&lt;/code&gt; is Define Quantization Table, length 67, table 0, and the bytes after it are the 64 divisors in zigzag order. &lt;code&gt;06 04 05 06 05 04&lt;/code&gt; is that first row I printed above, read diagonally.&lt;/p&gt;
&lt;p&gt;Walking the whole file:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;     0  FFD8  SOI
     2  FFE0  APP0  length 16
    20  FFDB  DQT   length 67
    89  FFDB  DQT   length 67
   158  FFC0  SOF0  length 17
   177  FFC4  DHT   length 31
   210  FFC4  DHT   length 181
   393  FFC4  DHT   length 31
   426  FFC4  DHT   length 181
   609  FFDA  SOS   length 12
 42593  FFD9  EOI
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Everything structural fits in the first 623 bytes. The remaining 42,000 are entropy-coded scan data with no framing at all, which creates one last problem: if a Huffman code happens to emit the byte &lt;code&gt;0xFF&lt;/code&gt;, a decoder scanning for markers would misread it. So the encoder stuffs a &lt;code&gt;0x00&lt;/code&gt; after every literal &lt;code&gt;0xFF&lt;/code&gt;, and the decoder throws it away. This file contains 504 of those.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-to-do-about-it&#34;&gt;What To Do About It&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Never move a quality number between tools.&lt;/strong&gt; Quality 80 in Photoshop, &lt;code&gt;cjpeg&lt;/code&gt;, &lt;code&gt;sips&lt;/code&gt;, and ffmpeg are four unrelated things. If you are porting a pipeline, re-tune by measuring output size or error, not by copying the integer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Turn off chroma subsampling for anything with saturated color edges.&lt;/strong&gt; Logos, screenshots, charts, text. &lt;code&gt;-sample 1x1&lt;/code&gt; in cjpeg, &lt;code&gt;subsampling=0&lt;/code&gt; in Pillow. Leave 4:2:0 on for photographs, where it is nearly free.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use &lt;code&gt;jpegtran&lt;/code&gt; for rotations and crops.&lt;/strong&gt; &lt;code&gt;-rotate&lt;/code&gt;, &lt;code&gt;-flip&lt;/code&gt;, &lt;code&gt;-crop&lt;/code&gt;, and &lt;code&gt;-perfect&lt;/code&gt; operate on coefficients and cost nothing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stop worrying about generation loss and start worrying about generation one.&lt;/strong&gt; The first save is where the damage is. Keep the original.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don&amp;rsquo;t put a JPEG in the middle of a pipeline.&lt;/strong&gt; Every intermediate step should be PNG or the raw source. JPEG is an output format.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Strip the metadata deliberately.&lt;/strong&gt; APP1 holds Exif, which holds GPS coordinates, camera serial numbers, and an embedded thumbnail. Some editors update the pixels and leave the old thumbnail in place, so the crop you made survives only in the big version.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The interesting thing about JPEG is not that it is lossy. Everyone signed up for lossy. It is that the one control the format exposes to users, the quality number, is not part of the format, has no defined meaning, and is quietly reinterpreted by every tool that offers it. You are not setting the quality. You are picking a preset out of a list you cannot see.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://www.itu.int/rec/T-REC-T.81-199209-I&#34;&gt;ITU-T T.81 / ISO/IEC 10918-1&lt;/a&gt; — the core JPEG specification; Annex K holds the example quantization tables everybody scales&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.itu.int/rec/T-REC-T.871&#34;&gt;ITU-T T.871&lt;/a&gt; — JFIF, standardized in 2011, nineteen years after the industry started shipping it&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.w3.org/Graphics/JPEG/jfif3.pdf&#34;&gt;W3C copy of the JFIF 1.02 specification&lt;/a&gt; — the original C-Cube document from September 1992&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://ijg.org/&#34;&gt;Independent JPEG Group&lt;/a&gt; — libjpeg, whose quality-to-table formula became the de facto meaning of the number&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://libjpeg-turbo.org/&#34;&gt;libjpeg-turbo&lt;/a&gt; — what almost everything actually links against today&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>MP4&#39;s boxes are exact about everything: every byte accounted for, every offset written down. JPEG is exact about its structure too, and then hands you one control that has no defined meaning at all.

It destroys things. That is the entire point, and it will not tell you how much.

Here is one 512x512 PNG, encoded to JPEG four times, on the same machine, with every encoder set to quality 80.

```
q80-cjpeg.jpg      42595 bytes    (libjpeg-turbo 3.2.0)
q80-pillow.jpg     42595 bytes    (Pillow 12.3.0)
q80-sips.jpg       67865 bytes    (macOS sips)
q80-ffmpeg.jpg     13847 bytes    (ffmpeg -q:v 80)
```

Same input. Same number typed into the same-named parameter. A 5x spread in output size.

---

## The Number Is an Index, Not a Measurement

There is no quality field in a JPEG file. Nothing in ISO/IEC 10918-1 defines a scale from 0 to 100. What the file actually carries is a **quantization table**: 64 integers that every DCT coefficient in an 8x8 block gets divided by before rounding. Bigger divisors, more coefficients rounded to zero, smaller file, more damage.

&#34;Quality 80&#34; is just a name your encoder gives to one particular table. Here is the first row of the luminance table each of those four files chose:

```
cjpeg    6   4   4   6  10  16  20  24
pillow   6   4   4   6  10  16  20  24
sips     2   2   2   3   4   5   7   8
ffmpeg   8  62  73  85 100 104 112 131
```

cjpeg and Pillow are identical because Pillow links libjpeg and inherits its table. Both are the standard Annex K example table scaled by a linear formula: at quality 80 the scale factor is 40%, and 16 x 0.40 rounds to 6, 11 x 0.40 rounds to 4, and so on down the row.

macOS `sips` divides by 2 where libjpeg divides by 6. Its quality 80 lands somewhere around libjpeg&#39;s 93, and it is not the standard table scaled differently, it is a different table. Apple picked their own numbers.

ffmpeg is the funny one. Its `-q:v` for MJPEG runs 2 to 31, where **lower** is better. So `-q:v 80` gets clamped to 31, the worst setting it has:

```
$ ffmpeg -i source.png -q:v 31 f31.jpg
$ cmp f31.jpg q80-ffmpeg.jpg
$
```

Byte-identical. Asking ffmpeg for 80 asks it for the ugliest image it knows how to make, and it does not warn you, because 80 is a perfectly valid thing to say to a parameter that happens to top out at 31.

None of these encoders is wrong. The specification never told them what 80 means.

---

## The Damage Is Mostly Not Where You Think

The DCT-and-quantize step gets all the attention. It is not usually the thing wrecking your image.

Before any of that happens, the encoder converts RGB to YCbCr and then, by default, throws away three quarters of the color information. 4:2:0 subsampling averages the two chroma channels over 2x2 pixel blocks. Human vision is much less sensitive to color detail than to brightness detail, so most of the time you cannot see it.

Most of the time. Here is a 400x120 image, pure red on pure blue, encoded at **quality 95** both ways:

```
                    size      max channel error   mean error
4:4:4 (no subsampling)   19688 bytes          20         0.59
4:2:0 (default)          10782 bytes         232        14.70
```

Quality 95 is a setting people reach for when they want the image to be basically untouched. At 4:2:0 a single channel is off by 232 out of 255. The red and blue have the same luminance, so the entire edge between them lives in chroma, and chroma is the part that got averaged away.

This is why red text on a colored background looks like it was scanned by a fax machine, why UI screenshots with colored syntax highlighting come out muddy, and why a logo saved as JPEG at &#34;high quality&#34; still has a smeared halo. Turning subsampling off costs about 80% more bytes here and takes the error from 232 to 20.

`cjpeg -sample 1x1` does it. In Pillow it is `subsampling=0`. Almost no tool exposes it in a GUI, and almost every default is 4:2:0.

---

## Generation Loss Converges

The folk wisdom is that re-saving a JPEG degrades it a little more each time, forever, until it turns to soup. I re-encoded the same image 50 times at quality 85, decoding and re-encoding each round, and measured the drift from the original:

```
gen   size(bytes)   mean err   max err
  1        48911       3.51        194
  2        49532       4.11        188
  5        49351       5.21        197
 10        49299       6.17        199
 25        49249       7.20        190
 50        49189       7.54        190
```

Most of the loss happens on save one. Generation two adds about half a point. Generations 25 through 50 add a third of a point between them, and the maximum error never moves at all.

It converges because quantization is idempotent once you land on the grid. Decode a coefficient that was rounded to 6 times its divisor, transform it back, and it quantizes to the same bucket. The image reaches a fixed point that survives re-encoding. What breaks this is changing anything: a different quality, a different subsampling mode, a crop that shifts the 8x8 block boundaries, or a rotation that resamples. Then you land on a new grid and pay the first-generation cost again.

Which is the actual reason `jpegtran` exists:

```
$ jpegtran -rotate 180 -outfile r1.jpg original.jpg
$ jpegtran -rotate 180 -outfile r2.jpg r1.jpg
$ cmp original.jpg r2.jpg
$
```

Two 180-degree rotations, byte-identical to the input. `jpegtran` permutes the already-quantized coefficient blocks without ever decoding to pixels, so there is nothing to re-quantize. Rotating, flipping, and cropping on 8-pixel boundaries are all lossless if you use the right tool. Almost nobody does.

---

## What the File Looks Like

Worth thirty seconds, because the structure is unusually clean. Every marker is `0xFF` followed by a type byte, and every marker except the two bare ones carries a 2-byte big-endian length:

```
$ xxd -g 1 -l 32 q80-cjpeg.jpg
00000000: ff d8 ff e0 00 10 4a 46 49 46 00 01 01 00 00 01  ......JFIF......
00000010: 00 01 00 00 ff db 00 43 00 06 04 05 06 05 04 06  .......C........
```

`ffd8` is Start of Image. `ffe0` is APP0, length `0x0010`, containing the literal string `JFIF\0`. Then at offset 20, `ffdb` is Define Quantization Table, length 67, table 0, and the bytes after it are the 64 divisors in zigzag order. `06 04 05 06 05 04` is that first row I printed above, read diagonally.

Walking the whole file:

```
     0  FFD8  SOI
     2  FFE0  APP0  length 16
    20  FFDB  DQT   length 67
    89  FFDB  DQT   length 67
   158  FFC0  SOF0  length 17
   177  FFC4  DHT   length 31
   210  FFC4  DHT   length 181
   393  FFC4  DHT   length 31
   426  FFC4  DHT   length 181
   609  FFDA  SOS   length 12
 42593  FFD9  EOI
```

Everything structural fits in the first 623 bytes. The remaining 42,000 are entropy-coded scan data with no framing at all, which creates one last problem: if a Huffman code happens to emit the byte `0xFF`, a decoder scanning for markers would misread it. So the encoder stuffs a `0x00` after every literal `0xFF`, and the decoder throws it away. This file contains 504 of those.

---

## What To Do About It

- **Never move a quality number between tools.** Quality 80 in Photoshop, `cjpeg`, `sips`, and ffmpeg are four unrelated things. If you are porting a pipeline, re-tune by measuring output size or error, not by copying the integer.
- **Turn off chroma subsampling for anything with saturated color edges.** Logos, screenshots, charts, text. `-sample 1x1` in cjpeg, `subsampling=0` in Pillow. Leave 4:2:0 on for photographs, where it is nearly free.
- **Use `jpegtran` for rotations and crops.** `-rotate`, `-flip`, `-crop`, and `-perfect` operate on coefficients and cost nothing.
- **Stop worrying about generation loss and start worrying about generation one.** The first save is where the damage is. Keep the original.
- **Don&#39;t put a JPEG in the middle of a pipeline.** Every intermediate step should be PNG or the raw source. JPEG is an output format.
- **Strip the metadata deliberately.** APP1 holds Exif, which holds GPS coordinates, camera serial numbers, and an embedded thumbnail. Some editors update the pixels and leave the old thumbnail in place, so the crop you made survives only in the big version.

The interesting thing about JPEG is not that it is lossy. Everyone signed up for lossy. It is that the one control the format exposes to users, the quality number, is not part of the format, has no defined meaning, and is quietly reinterpreted by every tool that offers it. You are not setting the quality. You are picking a preset out of a list you cannot see.

## Sources

- [ITU-T T.81 / ISO/IEC 10918-1](https://www.itu.int/rec/T-REC-T.81-199209-I) — the core JPEG specification; Annex K holds the example quantization tables everybody scales
- [ITU-T T.871](https://www.itu.int/rec/T-REC-T.871) — JFIF, standardized in 2011, nineteen years after the industry started shipping it
- [W3C copy of the JFIF 1.02 specification](https://www.w3.org/Graphics/JPEG/jfif3.pdf) — the original C-Cube document from September 1992
- [Independent JPEG Group](https://ijg.org/) — libjpeg, whose quality-to-table formula became the de facto meaning of the number
- [libjpeg-turbo](https://libjpeg-turbo.org/) — what almost everything actually links against today

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>An AVIF Is an MP4 With One Frame</title>
      <link>https://llbbl.blog/2026/08/23/an-avif-is-an-mp.html</link>
      <pubDate>Sun, 23 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/23/an-avif-is-an-mp.html</guid>
      <description>&lt;p&gt;GIF&amp;rsquo;s structure was a header, some tables, and a sequence of blocks with a one-byte type code. MP4 is stricter than that, and the strictness is the whole design. Every byte in the file lives inside a box, and every box starts the same way:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;4 bytes   size, big-endian, including this header
4 bytes   type, four ASCII characters
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That is it. Eight bytes, and any parser that knows nothing else about the format can walk the entire file, skipping what it doesn&amp;rsquo;t understand. Some boxes contain other boxes. Some contain payload. There is no data outside a box anywhere in the file.&lt;/p&gt;
&lt;p&gt;That design is why the container outgrew video entirely.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;same-header-different-extension&#34;&gt;Same Header, Different Extension&lt;/h2&gt;
&lt;p&gt;Here is a ten-second 640x360 H.264 clip:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;ftyp  32 bytes  @ 0
free  8 bytes  @ 32
mdat  63602 bytes  @ 40
moov  4424 bytes  @ 63642
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Here is a three-second AAC audio file:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;ftyp  28 bytes  @ 0
free  8 bytes  @ 28
mdat  26304 bytes  @ 36
moov  1287 bytes  @ 26340
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And here is a still image:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;ftyp  32 bytes  @ 0
meta  235 bytes  @ 32
  hdlr  33 bytes  @ 44
  pitm  14 bytes  @ 77
  iloc  30 bytes  @ 91
  iinf  40 bytes  @ 121
  iprp  106 bytes  @ 161
    ipco  75 bytes  @ 169
    ipma  23 bytes  @ 244
mdat  17667 bytes  @ 267
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That last one is an AVIF. A photograph. It has the same box header format, the same &lt;code&gt;ftyp&lt;/code&gt; first, the same &lt;code&gt;mdat&lt;/code&gt; holding the payload. What changed is that a still image has no timeline, so instead of &lt;code&gt;moov&lt;/code&gt; with its sample tables it uses &lt;code&gt;meta&lt;/code&gt; with an item structure: &lt;code&gt;pitm&lt;/code&gt; names the primary item, &lt;code&gt;iloc&lt;/code&gt; says where in &lt;code&gt;mdat&lt;/code&gt; that item&amp;rsquo;s bytes live, &lt;code&gt;iprp&lt;/code&gt; carries its properties.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;ftyp&lt;/code&gt; box says which dialect you are reading:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ xxd -g 1 -l 32 shot.avif
00000000: 00 00 00 20 66 74 79 70 61 76 69 66 00 00 00 00  ... ftypavif....
00000010: 61 76 69 66 6d 69 66 31 6d 69 61 66 4d 41 31 42  avifmif1miafMA1B
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Size 32, type &lt;code&gt;ftyp&lt;/code&gt;, major brand &lt;code&gt;avif&lt;/code&gt;, then four compatible brands: &lt;code&gt;avif&lt;/code&gt;, &lt;code&gt;mif1&lt;/code&gt;, &lt;code&gt;miaf&lt;/code&gt;, &lt;code&gt;MA1B&lt;/code&gt;. The &lt;code&gt;.m4a&lt;/code&gt; file above declares &lt;code&gt;M4A &lt;/code&gt; with &lt;code&gt;isom&lt;/code&gt; as a compatible brand. The MP4 declares &lt;code&gt;isom&lt;/code&gt; with &lt;code&gt;iso2&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;So &lt;code&gt;.mp4&lt;/code&gt;, &lt;code&gt;.mov&lt;/code&gt;, &lt;code&gt;.m4a&lt;/code&gt;, &lt;code&gt;.m4v&lt;/code&gt;, &lt;code&gt;.heic&lt;/code&gt;, and &lt;code&gt;.avif&lt;/code&gt; are one format with six extensions. Your iPhone photo library and your video library are the same container. That happened because in February 1998, ISO picked Apple&amp;rsquo;s QuickTime file format as the basis for MPEG-4&amp;rsquo;s container, and the box model turned out to be general enough that everyone who needed a container afterward just used it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-box-in-the-wrong-place&#34;&gt;The Box in the Wrong Place&lt;/h2&gt;
&lt;p&gt;Now the flaw that shaped an entire decade of web video.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;moov&lt;/code&gt; box holds the sample tables: which frame starts at which byte, how long it lasts, which chunk it belongs to. &lt;code&gt;mdat&lt;/code&gt; holds the frames. A player can decode nothing until it has read &lt;code&gt;moov&lt;/code&gt;, because &lt;code&gt;mdat&lt;/code&gt; has no internal framing at all. It is one undifferentiated run of bytes, and the only thing that says where frame 0 begins is a number inside &lt;code&gt;moov&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Look at where &lt;code&gt;moov&lt;/code&gt; ended up in that first file. Byte 63,642 of a 68,066-byte file.&lt;/p&gt;
&lt;p&gt;An encoder writing sequentially cannot know the byte offset of the last chunk until it has written the last chunk, so the natural thing is to write all of &lt;code&gt;mdat&lt;/code&gt; and then append &lt;code&gt;moov&lt;/code&gt; at the end. That is what nearly every encoder did by default, and it means the player must reach the last 6% of the file before it can show you the first frame.&lt;/p&gt;
&lt;p&gt;The fix is a post-processing pass:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ ffmpeg -i input -c:v libx264 -movflags +faststart out.mp4
&lt;/code&gt;&lt;/pre&gt;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;ftyp  32 bytes  @ 0
moov  4424 bytes  @ 32
free  8 bytes  @ 4456
mdat  63602 bytes  @ 4464
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Same boxes. Same sizes. Same total file length, 68,066 bytes both times, and I checked the &lt;code&gt;mdat&lt;/code&gt; payloads byte for byte: identical. All that changed is the order.&lt;/p&gt;
&lt;p&gt;It is not quite a memmove, though, because &lt;code&gt;moov&lt;/code&gt;&amp;rsquo;s offsets are absolute positions in the file:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;plain.mp4    stco has 1 chunk offsets; first five: [48]
fast.mp4     stco has 1 chunk offsets; first five: [4472]
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Moving &lt;code&gt;moov&lt;/code&gt; in front of &lt;code&gt;mdat&lt;/code&gt; pushed every byte of media 4,424 places later, so every entry in the chunk offset table had to be rewritten by exactly that amount. On a real file with thousands of chunks, that is thousands of pointers, all of which must be corrected, and if the rewrite changes the size of &lt;code&gt;moov&lt;/code&gt; (32-bit offsets overflowing into &lt;code&gt;co64&lt;/code&gt;) the whole thing has to be recomputed again.&lt;/p&gt;
&lt;p&gt;Absolute offsets are the design decision underneath most of MP4&amp;rsquo;s awkwardness. You cannot concatenate two MP4s. You cannot insert a second of video in the middle. You cannot append to a file that is still being written and have it remain playable. Everything is pointer arithmetic against byte zero.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;fragments-are-the-actual-answer&#34;&gt;Fragments Are the Actual Answer&lt;/h2&gt;
&lt;p&gt;Fragmented MP4 fixes it by giving up on the single index. Instead of one &lt;code&gt;moov&lt;/code&gt; describing the whole timeline, you get an initialization segment and then a run of self-describing chunks:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;[ ftyp + moov ]  [ moof + mdat ]  [ moof + mdat ]  [ moof + mdat ] ...
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Each &lt;code&gt;moof&lt;/code&gt; carries the sample table for the &lt;code&gt;mdat&lt;/code&gt; that follows it, with offsets relative to the fragment rather than the file. Which means you can start writing before you know how long the video is, cut the stream anywhere, serve any fragment independently, and switch bitrates between fragments without the player noticing.&lt;/p&gt;
&lt;p&gt;That property is the entire basis of HLS and DASH. Every adaptive-bitrate stream you have watched is this: a manifest, an init segment, and a pile of &lt;code&gt;moof&lt;/code&gt;/&lt;code&gt;mdat&lt;/code&gt; pairs that a player stitches together while quietly swapping quality levels based on your bandwidth.&lt;/p&gt;
&lt;p&gt;It also means the &lt;code&gt;moov&lt;/code&gt; placement problem is now mostly historical for streaming and still completely current for files. Anything you upload, download, or store as a single &lt;code&gt;.mp4&lt;/code&gt; still has one &lt;code&gt;moov&lt;/code&gt;, and it is still in whichever place the encoder happened to put it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-to-do-about-it&#34;&gt;What To Do About It&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Always pass &lt;code&gt;-movflags +faststart&lt;/code&gt;&lt;/strong&gt; when producing MP4 for the web. It costs one extra pass over the file at encode time and nothing at all afterward.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check where &lt;code&gt;moov&lt;/code&gt; landed&lt;/strong&gt; before blaming the network. Eight bytes of parsing tells you: read the size at offset 0, jump, read the type, repeat. If &lt;code&gt;moov&lt;/code&gt; is last, that is your slow start.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use fMP4 for anything live or adaptive.&lt;/strong&gt; A single-file MP4 cannot be written and played at the same time, no matter how you order the boxes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don&amp;rsquo;t concatenate MP4 files.&lt;/strong&gt; &lt;code&gt;cat a.mp4 b.mp4 &amp;gt; c.mp4&lt;/code&gt; produces a file whose first &lt;code&gt;moov&lt;/code&gt; describes only the first video and whose second &lt;code&gt;moov&lt;/code&gt; has offsets pointing into the wrong place. Remux with &lt;code&gt;ffmpeg -f concat&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Treat &lt;code&gt;.heic&lt;/code&gt; and &lt;code&gt;.avif&lt;/code&gt; as the same problem space.&lt;/strong&gt; If your image pipeline calls &lt;code&gt;identify&lt;/code&gt; or sniffs magic bytes, those files start with a box header, not a signature, and the four bytes that matter are at offset 4 rather than offset 0.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read &lt;code&gt;ftyp&lt;/code&gt; compatible brands, not the extension.&lt;/strong&gt; A file named &lt;code&gt;.mp4&lt;/code&gt; can declare &lt;code&gt;qt  &lt;/code&gt;, and a file named &lt;code&gt;.mov&lt;/code&gt; can declare &lt;code&gt;isom&lt;/code&gt;. The brands are the truth.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Everything in this series so far has failed by underspecifying something. MP4 does not have that problem. The box model is rigorous, self-describing, and general enough that it absorbed still images without anyone having to redesign it. What it got wrong was one thing: it wrote down byte offsets instead of relative ones, and made the index a single object that has to be complete before it can be written. Two decades of streaming infrastructure exist to work around that decision.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://www.iso.org/standard/68960.html&#34;&gt;ISO/IEC 14496-12&lt;/a&gt; — the ISO Base Media File Format; the box definition is clause 4.2&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://mp4ra.org/&#34;&gt;MP4 Registration Authority&lt;/a&gt; — the registry of every legal FourCC brand and box type&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://developer.apple.com/standards/classic-quicktime/&#34;&gt;Apple&amp;rsquo;s QuickTime File Format documentation&lt;/a&gt; — the atom model MP4 inherited&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://aomediacodec.github.io/av1-isobmff/&#34;&gt;AVIF specification&lt;/a&gt; — how AV1 intra frames map onto ISOBMFF items&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.rfc-editor.org/rfc/rfc8216&#34;&gt;RFC 8216&lt;/a&gt; — HTTP Live Streaming, which is fMP4 plus a text manifest&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://ffmpeg.org/ffmpeg-formats.html&#34;&gt;ffmpeg movflags documentation&lt;/a&gt; — &lt;code&gt;+faststart&lt;/code&gt; and the fragmentation options&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>GIF&#39;s structure was a header, some tables, and a sequence of blocks with a one-byte type code. MP4 is stricter than that, and the strictness is the whole design. Every byte in the file lives inside a box, and every box starts the same way:

```
4 bytes   size, big-endian, including this header
4 bytes   type, four ASCII characters
```

That is it. Eight bytes, and any parser that knows nothing else about the format can walk the entire file, skipping what it doesn&#39;t understand. Some boxes contain other boxes. Some contain payload. There is no data outside a box anywhere in the file.

That design is why the container outgrew video entirely.

---

## Same Header, Different Extension

Here is a ten-second 640x360 H.264 clip:

```
ftyp  32 bytes  @ 0
free  8 bytes  @ 32
mdat  63602 bytes  @ 40
moov  4424 bytes  @ 63642
```

Here is a three-second AAC audio file:

```
ftyp  28 bytes  @ 0
free  8 bytes  @ 28
mdat  26304 bytes  @ 36
moov  1287 bytes  @ 26340
```

And here is a still image:

```
ftyp  32 bytes  @ 0
meta  235 bytes  @ 32
  hdlr  33 bytes  @ 44
  pitm  14 bytes  @ 77
  iloc  30 bytes  @ 91
  iinf  40 bytes  @ 121
  iprp  106 bytes  @ 161
    ipco  75 bytes  @ 169
    ipma  23 bytes  @ 244
mdat  17667 bytes  @ 267
```

That last one is an AVIF. A photograph. It has the same box header format, the same `ftyp` first, the same `mdat` holding the payload. What changed is that a still image has no timeline, so instead of `moov` with its sample tables it uses `meta` with an item structure: `pitm` names the primary item, `iloc` says where in `mdat` that item&#39;s bytes live, `iprp` carries its properties.

The `ftyp` box says which dialect you are reading:

```
$ xxd -g 1 -l 32 shot.avif
00000000: 00 00 00 20 66 74 79 70 61 76 69 66 00 00 00 00  ... ftypavif....
00000010: 61 76 69 66 6d 69 66 31 6d 69 61 66 4d 41 31 42  avifmif1miafMA1B
```

Size 32, type `ftyp`, major brand `avif`, then four compatible brands: `avif`, `mif1`, `miaf`, `MA1B`. The `.m4a` file above declares `M4A ` with `isom` as a compatible brand. The MP4 declares `isom` with `iso2`.

So `.mp4`, `.mov`, `.m4a`, `.m4v`, `.heic`, and `.avif` are one format with six extensions. Your iPhone photo library and your video library are the same container. That happened because in February 1998, ISO picked Apple&#39;s QuickTime file format as the basis for MPEG-4&#39;s container, and the box model turned out to be general enough that everyone who needed a container afterward just used it.

---

## The Box in the Wrong Place

Now the flaw that shaped an entire decade of web video.

The `moov` box holds the sample tables: which frame starts at which byte, how long it lasts, which chunk it belongs to. `mdat` holds the frames. A player can decode nothing until it has read `moov`, because `mdat` has no internal framing at all. It is one undifferentiated run of bytes, and the only thing that says where frame 0 begins is a number inside `moov`.

Look at where `moov` ended up in that first file. Byte 63,642 of a 68,066-byte file.

An encoder writing sequentially cannot know the byte offset of the last chunk until it has written the last chunk, so the natural thing is to write all of `mdat` and then append `moov` at the end. That is what nearly every encoder did by default, and it means the player must reach the last 6% of the file before it can show you the first frame.

The fix is a post-processing pass:

```
$ ffmpeg -i input -c:v libx264 -movflags +faststart out.mp4
```

```
ftyp  32 bytes  @ 0
moov  4424 bytes  @ 32
free  8 bytes  @ 4456
mdat  63602 bytes  @ 4464
```

Same boxes. Same sizes. Same total file length, 68,066 bytes both times, and I checked the `mdat` payloads byte for byte: identical. All that changed is the order.

It is not quite a memmove, though, because `moov`&#39;s offsets are absolute positions in the file:

```
plain.mp4    stco has 1 chunk offsets; first five: [48]
fast.mp4     stco has 1 chunk offsets; first five: [4472]
```

Moving `moov` in front of `mdat` pushed every byte of media 4,424 places later, so every entry in the chunk offset table had to be rewritten by exactly that amount. On a real file with thousands of chunks, that is thousands of pointers, all of which must be corrected, and if the rewrite changes the size of `moov` (32-bit offsets overflowing into `co64`) the whole thing has to be recomputed again.

Absolute offsets are the design decision underneath most of MP4&#39;s awkwardness. You cannot concatenate two MP4s. You cannot insert a second of video in the middle. You cannot append to a file that is still being written and have it remain playable. Everything is pointer arithmetic against byte zero.

---

## Fragments Are the Actual Answer

Fragmented MP4 fixes it by giving up on the single index. Instead of one `moov` describing the whole timeline, you get an initialization segment and then a run of self-describing chunks:

```
[ ftyp + moov ]  [ moof + mdat ]  [ moof + mdat ]  [ moof + mdat ] ...
```

Each `moof` carries the sample table for the `mdat` that follows it, with offsets relative to the fragment rather than the file. Which means you can start writing before you know how long the video is, cut the stream anywhere, serve any fragment independently, and switch bitrates between fragments without the player noticing.

That property is the entire basis of HLS and DASH. Every adaptive-bitrate stream you have watched is this: a manifest, an init segment, and a pile of `moof`/`mdat` pairs that a player stitches together while quietly swapping quality levels based on your bandwidth.

It also means the `moov` placement problem is now mostly historical for streaming and still completely current for files. Anything you upload, download, or store as a single `.mp4` still has one `moov`, and it is still in whichever place the encoder happened to put it.

---

## What To Do About It

- **Always pass `-movflags +faststart`** when producing MP4 for the web. It costs one extra pass over the file at encode time and nothing at all afterward.
- **Check where `moov` landed** before blaming the network. Eight bytes of parsing tells you: read the size at offset 0, jump, read the type, repeat. If `moov` is last, that is your slow start.
- **Use fMP4 for anything live or adaptive.** A single-file MP4 cannot be written and played at the same time, no matter how you order the boxes.
- **Don&#39;t concatenate MP4 files.** `cat a.mp4 b.mp4 &gt; c.mp4` produces a file whose first `moov` describes only the first video and whose second `moov` has offsets pointing into the wrong place. Remux with `ffmpeg -f concat` instead.
- **Treat `.heic` and `.avif` as the same problem space.** If your image pipeline calls `identify` or sniffs magic bytes, those files start with a box header, not a signature, and the four bytes that matter are at offset 4 rather than offset 0.
- **Read `ftyp` compatible brands, not the extension.** A file named `.mp4` can declare `qt  `, and a file named `.mov` can declare `isom`. The brands are the truth.

Everything in this series so far has failed by underspecifying something. MP4 does not have that problem. The box model is rigorous, self-describing, and general enough that it absorbed still images without anyone having to redesign it. What it got wrong was one thing: it wrote down byte offsets instead of relative ones, and made the index a single object that has to be complete before it can be written. Two decades of streaming infrastructure exist to work around that decision.

## Sources

- [ISO/IEC 14496-12](https://www.iso.org/standard/68960.html) — the ISO Base Media File Format; the box definition is clause 4.2
- [MP4 Registration Authority](https://mp4ra.org/) — the registry of every legal FourCC brand and box type
- [Apple&#39;s QuickTime File Format documentation](https://developer.apple.com/standards/classic-quicktime/) — the atom model MP4 inherited
- [AVIF specification](https://aomediacodec.github.io/av1-isobmff/) — how AV1 intra frames map onto ISOBMFF items
- [RFC 8216](https://www.rfc-editor.org/rfc/rfc8216) — HTTP Live Streaming, which is fMP4 plus a text manifest
- [ffmpeg movflags documentation](https://ffmpeg.org/ffmpeg-formats.html) — `+faststart` and the fragmentation options

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>Every XML Feature Is One You Turn Off</title>
      <link>https://llbbl.blog/2026/08/22/every-xml-feature-is-one.html</link>
      <pubDate>Sat, 22 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/22/every-xml-feature-is-one.html</guid>
      <description>&lt;p&gt;Last post ended on a question. XML is the format in this series that specified everything, so what did all that specifying buy?&lt;/p&gt;
&lt;p&gt;An answer for every question, and an attack surface made entirely of answers.&lt;/p&gt;
&lt;p&gt;The other formats in this series failed by leaving things out. CSV never had a standard. JSON declined to say what a number means. YAML wrote the rules down and then most implementations kept using the old ones. XML failed the other way. It defined a schema language, a query language, a transformation language, a namespace system, and an entity system, and the entity system will read files off your disk.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;two-kinds-of-correct&#34;&gt;Two Kinds of Correct&lt;/h2&gt;
&lt;p&gt;Start with something XML got right, because it is the only format here that made this distinction at all.&lt;/p&gt;
&lt;p&gt;The specification defines two separate bars:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A data object is an XML document if it is &lt;strong&gt;well-formed&lt;/strong&gt;, as defined in this specification. In addition, the XML document is &lt;strong&gt;valid&lt;/strong&gt; if it meets certain further constraints.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Well-formed is syntax. Tags match, there is one root, attributes are quoted. Valid is semantics: the document declares a schema and conforms to it.&lt;/p&gt;
&lt;p&gt;CSV has neither concept. JSON has only the first one. XML separated them in 1998, which was ahead of its time, and then almost nobody used the second one. Most XML in production is well-formed and unvalidated, which means it has exactly the same guarantees as JSON with more punctuation.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-entity-system&#34;&gt;The Entity System&lt;/h2&gt;
&lt;p&gt;XML documents are built from entities. Five are predefined, and you know them:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;&amp;amp;amp;  &amp;amp;lt;  &amp;amp;gt;  &amp;amp;quot;  &amp;amp;apos;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You can also declare your own in the document type declaration, which is how you get constants in a config file. And an entity can be declared to pull its content from somewhere else:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-xml&#34; data-lang=&#34;xml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;&amp;lt;!DOCTYPE foo [
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;  &amp;lt;!ENTITY xxe SYSTEM &amp;#34;file:///tmp/xmltest/secret.txt&amp;#34;&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;]&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;foo&amp;gt;&lt;/span&gt;&amp;amp;xxe;&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;/foo&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That is a legal XML document. &lt;code&gt;SYSTEM&lt;/code&gt; means &amp;ldquo;go get it.&amp;rdquo; Here is what a parser does with it:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;ElementTree  ParseError: undefined entity &amp;amp;xxe;
lxml         XMLSyntaxError: Entity &amp;#39;xxe&amp;#39; not defined
defusedxml   EntitiesForbidden(name=&amp;#39;xxe&amp;#39;, system_id=&amp;#39;file:///tmp/xmltest/secret.txt&amp;#39;)
lxml, resolve_entities=True    &amp;#39;SECRET-CANARY-12345\n&amp;#39;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The first three refuse. The fourth read a file off the disk and put its contents in the document tree.&lt;/p&gt;
&lt;p&gt;The difference between the second line and the fourth is one keyword argument. Not a patch, not an old version, not a misconfiguration. A parser option, on a current library, named after something that sounds like ordinary XML processing. Of course you want entities resolved. Entities are how the format works.&lt;/p&gt;
&lt;p&gt;This is XXE, and it is not a bug in any implementation. Every one of those parsers is behaving as specified. The specification says an external entity is fetched, so the ones that fetch it are correct and the ones that refuse are deliberately non-conforming for your safety.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-bomb-that-got-fixed&#34;&gt;The Bomb That Got Fixed&lt;/h2&gt;
&lt;p&gt;The same entity system nests, which produces the XML version of the billion laughs attack:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-xml&#34; data-lang=&#34;xml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;&amp;lt;!DOCTYPE lolz [
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;  &amp;lt;!ENTITY lol &amp;#34;lol&amp;#34;&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#75715e&#34;&gt;&amp;lt;!ENTITY lol1 &amp;#34;&amp;amp;lol;&amp;amp;lol;&amp;amp;lol;&amp;amp;lol;&amp;amp;lol;&amp;amp;lol;&amp;amp;lol;&amp;amp;lol;&amp;amp;lol;&amp;amp;lol;&amp;#34;&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#75715e&#34;&gt;&amp;lt;!ENTITY lol2 &amp;#34;&amp;amp;lol1;&amp;amp;lol1;&amp;amp;lol1;&amp;amp;lol1;&amp;amp;lol1;&amp;amp;lol1;&amp;amp;lol1;&amp;amp;lol1;&amp;amp;lol1;&amp;amp;lol1;&amp;#34;&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;]&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each level multiplies by ten. A 291-byte file at three levels expands to 3,000 characters. Add two more levels and the arithmetic says 300,000, and the file is still under 500 bytes.&lt;/p&gt;
&lt;p&gt;Except it doesn&amp;rsquo;t, and this is the part worth reporting honestly. At five levels, current libxml2 stops:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;XMLSyntaxError: Maximum entity amplification factor exceeded,
see xmlCtxtSetMaxAmplification
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;It refuses even with &lt;code&gt;resolve_entities=True&lt;/code&gt;. Somebody went and put a ratio limit in the parser, and it works. Compare that to YAML, where &lt;code&gt;safe_load&lt;/code&gt; still expands aliases without complaint, and the equivalent 202-byte payload produced 74,732 nodes with no objection at all.&lt;/p&gt;
&lt;p&gt;So the XML ecosystem fixed the denial-of-service and left the file-read one flag away. That ordering tells you something about which failure people actually hit.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-the-rest-of-it-bought&#34;&gt;What the Rest of It Bought&lt;/h2&gt;
&lt;p&gt;XML shipped an enormous amount of specification, and the pieces are individually good:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;XSD&lt;/strong&gt; defines 19 primitive datatypes with real inheritance, so &lt;code&gt;&amp;lt;price&amp;gt;12.50&amp;lt;/price&amp;gt;&lt;/code&gt; can be a decimal rather than a hopeful string. This is precisely what CSV lacks and what JSON refuses to commit to.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;XPath&lt;/strong&gt; addresses any node in a document without writing a traversal.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;XSLT&lt;/strong&gt; transforms one document into another declaratively.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Namespaces&lt;/strong&gt; let two vocabularies coexist in one file without colliding, which is the problem every format in this series either ignores or solves with a naming convention.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of that is bad engineering. Read the list again and notice that it is a description of the problems the other five posts were about. XML solved them. In 1998.&lt;/p&gt;
&lt;p&gt;The cost was that using XML correctly means knowing which parts to switch off, and the defaults were set in an era that assumed documents came from people you knew. Every hardening guide for XML is a list of features to disable: no DTDs, no external entities, no network access, no schema fetching.&lt;/p&gt;
&lt;p&gt;There is a version of this where the lesson is &amp;ldquo;XML was too complicated.&amp;rdquo; I don&amp;rsquo;t think that&amp;rsquo;s it. The formats that replaced it are simpler and they have the same problems, plus the ones XML had already solved. Nobody misses XSLT, and everybody has now written their own worse version of it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-to-do-about-it&#34;&gt;What To Do About It&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Disable DTD processing entirely&lt;/strong&gt; unless you know you need it. In Python that is &lt;code&gt;defusedxml&lt;/code&gt;; in Java it is &lt;code&gt;disallow-doctype-decl&lt;/code&gt;. This closes XXE and entity expansion in one move.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never enable &lt;code&gt;resolve_entities&lt;/code&gt; on input you did not write.&lt;/strong&gt; It is the single flag that turns a parser into a file reader.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Validate against a schema at the trust boundary&lt;/strong&gt;, not just parse. Well-formed is not a security property. It is barely a correctness property.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stream large documents.&lt;/strong&gt; SAX and StAX read in constant memory; DOM builds the whole tree first, which is its own denial of service if the document is attacker-sized.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Know your parser&amp;rsquo;s defaults and pin them explicitly.&lt;/strong&gt; They have changed over time, usually toward safety, and code that relies on a safe default is one dependency upgrade from a different one.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;XML answered every question these posts have raised. It has types, a validation model, a query language, and a namespace system, and it had all of them a decade before the formats that replaced it. What it could not do was make the safe path the default one, and that turned out to matter more than any of the rest of it.&lt;/p&gt;
&lt;p&gt;That is the actual pattern across this whole series. Not that formats are underspecified or overspecified, but that the defaults are the specification most people ever use.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://www.w3.org/TR/2008/REC-xml-20081126/&#34;&gt;XML 1.0 (Fifth Edition)&lt;/a&gt; — 26 November 2008; the well-formed and valid definitions, and the five predefined entities&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.w3.org/TR/1998/REC-xml-19980210&#34;&gt;XML 1.0 (First Edition)&lt;/a&gt; — 10 February 1998, the original Recommendation&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.w3.org/TR/2004/REC-xml11-20040204/&#34;&gt;XML 1.1&lt;/a&gt; — 4 February 2004; the revision almost nobody adopted&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.w3.org/TR/xmlschema11-2/&#34;&gt;XSD 1.1 Part 2: Datatypes&lt;/a&gt; — the 19 primitive datatypes&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html&#34;&gt;OWASP XXE Prevention Cheat Sheet&lt;/a&gt; — per-parser hardening settings&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://pypi.org/project/defusedxml/&#34;&gt;defusedxml&lt;/a&gt; — the Python library that turns the dangerous parts off for you&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>Last post ended on a question. XML is the format in this series that specified everything, so what did all that specifying buy?

An answer for every question, and an attack surface made entirely of answers.

The other formats in this series failed by leaving things out. CSV never had a standard. JSON declined to say what a number means. YAML wrote the rules down and then most implementations kept using the old ones. XML failed the other way. It defined a schema language, a query language, a transformation language, a namespace system, and an entity system, and the entity system will read files off your disk.

---

## Two Kinds of Correct

Start with something XML got right, because it is the only format here that made this distinction at all.

The specification defines two separate bars:

&gt; A data object is an XML document if it is **well-formed**, as defined in this specification. In addition, the XML document is **valid** if it meets certain further constraints.

Well-formed is syntax. Tags match, there is one root, attributes are quoted. Valid is semantics: the document declares a schema and conforms to it.

CSV has neither concept. JSON has only the first one. XML separated them in 1998, which was ahead of its time, and then almost nobody used the second one. Most XML in production is well-formed and unvalidated, which means it has exactly the same guarantees as JSON with more punctuation.

---

## The Entity System

XML documents are built from entities. Five are predefined, and you know them:

```
&amp;amp;  &amp;lt;  &amp;gt;  &amp;quot;  &amp;apos;
```

You can also declare your own in the document type declaration, which is how you get constants in a config file. And an entity can be declared to pull its content from somewhere else:

```xml
&lt;!DOCTYPE foo [
  &lt;!ENTITY xxe SYSTEM &#34;file:///tmp/xmltest/secret.txt&#34;&gt;
]&gt;
&lt;foo&gt;&amp;xxe;&lt;/foo&gt;
```

That is a legal XML document. `SYSTEM` means &#34;go get it.&#34; Here is what a parser does with it:

```
ElementTree  ParseError: undefined entity &amp;xxe;
lxml         XMLSyntaxError: Entity &#39;xxe&#39; not defined
defusedxml   EntitiesForbidden(name=&#39;xxe&#39;, system_id=&#39;file:///tmp/xmltest/secret.txt&#39;)
lxml, resolve_entities=True    &#39;SECRET-CANARY-12345\n&#39;
```

The first three refuse. The fourth read a file off the disk and put its contents in the document tree.

The difference between the second line and the fourth is one keyword argument. Not a patch, not an old version, not a misconfiguration. A parser option, on a current library, named after something that sounds like ordinary XML processing. Of course you want entities resolved. Entities are how the format works.

This is XXE, and it is not a bug in any implementation. Every one of those parsers is behaving as specified. The specification says an external entity is fetched, so the ones that fetch it are correct and the ones that refuse are deliberately non-conforming for your safety.

---

## The Bomb That Got Fixed

The same entity system nests, which produces the XML version of the billion laughs attack:

```xml
&lt;!DOCTYPE lolz [
  &lt;!ENTITY lol &#34;lol&#34;&gt;
  &lt;!ENTITY lol1 &#34;&amp;lol;&amp;lol;&amp;lol;&amp;lol;&amp;lol;&amp;lol;&amp;lol;&amp;lol;&amp;lol;&amp;lol;&#34;&gt;
  &lt;!ENTITY lol2 &#34;&amp;lol1;&amp;lol1;&amp;lol1;&amp;lol1;&amp;lol1;&amp;lol1;&amp;lol1;&amp;lol1;&amp;lol1;&amp;lol1;&#34;&gt;
]&gt;
```

Each level multiplies by ten. A 291-byte file at three levels expands to 3,000 characters. Add two more levels and the arithmetic says 300,000, and the file is still under 500 bytes.

Except it doesn&#39;t, and this is the part worth reporting honestly. At five levels, current libxml2 stops:

```
XMLSyntaxError: Maximum entity amplification factor exceeded,
see xmlCtxtSetMaxAmplification
```

It refuses even with `resolve_entities=True`. Somebody went and put a ratio limit in the parser, and it works. Compare that to YAML, where `safe_load` still expands aliases without complaint, and the equivalent 202-byte payload produced 74,732 nodes with no objection at all.

So the XML ecosystem fixed the denial-of-service and left the file-read one flag away. That ordering tells you something about which failure people actually hit.

---

## What the Rest of It Bought

XML shipped an enormous amount of specification, and the pieces are individually good:

- **XSD** defines 19 primitive datatypes with real inheritance, so `&lt;price&gt;12.50&lt;/price&gt;` can be a decimal rather than a hopeful string. This is precisely what CSV lacks and what JSON refuses to commit to.
- **XPath** addresses any node in a document without writing a traversal.
- **XSLT** transforms one document into another declaratively.
- **Namespaces** let two vocabularies coexist in one file without colliding, which is the problem every format in this series either ignores or solves with a naming convention.

None of that is bad engineering. Read the list again and notice that it is a description of the problems the other five posts were about. XML solved them. In 1998.

The cost was that using XML correctly means knowing which parts to switch off, and the defaults were set in an era that assumed documents came from people you knew. Every hardening guide for XML is a list of features to disable: no DTDs, no external entities, no network access, no schema fetching.

There is a version of this where the lesson is &#34;XML was too complicated.&#34; I don&#39;t think that&#39;s it. The formats that replaced it are simpler and they have the same problems, plus the ones XML had already solved. Nobody misses XSLT, and everybody has now written their own worse version of it.

---

## What To Do About It

- **Disable DTD processing entirely** unless you know you need it. In Python that is `defusedxml`; in Java it is `disallow-doctype-decl`. This closes XXE and entity expansion in one move.
- **Never enable `resolve_entities` on input you did not write.** It is the single flag that turns a parser into a file reader.
- **Validate against a schema at the trust boundary**, not just parse. Well-formed is not a security property. It is barely a correctness property.
- **Stream large documents.** SAX and StAX read in constant memory; DOM builds the whole tree first, which is its own denial of service if the document is attacker-sized.
- **Know your parser&#39;s defaults and pin them explicitly.** They have changed over time, usually toward safety, and code that relies on a safe default is one dependency upgrade from a different one.

XML answered every question these posts have raised. It has types, a validation model, a query language, and a namespace system, and it had all of them a decade before the formats that replaced it. What it could not do was make the safe path the default one, and that turned out to matter more than any of the rest of it.

That is the actual pattern across this whole series. Not that formats are underspecified or overspecified, but that the defaults are the specification most people ever use.

## Sources

- [XML 1.0 (Fifth Edition)](https://www.w3.org/TR/2008/REC-xml-20081126/) — 26 November 2008; the well-formed and valid definitions, and the five predefined entities
- [XML 1.0 (First Edition)](https://www.w3.org/TR/1998/REC-xml-19980210) — 10 February 1998, the original Recommendation
- [XML 1.1](https://www.w3.org/TR/2004/REC-xml11-20040204/) — 4 February 2004; the revision almost nobody adopted
- [XSD 1.1 Part 2: Datatypes](https://www.w3.org/TR/xmlschema11-2/) — the 19 primitive datatypes
- [OWASP XXE Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html) — per-parser hardening settings
- [defusedxml](https://pypi.org/project/defusedxml/) — the Python library that turns the dangerous parts off for you

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>Norway Is Not a Boolean</title>
      <link>https://llbbl.blog/2026/08/21/norway-is-not-a-boolean.html</link>
      <pubDate>Fri, 21 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/21/norway-is-not-a-boolean.html</guid>
      <description>&lt;p&gt;JSON&amp;rsquo;s problem is that its specification is too small. It tells you &lt;code&gt;9007199254740993&lt;/code&gt; is a well-formed number and then declines to say which number.&lt;/p&gt;
&lt;p&gt;YAML went the other way. The 1.2.2 specification is a book. It has a formal grammar, a chapter on recommended schemas, and an answer for nearly everything. And it will still read your config file and decide, without asking, that Norway is false.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-norway-problem&#34;&gt;The Norway Problem&lt;/h2&gt;
&lt;p&gt;Here is a config file. Every value in it is a string that a human would read as a string.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;country&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;NO&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;duration&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;:&lt;span style=&#34;color:#ae81ff&#34;&gt;20&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;mode&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;0755&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Three parsers, on those exact bytes:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;PyYAML 6.0.3   {&amp;#39;country&amp;#39;: False, &amp;#39;duration&amp;#39;: 80,     &amp;#39;mode&amp;#39;: 493}
ruamel.yaml    {&amp;#39;country&amp;#39;: &amp;#39;NO&amp;#39;,  &amp;#39;duration&amp;#39;: &amp;#39;1:20&amp;#39;, &amp;#39;mode&amp;#39;: 755}
js-yaml        {&amp;#34;country&amp;#34;:&amp;#34;NO&amp;#34;,   &amp;#34;duration&amp;#34;:&amp;#34;1:20&amp;#34;,  &amp;#34;mode&amp;#34;:755}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;NO&lt;/code&gt; is the ISO 3166 code for Norway. PyYAML returns the boolean &lt;code&gt;false&lt;/code&gt;, because YAML 1.1 recognized twenty-two spellings of true and false, and &lt;code&gt;NO&lt;/code&gt; is one of them. The spec lists them as a single regular expression:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;y|Y|yes|Yes|YES|n|N|no|No|NO
|true|True|TRUE|false|False|FALSE
|on|On|ON|off|Off|OFF
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Count them. Twenty-two. Six of those are country codes, single letters, or ordinary English words that appear in real data. Nothing in the file said &amp;ldquo;this is a boolean.&amp;rdquo; The parser inferred it from the shape of the text, and the shape of the text was two letters.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;1:20&lt;/code&gt; became &lt;code&gt;80&lt;/code&gt; because YAML 1.1 supported sexagesimal integers, so a duration is read as base 60. One times sixty, plus twenty.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;0755&lt;/code&gt; became &lt;code&gt;493&lt;/code&gt; because a leading zero meant octal. That is a file mode that no longer means what it says.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;it-was-fixed-in-2009&#34;&gt;It Was Fixed in 2009&lt;/h2&gt;
&lt;p&gt;This is the part that makes YAML different from the other formats in this series.&lt;/p&gt;
&lt;p&gt;CSV never had a standard. Markdown had too many. YAML had exactly one problem, everybody agreed it was a problem, and the working group fixed it. YAML 1.2 arrived in 2009 and threw all of it out. Base 60 is gone. Implicit octal is gone. The Core schema recognizes &lt;code&gt;true&lt;/code&gt; and &lt;code&gt;false&lt;/code&gt; and their case variants, and nothing else.&lt;/p&gt;
&lt;p&gt;Seventeen years later, the two YAML 1.2 parsers above return strings, and PyYAML returns &lt;code&gt;False&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;PyYAML implements YAML 1.1. It is the default YAML library for Python, it is what &lt;code&gt;pip install pyyaml&lt;/code&gt; gives you, and the specification it implements was superseded when the iPhone 3GS was current. The fix exists. It shipped. Most of the ecosystem simply stayed where it was, because changing the type of &lt;code&gt;NO&lt;/code&gt; in a minor release breaks every config file that relied on it.&lt;/p&gt;
&lt;p&gt;A format can be fixed and still be broken, if the fix arrives after the implementations do.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;everything-else-that-isnt-a-string&#34;&gt;Everything Else That Isn&amp;rsquo;t a String&lt;/h2&gt;
&lt;p&gt;The country-code case is famous. It is not the only one, and the rest are quieter:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;version: 1.10   -&amp;gt;  1.1        (float, and .10 became .1)
build: 010      -&amp;gt;  8          (octal)
port: 8080      -&amp;gt;  8080       (int, fine, until you concatenate it)
answers: [y, n] -&amp;gt;  [&amp;#39;y&amp;#39;, &amp;#39;n&amp;#39;] (strings)
answers: [yes, no] -&amp;gt; [True, False]
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The first one is the one that should bother you. A semantic version of &lt;code&gt;1.10&lt;/code&gt; parses as the float &lt;code&gt;1.1&lt;/code&gt;, which is a different version, and it does it silently in a file whose entire job is to record which version you meant.&lt;/p&gt;
&lt;p&gt;And note the last two lines. &lt;code&gt;y&lt;/code&gt; and &lt;code&gt;n&lt;/code&gt; stay strings in PyYAML while &lt;code&gt;yes&lt;/code&gt; and &lt;code&gt;no&lt;/code&gt; become booleans, because PyYAML&amp;rsquo;s resolver implements a narrower set than the 1.1 spec&amp;rsquo;s regexp advertises. So the answer to &amp;ldquo;does this parser coerce single letters&amp;rdquo; is neither yes nor no. It is &amp;ldquo;some of them, and you have to test.&amp;rdquo;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;two-ways-to-weaponize-the-convenience&#34;&gt;Two Ways to Weaponize the Convenience&lt;/h2&gt;
&lt;p&gt;YAML has anchors. You define a node once with &lt;code&gt;&amp;amp;name&lt;/code&gt; and reference it with &lt;code&gt;*name&lt;/code&gt;. It is a useful feature for config files with repeated blocks, and it composes.&lt;/p&gt;
&lt;p&gt;That is the problem. It composes exponentially.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;a&lt;/span&gt;: &lt;span style=&#34;color:#75715e&#34;&gt;&amp;amp;a&lt;/span&gt; [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;lol&amp;#34;&lt;/span&gt;,&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;lol&amp;#34;&lt;/span&gt;,&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;lol&amp;#34;&lt;/span&gt;,&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;lol&amp;#34;&lt;/span&gt;,&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;lol&amp;#34;&lt;/span&gt;,&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;lol&amp;#34;&lt;/span&gt;,&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;lol&amp;#34;&lt;/span&gt;,&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;lol&amp;#34;&lt;/span&gt;,&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;lol&amp;#34;&lt;/span&gt;]
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;b&lt;/span&gt;: &lt;span style=&#34;color:#75715e&#34;&gt;&amp;amp;b&lt;/span&gt; [&lt;span style=&#34;color:#75715e&#34;&gt;*a,*a,*a,*a,*a,*a,*a,*a,*a]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;c&lt;/span&gt;: &lt;span style=&#34;color:#75715e&#34;&gt;&amp;amp;c&lt;/span&gt; [&lt;span style=&#34;color:#75715e&#34;&gt;*b,*b,*b,*b,*b,*b,*b,*b,*b]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;d&lt;/span&gt;: &lt;span style=&#34;color:#75715e&#34;&gt;&amp;amp;d&lt;/span&gt; [&lt;span style=&#34;color:#75715e&#34;&gt;*c,*c,*c,*c,*c,*c,*c,*c,*c]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;e&lt;/span&gt;: &lt;span style=&#34;color:#75715e&#34;&gt;&amp;amp;e&lt;/span&gt; [&lt;span style=&#34;color:#75715e&#34;&gt;*d,*d,*d,*d,*d,*d,*d,*d,*d]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That file is 202 bytes. Expanding it produces 74,732 nodes, of which 59,049 are copies of the string &lt;code&gt;lol&lt;/code&gt;. Add one more line and multiply by nine. This is the billion laughs attack, and the important detail is that &lt;code&gt;safe_load&lt;/code&gt; does not stop it. Aliases are not a dangerous tag, they are a core language feature working as designed.&lt;/p&gt;
&lt;p&gt;The second way is tags. YAML can annotate a node with a type, and PyYAML historically honored tags that construct arbitrary Python objects:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;!!python/object/apply:os.system&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;args&lt;/span&gt;: [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;id&amp;#39;&lt;/span&gt;]
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;yaml.load()&lt;/code&gt; on untrusted input would run that. It became &lt;a href=&#34;https://nvd.nist.gov/vuln/detail/CVE-2017-18342&#34;&gt;CVE-2017-18342&lt;/a&gt;, CVSS 9.8, published June 2018, with a description that is unusually blunt for the genre: &lt;em&gt;&amp;ldquo;In PyYAML before 5.1, the yaml.load() API could execute arbitrary code if used with untrusted data.&amp;rdquo;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The fix took two releases and three years. PyYAML 5.1 deprecated the unsafe default in March 2019. PyYAML 6.0 finally made the &lt;code&gt;Loader&lt;/code&gt; argument mandatory in October 2021, so the dangerous call stopped being the short one:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;&amp;gt;&amp;gt;&amp;gt; yaml.load(&amp;#39;a: 1&amp;#39;)
TypeError: load() missing 1 required positional argument: &amp;#39;Loader&amp;#39;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The vulnerability was published in 2018. Making the unsafe call harder to type than the safe one landed in 2021.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-to-do-about-it&#34;&gt;What To Do About It&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Quote anything that isn&amp;rsquo;t obviously a number.&lt;/strong&gt; Country codes, versions, file modes, git SHAs, anything a human would call an identifier. Quoting is never wrong.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Know which YAML version your parser speaks.&lt;/strong&gt; If it is Python, assume 1.1 and the Norway problem unless you chose otherwise. &lt;code&gt;ruamel.yaml&lt;/code&gt; gives you 1.2.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never call &lt;code&gt;yaml.load&lt;/code&gt; on input you did not write.&lt;/strong&gt; &lt;code&gt;safe_load&lt;/code&gt;, always. On PyYAML 6 the language makes you say which you meant, which is the correct design.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bound the input.&lt;/strong&gt; &lt;code&gt;safe_load&lt;/code&gt; is not a defense against alias expansion. If you parse YAML you did not author, cap the document size before it reaches the parser.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use a schema.&lt;/strong&gt; The value of a schema here is not validation, it is that it declares the type instead of letting the parser guess it from the characters.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;YAML&amp;rsquo;s failure is the opposite of JSON&amp;rsquo;s, and it produces the same result. JSON declined to say what values mean, so implementations disagreed. YAML said what values mean in enormous detail, got it wrong in 2005, corrected it in 2009, and the correction never fully landed.&lt;/p&gt;
&lt;p&gt;Next in this series is XML, which is the one format here that did specify everything. It has a schema language, a query language, a transformation language, and a namespace system. It is worth asking what all of that bought.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://yaml.org/spec/1.2.2/&#34;&gt;YAML 1.2.2 Specification&lt;/a&gt; — October 2021; the schemas chapter and the rule that tabs &amp;ldquo;must not be used in indentation, since different systems treat tabs differently&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://yaml.org/type/bool.html&#34;&gt;YAML 1.1 Boolean type&lt;/a&gt; — the twenty-two-form regexp, working draft dated 2005&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://nvd.nist.gov/vuln/detail/CVE-2017-18342&#34;&gt;CVE-2017-18342&lt;/a&gt; — the &lt;code&gt;yaml.load()&lt;/code&gt; RCE, CVSS 9.8&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/yaml/pyyaml/blob/main/CHANGES&#34;&gt;PyYAML CHANGES&lt;/a&gt; — 5.1 (2019) deprecated the unsafe default, 6.0 (2021) made &lt;code&gt;Loader&lt;/code&gt; required&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>JSON&#39;s problem is that its specification is too small. It tells you `9007199254740993` is a well-formed number and then declines to say which number.

YAML went the other way. The 1.2.2 specification is a book. It has a formal grammar, a chapter on recommended schemas, and an answer for nearly everything. And it will still read your config file and decide, without asking, that Norway is false.

---

## The Norway Problem

Here is a config file. Every value in it is a string that a human would read as a string.

```yaml
country: NO
duration: 1:20
mode: 0755
```

Three parsers, on those exact bytes:

```
PyYAML 6.0.3   {&#39;country&#39;: False, &#39;duration&#39;: 80,     &#39;mode&#39;: 493}
ruamel.yaml    {&#39;country&#39;: &#39;NO&#39;,  &#39;duration&#39;: &#39;1:20&#39;, &#39;mode&#39;: 755}
js-yaml        {&#34;country&#34;:&#34;NO&#34;,   &#34;duration&#34;:&#34;1:20&#34;,  &#34;mode&#34;:755}
```

`NO` is the ISO 3166 code for Norway. PyYAML returns the boolean `false`, because YAML 1.1 recognized twenty-two spellings of true and false, and `NO` is one of them. The spec lists them as a single regular expression:

```
y|Y|yes|Yes|YES|n|N|no|No|NO
|true|True|TRUE|false|False|FALSE
|on|On|ON|off|Off|OFF
```

Count them. Twenty-two. Six of those are country codes, single letters, or ordinary English words that appear in real data. Nothing in the file said &#34;this is a boolean.&#34; The parser inferred it from the shape of the text, and the shape of the text was two letters.

`1:20` became `80` because YAML 1.1 supported sexagesimal integers, so a duration is read as base 60. One times sixty, plus twenty.

`0755` became `493` because a leading zero meant octal. That is a file mode that no longer means what it says.

---

## It Was Fixed in 2009

This is the part that makes YAML different from the other formats in this series.

CSV never had a standard. Markdown had too many. YAML had exactly one problem, everybody agreed it was a problem, and the working group fixed it. YAML 1.2 arrived in 2009 and threw all of it out. Base 60 is gone. Implicit octal is gone. The Core schema recognizes `true` and `false` and their case variants, and nothing else.

Seventeen years later, the two YAML 1.2 parsers above return strings, and PyYAML returns `False`.

PyYAML implements YAML 1.1. It is the default YAML library for Python, it is what `pip install pyyaml` gives you, and the specification it implements was superseded when the iPhone 3GS was current. The fix exists. It shipped. Most of the ecosystem simply stayed where it was, because changing the type of `NO` in a minor release breaks every config file that relied on it.

A format can be fixed and still be broken, if the fix arrives after the implementations do.

---

## Everything Else That Isn&#39;t a String

The country-code case is famous. It is not the only one, and the rest are quieter:

```
version: 1.10   -&gt;  1.1        (float, and .10 became .1)
build: 010      -&gt;  8          (octal)
port: 8080      -&gt;  8080       (int, fine, until you concatenate it)
answers: [y, n] -&gt;  [&#39;y&#39;, &#39;n&#39;] (strings)
answers: [yes, no] -&gt; [True, False]
```

The first one is the one that should bother you. A semantic version of `1.10` parses as the float `1.1`, which is a different version, and it does it silently in a file whose entire job is to record which version you meant.

And note the last two lines. `y` and `n` stay strings in PyYAML while `yes` and `no` become booleans, because PyYAML&#39;s resolver implements a narrower set than the 1.1 spec&#39;s regexp advertises. So the answer to &#34;does this parser coerce single letters&#34; is neither yes nor no. It is &#34;some of them, and you have to test.&#34;

---

## Two Ways to Weaponize the Convenience

YAML has anchors. You define a node once with `&amp;name` and reference it with `*name`. It is a useful feature for config files with repeated blocks, and it composes.

That is the problem. It composes exponentially.

```yaml
a: &amp;a [&#34;lol&#34;,&#34;lol&#34;,&#34;lol&#34;,&#34;lol&#34;,&#34;lol&#34;,&#34;lol&#34;,&#34;lol&#34;,&#34;lol&#34;,&#34;lol&#34;]
b: &amp;b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
c: &amp;c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
d: &amp;d [*c,*c,*c,*c,*c,*c,*c,*c,*c]
e: &amp;e [*d,*d,*d,*d,*d,*d,*d,*d,*d]
```

That file is 202 bytes. Expanding it produces 74,732 nodes, of which 59,049 are copies of the string `lol`. Add one more line and multiply by nine. This is the billion laughs attack, and the important detail is that `safe_load` does not stop it. Aliases are not a dangerous tag, they are a core language feature working as designed.

The second way is tags. YAML can annotate a node with a type, and PyYAML historically honored tags that construct arbitrary Python objects:

```yaml
!!python/object/apply:os.system
args: [&#39;id&#39;]
```

`yaml.load()` on untrusted input would run that. It became [CVE-2017-18342](https://nvd.nist.gov/vuln/detail/CVE-2017-18342), CVSS 9.8, published June 2018, with a description that is unusually blunt for the genre: *&#34;In PyYAML before 5.1, the yaml.load() API could execute arbitrary code if used with untrusted data.&#34;*

The fix took two releases and three years. PyYAML 5.1 deprecated the unsafe default in March 2019. PyYAML 6.0 finally made the `Loader` argument mandatory in October 2021, so the dangerous call stopped being the short one:

```
&gt;&gt;&gt; yaml.load(&#39;a: 1&#39;)
TypeError: load() missing 1 required positional argument: &#39;Loader&#39;
```

The vulnerability was published in 2018. Making the unsafe call harder to type than the safe one landed in 2021.

---

## What To Do About It

- **Quote anything that isn&#39;t obviously a number.** Country codes, versions, file modes, git SHAs, anything a human would call an identifier. Quoting is never wrong.
- **Know which YAML version your parser speaks.** If it is Python, assume 1.1 and the Norway problem unless you chose otherwise. `ruamel.yaml` gives you 1.2.
- **Never call `yaml.load` on input you did not write.** `safe_load`, always. On PyYAML 6 the language makes you say which you meant, which is the correct design.
- **Bound the input.** `safe_load` is not a defense against alias expansion. If you parse YAML you did not author, cap the document size before it reaches the parser.
- **Use a schema.** The value of a schema here is not validation, it is that it declares the type instead of letting the parser guess it from the characters.

YAML&#39;s failure is the opposite of JSON&#39;s, and it produces the same result. JSON declined to say what values mean, so implementations disagreed. YAML said what values mean in enormous detail, got it wrong in 2005, corrected it in 2009, and the correction never fully landed.

Next in this series is XML, which is the one format here that did specify everything. It has a schema language, a query language, a transformation language, and a namespace system. It is worth asking what all of that bought.

## Sources

- [YAML 1.2.2 Specification](https://yaml.org/spec/1.2.2/) — October 2021; the schemas chapter and the rule that tabs &#34;must not be used in indentation, since different systems treat tabs differently&#34;
- [YAML 1.1 Boolean type](https://yaml.org/type/bool.html) — the twenty-two-form regexp, working draft dated 2005
- [CVE-2017-18342](https://nvd.nist.gov/vuln/detail/CVE-2017-18342) — the `yaml.load()` RCE, CVSS 9.8
- [PyYAML CHANGES](https://github.com/yaml/pyyaml/blob/main/CHANGES) — 5.1 (2019) deprecated the unsafe default, 6.0 (2021) made `Loader` required

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>Your JSON Parser Disagrees With Mine</title>
      <link>https://llbbl.blog/2026/08/20/your-json-parser-disagrees-with.html</link>
      <pubDate>Thu, 20 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/20/your-json-parser-disagrees-with.html</guid>
      <description>&lt;p&gt;Markdown has too many specs. CSV has one that nobody agreed to follow. JSON is supposed to be the happy ending: a grammar small enough to print on a business card, standardized twice over, and elevated to a full Internet Standard.&lt;/p&gt;
&lt;p&gt;It is, and the parsers still disagree with each other about what your file says.&lt;/p&gt;
&lt;p&gt;Not about whether it&amp;rsquo;s valid. About what the values are.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-same-number-three-answers&#34;&gt;The Same Number, Three Answers&lt;/h2&gt;
&lt;p&gt;Here is a JSON document. It is unambiguously valid by every specification.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-json&#34; data-lang=&#34;json&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;{&lt;span style=&#34;color:#f92672&#34;&gt;&amp;#34;id&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;9007199254740993&lt;/span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Three parsers, on the same machine (Node 24.13.0, Python 3.13.12, jq 1.8.2), on those exact bytes:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;node   : {&amp;#34;id&amp;#34;:9007199254740992}
python : {&amp;#34;id&amp;#34;: 9007199254740993}
jq     : {&amp;#34;id&amp;#34;:9007199254740993}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Node gave back a different number than the one in the file. It didn&amp;rsquo;t error, didn&amp;rsquo;t warn, didn&amp;rsquo;t round-trip. The last digit changed from 3 to 2.&lt;/p&gt;
&lt;p&gt;The reason is that JSON&amp;rsquo;s grammar allows a number to be any sequence of digits, but JavaScript represents every number as an IEEE 754 double. Above 2⁵³ the integers stop being exactly representable, and &lt;code&gt;Number.MAX_SAFE_INTEGER&lt;/code&gt; is 9007199254740991. Our value is two past it.&lt;/p&gt;
&lt;p&gt;This is not a JavaScript bug. Node is behaving exactly as specified. The specification simply declines to say how many digits a number may have, and every implementation answers that question with whatever its host language does.&lt;/p&gt;
&lt;p&gt;Those version numbers matter, which is its own version of the problem. &lt;code&gt;jq&lt;/code&gt; only began preserving decimal literals in 1.7, whose release notes list &amp;ldquo;use decimal number literals to preserve precision.&amp;rdquo; Run that same file through jq 1.6 and it goes through a double and hands you Node&amp;rsquo;s answer. The tool doesn&amp;rsquo;t just disagree with other parsers. It disagrees with its own past self.&lt;/p&gt;
&lt;p&gt;If you have ever wondered why APIs send 64-bit IDs as strings, this is why. Twitter&amp;rsquo;s snowflake IDs, database primary keys, anything above 2⁵³ has to be quoted or it silently degrades in half the ecosystem.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;duplicate-keys-are-legal&#34;&gt;Duplicate Keys Are Legal&lt;/h2&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-json&#34; data-lang=&#34;json&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;{&lt;span style=&#34;color:#f92672&#34;&gt;&amp;#34;role&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;user&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#f92672&#34;&gt;&amp;#34;role&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;admin&amp;#34;&lt;/span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;RFC 8259 says names within an object &lt;em&gt;should&lt;/em&gt; be unique. Should, not must. And it goes on to describe what happens otherwise as varying between implementations.&lt;/p&gt;
&lt;p&gt;In practice:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;node   : {&amp;#34;role&amp;#34;:&amp;#34;admin&amp;#34;}
python : {&amp;#34;role&amp;#34;: &amp;#34;admin&amp;#34;}
jq     : {&amp;#34;role&amp;#34;:&amp;#34;admin&amp;#34;}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;All three take the last one. That&amp;rsquo;s the common behavior, and it is not required.&lt;/p&gt;
&lt;p&gt;The RFC itself spells out all three possibilities:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;When the names within an object are not unique, the behavior of software that receives such an object is unpredictable. Many implementations report the last name/value pair only. Other implementations report an error or fail to parse the object, and some implementations report all of the name/value pairs, including duplicates.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That third option, keeping both, isn&amp;rsquo;t even representable in most languages&amp;rsquo; object types. Nicolas Seriot tested parsers across a dozen languages against cases like this one and concluded there are &amp;ldquo;no two parsers that agree on what is wrong and what is right.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Python will show you both if you ask:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;raw pairs: [(&amp;#39;role&amp;#39;, &amp;#39;user&amp;#39;), (&amp;#39;role&amp;#39;, &amp;#39;admin&amp;#39;)]
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The pairs are all there in the document. Choosing one is an interpretation layered on top of parsing.&lt;/p&gt;
&lt;p&gt;Now put two parsers in one system. Apache CouchDB did, and it became CVE-2017-12635.&lt;/p&gt;
&lt;p&gt;CouchDB used an Erlang parser for authentication and a JavaScript engine for the validation that runs when a document is written. The Erlang parser resolved duplicate keys to the &lt;strong&gt;first&lt;/strong&gt; value. The JavaScript engine resolved them to the &lt;strong&gt;last&lt;/strong&gt;. So a request like this:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-json&#34; data-lang=&#34;json&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;{&lt;span style=&#34;color:#f92672&#34;&gt;&amp;#34;roles&amp;#34;&lt;/span&gt;: [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;_admin&amp;#34;&lt;/span&gt;], &lt;span style=&#34;color:#960050;background-color:#1e0010&#34;&gt;...,&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;&amp;#34;roles&amp;#34;&lt;/span&gt;: []}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;was read by the write-time validation as an ordinary unprivileged user, because it saw the last &lt;code&gt;roles&lt;/code&gt; key and found it empty. It was then read by the authentication layer as an administrator, because that saw the first one. Non-admin users could grant themselves admin.&lt;/p&gt;
&lt;p&gt;CouchDB&amp;rsquo;s fix was to change the Erlang parser to take the last key, matching JavaScript. Not because last-wins is correct, but because &lt;em&gt;agreeing&lt;/em&gt; is correct.&lt;/p&gt;
&lt;p&gt;This is the JSON version of the ZIP two-index problem from a few posts back. When a format permits two answers to the same question, the gap between two components is where the vulnerability lives.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;nan-is-not-json-and-python-emits-it-anyway&#34;&gt;NaN Is Not JSON, and Python Emits It Anyway&lt;/h2&gt;
&lt;p&gt;JSON has no way to express not-a-number or infinity. The grammar has no room for them.&lt;/p&gt;
&lt;p&gt;Python&amp;rsquo;s standard library writes them regardless:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;&amp;gt;&amp;gt;&amp;gt; json.dumps({&amp;#34;a&amp;#34;: float(&amp;#34;nan&amp;#34;), &amp;#34;b&amp;#34;: float(&amp;#34;inf&amp;#34;)})
&amp;#39;{&amp;#34;a&amp;#34;: NaN, &amp;#34;b&amp;#34;: Infinity}&amp;#39;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That output is not JSON. It&amp;rsquo;s Python&amp;rsquo;s default behavior, and it produces a file that other parsers reject or mangle. Feeding those exact bytes onward:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;node : SyntaxError - Unexpected token &amp;#39;N&amp;#39;, &amp;#34;{&amp;#34;a&amp;#34;: NaN, &amp;#34;b&amp;#34;: &amp;#34;... is not valid JSON
jq   : {&amp;#34;a&amp;#34;:null,&amp;#34;b&amp;#34;:1.7976931348623157e+308}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Node&amp;rsquo;s response is correct and useful: this is not JSON, here&amp;rsquo;s where it broke.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;jq&lt;/code&gt;&amp;rsquo;s response is the one that should worry you. It accepted the invalid document and made up values. &lt;code&gt;NaN&lt;/code&gt; became &lt;code&gt;null&lt;/code&gt;. &lt;code&gt;Infinity&lt;/code&gt; became &lt;code&gt;1.7976931348623157e+308&lt;/code&gt;, the largest finite double. No error, no warning. If that ran in the middle of a data pipeline you would get numbers out the other end, and they would be wrong in a way no downstream check is likely to catch.&lt;/p&gt;
&lt;p&gt;The same divergence shows up with a merely-enormous exponent, which &lt;em&gt;is&lt;/em&gt; valid JSON:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;input: {&amp;#34;v&amp;#34;: 1e999}

node   : {&amp;#34;v&amp;#34;:null}
python : {&amp;#39;v&amp;#39;: inf}
jq     : {&amp;#34;v&amp;#34;:1E+999}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Three parsers, one valid input, three different values. Node converts to infinity then serializes it as &lt;code&gt;null&lt;/code&gt; because it can&amp;rsquo;t represent infinity on the way out. Python gives you a float infinity object. &lt;code&gt;jq&lt;/code&gt; preserves the literal.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;why-a-small-spec-doesnt-save-you&#34;&gt;Why a Small Spec Doesn&amp;rsquo;t Save You&lt;/h2&gt;
&lt;p&gt;JSON&amp;rsquo;s specifications are good, and they are small. The problem is that they specify &lt;strong&gt;syntax&lt;/strong&gt;, and almost every failure above is about &lt;strong&gt;semantics&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The grammar tells you &lt;code&gt;9007199254740993&lt;/code&gt; is a well-formed number. It does not tell you what number it is, because that would require committing to a numeric model, and committing to a numeric model would have meant excluding some language from implementing JSON natively. The looseness is why JSON is everywhere. It is also why the same bytes mean different things in different places.&lt;/p&gt;
&lt;p&gt;The standards process eventually acknowledged this. RFC 7493 defines &lt;strong&gt;I-JSON&lt;/strong&gt;, a restricted profile that closes these holes: no duplicate names at all, numbers that should not exceed what an IEEE 754 double holds exactly, high-precision values recommended to travel as strings, and mandatory UTF-8. Only the duplicate-name rule is a hard &lt;code&gt;MUST NOT&lt;/code&gt;, which tells you something about how much of this was still negotiable in 2015.&lt;/p&gt;
&lt;p&gt;I-JSON is what most people think JSON already is. It exists as a separate document precisely because JSON isn&amp;rsquo;t that.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-to-do-about-it&#34;&gt;What To Do About It&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Send large integers as strings.&lt;/strong&gt; Anything that could exceed 2⁵³: IDs, timestamps in nanoseconds, financial values in minor units.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reject duplicate keys&lt;/strong&gt; at your trust boundary rather than letting your parser pick. Most libraries offer a hook.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don&amp;rsquo;t let a language&amp;rsquo;s default serializer decide&lt;/strong&gt; whether it emits valid JSON. Python needs &lt;code&gt;allow_nan=False&lt;/code&gt; to be honest.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Validate before you transform.&lt;/strong&gt; A parser that repairs invalid input is more dangerous than one that rejects it, because the repair is silent.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Target I-JSON&lt;/strong&gt; for anything crossing a system boundary. It costs nothing and removes the whole category.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;JSON did not fail. It succeeded so completely that it got implemented hundreds of times by people reading a short document, and a short document leaves a lot of decisions to the reader. The format that&amp;rsquo;s easy to implement is the format that gets implemented differently everywhere.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the same sentence I could have written about Markdown, and about CSV. The pattern across this whole series is that a format&amp;rsquo;s ambiguities don&amp;rsquo;t stay theoretical. They become somebody&amp;rsquo;s incident.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://datatracker.ietf.org/doc/html/rfc8259&#34;&gt;RFC 8259&lt;/a&gt; — the current JSON standard, and STD 90&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://datatracker.ietf.org/doc/html/rfc7493&#34;&gt;RFC 7493&lt;/a&gt; — I-JSON, the profile that closes the interoperability holes&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://ecma-international.org/publications-and-standards/standards/ecma-404/&#34;&gt;ECMA-404&lt;/a&gt; — the parallel Ecma grammar standard&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://seriot.ch/security/parsing_json.html&#34;&gt;Nicolas Seriot, &amp;ldquo;Parsing JSON is a Minefield&amp;rdquo;&lt;/a&gt; — the systematic survey of parser disagreement&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/nst/JSONTestSuite&#34;&gt;JSONTestSuite&lt;/a&gt; — the executable test corpus behind that research, over 300 cases&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://docs.couchdb.org/en/stable/cve/2017-12635.html&#34;&gt;CouchDB&amp;rsquo;s writeup of CVE-2017-12635&lt;/a&gt; — the duplicate-key privilege escalation, in the vendor&amp;rsquo;s own words&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>Markdown has too many specs. CSV has one that nobody agreed to follow. JSON is supposed to be the happy ending: a grammar small enough to print on a business card, standardized twice over, and elevated to a full Internet Standard.

It is, and the parsers still disagree with each other about what your file says.

Not about whether it&#39;s valid. About what the values are.

---

## The Same Number, Three Answers

Here is a JSON document. It is unambiguously valid by every specification.

```json
{&#34;id&#34;: 9007199254740993}
```

Three parsers, on the same machine (Node 24.13.0, Python 3.13.12, jq 1.8.2), on those exact bytes:

```
node   : {&#34;id&#34;:9007199254740992}
python : {&#34;id&#34;: 9007199254740993}
jq     : {&#34;id&#34;:9007199254740993}
```

Node gave back a different number than the one in the file. It didn&#39;t error, didn&#39;t warn, didn&#39;t round-trip. The last digit changed from 3 to 2.

The reason is that JSON&#39;s grammar allows a number to be any sequence of digits, but JavaScript represents every number as an IEEE 754 double. Above 2⁵³ the integers stop being exactly representable, and `Number.MAX_SAFE_INTEGER` is 9007199254740991. Our value is two past it.

This is not a JavaScript bug. Node is behaving exactly as specified. The specification simply declines to say how many digits a number may have, and every implementation answers that question with whatever its host language does.

Those version numbers matter, which is its own version of the problem. `jq` only began preserving decimal literals in 1.7, whose release notes list &#34;use decimal number literals to preserve precision.&#34; Run that same file through jq 1.6 and it goes through a double and hands you Node&#39;s answer. The tool doesn&#39;t just disagree with other parsers. It disagrees with its own past self.

If you have ever wondered why APIs send 64-bit IDs as strings, this is why. Twitter&#39;s snowflake IDs, database primary keys, anything above 2⁵³ has to be quoted or it silently degrades in half the ecosystem.

---

## Duplicate Keys Are Legal

```json
{&#34;role&#34;: &#34;user&#34;, &#34;role&#34;: &#34;admin&#34;}
```

RFC 8259 says names within an object *should* be unique. Should, not must. And it goes on to describe what happens otherwise as varying between implementations.

In practice:

```
node   : {&#34;role&#34;:&#34;admin&#34;}
python : {&#34;role&#34;: &#34;admin&#34;}
jq     : {&#34;role&#34;:&#34;admin&#34;}
```

All three take the last one. That&#39;s the common behavior, and it is not required.

The RFC itself spells out all three possibilities:

&gt; When the names within an object are not unique, the behavior of software that receives such an object is unpredictable. Many implementations report the last name/value pair only. Other implementations report an error or fail to parse the object, and some implementations report all of the name/value pairs, including duplicates.

That third option, keeping both, isn&#39;t even representable in most languages&#39; object types. Nicolas Seriot tested parsers across a dozen languages against cases like this one and concluded there are &#34;no two parsers that agree on what is wrong and what is right.&#34;

Python will show you both if you ask:

```
raw pairs: [(&#39;role&#39;, &#39;user&#39;), (&#39;role&#39;, &#39;admin&#39;)]
```

The pairs are all there in the document. Choosing one is an interpretation layered on top of parsing.

Now put two parsers in one system. Apache CouchDB did, and it became CVE-2017-12635.

CouchDB used an Erlang parser for authentication and a JavaScript engine for the validation that runs when a document is written. The Erlang parser resolved duplicate keys to the **first** value. The JavaScript engine resolved them to the **last**. So a request like this:

```json
{&#34;roles&#34;: [&#34;_admin&#34;], ..., &#34;roles&#34;: []}
```

was read by the write-time validation as an ordinary unprivileged user, because it saw the last `roles` key and found it empty. It was then read by the authentication layer as an administrator, because that saw the first one. Non-admin users could grant themselves admin.

CouchDB&#39;s fix was to change the Erlang parser to take the last key, matching JavaScript. Not because last-wins is correct, but because *agreeing* is correct.

This is the JSON version of the ZIP two-index problem from a few posts back. When a format permits two answers to the same question, the gap between two components is where the vulnerability lives.

---

## NaN Is Not JSON, and Python Emits It Anyway

JSON has no way to express not-a-number or infinity. The grammar has no room for them.

Python&#39;s standard library writes them regardless:

```
&gt;&gt;&gt; json.dumps({&#34;a&#34;: float(&#34;nan&#34;), &#34;b&#34;: float(&#34;inf&#34;)})
&#39;{&#34;a&#34;: NaN, &#34;b&#34;: Infinity}&#39;
```

That output is not JSON. It&#39;s Python&#39;s default behavior, and it produces a file that other parsers reject or mangle. Feeding those exact bytes onward:

```
node : SyntaxError - Unexpected token &#39;N&#39;, &#34;{&#34;a&#34;: NaN, &#34;b&#34;: &#34;... is not valid JSON
jq   : {&#34;a&#34;:null,&#34;b&#34;:1.7976931348623157e+308}
```

Node&#39;s response is correct and useful: this is not JSON, here&#39;s where it broke.

`jq`&#39;s response is the one that should worry you. It accepted the invalid document and made up values. `NaN` became `null`. `Infinity` became `1.7976931348623157e+308`, the largest finite double. No error, no warning. If that ran in the middle of a data pipeline you would get numbers out the other end, and they would be wrong in a way no downstream check is likely to catch.

The same divergence shows up with a merely-enormous exponent, which *is* valid JSON:

```
input: {&#34;v&#34;: 1e999}

node   : {&#34;v&#34;:null}
python : {&#39;v&#39;: inf}
jq     : {&#34;v&#34;:1E+999}
```

Three parsers, one valid input, three different values. Node converts to infinity then serializes it as `null` because it can&#39;t represent infinity on the way out. Python gives you a float infinity object. `jq` preserves the literal.

---

## Why a Small Spec Doesn&#39;t Save You

JSON&#39;s specifications are good, and they are small. The problem is that they specify **syntax**, and almost every failure above is about **semantics**.

The grammar tells you `9007199254740993` is a well-formed number. It does not tell you what number it is, because that would require committing to a numeric model, and committing to a numeric model would have meant excluding some language from implementing JSON natively. The looseness is why JSON is everywhere. It is also why the same bytes mean different things in different places.

The standards process eventually acknowledged this. RFC 7493 defines **I-JSON**, a restricted profile that closes these holes: no duplicate names at all, numbers that should not exceed what an IEEE 754 double holds exactly, high-precision values recommended to travel as strings, and mandatory UTF-8. Only the duplicate-name rule is a hard `MUST NOT`, which tells you something about how much of this was still negotiable in 2015.

I-JSON is what most people think JSON already is. It exists as a separate document precisely because JSON isn&#39;t that.

---

## What To Do About It

- **Send large integers as strings.** Anything that could exceed 2⁵³: IDs, timestamps in nanoseconds, financial values in minor units.
- **Reject duplicate keys** at your trust boundary rather than letting your parser pick. Most libraries offer a hook.
- **Don&#39;t let a language&#39;s default serializer decide** whether it emits valid JSON. Python needs `allow_nan=False` to be honest.
- **Validate before you transform.** A parser that repairs invalid input is more dangerous than one that rejects it, because the repair is silent.
- **Target I-JSON** for anything crossing a system boundary. It costs nothing and removes the whole category.

JSON did not fail. It succeeded so completely that it got implemented hundreds of times by people reading a short document, and a short document leaves a lot of decisions to the reader. The format that&#39;s easy to implement is the format that gets implemented differently everywhere.

That&#39;s the same sentence I could have written about Markdown, and about CSV. The pattern across this whole series is that a format&#39;s ambiguities don&#39;t stay theoretical. They become somebody&#39;s incident.

## Sources

- [RFC 8259](https://datatracker.ietf.org/doc/html/rfc8259) — the current JSON standard, and STD 90
- [RFC 7493](https://datatracker.ietf.org/doc/html/rfc7493) — I-JSON, the profile that closes the interoperability holes
- [ECMA-404](https://ecma-international.org/publications-and-standards/standards/ecma-404/) — the parallel Ecma grammar standard
- [Nicolas Seriot, &#34;Parsing JSON is a Minefield&#34;](https://seriot.ch/security/parsing_json.html) — the systematic survey of parser disagreement
- [JSONTestSuite](https://github.com/nst/JSONTestSuite) — the executable test corpus behind that research, over 300 cases
- [CouchDB&#39;s writeup of CVE-2017-12635](https://docs.couchdb.org/en/stable/cve/2017-12635.html) — the duplicate-key privilege escalation, in the vendor&#39;s own words

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>Nobody Agrees What a CSV Is</title>
      <link>https://llbbl.blog/2026/08/19/nobody-agrees-what-a-csv.html</link>
      <pubDate>Wed, 19 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/19/nobody-agrees-what-a-csv.html</guid>
      <description>&lt;p&gt;CSV is simple but powerful. Values, separated by commas. It&amp;rsquo;s easy to understand and use.&lt;/p&gt;
&lt;p&gt;It is also, by a wide margin, the one that destroys the most data.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s not a paradox. It&amp;rsquo;s cause and effect. A format simple enough that everyone writes their own parser is a format with as many dialects as it has parsers, and CSV&amp;rsquo;s defining property is that it carries no information about how to read it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;there-is-no-standard&#34;&gt;There Is No Standard&lt;/h2&gt;
&lt;p&gt;RFC 4180 exists. Yakov Shafranovich published it in October 2005, it registers the &lt;code&gt;text/csv&lt;/code&gt; media type, and it gives an ABNF grammar.&lt;/p&gt;
&lt;p&gt;It is also &lt;strong&gt;Informational&lt;/strong&gt;, not Standards Track. It was written to describe what people were already doing, twenty-odd years after spreadsheets started emitting it. Section 2 says so outright: there is &amp;ldquo;no formal specification in existence,&amp;rdquo; and what follows documents &amp;ldquo;the format that seems to be followed by most implementations.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;By the time someone wrote it down, every spreadsheet, database, and scripting language had already shipped its own interpretation. The RFC didn&amp;rsquo;t settle anything. It just added one more dialect, with the distinction of having a number.&lt;/p&gt;
&lt;p&gt;Eight years later, RFC 7111 added URI fragments for pointing at a row, column, or cell inside a &lt;code&gt;text/csv&lt;/code&gt; file. Also Informational. CSV still had no standard, but you could now cite a specific cell of one.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;where-does-a-record-end&#34;&gt;Where Does a Record End?&lt;/h2&gt;
&lt;p&gt;The obvious answer is &amp;ldquo;at the newline,&amp;rdquo; and the obvious answer is wrong, because a quoted field is allowed to contain one.&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;name,notes
Alice,&amp;#34;line one
line two&amp;#34;
Bob,fine
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That&amp;rsquo;s a valid three-row CSV. Split it on newlines and you get four:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;naive split gives 4 lines:
   &amp;#39;name,notes&amp;#39;
   &amp;#39;Alice,&amp;#34;line one&amp;#39;
   &amp;#39;line two&amp;#34;&amp;#39;
   &amp;#39;Bob,fine&amp;#39;

a real CSV parser gives 3 rows:
   [&amp;#39;name&amp;#39;, &amp;#39;notes&amp;#39;]
   [&amp;#39;Alice&amp;#39;, &amp;#39;line one\nline two&amp;#39;]
   [&amp;#39;Bob&amp;#39;, &amp;#39;fine&amp;#39;]
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Every &lt;code&gt;head&lt;/code&gt;, &lt;code&gt;wc -l&lt;/code&gt;, &lt;code&gt;split(&amp;quot;\n&amp;quot;)&lt;/code&gt;, and shell pipeline that assumes one record per line is wrong on this file. Not wrong on a malformed file. Wrong on a correct one.&lt;/p&gt;
&lt;p&gt;This is the single most common CSV bug, and it&amp;rsquo;s invisible in testing, because your test fixtures don&amp;rsquo;t have newlines in them until a user pastes an address into a form.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;whats-the-delimiter&#34;&gt;What&amp;rsquo;s the Delimiter?&lt;/h2&gt;
&lt;p&gt;In most of Europe the decimal separator is a comma. &lt;code&gt;12,50&lt;/code&gt; is twelve and a half euros. Which means a comma cannot also be a field separator, so those locales use semicolons.&lt;/p&gt;
&lt;p&gt;Feed a German CSV to an RFC 4180 parser and everything survives, in the sense that nothing throws:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;input:  produkt;preis
        Kaffee;12,50
        Tee;9,90

parsed as comma-delimited:
   [&amp;#39;produkt;preis&amp;#39;]
   [&amp;#39;Kaffee;12&amp;#39;, &amp;#39;50&amp;#39;]
   [&amp;#39;Tee;9&amp;#39;, &amp;#39;90&amp;#39;]

parsed as semicolon-delimited:
   [&amp;#39;produkt&amp;#39;, &amp;#39;preis&amp;#39;]
   [&amp;#39;Kaffee&amp;#39;, &amp;#39;12,50&amp;#39;]
   [&amp;#39;Tee&amp;#39;, &amp;#39;9,90&amp;#39;]
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The first reading gives you two columns of nonsense with no error. The prices split down the middle of the decimal point. A pipeline that ingests this will happily compute statistics on the number 12 and the number 50.&lt;/p&gt;
&lt;p&gt;Excel picks the delimiter based on your operating system&amp;rsquo;s regional settings, which means the same file opens differently on two machines in the same office.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;is-the-first-row-a-header&#34;&gt;Is the First Row a Header?&lt;/h2&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;1,2,3
4,5,6
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Header or data? Nothing in the file says.&lt;/p&gt;
&lt;p&gt;RFC 4180&amp;rsquo;s answer is that you put it in the MIME type: &lt;code&gt;text/csv; header=present&lt;/code&gt;. Which is a real answer, and also means the information lives outside the file, in a transport layer that gets stripped the moment someone saves the attachment to disk.&lt;/p&gt;
&lt;p&gt;So in practice every tool guesses, usually by checking whether the first row looks less numeric than the rest.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-part-that-destroys-data&#34;&gt;The Part That Destroys Data&lt;/h2&gt;
&lt;p&gt;Everything above is a parsing problem. This one is worse, because the file parses fine and the damage happens after.&lt;/p&gt;
&lt;p&gt;CSV has no types. Every value is text. So every spreadsheet and dataframe library applies type inference on import, and type inference is lossy.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s a file with four columns of identifiers, all of which are strings that happen to be made of digits:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;gene,zip,card,accession
SEPT7,02138,4532012345678901,0004928
MARCH1,01234,4111111111111111,0000071
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Read it with a type-inferring reader and:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;  gene  zip             card  accession
 SEPT7 2138 4532012345678901       4928
MARCH1 1234 4111111111111111         71
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The ZIP code &lt;code&gt;02138&lt;/code&gt; is now &lt;code&gt;2138&lt;/code&gt;. The accession number &lt;code&gt;0004928&lt;/code&gt; is now &lt;code&gt;4928&lt;/code&gt;. Nobody was asked. Nothing warned. Save that back to CSV and the original values are gone from disk.&lt;/p&gt;
&lt;p&gt;Spreadsheets are worse than this, because they store every number as an IEEE 754 double. Microsoft is blunt about the consequence:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Excel has a maximum precision of &lt;strong&gt;15 significant digits&lt;/strong&gt;, which means that for any number containing 16 or more digits, such as a credit card number, any numbers past the 15th digit are rounded down to zero.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The example they reach for is a card number:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;typed into a cell     1234 5678 9087 6543
Excel shows           1.23E+15
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Microsoft calls that &amp;ldquo;truncating numerical data to 15 digits of precision and converting to a number displayed in scientific notation.&amp;rdquo; Note that this is the vendor describing its own product, not a bug report.&lt;/p&gt;
&lt;p&gt;The card number in the file above is also 16 digits. pandas read it back intact. Excel would not.&lt;/p&gt;
&lt;p&gt;Credit card numbers are 16 digits. Many national ID numbers are longer. They are not numbers in any meaningful sense, they&amp;rsquo;re strings of digits, and a format with no type information cannot tell the difference.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-gene-name-problem&#34;&gt;The Gene Name Problem&lt;/h2&gt;
&lt;p&gt;The best-documented case of this is genomics, because biologists name genes things like &lt;code&gt;SEPT1&lt;/code&gt; and &lt;code&gt;MARCH1&lt;/code&gt; and spreadsheets read those as dates.&lt;/p&gt;
&lt;p&gt;In 2016 Ziemann and colleagues screened 35,175 supplementary Excel files from 18 journals covering 2005 to 2015. Among articles containing Excel gene lists, &lt;strong&gt;19.6%&lt;/strong&gt; had gene names corrupted this way. One in five.&lt;/p&gt;
&lt;p&gt;A follow-up in 2021, &amp;ldquo;Gene name errors: Lessons not learned,&amp;rdquo; found &lt;strong&gt;30.9%&lt;/strong&gt; across a broader sample drawn from PubMed Central. Worth being careful comparing those two numbers directly, because the second study used a different sampling frame and also detected an additional error category the first one didn&amp;rsquo;t look for. The honest summary is that the problem did not go away in the five years after being loudly published.&lt;/p&gt;
&lt;p&gt;The resolution is the remarkable part. The field did not fix the spreadsheets. It &lt;strong&gt;renamed the genes&lt;/strong&gt;. The HUGO Gene Nomenclature Committee&amp;rsquo;s 2020 guidelines state that &amp;ldquo;all symbols that auto-converted to dates in Microsoft Excel have been changed,&amp;rdquo; giving &lt;code&gt;SEPT1&lt;/code&gt; becoming &lt;code&gt;SEPTIN1&lt;/code&gt; and &lt;code&gt;MARCH1&lt;/code&gt; becoming &lt;code&gt;MARCHF1&lt;/code&gt; as examples.&lt;/p&gt;
&lt;p&gt;Human genes were renamed because a file format cannot say what type a column is.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-to-do-about-it&#34;&gt;What To Do About It&lt;/h2&gt;
&lt;p&gt;CSV isn&amp;rsquo;t going away, and mostly shouldn&amp;rsquo;t. It&amp;rsquo;s readable, streamable, diffable, and every tool on earth reads it.&lt;/p&gt;
&lt;p&gt;The practical defenses are short:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Quote everything.&lt;/strong&gt; It&amp;rsquo;s never wrong and it removes a whole class of ambiguity.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Treat identifiers as strings explicitly&lt;/strong&gt; at the point of import. Every serious CSV reader lets you pin column types; use it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never round-trip through a spreadsheet&lt;/strong&gt; if the data contains identifiers. Opening and saving is a lossy operation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Say what you mean out of band.&lt;/strong&gt; Delimiter, encoding, header presence, quoting style. The file will not.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use something else when you can.&lt;/strong&gt; Parquet and even JSON Lines carry types. If the consumer is a program rather than a person, the readability argument for CSV mostly evaporates.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The lesson generalizes past CSV, and it&amp;rsquo;s the same one from the text file post. A format that carries no description of itself pushes that burden onto every reader, and readers guess. Usually well. Occasionally by silently deleting the leading zero from your ZIP code.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://datatracker.ietf.org/doc/html/rfc4180&#34;&gt;RFC 4180&lt;/a&gt; — the Informational spec that documents CSV rather than defining it&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://datatracker.ietf.org/doc/html/rfc7111&#34;&gt;RFC 7111&lt;/a&gt; — URI fragment selectors for &lt;code&gt;text/csv&lt;/code&gt;, January 2014&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://doi.org/10.1186/s13059-016-1044-7&#34;&gt;Ziemann et al., &amp;ldquo;Gene name errors are widespread in the scientific literature&amp;rdquo;&lt;/a&gt; — Genome Biology, 2016; the 19.6% figure (&lt;a href=&#34;https://pmc.ncbi.nlm.nih.gov/articles/PMC4994289/&#34;&gt;free full text&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://doi.org/10.1371/journal.pcbi.1008984&#34;&gt;Abeysooriya et al., &amp;ldquo;Gene name errors: Lessons not learned&amp;rdquo;&lt;/a&gt; — PLOS Computational Biology, 2021; the 30.9% follow-up&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://doi.org/10.1038/s41588-020-0669-3&#34;&gt;Bruford et al., &amp;ldquo;Guidelines for human gene nomenclature&amp;rdquo;&lt;/a&gt; — Nature Genetics, 2020; the renaming (&lt;a href=&#34;https://pmc.ncbi.nlm.nih.gov/articles/PMC7494048/&#34;&gt;free full text&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://learn.microsoft.com/en-us/troubleshoot/microsoft-365-apps/excel/floating-point-arithmetic-inaccurate-result&#34;&gt;Microsoft on Excel&amp;rsquo;s floating-point precision&lt;/a&gt; — Excel follows IEEE 754 and stores 15 digits of precision&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://support.microsoft.com/en-US/Excel/keeping-leading-zeros-and-large-numbers&#34;&gt;Microsoft on leading zeros and large numbers&lt;/a&gt; — &amp;ldquo;any numbers past the 15th digit are rounded down to zero,&amp;rdquo; with a credit card as the example&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://support.microsoft.com/en-US/Excel/get-started/import-or-export-text-txt-or-csv-files&#34;&gt;Microsoft on importing and exporting text files&lt;/a&gt; — the CSV list separator comes from Windows Region settings&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>CSV is simple but powerful. Values, separated by commas. It&#39;s easy to understand and use.

It is also, by a wide margin, the one that destroys the most data.

That&#39;s not a paradox. It&#39;s cause and effect. A format simple enough that everyone writes their own parser is a format with as many dialects as it has parsers, and CSV&#39;s defining property is that it carries no information about how to read it.

---

## There Is No Standard

RFC 4180 exists. Yakov Shafranovich published it in October 2005, it registers the `text/csv` media type, and it gives an ABNF grammar.

It is also **Informational**, not Standards Track. It was written to describe what people were already doing, twenty-odd years after spreadsheets started emitting it. Section 2 says so outright: there is &#34;no formal specification in existence,&#34; and what follows documents &#34;the format that seems to be followed by most implementations.&#34;

By the time someone wrote it down, every spreadsheet, database, and scripting language had already shipped its own interpretation. The RFC didn&#39;t settle anything. It just added one more dialect, with the distinction of having a number.

Eight years later, RFC 7111 added URI fragments for pointing at a row, column, or cell inside a `text/csv` file. Also Informational. CSV still had no standard, but you could now cite a specific cell of one.

---

## Where Does a Record End?

The obvious answer is &#34;at the newline,&#34; and the obvious answer is wrong, because a quoted field is allowed to contain one.

```
name,notes
Alice,&#34;line one
line two&#34;
Bob,fine
```

That&#39;s a valid three-row CSV. Split it on newlines and you get four:

```
naive split gives 4 lines:
   &#39;name,notes&#39;
   &#39;Alice,&#34;line one&#39;
   &#39;line two&#34;&#39;
   &#39;Bob,fine&#39;

a real CSV parser gives 3 rows:
   [&#39;name&#39;, &#39;notes&#39;]
   [&#39;Alice&#39;, &#39;line one\nline two&#39;]
   [&#39;Bob&#39;, &#39;fine&#39;]
```

Every `head`, `wc -l`, `split(&#34;\n&#34;)`, and shell pipeline that assumes one record per line is wrong on this file. Not wrong on a malformed file. Wrong on a correct one.

This is the single most common CSV bug, and it&#39;s invisible in testing, because your test fixtures don&#39;t have newlines in them until a user pastes an address into a form.

---

## What&#39;s the Delimiter?

In most of Europe the decimal separator is a comma. `12,50` is twelve and a half euros. Which means a comma cannot also be a field separator, so those locales use semicolons.

Feed a German CSV to an RFC 4180 parser and everything survives, in the sense that nothing throws:

```
input:  produkt;preis
        Kaffee;12,50
        Tee;9,90

parsed as comma-delimited:
   [&#39;produkt;preis&#39;]
   [&#39;Kaffee;12&#39;, &#39;50&#39;]
   [&#39;Tee;9&#39;, &#39;90&#39;]

parsed as semicolon-delimited:
   [&#39;produkt&#39;, &#39;preis&#39;]
   [&#39;Kaffee&#39;, &#39;12,50&#39;]
   [&#39;Tee&#39;, &#39;9,90&#39;]
```

The first reading gives you two columns of nonsense with no error. The prices split down the middle of the decimal point. A pipeline that ingests this will happily compute statistics on the number 12 and the number 50.

Excel picks the delimiter based on your operating system&#39;s regional settings, which means the same file opens differently on two machines in the same office.

---

## Is the First Row a Header?

```
1,2,3
4,5,6
```

Header or data? Nothing in the file says.

RFC 4180&#39;s answer is that you put it in the MIME type: `text/csv; header=present`. Which is a real answer, and also means the information lives outside the file, in a transport layer that gets stripped the moment someone saves the attachment to disk.

So in practice every tool guesses, usually by checking whether the first row looks less numeric than the rest.

---

## The Part That Destroys Data

Everything above is a parsing problem. This one is worse, because the file parses fine and the damage happens after.

CSV has no types. Every value is text. So every spreadsheet and dataframe library applies type inference on import, and type inference is lossy.

Here&#39;s a file with four columns of identifiers, all of which are strings that happen to be made of digits:

```
gene,zip,card,accession
SEPT7,02138,4532012345678901,0004928
MARCH1,01234,4111111111111111,0000071
```

Read it with a type-inferring reader and:

```
  gene  zip             card  accession
 SEPT7 2138 4532012345678901       4928
MARCH1 1234 4111111111111111         71
```

The ZIP code `02138` is now `2138`. The accession number `0004928` is now `4928`. Nobody was asked. Nothing warned. Save that back to CSV and the original values are gone from disk.

Spreadsheets are worse than this, because they store every number as an IEEE 754 double. Microsoft is blunt about the consequence:

&gt; Excel has a maximum precision of **15 significant digits**, which means that for any number containing 16 or more digits, such as a credit card number, any numbers past the 15th digit are rounded down to zero.

The example they reach for is a card number:

```
typed into a cell     1234 5678 9087 6543
Excel shows           1.23E+15
```

Microsoft calls that &#34;truncating numerical data to 15 digits of precision and converting to a number displayed in scientific notation.&#34; Note that this is the vendor describing its own product, not a bug report.

The card number in the file above is also 16 digits. pandas read it back intact. Excel would not.

Credit card numbers are 16 digits. Many national ID numbers are longer. They are not numbers in any meaningful sense, they&#39;re strings of digits, and a format with no type information cannot tell the difference.

---

## The Gene Name Problem

The best-documented case of this is genomics, because biologists name genes things like `SEPT1` and `MARCH1` and spreadsheets read those as dates.

In 2016 Ziemann and colleagues screened 35,175 supplementary Excel files from 18 journals covering 2005 to 2015. Among articles containing Excel gene lists, **19.6%** had gene names corrupted this way. One in five.

A follow-up in 2021, &#34;Gene name errors: Lessons not learned,&#34; found **30.9%** across a broader sample drawn from PubMed Central. Worth being careful comparing those two numbers directly, because the second study used a different sampling frame and also detected an additional error category the first one didn&#39;t look for. The honest summary is that the problem did not go away in the five years after being loudly published.

The resolution is the remarkable part. The field did not fix the spreadsheets. It **renamed the genes**. The HUGO Gene Nomenclature Committee&#39;s 2020 guidelines state that &#34;all symbols that auto-converted to dates in Microsoft Excel have been changed,&#34; giving `SEPT1` becoming `SEPTIN1` and `MARCH1` becoming `MARCHF1` as examples.

Human genes were renamed because a file format cannot say what type a column is.

---

## What To Do About It

CSV isn&#39;t going away, and mostly shouldn&#39;t. It&#39;s readable, streamable, diffable, and every tool on earth reads it.

The practical defenses are short:

- **Quote everything.** It&#39;s never wrong and it removes a whole class of ambiguity.
- **Treat identifiers as strings explicitly** at the point of import. Every serious CSV reader lets you pin column types; use it.
- **Never round-trip through a spreadsheet** if the data contains identifiers. Opening and saving is a lossy operation.
- **Say what you mean out of band.** Delimiter, encoding, header presence, quoting style. The file will not.
- **Use something else when you can.** Parquet and even JSON Lines carry types. If the consumer is a program rather than a person, the readability argument for CSV mostly evaporates.

The lesson generalizes past CSV, and it&#39;s the same one from the text file post. A format that carries no description of itself pushes that burden onto every reader, and readers guess. Usually well. Occasionally by silently deleting the leading zero from your ZIP code.

## Sources

- [RFC 4180](https://datatracker.ietf.org/doc/html/rfc4180) — the Informational spec that documents CSV rather than defining it
- [RFC 7111](https://datatracker.ietf.org/doc/html/rfc7111) — URI fragment selectors for `text/csv`, January 2014
- [Ziemann et al., &#34;Gene name errors are widespread in the scientific literature&#34;](https://doi.org/10.1186/s13059-016-1044-7) — Genome Biology, 2016; the 19.6% figure ([free full text](https://pmc.ncbi.nlm.nih.gov/articles/PMC4994289/))
- [Abeysooriya et al., &#34;Gene name errors: Lessons not learned&#34;](https://doi.org/10.1371/journal.pcbi.1008984) — PLOS Computational Biology, 2021; the 30.9% follow-up
- [Bruford et al., &#34;Guidelines for human gene nomenclature&#34;](https://doi.org/10.1038/s41588-020-0669-3) — Nature Genetics, 2020; the renaming ([free full text](https://pmc.ncbi.nlm.nih.gov/articles/PMC7494048/))
- [Microsoft on Excel&#39;s floating-point precision](https://learn.microsoft.com/en-us/troubleshoot/microsoft-365-apps/excel/floating-point-arithmetic-inaccurate-result) — Excel follows IEEE 754 and stores 15 digits of precision
- [Microsoft on leading zeros and large numbers](https://support.microsoft.com/en-US/Excel/keeping-leading-zeros-and-large-numbers) — &#34;any numbers past the 15th digit are rounded down to zero,&#34; with a credit card as the example
- [Microsoft on importing and exporting text files](https://support.microsoft.com/en-US/Excel/get-started/import-or-export-text-txt-or-csv-files) — the CSV list separator comes from Windows Region settings

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>Nobody Agrees What a CSV Is</title>
      <link>https://llbbl.blog/2026/08/18/nobody-agrees-what-a-csv.html</link>
      <pubDate>Tue, 18 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/18/nobody-agrees-what-a-csv.html</guid>
      <description>&lt;p&gt;CSV is the simple but powerful. Values, separated by commas. It&amp;rsquo;s easy to understand and use.&lt;/p&gt;
&lt;p&gt;It is also, by a wide margin, the one that destroys the most data.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s not a paradox. It&amp;rsquo;s cause and effect. A format simple enough that everyone writes their own parser is a format with as many dialects as it has parsers, and CSV&amp;rsquo;s defining property is that it carries no information about how to read it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;there-is-no-standard&#34;&gt;There Is No Standard&lt;/h2&gt;
&lt;p&gt;RFC 4180 exists. Yakov Shafranovich published it in October 2005, it registers the &lt;code&gt;text/csv&lt;/code&gt; media type, and it gives an ABNF grammar.&lt;/p&gt;
&lt;p&gt;It is also &lt;strong&gt;Informational&lt;/strong&gt;, not Standards Track. It was written to describe what people were already doing, twenty-odd years after spreadsheets started emitting it. Section 2 says so outright: there is &amp;ldquo;no formal specification in existence,&amp;rdquo; and what follows documents &amp;ldquo;the format that seems to be followed by most implementations.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;By the time someone wrote it down, every spreadsheet, database, and scripting language had already shipped its own interpretation. The RFC didn&amp;rsquo;t settle anything. It just added one more dialect, with the distinction of having a number.&lt;/p&gt;
&lt;p&gt;Eight years later, RFC 7111 added URI fragments for pointing at a row, column, or cell inside a &lt;code&gt;text/csv&lt;/code&gt; file. Also Informational. CSV still had no standard, but you could now cite a specific cell of one.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;where-does-a-record-end&#34;&gt;Where Does a Record End?&lt;/h2&gt;
&lt;p&gt;The obvious answer is &amp;ldquo;at the newline,&amp;rdquo; and the obvious answer is wrong, because a quoted field is allowed to contain one.&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;name,notes
Alice,&amp;#34;line one
line two&amp;#34;
Bob,fine
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That&amp;rsquo;s a valid three-row CSV. Split it on newlines and you get four:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;naive split gives 4 lines:
   &amp;#39;name,notes&amp;#39;
   &amp;#39;Alice,&amp;#34;line one&amp;#39;
   &amp;#39;line two&amp;#34;&amp;#39;
   &amp;#39;Bob,fine&amp;#39;

a real CSV parser gives 3 rows:
   [&amp;#39;name&amp;#39;, &amp;#39;notes&amp;#39;]
   [&amp;#39;Alice&amp;#39;, &amp;#39;line one\nline two&amp;#39;]
   [&amp;#39;Bob&amp;#39;, &amp;#39;fine&amp;#39;]
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Every &lt;code&gt;head&lt;/code&gt;, &lt;code&gt;wc -l&lt;/code&gt;, &lt;code&gt;split(&amp;quot;\n&amp;quot;)&lt;/code&gt;, and shell pipeline that assumes one record per line is wrong on this file. Not wrong on a malformed file. Wrong on a correct one.&lt;/p&gt;
&lt;p&gt;This is the single most common CSV bug, and it&amp;rsquo;s invisible in testing, because your test fixtures don&amp;rsquo;t have newlines in them until a user pastes an address into a form.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;whats-the-delimiter&#34;&gt;What&amp;rsquo;s the Delimiter?&lt;/h2&gt;
&lt;p&gt;In most of Europe the decimal separator is a comma. &lt;code&gt;12,50&lt;/code&gt; is twelve and a half euros. Which means a comma cannot also be a field separator, so those locales use semicolons.&lt;/p&gt;
&lt;p&gt;Feed a German CSV to an RFC 4180 parser and everything survives, in the sense that nothing throws:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;input:  produkt;preis
        Kaffee;12,50
        Tee;9,90

parsed as comma-delimited:
   [&amp;#39;produkt;preis&amp;#39;]
   [&amp;#39;Kaffee;12&amp;#39;, &amp;#39;50&amp;#39;]
   [&amp;#39;Tee;9&amp;#39;, &amp;#39;90&amp;#39;]

parsed as semicolon-delimited:
   [&amp;#39;produkt&amp;#39;, &amp;#39;preis&amp;#39;]
   [&amp;#39;Kaffee&amp;#39;, &amp;#39;12,50&amp;#39;]
   [&amp;#39;Tee&amp;#39;, &amp;#39;9,90&amp;#39;]
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The first reading gives you two columns of nonsense with no error. The prices split down the middle of the decimal point. A pipeline that ingests this will happily compute statistics on the number 12 and the number 50.&lt;/p&gt;
&lt;p&gt;Excel picks the delimiter based on your operating system&amp;rsquo;s regional settings, which means the same file opens differently on two machines in the same office.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;is-the-first-row-a-header&#34;&gt;Is the First Row a Header?&lt;/h2&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;1,2,3
4,5,6
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Header or data? Nothing in the file says.&lt;/p&gt;
&lt;p&gt;RFC 4180&amp;rsquo;s answer is that you put it in the MIME type: &lt;code&gt;text/csv; header=present&lt;/code&gt;. Which is a real answer, and also means the information lives outside the file, in a transport layer that gets stripped the moment someone saves the attachment to disk.&lt;/p&gt;
&lt;p&gt;So in practice every tool guesses, usually by checking whether the first row looks less numeric than the rest.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-part-that-destroys-data&#34;&gt;The Part That Destroys Data&lt;/h2&gt;
&lt;p&gt;Everything above is a parsing problem. This one is worse, because the file parses fine and the damage happens after.&lt;/p&gt;
&lt;p&gt;CSV has no types. Every value is text. So every spreadsheet and dataframe library applies type inference on import, and type inference is lossy.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s a file with four columns of identifiers, all of which are strings that happen to be made of digits:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;gene,zip,card,accession
SEPT7,02138,4532012345678901,0004928
MARCH1,01234,4111111111111111,0000071
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Read it with a type-inferring reader and:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;  gene  zip             card  accession
 SEPT7 2138 4532012345678901       4928
MARCH1 1234 4111111111111111         71
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The ZIP code &lt;code&gt;02138&lt;/code&gt; is now &lt;code&gt;2138&lt;/code&gt;. The accession number &lt;code&gt;0004928&lt;/code&gt; is now &lt;code&gt;4928&lt;/code&gt;. Nobody was asked. Nothing warned. Save that back to CSV and the original values are gone from disk.&lt;/p&gt;
&lt;p&gt;Spreadsheets are worse than this, because they store every number as an IEEE 754 double. Microsoft is blunt about the consequence:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Excel has a maximum precision of &lt;strong&gt;15 significant digits&lt;/strong&gt;, which means that for any number containing 16 or more digits, such as a credit card number, any numbers past the 15th digit are rounded down to zero.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The example they reach for is a card number:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;typed into a cell     1234 5678 9087 6543
Excel shows           1.23E+15
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Microsoft calls that &amp;ldquo;truncating numerical data to 15 digits of precision and converting to a number displayed in scientific notation.&amp;rdquo; Note that this is the vendor describing its own product, not a bug report.&lt;/p&gt;
&lt;p&gt;The card number in the file above is also 16 digits. pandas read it back intact. Excel would not.&lt;/p&gt;
&lt;p&gt;Credit card numbers are 16 digits. Many national ID numbers are longer. They are not numbers in any meaningful sense, they&amp;rsquo;re strings of digits, and a format with no type information cannot tell the difference.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-gene-name-problem&#34;&gt;The Gene Name Problem&lt;/h2&gt;
&lt;p&gt;The best-documented case of this is genomics, because biologists name genes things like &lt;code&gt;SEPT1&lt;/code&gt; and &lt;code&gt;MARCH1&lt;/code&gt; and spreadsheets read those as dates.&lt;/p&gt;
&lt;p&gt;In 2016 Ziemann and colleagues screened 35,175 supplementary Excel files from 18 journals covering 2005 to 2015. Among articles containing Excel gene lists, &lt;strong&gt;19.6%&lt;/strong&gt; had gene names corrupted this way. One in five.&lt;/p&gt;
&lt;p&gt;A follow-up in 2021, &amp;ldquo;Gene name errors: Lessons not learned,&amp;rdquo; found &lt;strong&gt;30.9%&lt;/strong&gt; across a broader sample drawn from PubMed Central. Worth being careful comparing those two numbers directly, because the second study used a different sampling frame and also detected an additional error category the first one didn&amp;rsquo;t look for. The honest summary is that the problem did not go away in the five years after being loudly published.&lt;/p&gt;
&lt;p&gt;The resolution is the remarkable part. The field did not fix the spreadsheets. It &lt;strong&gt;renamed the genes&lt;/strong&gt;. The HUGO Gene Nomenclature Committee&amp;rsquo;s 2020 guidelines state that &amp;ldquo;all symbols that auto-converted to dates in Microsoft Excel have been changed,&amp;rdquo; giving &lt;code&gt;SEPT1&lt;/code&gt; becoming &lt;code&gt;SEPTIN1&lt;/code&gt; and &lt;code&gt;MARCH1&lt;/code&gt; becoming &lt;code&gt;MARCHF1&lt;/code&gt; as examples.&lt;/p&gt;
&lt;p&gt;Human genes were renamed because a file format cannot say what type a column is.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-to-do-about-it&#34;&gt;What To Do About It&lt;/h2&gt;
&lt;p&gt;CSV isn&amp;rsquo;t going away, and mostly shouldn&amp;rsquo;t. It&amp;rsquo;s readable, streamable, diffable, and every tool on earth reads it.&lt;/p&gt;
&lt;p&gt;The practical defenses are short:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Quote everything.&lt;/strong&gt; It&amp;rsquo;s never wrong and it removes a whole class of ambiguity.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Treat identifiers as strings explicitly&lt;/strong&gt; at the point of import. Every serious CSV reader lets you pin column types; use it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never round-trip through a spreadsheet&lt;/strong&gt; if the data contains identifiers. Opening and saving is a lossy operation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Say what you mean out of band.&lt;/strong&gt; Delimiter, encoding, header presence, quoting style. The file will not.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use something else when you can.&lt;/strong&gt; Parquet and even JSON Lines carry types. If the consumer is a program rather than a person, the readability argument for CSV mostly evaporates.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The lesson generalizes past CSV, and it&amp;rsquo;s the same one from the text file post. A format that carries no description of itself pushes that burden onto every reader, and readers guess. Usually well. Occasionally by silently deleting the leading zero from your ZIP code.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://datatracker.ietf.org/doc/html/rfc4180&#34;&gt;RFC 4180&lt;/a&gt; — the Informational spec that documents CSV rather than defining it&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://datatracker.ietf.org/doc/html/rfc7111&#34;&gt;RFC 7111&lt;/a&gt; — URI fragment selectors for &lt;code&gt;text/csv&lt;/code&gt;, January 2014&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://doi.org/10.1186/s13059-016-1044-7&#34;&gt;Ziemann et al., &amp;ldquo;Gene name errors are widespread in the scientific literature&amp;rdquo;&lt;/a&gt; — Genome Biology, 2016; the 19.6% figure (&lt;a href=&#34;https://pmc.ncbi.nlm.nih.gov/articles/PMC4994289/&#34;&gt;free full text&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://doi.org/10.1371/journal.pcbi.1008984&#34;&gt;Abeysooriya et al., &amp;ldquo;Gene name errors: Lessons not learned&amp;rdquo;&lt;/a&gt; — PLOS Computational Biology, 2021; the 30.9% follow-up&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://doi.org/10.1038/s41588-020-0669-3&#34;&gt;Bruford et al., &amp;ldquo;Guidelines for human gene nomenclature&amp;rdquo;&lt;/a&gt; — Nature Genetics, 2020; the renaming (&lt;a href=&#34;https://pmc.ncbi.nlm.nih.gov/articles/PMC7494048/&#34;&gt;free full text&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://learn.microsoft.com/en-us/troubleshoot/microsoft-365-apps/excel/floating-point-arithmetic-inaccurate-result&#34;&gt;Microsoft on Excel&amp;rsquo;s floating-point precision&lt;/a&gt; — Excel follows IEEE 754 and stores 15 digits of precision&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://support.microsoft.com/en-US/Excel/keeping-leading-zeros-and-large-numbers&#34;&gt;Microsoft on leading zeros and large numbers&lt;/a&gt; — &amp;ldquo;any numbers past the 15th digit are rounded down to zero,&amp;rdquo; with a credit card as the example&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://support.microsoft.com/en-US/Excel/get-started/import-or-export-text-txt-or-csv-files&#34;&gt;Microsoft on importing and exporting text files&lt;/a&gt; — the CSV list separator comes from Windows Region settings&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>CSV is the simple but powerful. Values, separated by commas. It&#39;s easy to understand and use.

It is also, by a wide margin, the one that destroys the most data.

That&#39;s not a paradox. It&#39;s cause and effect. A format simple enough that everyone writes their own parser is a format with as many dialects as it has parsers, and CSV&#39;s defining property is that it carries no information about how to read it.

---

## There Is No Standard

RFC 4180 exists. Yakov Shafranovich published it in October 2005, it registers the `text/csv` media type, and it gives an ABNF grammar.

It is also **Informational**, not Standards Track. It was written to describe what people were already doing, twenty-odd years after spreadsheets started emitting it. Section 2 says so outright: there is &#34;no formal specification in existence,&#34; and what follows documents &#34;the format that seems to be followed by most implementations.&#34;

By the time someone wrote it down, every spreadsheet, database, and scripting language had already shipped its own interpretation. The RFC didn&#39;t settle anything. It just added one more dialect, with the distinction of having a number.

Eight years later, RFC 7111 added URI fragments for pointing at a row, column, or cell inside a `text/csv` file. Also Informational. CSV still had no standard, but you could now cite a specific cell of one.

---

## Where Does a Record End?

The obvious answer is &#34;at the newline,&#34; and the obvious answer is wrong, because a quoted field is allowed to contain one.

```
name,notes
Alice,&#34;line one
line two&#34;
Bob,fine
```

That&#39;s a valid three-row CSV. Split it on newlines and you get four:

```
naive split gives 4 lines:
   &#39;name,notes&#39;
   &#39;Alice,&#34;line one&#39;
   &#39;line two&#34;&#39;
   &#39;Bob,fine&#39;

a real CSV parser gives 3 rows:
   [&#39;name&#39;, &#39;notes&#39;]
   [&#39;Alice&#39;, &#39;line one\nline two&#39;]
   [&#39;Bob&#39;, &#39;fine&#39;]
```

Every `head`, `wc -l`, `split(&#34;\n&#34;)`, and shell pipeline that assumes one record per line is wrong on this file. Not wrong on a malformed file. Wrong on a correct one.

This is the single most common CSV bug, and it&#39;s invisible in testing, because your test fixtures don&#39;t have newlines in them until a user pastes an address into a form.

---

## What&#39;s the Delimiter?

In most of Europe the decimal separator is a comma. `12,50` is twelve and a half euros. Which means a comma cannot also be a field separator, so those locales use semicolons.

Feed a German CSV to an RFC 4180 parser and everything survives, in the sense that nothing throws:

```
input:  produkt;preis
        Kaffee;12,50
        Tee;9,90

parsed as comma-delimited:
   [&#39;produkt;preis&#39;]
   [&#39;Kaffee;12&#39;, &#39;50&#39;]
   [&#39;Tee;9&#39;, &#39;90&#39;]

parsed as semicolon-delimited:
   [&#39;produkt&#39;, &#39;preis&#39;]
   [&#39;Kaffee&#39;, &#39;12,50&#39;]
   [&#39;Tee&#39;, &#39;9,90&#39;]
```

The first reading gives you two columns of nonsense with no error. The prices split down the middle of the decimal point. A pipeline that ingests this will happily compute statistics on the number 12 and the number 50.

Excel picks the delimiter based on your operating system&#39;s regional settings, which means the same file opens differently on two machines in the same office.

---

## Is the First Row a Header?

```
1,2,3
4,5,6
```

Header or data? Nothing in the file says.

RFC 4180&#39;s answer is that you put it in the MIME type: `text/csv; header=present`. Which is a real answer, and also means the information lives outside the file, in a transport layer that gets stripped the moment someone saves the attachment to disk.

So in practice every tool guesses, usually by checking whether the first row looks less numeric than the rest.

---

## The Part That Destroys Data

Everything above is a parsing problem. This one is worse, because the file parses fine and the damage happens after.

CSV has no types. Every value is text. So every spreadsheet and dataframe library applies type inference on import, and type inference is lossy.

Here&#39;s a file with four columns of identifiers, all of which are strings that happen to be made of digits:

```
gene,zip,card,accession
SEPT7,02138,4532012345678901,0004928
MARCH1,01234,4111111111111111,0000071
```

Read it with a type-inferring reader and:

```
  gene  zip             card  accession
 SEPT7 2138 4532012345678901       4928
MARCH1 1234 4111111111111111         71
```

The ZIP code `02138` is now `2138`. The accession number `0004928` is now `4928`. Nobody was asked. Nothing warned. Save that back to CSV and the original values are gone from disk.

Spreadsheets are worse than this, because they store every number as an IEEE 754 double. Microsoft is blunt about the consequence:

&gt; Excel has a maximum precision of **15 significant digits**, which means that for any number containing 16 or more digits, such as a credit card number, any numbers past the 15th digit are rounded down to zero.

The example they reach for is a card number:

```
typed into a cell     1234 5678 9087 6543
Excel shows           1.23E+15
```

Microsoft calls that &#34;truncating numerical data to 15 digits of precision and converting to a number displayed in scientific notation.&#34; Note that this is the vendor describing its own product, not a bug report.

The card number in the file above is also 16 digits. pandas read it back intact. Excel would not.

Credit card numbers are 16 digits. Many national ID numbers are longer. They are not numbers in any meaningful sense, they&#39;re strings of digits, and a format with no type information cannot tell the difference.

---

## The Gene Name Problem

The best-documented case of this is genomics, because biologists name genes things like `SEPT1` and `MARCH1` and spreadsheets read those as dates.

In 2016 Ziemann and colleagues screened 35,175 supplementary Excel files from 18 journals covering 2005 to 2015. Among articles containing Excel gene lists, **19.6%** had gene names corrupted this way. One in five.

A follow-up in 2021, &#34;Gene name errors: Lessons not learned,&#34; found **30.9%** across a broader sample drawn from PubMed Central. Worth being careful comparing those two numbers directly, because the second study used a different sampling frame and also detected an additional error category the first one didn&#39;t look for. The honest summary is that the problem did not go away in the five years after being loudly published.

The resolution is the remarkable part. The field did not fix the spreadsheets. It **renamed the genes**. The HUGO Gene Nomenclature Committee&#39;s 2020 guidelines state that &#34;all symbols that auto-converted to dates in Microsoft Excel have been changed,&#34; giving `SEPT1` becoming `SEPTIN1` and `MARCH1` becoming `MARCHF1` as examples.

Human genes were renamed because a file format cannot say what type a column is.

---

## What To Do About It

CSV isn&#39;t going away, and mostly shouldn&#39;t. It&#39;s readable, streamable, diffable, and every tool on earth reads it.

The practical defenses are short:

- **Quote everything.** It&#39;s never wrong and it removes a whole class of ambiguity.
- **Treat identifiers as strings explicitly** at the point of import. Every serious CSV reader lets you pin column types; use it.
- **Never round-trip through a spreadsheet** if the data contains identifiers. Opening and saving is a lossy operation.
- **Say what you mean out of band.** Delimiter, encoding, header presence, quoting style. The file will not.
- **Use something else when you can.** Parquet and even JSON Lines carry types. If the consumer is a program rather than a person, the readability argument for CSV mostly evaporates.

The lesson generalizes past CSV, and it&#39;s the same one from the text file post. A format that carries no description of itself pushes that burden onto every reader, and readers guess. Usually well. Occasionally by silently deleting the leading zero from your ZIP code.

## Sources

- [RFC 4180](https://datatracker.ietf.org/doc/html/rfc4180) — the Informational spec that documents CSV rather than defining it
- [RFC 7111](https://datatracker.ietf.org/doc/html/rfc7111) — URI fragment selectors for `text/csv`, January 2014
- [Ziemann et al., &#34;Gene name errors are widespread in the scientific literature&#34;](https://doi.org/10.1186/s13059-016-1044-7) — Genome Biology, 2016; the 19.6% figure ([free full text](https://pmc.ncbi.nlm.nih.gov/articles/PMC4994289/))
- [Abeysooriya et al., &#34;Gene name errors: Lessons not learned&#34;](https://doi.org/10.1371/journal.pcbi.1008984) — PLOS Computational Biology, 2021; the 30.9% follow-up
- [Bruford et al., &#34;Guidelines for human gene nomenclature&#34;](https://doi.org/10.1038/s41588-020-0669-3) — Nature Genetics, 2020; the renaming ([free full text](https://pmc.ncbi.nlm.nih.gov/articles/PMC7494048/))
- [Microsoft on Excel&#39;s floating-point precision](https://learn.microsoft.com/en-us/troubleshoot/microsoft-365-apps/excel/floating-point-arithmetic-inaccurate-result) — Excel follows IEEE 754 and stores 15 digits of precision
- [Microsoft on leading zeros and large numbers](https://support.microsoft.com/en-US/Excel/keeping-leading-zeros-and-large-numbers) — &#34;any numbers past the 15th digit are rounded down to zero,&#34; with a credit card as the example
- [Microsoft on importing and exporting text files](https://support.microsoft.com/en-US/Excel/get-started/import-or-export-text-txt-or-csv-files) — the CSV list separator comes from Windows Region settings

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>Word Documents Used to Be Filesystems</title>
      <link>https://llbbl.blog/2026/08/18/word-documents-used-to-be.html</link>
      <pubDate>Tue, 18 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/18/word-documents-used-to-be.html</guid>
      <description>&lt;p&gt;Last post ended on a promise: a &lt;code&gt;.docx&lt;/code&gt; is a ZIP file, and you already know how ZIP works.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s true, and it&amp;rsquo;s the smaller half of the story. The interesting part is what &lt;code&gt;.docx&lt;/code&gt; replaced, because the old &lt;code&gt;.doc&lt;/code&gt; format was doing something strange. It wasn&amp;rsquo;t a document. It was a filesystem with a document living inside it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;a-filesystem-in-a-file&#34;&gt;A Filesystem in a File&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s a real &lt;code&gt;.doc&lt;/code&gt; from 2015, 10,240 bytes. The first eight bytes:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;d0 cf 11 e0 a1 b1 1a e1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That&amp;rsquo;s the Compound File Binary Format signature, also called OLE2. &lt;code&gt;file(1)&lt;/code&gt; recognizes it and doesn&amp;rsquo;t even mention Word:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ file &amp;#34;rich text.doc&amp;#34;
Composite Document File V2 Document, Little Endian, Os: Windows,
Version 1.0, Code page: -535, Revision Number: 0,
Create Time/Date: Thu Dec 10 13:38:22 2015
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&amp;ldquo;Composite Document File&amp;rdquo; is the honest description. CFBF is a container that implements directories and files, called &lt;strong&gt;storages&lt;/strong&gt; and &lt;strong&gt;streams&lt;/strong&gt;, inside a single flat file. It has a File Allocation Table. It has sectors. If that sounds like FAT16, that&amp;rsquo;s because it&amp;rsquo;s the same idea, scaled down to live inside one file on a real filesystem.&lt;/p&gt;
&lt;p&gt;Cracking this one open gives:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;     106 bytes  CompObj
      20 bytes  Ole
     116 bytes  DocumentSummaryInformation
     312 bytes  SummaryInformation
    2411 bytes  1Table
    3620 bytes  WordDocument

sector size      : 2^9 = 512 bytes
mini sector size : 2^6 = 64 bytes
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Two sector sizes, because a 512-byte sector is wasteful for a 20-byte stream. Streams under 4,096 bytes get allocated out of a separate &lt;strong&gt;mini-FAT&lt;/strong&gt; in 64-byte units. There is a fragmentation strategy inside your Word document.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;WordDocument&lt;/code&gt; stream is the main event, and it opens with a File Information Block whose magic number is &lt;code&gt;0xA5EC&lt;/code&gt;:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;WordDocument stream: 3620 bytes
  FIB magic (wIdent) = 0xA5EC
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;None of this is the text yet. This is all container.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-text-is-not-in-order&#34;&gt;The Text Is Not in Order&lt;/h2&gt;
&lt;p&gt;You&amp;rsquo;d expect the document&amp;rsquo;s text to sit in the &lt;code&gt;WordDocument&lt;/code&gt; stream in reading order. It doesn&amp;rsquo;t. It sits there in &lt;strong&gt;edit order&lt;/strong&gt;, and a separate structure called a &lt;strong&gt;piece table&lt;/strong&gt; says how to reassemble it.&lt;/p&gt;
&lt;p&gt;The piece table is a list of descriptors, each saying &amp;ldquo;characters at logical position X through Y live at physical offset Z.&amp;rdquo; Reading a &lt;code&gt;.doc&lt;/code&gt; means walking that table and gathering fragments scattered through the stream.&lt;/p&gt;
&lt;p&gt;Why build it that way? Because of a feature called &lt;strong&gt;Fast Save&lt;/strong&gt;, and because in 1990 writing to disk was slow. When you edited a document, Word didn&amp;rsquo;t rewrite the file. It appended your new text to the end of the stream and updated the piece table to point at it. Saving a one-word change to a 200-page document meant writing a few dozen bytes instead of a few hundred kilobytes.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s a good optimization. It has an obvious and terrible consequence.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The old text is still in the file.&lt;/strong&gt; Deleting a paragraph removed it from the piece table, not from the stream. The bytes stayed exactly where they were, unreferenced, invisible in Word, and completely readable in a hex editor.&lt;/p&gt;
&lt;p&gt;Microsoft documented this themselves, in a knowledge base article about minimizing metadata in Word documents: &lt;em&gt;&amp;ldquo;Because of the design of the FastSave feature, text that you delete from a document may remain in the document, even after you save the document.&amp;rdquo;&lt;/em&gt; The recommended fix was to go into Options and clear the &amp;ldquo;Allow fast saves&amp;rdquo; check box. From Word 97 SR-1 onward they turned it off by default.&lt;/p&gt;
&lt;p&gt;For years, &amp;ldquo;open the document in a text editor and scroll&amp;rdquo; was a functioning technique for reading text someone believed they had deleted. Every organization circulating Word files was potentially shipping its own edit history.&lt;/p&gt;
&lt;p&gt;The piece table itself has a respectable pedigree. Charles Simonyi brought the technique to Microsoft from Xerox PARC&amp;rsquo;s Bravo editor, and it&amp;rsquo;s an elegant way to represent an editable buffer. It&amp;rsquo;s still how many text editors model documents in memory. The mistake wasn&amp;rsquo;t the data structure. The mistake was persisting the whole scratch buffer to disk and shipping it to other people.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;then-it-became-a-zip-of-xml&#34;&gt;Then It Became a ZIP of XML&lt;/h2&gt;
&lt;p&gt;Office 2007 replaced all of it with the Open Packaging Conventions: ECMA-376, later ISO/IEC 29500. A &lt;code&gt;.docx&lt;/code&gt; is a ZIP archive containing XML.&lt;/p&gt;
&lt;p&gt;Every &lt;code&gt;.docx&lt;/code&gt; opens with the same four bytes:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;50 4b 03 04    &amp;lt;- PK\x03\x04, a ZIP local file header
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;PK&lt;/code&gt;. Phil Katz&amp;rsquo;s initials, from the last post, sitting at byte zero of every Word document written since 2007.&lt;/p&gt;
&lt;p&gt;Unzip one and the structure is legible:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;[Content_Types].xml
_rels/.rels
word/document.xml
word/_rels/document.xml.rels
word/styles.xml
word/settings.xml
word/fontTable.xml
word/theme/theme1.xml
docProps/core.xml
docProps/app.xml
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;word/document.xml&lt;/code&gt; holds the text. &lt;code&gt;[Content_Types].xml&lt;/code&gt; maps each part to a MIME type. &lt;code&gt;_rels/.rels&lt;/code&gt; is a relationship graph saying which part is the main document and how the parts connect. The whole thing is a tiny website, zipped.&lt;/p&gt;
&lt;p&gt;The text itself is WordprocessingML:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-xml&#34; data-lang=&#34;xml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;w:p&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;w:r&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;w:t&amp;gt;&lt;/span&gt;Hello, World!&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;/w:t&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;/w:r&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;&amp;lt;/w:p&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A paragraph containing a run containing text. Verbose, but you can read it, and more importantly a program you wrote in an afternoon can read it. That is important when building foundational file formats that outlive the creators.&lt;/p&gt;
&lt;p&gt;Extracting text from a &lt;code&gt;.doc&lt;/code&gt; meant implementing a filesystem and a piece table. Extracting text from a &lt;code&gt;.docx&lt;/code&gt; means unzipping and finding &lt;code&gt;&amp;lt;w:t&amp;gt;&lt;/code&gt; elements.&lt;/p&gt;
&lt;p&gt;The XML contains the document, not the document&amp;rsquo;s history. Deleted text is deleted.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;xml-did-not-mean-simple&#34;&gt;XML Did Not Mean Simple&lt;/h2&gt;
&lt;p&gt;It would be tidy to end on &amp;ldquo;and then it got clean.&amp;rdquo; The specification runs to several thousand pages, and the ISO fast-track that pushed it through in 2008 was contentious enough to deserve its own post.&lt;/p&gt;
&lt;p&gt;What matters here is the shape it settled into. The standard shipped split in two: &lt;strong&gt;Strict&lt;/strong&gt;, the clean format, and &lt;strong&gt;Transitional&lt;/strong&gt;, which carries the legacy baggage forward so documents converted from the binary era still render correctly.&lt;/p&gt;
&lt;p&gt;Guess which one nearly everything emits.&lt;/p&gt;
&lt;p&gt;Open a Transitional document&amp;rsquo;s settings and you find a &lt;code&gt;&amp;lt;w:compat&amp;gt;&lt;/code&gt; block. Its children are a museum:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;w:truncateFontHeightsLikeWP6    WordPerfect 6
w:suppressTopSpacingWP          WordPerfect
w:lineWrapLikeWord6             Word 6
w:autoSpaceLikeWord95           Word 95
w:footnoteLayoutLikeWW8         Word 97
w:useWord97LineBreakRules       Word 97
w:mwSmallCaps                   Mac Word
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Every one of those is a flag asking the renderer to reproduce how a specific piece of 1990s software behaved. Not what the format should do. What Word 6 &lt;em&gt;did&lt;/em&gt; do, quirks included. Implementing this correctly means emulating applications whose behavior was never written down anywhere.&lt;/p&gt;
&lt;p&gt;The bugs were load-bearing, so they got standardized. The format stopped being a filesystem, but it did not stop being a thirty-year-old application&amp;rsquo;s memory dumped to disk. It just picked a more legible way to write it down.&lt;/p&gt;
&lt;p&gt;Which is, in fairness, an enormous improvement. You can read the file now. You just can&amp;rsquo;t read all of it quickly.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/53989ce4-7b05-4f8d-829b-d08d6148375b&#34;&gt;MS-CFB: Compound File Binary Format&lt;/a&gt; — Microsoft&amp;rsquo;s spec for the OLE2 container&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/ccd7b486-7881-484c-a137-51170af7cc22&#34;&gt;MS-DOC: Word Binary File Format&lt;/a&gt; — the FIB, the piece table, and the stream layout&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://ecma-international.org/publications-and-standards/standards/ecma-376/&#34;&gt;ECMA-376&lt;/a&gt; — Office Open XML, the basis for &lt;code&gt;.docx&lt;/code&gt;, and free to download. This is the same specification ISO published as ISO/IEC 29500, so read it here rather than paying ISO for the identical text&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.loc.gov/preservation/digital/formats/fdd/fdd000395.shtml&#34;&gt;Library of Congress format description for OOXML&lt;/a&gt; — preservation notes and format history&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://jeffpar.github.io/kbarchive/kb/223/Q223790/&#34;&gt;KB Q223790: WD97: How to Minimize Metadata in Word Documents&lt;/a&gt; — the fast-save warning, archived; Microsoft no longer hosts it&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;http://www.datypic.com/sc/ooxml/t-w_CT_Compat.html&#34;&gt;&lt;code&gt;w:compat&lt;/code&gt; schema reference&lt;/a&gt; — the full list of compatibility settings, browsable without downloading the spec&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>Last post ended on a promise: a `.docx` is a ZIP file, and you already know how ZIP works.

That&#39;s true, and it&#39;s the smaller half of the story. The interesting part is what `.docx` replaced, because the old `.doc` format was doing something strange. It wasn&#39;t a document. It was a filesystem with a document living inside it.

---

## A Filesystem in a File

Here&#39;s a real `.doc` from 2015, 10,240 bytes. The first eight bytes:

```
d0 cf 11 e0 a1 b1 1a e1
```

That&#39;s the Compound File Binary Format signature, also called OLE2. `file(1)` recognizes it and doesn&#39;t even mention Word:

```
$ file &#34;rich text.doc&#34;
Composite Document File V2 Document, Little Endian, Os: Windows,
Version 1.0, Code page: -535, Revision Number: 0,
Create Time/Date: Thu Dec 10 13:38:22 2015
```

&#34;Composite Document File&#34; is the honest description. CFBF is a container that implements directories and files, called **storages** and **streams**, inside a single flat file. It has a File Allocation Table. It has sectors. If that sounds like FAT16, that&#39;s because it&#39;s the same idea, scaled down to live inside one file on a real filesystem.

Cracking this one open gives:

```
     106 bytes  CompObj
      20 bytes  Ole
     116 bytes  DocumentSummaryInformation
     312 bytes  SummaryInformation
    2411 bytes  1Table
    3620 bytes  WordDocument

sector size      : 2^9 = 512 bytes
mini sector size : 2^6 = 64 bytes
```

Two sector sizes, because a 512-byte sector is wasteful for a 20-byte stream. Streams under 4,096 bytes get allocated out of a separate **mini-FAT** in 64-byte units. There is a fragmentation strategy inside your Word document.

The `WordDocument` stream is the main event, and it opens with a File Information Block whose magic number is `0xA5EC`:

```
WordDocument stream: 3620 bytes
  FIB magic (wIdent) = 0xA5EC
```

None of this is the text yet. This is all container.

---

## The Text Is Not in Order

You&#39;d expect the document&#39;s text to sit in the `WordDocument` stream in reading order. It doesn&#39;t. It sits there in **edit order**, and a separate structure called a **piece table** says how to reassemble it.

The piece table is a list of descriptors, each saying &#34;characters at logical position X through Y live at physical offset Z.&#34; Reading a `.doc` means walking that table and gathering fragments scattered through the stream.

Why build it that way? Because of a feature called **Fast Save**, and because in 1990 writing to disk was slow. When you edited a document, Word didn&#39;t rewrite the file. It appended your new text to the end of the stream and updated the piece table to point at it. Saving a one-word change to a 200-page document meant writing a few dozen bytes instead of a few hundred kilobytes.

That&#39;s a good optimization. It has an obvious and terrible consequence.

**The old text is still in the file.** Deleting a paragraph removed it from the piece table, not from the stream. The bytes stayed exactly where they were, unreferenced, invisible in Word, and completely readable in a hex editor.

Microsoft documented this themselves, in a knowledge base article about minimizing metadata in Word documents: *&#34;Because of the design of the FastSave feature, text that you delete from a document may remain in the document, even after you save the document.&#34;* The recommended fix was to go into Options and clear the &#34;Allow fast saves&#34; check box. From Word 97 SR-1 onward they turned it off by default.

For years, &#34;open the document in a text editor and scroll&#34; was a functioning technique for reading text someone believed they had deleted. Every organization circulating Word files was potentially shipping its own edit history.

The piece table itself has a respectable pedigree. Charles Simonyi brought the technique to Microsoft from Xerox PARC&#39;s Bravo editor, and it&#39;s an elegant way to represent an editable buffer. It&#39;s still how many text editors model documents in memory. The mistake wasn&#39;t the data structure. The mistake was persisting the whole scratch buffer to disk and shipping it to other people.

---

## Then It Became a ZIP of XML

Office 2007 replaced all of it with the Open Packaging Conventions: ECMA-376, later ISO/IEC 29500. A `.docx` is a ZIP archive containing XML.

Every `.docx` opens with the same four bytes:

```
50 4b 03 04    &lt;- PK\x03\x04, a ZIP local file header
```

`PK`. Phil Katz&#39;s initials, from the last post, sitting at byte zero of every Word document written since 2007.

Unzip one and the structure is legible:

```
[Content_Types].xml
_rels/.rels
word/document.xml
word/_rels/document.xml.rels
word/styles.xml
word/settings.xml
word/fontTable.xml
word/theme/theme1.xml
docProps/core.xml
docProps/app.xml
```

`word/document.xml` holds the text. `[Content_Types].xml` maps each part to a MIME type. `_rels/.rels` is a relationship graph saying which part is the main document and how the parts connect. The whole thing is a tiny website, zipped.

The text itself is WordprocessingML:

```xml
&lt;w:p&gt;
  &lt;w:r&gt;
    &lt;w:t&gt;Hello, World!&lt;/w:t&gt;
  &lt;/w:r&gt;
&lt;/w:p&gt;
```

A paragraph containing a run containing text. Verbose, but you can read it, and more importantly a program you wrote in an afternoon can read it. That is important when building foundational file formats that outlive the creators.

Extracting text from a `.doc` meant implementing a filesystem and a piece table. Extracting text from a `.docx` means unzipping and finding `&lt;w:t&gt;` elements.

The XML contains the document, not the document&#39;s history. Deleted text is deleted.

---

## XML Did Not Mean Simple

It would be tidy to end on &#34;and then it got clean.&#34; The specification runs to several thousand pages, and the ISO fast-track that pushed it through in 2008 was contentious enough to deserve its own post.

What matters here is the shape it settled into. The standard shipped split in two: **Strict**, the clean format, and **Transitional**, which carries the legacy baggage forward so documents converted from the binary era still render correctly.

Guess which one nearly everything emits.

Open a Transitional document&#39;s settings and you find a `&lt;w:compat&gt;` block. Its children are a museum:

```
w:truncateFontHeightsLikeWP6    WordPerfect 6
w:suppressTopSpacingWP          WordPerfect
w:lineWrapLikeWord6             Word 6
w:autoSpaceLikeWord95           Word 95
w:footnoteLayoutLikeWW8         Word 97
w:useWord97LineBreakRules       Word 97
w:mwSmallCaps                   Mac Word
```

Every one of those is a flag asking the renderer to reproduce how a specific piece of 1990s software behaved. Not what the format should do. What Word 6 *did* do, quirks included. Implementing this correctly means emulating applications whose behavior was never written down anywhere.

The bugs were load-bearing, so they got standardized. The format stopped being a filesystem, but it did not stop being a thirty-year-old application&#39;s memory dumped to disk. It just picked a more legible way to write it down.

Which is, in fairness, an enormous improvement. You can read the file now. You just can&#39;t read all of it quickly.

## Sources

- [MS-CFB: Compound File Binary Format](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/53989ce4-7b05-4f8d-829b-d08d6148375b) — Microsoft&#39;s spec for the OLE2 container
- [MS-DOC: Word Binary File Format](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/ccd7b486-7881-484c-a137-51170af7cc22) — the FIB, the piece table, and the stream layout
- [ECMA-376](https://ecma-international.org/publications-and-standards/standards/ecma-376/) — Office Open XML, the basis for `.docx`, and free to download. This is the same specification ISO published as ISO/IEC 29500, so read it here rather than paying ISO for the identical text
- [Library of Congress format description for OOXML](https://www.loc.gov/preservation/digital/formats/fdd/fdd000395.shtml) — preservation notes and format history
- [KB Q223790: WD97: How to Minimize Metadata in Word Documents](https://jeffpar.github.io/kbarchive/kb/223/Q223790/) — the fast-save warning, archived; Microsoft no longer hosts it
- [`w:compat` schema reference](http://www.datypic.com/sc/ooxml/t-w_CT_Compat.html) — the full list of compatibility settings, browsable without downloading the spec

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>ZIP Files Are Read Backwards</title>
      <link>https://llbbl.blog/2026/08/17/zip-files-are-read-backwards.html</link>
      <pubDate>Mon, 17 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/17/zip-files-are-read-backwards.html</guid>
      <description>&lt;p&gt;Every format in this series so far reads front to back. PNG starts with a signature and you walk chunks in order. A text file is bytes from the beginning. Markdown parsers scan line by line, top to bottom.&lt;/p&gt;
&lt;p&gt;ZIP reads backwards. The index is at the end of the file, and a reader is expected to seek to the end first and work its way back.&lt;/p&gt;
&lt;p&gt;That one decision explains almost everything strange about ZIP, including a few things that look like bugs and one thing that is definitely a bug.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-index-lives-at-the-end&#34;&gt;The Index Lives at the End&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s a real ZIP containing two small text files. 241 bytes total. Scanning it for the four-byte record signatures gives the whole layout:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;offset  bytes         ascii   record
     0  50 4b 03 04   P K . . Local File Header      &amp;lt;- hello.txt
    53  50 4b 03 04   P K . . Local File Header      &amp;lt;- second.txt
   108  50 4b 01 02   P K . . Central Directory Header
   163  50 4b 01 02   P K . . Central Directory Header
   219  50 4b 05 06   P K . . End of Central Directory
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Every one of those starts with the same two bytes. &lt;code&gt;0x50&lt;/code&gt; is decimal 80, which is &lt;code&gt;P&lt;/code&gt; in ASCII. &lt;code&gt;0x4b&lt;/code&gt; is decimal 75, which is &lt;code&gt;K&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;PK&lt;/code&gt;. Phil Katz, who wrote PKZIP in 1989, put his initials in the first two bytes of every structure in the format, and they are still there in every &lt;code&gt;.docx&lt;/code&gt;, &lt;code&gt;.jar&lt;/code&gt;, and &lt;code&gt;.epub&lt;/code&gt; on your machine.&lt;/p&gt;
&lt;p&gt;The two bytes after &lt;code&gt;PK&lt;/code&gt; are the record type: &lt;code&gt;03 04&lt;/code&gt; for a local file header, &lt;code&gt;01 02&lt;/code&gt; for a central directory entry, &lt;code&gt;05 06&lt;/code&gt; for the end-of-central-directory record. Those aren&amp;rsquo;t printable characters, which is deliberate. A four-byte constant made of two readable letters and two control bytes is unlikely to appear by accident in text, and easy to spot by eye in a hex dump.&lt;/p&gt;
&lt;p&gt;The last 22 bytes are the &lt;strong&gt;End of Central Directory&lt;/strong&gt; record, and it&amp;rsquo;s the entry point:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;50 4b 05 06 00 00 00 00 02 00 02 00 6f 00 00 00 6c 00 00 00 00 00

signature            0x06054b50
total CD records     2
central dir size     111 bytes
central dir offset   108
comment length       0
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;A reader opens the file, jumps to the end, finds that record, reads &amp;ldquo;the index is at offset 108,&amp;rdquo; seeks there, and reads the catalog. Listing the contents of a 4 GB archive touches a few hundred bytes.&lt;/p&gt;
&lt;p&gt;Note that each file appears &lt;strong&gt;twice&lt;/strong&gt;: once as a Local File Header immediately before its compressed data, and once as an entry in the Central Directory at the end. Hold that thought.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;why-would-you-do-this&#34;&gt;Why Would You Do This?&lt;/h2&gt;
&lt;p&gt;Because in 1989 you were writing to a floppy disk, and often to a floppy disk that wasn&amp;rsquo;t big enough.&lt;/p&gt;
&lt;p&gt;If the index goes at the front, you have to know everything about every file before you write the first byte: how many files, how big each one compresses to, where each one lands. That means compressing everything to a temporary location, then writing the header, then copying it all back. On a machine with 640K of RAM and two floppy drives, that&amp;rsquo;s brutal.&lt;/p&gt;
&lt;p&gt;Put the index at the end and you can stream. Compress a file, write it, remember where it went. Compress the next one. When you run out of files, write down everything you remembered. One pass, no temporary copy, and you never needed to know the total size in advance.&lt;/p&gt;
&lt;p&gt;TAR solved the same problem by having no index at all, which is why &lt;code&gt;tar&lt;/code&gt; has to read an entire archive to find one file, and why you cannot randomly access a &lt;code&gt;.tar.gz&lt;/code&gt;. ZIP got both streaming writes and random-access reads. That&amp;rsquo;s the trade that made it win.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-backwards-scan-is-fuzzier-than-it-sounds&#34;&gt;The Backwards Scan Is Fuzzier Than It Sounds&lt;/h2&gt;
&lt;p&gt;The EOCD record is 22 bytes, so you&amp;rsquo;d think a reader could just read the last 22 bytes and be done.&lt;/p&gt;
&lt;p&gt;It can&amp;rsquo;t, because the record ends with a variable-length archive comment of up to 65,535 bytes. The signature isn&amp;rsquo;t at a fixed offset from the end of the file. So a reader has to seek near the end and &lt;strong&gt;scan backwards looking for the four-byte signature&lt;/strong&gt;, potentially across 65,557 bytes.&lt;/p&gt;
&lt;p&gt;Searching for a magic number is not the same as knowing where a structure is. If those four bytes happen to appear inside the comment, or inside compressed data near the end of the file, a naive parser can lock onto the wrong one. Different implementations pick different candidates when there&amp;rsquo;s more than one. This is a recurring source of &amp;ldquo;this archive opens in one tool and not another.&amp;rdquo;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;you-can-put-anything-in-front-of-a-zip&#34;&gt;You Can Put Anything in Front of a ZIP&lt;/h2&gt;
&lt;p&gt;If a reader finds the archive by scanning backwards from the end, then whatever sits at the &lt;em&gt;front&lt;/em&gt; of the file is not the reader&amp;rsquo;s problem.&lt;/p&gt;
&lt;p&gt;Take a valid 69-byte PNG, take the 241-byte ZIP, and concatenate them with &lt;code&gt;cat&lt;/code&gt;. No special tooling:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ file polyglot.png
polyglot.png: PNG image data, 1 x 1, 8-bit/color RGB, non-interlaced

$ unzip -l polyglot.png
  Length      Date    Time    Name
---------  ---------- -----   ----
       12  08-10-2026 16:44   hello.txt
       13  08-10-2026 16:44   second.txt
---------                     -------
       25                     2 files
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;One 310-byte file. An image viewer reads the PNG signature at byte 0 and renders an image. An archive tool scans backwards, finds the EOCD, and extracts two files. Both are correct. Neither is being fooled by a trick; they&amp;rsquo;re each doing exactly what their format says to do.&lt;/p&gt;
&lt;p&gt;This is the mechanism behind self-extracting archives, where the front of the file is a real executable and the back is a real ZIP. The same property is why &amp;ldquo;GIFAR&amp;rdquo; attacks worked: a file that a server accepted as a harmless image was loaded by Java as an archive of classes.&lt;/p&gt;
&lt;p&gt;It also means the offsets inside the Central Directory are relative to the start of the &lt;em&gt;archive&lt;/em&gt;, not the start of the file, and readers have to work out that difference. Prepending data shifts everything, and well-behaved parsers cope by computing the delta between where the EOCD says the directory should be and where it found it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;two-indexes-one-file&#34;&gt;Two Indexes, One File&lt;/h2&gt;
&lt;p&gt;Back to that detail from earlier: every file&amp;rsquo;s name and metadata are stored twice, in the Local File Header and again in the Central Directory.&lt;/p&gt;
&lt;p&gt;Nothing enforces that they agree.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the same archive with &lt;strong&gt;only the Central Directory copy&lt;/strong&gt; of the first filename patched from &lt;code&gt;hello.txt&lt;/code&gt; to &lt;code&gt;BOGUS.txt&lt;/code&gt;. The local header is untouched:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ unzip -l mismatch.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
       12  08-10-2026 16:44   BOGUS.txt
       13  08-10-2026 16:44   second.txt

  local file header at offset 0 still says: hello.txt
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The archive is not corrupt. &lt;code&gt;unzip&lt;/code&gt; lists it happily. It just contains two different answers to &amp;ldquo;what is this file called,&amp;rdquo; and which one you get depends on which structure your parser decided to trust.&lt;/p&gt;
&lt;p&gt;Now imagine two programs reading the same archive, one checking a signature and the other extracting files. That&amp;rsquo;s the Android &amp;ldquo;Master Key&amp;rdquo; bug from 2013, and the detail is better than the summary.&lt;/p&gt;
&lt;p&gt;An APK is a ZIP. The attacker puts two entries in it, both named &lt;code&gt;classes.dex&lt;/code&gt;. Android&amp;rsquo;s Java verifier loaded entries into a map keyed by filename, so a duplicate name overwrote the earlier one and &lt;strong&gt;the last entry was the one whose signature got checked&lt;/strong&gt;. The native installer used a hash table with linear probing that didn&amp;rsquo;t replace on collision, so &lt;strong&gt;the first entry was the one that got loaded and run&lt;/strong&gt;. Plant malicious code first, legitimately signed code second, and the device verifies one file and executes the other.&lt;/p&gt;
&lt;p&gt;A second bug the same year came from the same &amp;ldquo;two readings, one file&amp;rdquo; family, via a signed integer. The extra-field length is a 16-bit value, and the Java code read it &lt;em&gt;signed&lt;/em&gt;. A length of 65,533 (&lt;code&gt;0xFFFD&lt;/code&gt;) sign-extends to −3. Since the offset of the compressed data is computed by &lt;strong&gt;adding&lt;/strong&gt; that length, a negative value moves the read pointer backward into the header region instead of forward past it.&lt;/p&gt;
&lt;p&gt;The lesson generalizes past ZIP. Any format that stores the same fact twice has to decide what happens when the copies disagree, and &amp;ldquo;the spec doesn&amp;rsquo;t say&amp;rdquo; is the same answer as &amp;ldquo;attackers decide.&amp;rdquo;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;offsets-are-just-numbers&#34;&gt;Offsets Are Just Numbers&lt;/h2&gt;
&lt;p&gt;The Central Directory locates each file by offset. Nothing in the format says two entries can&amp;rsquo;t point at the same bytes.&lt;/p&gt;
&lt;p&gt;The classic zip bomb didn&amp;rsquo;t need that. &lt;code&gt;42.zip&lt;/code&gt; is 42 kilobytes of archives nested five layers deep, sixteen at each layer, unpacking to roughly 4.5 petabytes. The defense is obvious once you&amp;rsquo;ve seen it: cap recursion depth, don&amp;rsquo;t auto-extract nested archives.&lt;/p&gt;
&lt;p&gt;David Fifield&amp;rsquo;s 2019 construction doesn&amp;rsquo;t recurse at all. It expands in a &lt;strong&gt;single pass&lt;/strong&gt;, so depth limits are irrelevant. The trick is overlap: many Central Directory entries reference one shared kernel of compressed data, and each entry&amp;rsquo;s compressed stream uses DEFLATE&amp;rsquo;s stored-block mode to quote the &lt;em&gt;next&lt;/em&gt; entry&amp;rsquo;s local file header as literal bytes. Entries nest inside each other, and output grows quadratically against input.&lt;/p&gt;
&lt;p&gt;He published several, and they aren&amp;rsquo;t interchangeable:&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;File&lt;/th&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Compressed&lt;/th&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Uncompressed&lt;/th&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Ratio&lt;/th&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Needs Zip64&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;&lt;code&gt;zbsm.zip&lt;/code&gt;&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;42 KB&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;5.5 GB&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;~130,000:1&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;No&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;&lt;code&gt;zblg.zip&lt;/code&gt;&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;10 MB&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;281.4 TB&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;~28,000,000:1&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;No&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;&lt;code&gt;zbxl.zip&lt;/code&gt;&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;46 MB&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;4.5 PB&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;~98,000,000:1&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Yes&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The Zip64 requirement on the largest one matters, because not every reader supports Zip64, which makes the merely-enormous version the more portable weapon.&lt;/p&gt;
&lt;p&gt;None of these are malformed files. Every one is a valid archive that a conforming parser is supposed to accept. The format allows two entries to describe the same bytes, and no rule anywhere says the total uncompressed size has to bear any relationship to the file you&amp;rsquo;re holding.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;everything-is-secretly-a-zip&#34;&gt;Everything Is Secretly a ZIP&lt;/h2&gt;
&lt;p&gt;Once you know the structure, you start recognizing it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.docx&lt;/code&gt;, &lt;code&gt;.xlsx&lt;/code&gt;, &lt;code&gt;.pptx&lt;/code&gt; are ZIP archives of XML&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.jar&lt;/code&gt;, &lt;code&gt;.war&lt;/code&gt;, &lt;code&gt;.apk&lt;/code&gt; are ZIP archives of class files and resources&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.epub&lt;/code&gt; is a ZIP of XHTML&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.odt&lt;/code&gt;, &lt;code&gt;.ods&lt;/code&gt; are ZIP of XML again&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;rsquo;s not a coincidence or a hack. ISO/IEC 21320-1, &amp;ldquo;Document Container File,&amp;rdquo; defines a constrained ZIP profile for exactly this use. It narrows the format so a &lt;code&gt;.docx&lt;/code&gt; reader doesn&amp;rsquo;t have to implement all of ZIP&amp;rsquo;s accumulated history: compression must be stored or deflated and nothing else, and the various encryption and digital-signature mechanisms in the original spec are all forbidden.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s a narrowing, not a rewrite. Zip64 version 1 is still permitted, for instance; only version 2 is ruled out. The profile is best understood as a list of the parts of ZIP that turned out to be a bad idea.&lt;/p&gt;
&lt;p&gt;Which means the next post in this series is mostly about a ZIP file with XML inside it. You already know half of how a Word document works.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT&#34;&gt;PKWARE APPNOTE.TXT&lt;/a&gt; — the original and still-authoritative ZIP specification, currently version 6.3.10; §4.3.16 defines the end of central directory record&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.iso.org/standard/60101.html&#34;&gt;ISO/IEC 21320-1:2015&lt;/a&gt; — the constrained ZIP profile used by document formats. Fair warning, this one is a paid ISO standard; the catalog page tells you what it covers but you cannot read the text without buying it&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.loc.gov/preservation/digital/formats/fdd/fdd000354.shtml&#34;&gt;Library of Congress format description for ZIP&lt;/a&gt; — history and preservation notes&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.bamsoftware.com/hacks/zipbomb/&#34;&gt;David Fifield: A Better Zip Bomb&lt;/a&gt; — the overlapping-stream construction&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>Every format in this series so far reads front to back. PNG starts with a signature and you walk chunks in order. A text file is bytes from the beginning. Markdown parsers scan line by line, top to bottom.

ZIP reads backwards. The index is at the end of the file, and a reader is expected to seek to the end first and work its way back.

That one decision explains almost everything strange about ZIP, including a few things that look like bugs and one thing that is definitely a bug.

---

## The Index Lives at the End

Here&#39;s a real ZIP containing two small text files. 241 bytes total. Scanning it for the four-byte record signatures gives the whole layout:

```
offset  bytes         ascii   record
     0  50 4b 03 04   P K . . Local File Header      &lt;- hello.txt
    53  50 4b 03 04   P K . . Local File Header      &lt;- second.txt
   108  50 4b 01 02   P K . . Central Directory Header
   163  50 4b 01 02   P K . . Central Directory Header
   219  50 4b 05 06   P K . . End of Central Directory
```

Every one of those starts with the same two bytes. `0x50` is decimal 80, which is `P` in ASCII. `0x4b` is decimal 75, which is `K`.

`PK`. Phil Katz, who wrote PKZIP in 1989, put his initials in the first two bytes of every structure in the format, and they are still there in every `.docx`, `.jar`, and `.epub` on your machine.

The two bytes after `PK` are the record type: `03 04` for a local file header, `01 02` for a central directory entry, `05 06` for the end-of-central-directory record. Those aren&#39;t printable characters, which is deliberate. A four-byte constant made of two readable letters and two control bytes is unlikely to appear by accident in text, and easy to spot by eye in a hex dump.

The last 22 bytes are the **End of Central Directory** record, and it&#39;s the entry point:

```
50 4b 05 06 00 00 00 00 02 00 02 00 6f 00 00 00 6c 00 00 00 00 00

signature            0x06054b50
total CD records     2
central dir size     111 bytes
central dir offset   108
comment length       0
```

A reader opens the file, jumps to the end, finds that record, reads &#34;the index is at offset 108,&#34; seeks there, and reads the catalog. Listing the contents of a 4 GB archive touches a few hundred bytes.

Note that each file appears **twice**: once as a Local File Header immediately before its compressed data, and once as an entry in the Central Directory at the end. Hold that thought.

---

## Why Would You Do This?

Because in 1989 you were writing to a floppy disk, and often to a floppy disk that wasn&#39;t big enough.

If the index goes at the front, you have to know everything about every file before you write the first byte: how many files, how big each one compresses to, where each one lands. That means compressing everything to a temporary location, then writing the header, then copying it all back. On a machine with 640K of RAM and two floppy drives, that&#39;s brutal.

Put the index at the end and you can stream. Compress a file, write it, remember where it went. Compress the next one. When you run out of files, write down everything you remembered. One pass, no temporary copy, and you never needed to know the total size in advance.

TAR solved the same problem by having no index at all, which is why `tar` has to read an entire archive to find one file, and why you cannot randomly access a `.tar.gz`. ZIP got both streaming writes and random-access reads. That&#39;s the trade that made it win.

---

## The Backwards Scan Is Fuzzier Than It Sounds

The EOCD record is 22 bytes, so you&#39;d think a reader could just read the last 22 bytes and be done.

It can&#39;t, because the record ends with a variable-length archive comment of up to 65,535 bytes. The signature isn&#39;t at a fixed offset from the end of the file. So a reader has to seek near the end and **scan backwards looking for the four-byte signature**, potentially across 65,557 bytes.

Searching for a magic number is not the same as knowing where a structure is. If those four bytes happen to appear inside the comment, or inside compressed data near the end of the file, a naive parser can lock onto the wrong one. Different implementations pick different candidates when there&#39;s more than one. This is a recurring source of &#34;this archive opens in one tool and not another.&#34;

---

## You Can Put Anything in Front of a ZIP

If a reader finds the archive by scanning backwards from the end, then whatever sits at the *front* of the file is not the reader&#39;s problem.

Take a valid 69-byte PNG, take the 241-byte ZIP, and concatenate them with `cat`. No special tooling:

```
$ file polyglot.png
polyglot.png: PNG image data, 1 x 1, 8-bit/color RGB, non-interlaced

$ unzip -l polyglot.png
  Length      Date    Time    Name
---------  ---------- -----   ----
       12  08-10-2026 16:44   hello.txt
       13  08-10-2026 16:44   second.txt
---------                     -------
       25                     2 files
```

One 310-byte file. An image viewer reads the PNG signature at byte 0 and renders an image. An archive tool scans backwards, finds the EOCD, and extracts two files. Both are correct. Neither is being fooled by a trick; they&#39;re each doing exactly what their format says to do.

This is the mechanism behind self-extracting archives, where the front of the file is a real executable and the back is a real ZIP. The same property is why &#34;GIFAR&#34; attacks worked: a file that a server accepted as a harmless image was loaded by Java as an archive of classes.

It also means the offsets inside the Central Directory are relative to the start of the *archive*, not the start of the file, and readers have to work out that difference. Prepending data shifts everything, and well-behaved parsers cope by computing the delta between where the EOCD says the directory should be and where it found it.

---

## Two Indexes, One File

Back to that detail from earlier: every file&#39;s name and metadata are stored twice, in the Local File Header and again in the Central Directory.

Nothing enforces that they agree.

Here&#39;s the same archive with **only the Central Directory copy** of the first filename patched from `hello.txt` to `BOGUS.txt`. The local header is untouched:

```
$ unzip -l mismatch.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
       12  08-10-2026 16:44   BOGUS.txt
       13  08-10-2026 16:44   second.txt

  local file header at offset 0 still says: hello.txt
```

The archive is not corrupt. `unzip` lists it happily. It just contains two different answers to &#34;what is this file called,&#34; and which one you get depends on which structure your parser decided to trust.

Now imagine two programs reading the same archive, one checking a signature and the other extracting files. That&#39;s the Android &#34;Master Key&#34; bug from 2013, and the detail is better than the summary.

An APK is a ZIP. The attacker puts two entries in it, both named `classes.dex`. Android&#39;s Java verifier loaded entries into a map keyed by filename, so a duplicate name overwrote the earlier one and **the last entry was the one whose signature got checked**. The native installer used a hash table with linear probing that didn&#39;t replace on collision, so **the first entry was the one that got loaded and run**. Plant malicious code first, legitimately signed code second, and the device verifies one file and executes the other.

A second bug the same year came from the same &#34;two readings, one file&#34; family, via a signed integer. The extra-field length is a 16-bit value, and the Java code read it *signed*. A length of 65,533 (`0xFFFD`) sign-extends to −3. Since the offset of the compressed data is computed by **adding** that length, a negative value moves the read pointer backward into the header region instead of forward past it.

The lesson generalizes past ZIP. Any format that stores the same fact twice has to decide what happens when the copies disagree, and &#34;the spec doesn&#39;t say&#34; is the same answer as &#34;attackers decide.&#34;

---

## Offsets Are Just Numbers

The Central Directory locates each file by offset. Nothing in the format says two entries can&#39;t point at the same bytes.

The classic zip bomb didn&#39;t need that. `42.zip` is 42 kilobytes of archives nested five layers deep, sixteen at each layer, unpacking to roughly 4.5 petabytes. The defense is obvious once you&#39;ve seen it: cap recursion depth, don&#39;t auto-extract nested archives.

David Fifield&#39;s 2019 construction doesn&#39;t recurse at all. It expands in a **single pass**, so depth limits are irrelevant. The trick is overlap: many Central Directory entries reference one shared kernel of compressed data, and each entry&#39;s compressed stream uses DEFLATE&#39;s stored-block mode to quote the *next* entry&#39;s local file header as literal bytes. Entries nest inside each other, and output grows quadratically against input.

He published several, and they aren&#39;t interchangeable:

| File | Compressed | Uncompressed | Ratio | Needs Zip64 |
| :--- | :--- | :--- | :--- | :--- |
| `zbsm.zip` | 42 KB | 5.5 GB | ~130,000:1 | No |
| `zblg.zip` | 10 MB | 281.4 TB | ~28,000,000:1 | No |
| `zbxl.zip` | 46 MB | 4.5 PB | ~98,000,000:1 | Yes |

The Zip64 requirement on the largest one matters, because not every reader supports Zip64, which makes the merely-enormous version the more portable weapon.

None of these are malformed files. Every one is a valid archive that a conforming parser is supposed to accept. The format allows two entries to describe the same bytes, and no rule anywhere says the total uncompressed size has to bear any relationship to the file you&#39;re holding.

---

## Everything Is Secretly a ZIP

Once you know the structure, you start recognizing it:

- `.docx`, `.xlsx`, `.pptx` are ZIP archives of XML
- `.jar`, `.war`, `.apk` are ZIP archives of class files and resources
- `.epub` is a ZIP of XHTML
- `.odt`, `.ods` are ZIP of XML again

That&#39;s not a coincidence or a hack. ISO/IEC 21320-1, &#34;Document Container File,&#34; defines a constrained ZIP profile for exactly this use. It narrows the format so a `.docx` reader doesn&#39;t have to implement all of ZIP&#39;s accumulated history: compression must be stored or deflated and nothing else, and the various encryption and digital-signature mechanisms in the original spec are all forbidden.

It&#39;s a narrowing, not a rewrite. Zip64 version 1 is still permitted, for instance; only version 2 is ruled out. The profile is best understood as a list of the parts of ZIP that turned out to be a bad idea.

Which means the next post in this series is mostly about a ZIP file with XML inside it. You already know half of how a Word document works.

## Sources

- [PKWARE APPNOTE.TXT](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT) — the original and still-authoritative ZIP specification, currently version 6.3.10; §4.3.16 defines the end of central directory record
- [ISO/IEC 21320-1:2015](https://www.iso.org/standard/60101.html) — the constrained ZIP profile used by document formats. Fair warning, this one is a paid ISO standard; the catalog page tells you what it covers but you cannot read the text without buying it
- [Library of Congress format description for ZIP](https://www.loc.gov/preservation/digital/formats/fdd/fdd000354.shtml) — history and preservation notes
- [David Fifield: A Better Zip Bomb](https://www.bamsoftware.com/hacks/zipbomb/) — the overlapping-stream construction

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>Markdown Is Not a Format, It&#39;s an Argument</title>
      <link>https://llbbl.blog/2026/08/16/markdown-is-not-a-format.html</link>
      <pubDate>Sun, 16 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/16/markdown-is-not-a-format.html</guid>
      <description>&lt;p&gt;I&amp;rsquo;ve covered PNG and text files, and now it&amp;rsquo;s time for Markdown, which can be thought of as a philosophy of formatting or a lifestyle of text documents more so than an actually well defined file format. It has structure, and it has specifications, plural, and nothing agrees.&lt;/p&gt;
&lt;p&gt;Here is three lines of Markdown run through five parsers:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;INPUT: &amp;#34;- outer\n  - inner\n&amp;#34;

Python-Markdown      &amp;lt;ul&amp;gt; &amp;lt;li&amp;gt;outer&amp;lt;/li&amp;gt; &amp;lt;li&amp;gt;inner&amp;lt;/li&amp;gt; &amp;lt;/ul&amp;gt;
markdown2            &amp;lt;ul&amp;gt; &amp;lt;li&amp;gt;outer &amp;lt;ul&amp;gt; &amp;lt;li&amp;gt;inner&amp;lt;/li&amp;gt; &amp;lt;/ul&amp;gt;&amp;lt;/li&amp;gt; &amp;lt;/ul&amp;gt;
mistune              &amp;lt;ul&amp;gt; &amp;lt;li&amp;gt;outer&amp;lt;ul&amp;gt; &amp;lt;li&amp;gt;inner&amp;lt;/li&amp;gt; &amp;lt;/ul&amp;gt; &amp;lt;/li&amp;gt; &amp;lt;/ul&amp;gt;
marko (CommonMark)   &amp;lt;ul&amp;gt; &amp;lt;li&amp;gt; outer&amp;lt;ul&amp;gt; &amp;lt;li&amp;gt;inner&amp;lt;/li&amp;gt; &amp;lt;/ul&amp;gt; &amp;lt;/li&amp;gt; &amp;lt;/ul&amp;gt;
cmark-gfm (GitHub)   &amp;lt;ul&amp;gt; &amp;lt;li&amp;gt;outer &amp;lt;ul&amp;gt; &amp;lt;li&amp;gt;inner&amp;lt;/li&amp;gt; &amp;lt;/ul&amp;gt; &amp;lt;/li&amp;gt; &amp;lt;/ul&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Five parsers, five different results. Most of that is cosmetic whitespace, but look at the first one: Python-Markdown produced a &lt;strong&gt;flat list&lt;/strong&gt;. The nesting is gone. That&amp;rsquo;s not a formatting difference, that&amp;rsquo;s a different document.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-original-spec-was-an-essay&#34;&gt;The Original Spec Was an Essay&lt;/h2&gt;
&lt;p&gt;John Gruber released Markdown in March 2004, along with a Perl script called &lt;code&gt;Markdown.pl&lt;/code&gt;. The design goal was stated plainly:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The overriding design goal for Markdown&amp;rsquo;s formatting syntax is to make it as readable as possible. The idea is that a Markdown-formatted document should be publishable as-is, as plain text, without looking like it&amp;rsquo;s been marked up with tags or formatting instructions.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That goal was met, and it&amp;rsquo;s why we&amp;rsquo;re all still using it twenty years later. The syntax borrowed from conventions people had already invented for plain text email and Usenet: &lt;code&gt;=&lt;/code&gt; and &lt;code&gt;-&lt;/code&gt; underlines from Setext, &lt;code&gt;#&lt;/code&gt; headers from atx, &lt;code&gt;&amp;gt;&lt;/code&gt; quoting from Usenet, &lt;code&gt;*&lt;/code&gt; for emphasis from Textile and reStructuredText. None of it was new. That was the point.&lt;/p&gt;
&lt;p&gt;What Markdown shipped without was a grammar. The specification was English prose describing the syntax with examples, and the tiebreaker for anything the prose didn&amp;rsquo;t cover was &amp;ldquo;whatever &lt;code&gt;Markdown.pl&lt;/code&gt; does.&amp;rdquo; A Perl script full of regular expressions became the definition of the format by default.&lt;/p&gt;
&lt;p&gt;That works fine until someone writes a second implementation.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;where-the-prose-ran-out&#34;&gt;Where the Prose Ran Out&lt;/h2&gt;
&lt;p&gt;The ambiguities weren&amp;rsquo;t exotic. They were things you hit in the first week:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How much indentation nests a list?&lt;/strong&gt; Two spaces? Four? One tab? The original prose didn&amp;rsquo;t say clearly, and the answer interacts with the rule that four spaces means a code block.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens inside raw HTML?&lt;/strong&gt; If you write a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; and put Markdown inside it, does the Markdown get processed? Gruber&amp;rsquo;s implementation had behavior; the prose didn&amp;rsquo;t specify it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When does a &lt;code&gt;*&lt;/code&gt; open emphasis versus just being an asterisk?&lt;/strong&gt; In &lt;code&gt;a * b * c&lt;/code&gt;, are those multiplication signs or emphasis delimiters?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do underscores work inside words?&lt;/strong&gt; This one bites daily:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;INPUT: &amp;#34;snake_case_variable&amp;#34;

Python-Markdown      &amp;lt;p&amp;gt;snake_case_variable&amp;lt;/p&amp;gt;
markdown2            &amp;lt;p&amp;gt;snake&amp;lt;em&amp;gt;case&amp;lt;/em&amp;gt;variable&amp;lt;/p&amp;gt;
mistune              &amp;lt;p&amp;gt;snake_case_variable&amp;lt;/p&amp;gt;
marko (CommonMark)   &amp;lt;p&amp;gt;snake_case_variable&amp;lt;/p&amp;gt;
cmark-gfm (GitHub)   &amp;lt;p&amp;gt;snake_case_variable&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;markdown2 italicizes your variable name. Every other parser leaves it alone. Both are defensible readings of a spec that never addressed it.&lt;/p&gt;
&lt;p&gt;Or the heading with no space after the hash:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;INPUT: &amp;#34;#Heading&amp;#34;

Python-Markdown      &amp;lt;h1&amp;gt;Heading&amp;lt;/h1&amp;gt;
markdown2            &amp;lt;h1&amp;gt;Heading&amp;lt;/h1&amp;gt;
mistune              &amp;lt;p&amp;gt;#Heading&amp;lt;/p&amp;gt;
marko (CommonMark)   &amp;lt;p&amp;gt;#Heading&amp;lt;/p&amp;gt;
cmark-gfm (GitHub)   &amp;lt;p&amp;gt;#Heading&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Half of them give you a heading, half give you a paragraph starting with a hash. This one matters because &lt;code&gt;#hashtag&lt;/code&gt; at the start of a line is a real thing people write.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;everyone-wrote-their-own&#34;&gt;Everyone Wrote Their Own&lt;/h2&gt;
&lt;p&gt;With no formal spec, every implementation became a dialect, and the popular ones added features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PHP Markdown Extra&lt;/strong&gt; (Michel Fortin, 2005) added pipe tables, definition lists, footnotes, fenced code blocks, and attribute blocks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MultiMarkdown&lt;/strong&gt; (Fletcher Penney, 2005) added metadata frontmatter, cross-references, citations, and LaTeX export.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pandoc Markdown&lt;/strong&gt; (John MacFarlane, 2006) built a real AST-based parser and added YAML frontmatter, TeX math, grid tables, and citations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;kramdown&lt;/strong&gt; (Thomas Leitner, 2009) added inline attribute lists and its own math support.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each is a superset of a slightly different reading of the original. A document written for one is not guaranteed to render correctly in another, and the failure mode is silent: you don&amp;rsquo;t get a parse error, you get the wrong document.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;commonmark-specify-the-ambiguity-away&#34;&gt;CommonMark: Specify the Ambiguity Away&lt;/h2&gt;
&lt;p&gt;On 3 September 2014, Jeff Atwood announced a spec effort on Coding Horror under the name &lt;strong&gt;Standard Markdown&lt;/strong&gt;, with John MacFarlane as primary author and people from GitHub, Reddit, Stack Exchange, and Meteor involved. The goal was not a new dialect and not a replacement for Gruber&amp;rsquo;s syntax, but an unambiguous description of what the existing syntax should mean in every case.&lt;/p&gt;
&lt;p&gt;The name lasted about a day. That night, by Atwood&amp;rsquo;s account, Gruber emailed him and MacFarlane privately, called the name &amp;ldquo;infuriating,&amp;rdquo; and asked that the project be renamed and the domain taken down. On 4 September, Atwood published a follow-up retitling it &lt;strong&gt;Common Markdown&lt;/strong&gt;, which shortly became the one-word &lt;strong&gt;CommonMark&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Worth being precise here, because this story gets retold badly: this was not a trademark action. Gruber holds no registered trademark on &amp;ldquo;Markdown&amp;rdquo; and did not invoke one. It was an objection to the name, made in private email, and the only public record of his side is Atwood&amp;rsquo;s paraphrase. There is no Daring Fireball post about it.&lt;/p&gt;
&lt;p&gt;The naming fight is a footnote. The approach is the interesting part. Rather than describing the syntax in prose and hoping, CommonMark defines a parsing &lt;em&gt;algorithm&lt;/em&gt; and ships an executable test suite pairing exact input with exact expected HTML, more than 500 examples embedded in the spec document itself. Conformance is not a matter of opinion. You run the tests.&lt;/p&gt;
&lt;p&gt;The algorithm works in two passes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase one walks the document line by line and builds block structure.&lt;/strong&gt; Container blocks (blockquotes, lists, list items) and leaf blocks (headings, code blocks, paragraphs, HTML blocks) get assembled into a tree. Link reference definitions get collected. No inline formatting is considered at all in this phase, which is why block structure always wins: a &lt;code&gt;&amp;gt;&lt;/code&gt; at the start of a line is a blockquote marker regardless of what emphasis you thought you were in the middle of.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase two walks the text inside leaf blocks and resolves inline structure.&lt;/strong&gt; This is where emphasis, links, images, code spans, and inline HTML get parsed, using a delimiter stack.&lt;/p&gt;
&lt;p&gt;That two-phase split is the single most useful thing to know about Markdown parsing, because it explains most surprising behavior. If your emphasis &amp;ldquo;leaked&amp;rdquo; across a list item boundary, it didn&amp;rsquo;t; blocks were decided before emphasis was ever considered.&lt;/p&gt;
&lt;h3 id=&#34;the-emphasis-rules-are-hard&#34;&gt;The Emphasis Rules Are Hard&lt;/h3&gt;
&lt;p&gt;Emphasis is the hardest part of the spec, and CommonMark&amp;rsquo;s solution is a set of flanking rules. A run of &lt;code&gt;*&lt;/code&gt; or &lt;code&gt;_&lt;/code&gt; is classified as &lt;strong&gt;left-flanking&lt;/strong&gt; (can open emphasis) or &lt;strong&gt;right-flanking&lt;/strong&gt; (can close it) based on the characters on either side, roughly: a delimiter can open if it&amp;rsquo;s not followed by whitespace, and can close if it&amp;rsquo;s not preceded by whitespace, with extra conditions around punctuation.&lt;/p&gt;
&lt;p&gt;Then there&amp;rsquo;s a special case for underscores: an &lt;code&gt;_&lt;/code&gt; can open emphasis only if it&amp;rsquo;s left-flanking &lt;strong&gt;and not&lt;/strong&gt; right-flanking. That single asymmetry is what makes &lt;code&gt;snake_case_variable&lt;/code&gt; safe, because the middle underscores are both left- and right-flanking and are therefore disqualified from opening anything. Asterisks don&amp;rsquo;t get that rule, which is why &lt;code&gt;snake*case*variable&lt;/code&gt; still italicizes.&lt;/p&gt;
&lt;p&gt;This is what &amp;ldquo;specifying the ambiguity away&amp;rdquo; costs. The rule isn&amp;rsquo;t elegant. It exists because real documents contain identifiers, and a spec that italicizes your variable names is wrong no matter how clean its grammar is.&lt;/p&gt;
&lt;p&gt;You can see the payoff in the nesting case:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;INPUT: &amp;#34;*foo**bar**baz*&amp;#34;

Python-Markdown      &amp;lt;p&amp;gt;&amp;lt;em&amp;gt;foo&amp;lt;/em&amp;gt;&amp;lt;em&amp;gt;bar&amp;lt;/em&amp;gt;&amp;lt;em&amp;gt;baz&amp;lt;/em&amp;gt;&amp;lt;/p&amp;gt;
everyone else        &amp;lt;p&amp;gt;&amp;lt;em&amp;gt;foo&amp;lt;strong&amp;gt;bar&amp;lt;/strong&amp;gt;baz&amp;lt;/em&amp;gt;&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Four parsers agree, and the one that predates the delimiter-stack approach gets it wrong in a way that changes the meaning.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;gfm-is-a-layer-not-a-fork&#34;&gt;GFM Is a Layer, Not a Fork&lt;/h2&gt;
&lt;p&gt;GitHub Flavored Markdown is CommonMark plus five extensions, and it&amp;rsquo;s specified against CommonMark rather than diverging from it:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Tables&lt;/strong&gt;, pipe-delimited with alignment colons&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Task lists&lt;/strong&gt;, &lt;code&gt;- [ ]&lt;/code&gt; and &lt;code&gt;- [x]&lt;/code&gt;, rendered as checkboxes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Strikethrough&lt;/strong&gt;, &lt;code&gt;~~text~~&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Autolinks&lt;/strong&gt;, bare URLs linkified without brackets&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A raw HTML filter&lt;/strong&gt; that neutralizes dangerous tags by escaping their opening bracket&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That last one is a security control rather than a formatting feature, which tells you something about what it&amp;rsquo;s like to run a Markdown renderer on user-submitted content at GitHub&amp;rsquo;s scale.&lt;/p&gt;
&lt;p&gt;The extension boundary is visible if you feed the same table to both:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;INPUT:
| a | b |
|---|---|
| 1 | 2 |

CommonMark  &amp;lt;p&amp;gt;| a | b | |---|---| | 1 | 2 |&amp;lt;/p&amp;gt;
cmark-gfm   &amp;lt;table&amp;gt;&amp;lt;thead&amp;gt;&amp;lt;tr&amp;gt;&amp;lt;th&amp;gt;a&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;b&amp;lt;/th&amp;gt;&amp;lt;/tr&amp;gt;&amp;lt;/thead&amp;gt;...
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Tables are not Markdown. Tables are a GFM extension. CommonMark renders that input as a paragraph containing literal pipe characters, and it is correct to do so.&lt;/p&gt;
&lt;p&gt;Tables, footnotes, task lists, strikethrough, frontmatter, math, and Mermaid diagrams are all extensions. None of them are guaranteed anywhere.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-to-do-about-it&#34;&gt;What To Do About It&lt;/h2&gt;
&lt;p&gt;The practical takeaways are short.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Know which parser you&amp;rsquo;re targeting.&lt;/strong&gt; &amp;ldquo;It renders on GitHub&amp;rdquo; tells you about cmark-gfm, and nothing about your static site generator, your docs pipeline, or someone&amp;rsquo;s RSS reader.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prefer the constructs everyone agrees on.&lt;/strong&gt; Headings with a space after the hash, fenced code blocks, asterisks for emphasis, blank lines between blocks, four-space or consistent nesting. Boring Markdown survives transport.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don&amp;rsquo;t rely on parser-specific behavior you discovered by accident.&lt;/strong&gt; If nesting a list at two spaces works in your tool, that&amp;rsquo;s your tool, not the format.&lt;/p&gt;
&lt;p&gt;There is even a formal way to say which dialect you mean. RFC 7763 registers &lt;code&gt;text/markdown&lt;/code&gt; as a media type, and RFC 7764 defines a &lt;code&gt;variant&lt;/code&gt; parameter for exactly this problem:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;text/markdown; variant=CommonMark
text/markdown; variant=GFM
text/markdown; variant=Original
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The standards process looked at Markdown, concluded that saying &amp;ldquo;this is Markdown&amp;rdquo; is not specific enough to be useful, and standardized a way to say which Markdown you meant.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the tradeoff Markdown made. PNG picked one answer and enforced it with a checksum. A text file refuses to answer anything. Markdown let a million answers bloom, got adopted everywhere precisely because it was easy to implement badly, and has spent the last decade trying to agree with itself.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ll take that trade. But it&amp;rsquo;s worth knowing that when you write Markdown, you are not writing in a format. You&amp;rsquo;re writing in a dialect, and hoping the reader speaks it.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://daringfireball.net/projects/markdown/syntax&#34;&gt;Daring Fireball: Markdown&lt;/a&gt; — Gruber&amp;rsquo;s original 2004 syntax document and design goals&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://spec.commonmark.org/&#34;&gt;CommonMark Specification&lt;/a&gt; — the parsing algorithm, emphasis flanking rules, and executable test suite&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://spec.commonmark.org/0.31.2/#appendix-a-a-parsing-strategy&#34;&gt;CommonMark parsing strategy appendix&lt;/a&gt; — the two-phase block/inline design&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://github.github.com/gfm/&#34;&gt;GitHub Flavored Markdown Spec&lt;/a&gt; — the five extensions, specified against CommonMark&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://datatracker.ietf.org/doc/html/rfc7763&#34;&gt;RFC 7763&lt;/a&gt; and &lt;a href=&#34;https://datatracker.ietf.org/doc/html/rfc7764&#34;&gt;RFC 7764&lt;/a&gt; — the &lt;code&gt;text/markdown&lt;/code&gt; media type and the registered dialect variants, both by S. Leonard, March 2016&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://blog.codinghorror.com/standard-flavored-markdown/&#34;&gt;Coding Horror: Standard Flavored Markdown&lt;/a&gt; and &lt;a href=&#34;https://blog.codinghorror.com/standard-markdown-is-now-common-markdown/&#34;&gt;Standard Markdown is now Common Markdown&lt;/a&gt; — Atwood&amp;rsquo;s announcement and the rename a day later&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://daringfireball.net/2004/03/introducing_markdown&#34;&gt;Daring Fireball: Introducing Markdown&lt;/a&gt; — the original 15 March 2004 announcement&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/github/cmark-gfm/blob/master/extensions/tagfilter.c&#34;&gt;&lt;code&gt;tagfilter.c&lt;/code&gt; in cmark-gfm&lt;/a&gt; — the nine tags GFM&amp;rsquo;s raw HTML filter neutralizes&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>I&#39;ve covered PNG and text files, and now it&#39;s time for Markdown, which can be thought of as a philosophy of formatting or a lifestyle of text documents more so than an actually well defined file format. It has structure, and it has specifications, plural, and nothing agrees.

Here is three lines of Markdown run through five parsers:

```
INPUT: &#34;- outer\n  - inner\n&#34;

Python-Markdown      &lt;ul&gt; &lt;li&gt;outer&lt;/li&gt; &lt;li&gt;inner&lt;/li&gt; &lt;/ul&gt;
markdown2            &lt;ul&gt; &lt;li&gt;outer &lt;ul&gt; &lt;li&gt;inner&lt;/li&gt; &lt;/ul&gt;&lt;/li&gt; &lt;/ul&gt;
mistune              &lt;ul&gt; &lt;li&gt;outer&lt;ul&gt; &lt;li&gt;inner&lt;/li&gt; &lt;/ul&gt; &lt;/li&gt; &lt;/ul&gt;
marko (CommonMark)   &lt;ul&gt; &lt;li&gt; outer&lt;ul&gt; &lt;li&gt;inner&lt;/li&gt; &lt;/ul&gt; &lt;/li&gt; &lt;/ul&gt;
cmark-gfm (GitHub)   &lt;ul&gt; &lt;li&gt;outer &lt;ul&gt; &lt;li&gt;inner&lt;/li&gt; &lt;/ul&gt; &lt;/li&gt; &lt;/ul&gt;
```

Five parsers, five different results. Most of that is cosmetic whitespace, but look at the first one: Python-Markdown produced a **flat list**. The nesting is gone. That&#39;s not a formatting difference, that&#39;s a different document.

---

## The Original Spec Was an Essay

John Gruber released Markdown in March 2004, along with a Perl script called `Markdown.pl`. The design goal was stated plainly:

&gt; The overriding design goal for Markdown&#39;s formatting syntax is to make it as readable as possible. The idea is that a Markdown-formatted document should be publishable as-is, as plain text, without looking like it&#39;s been marked up with tags or formatting instructions.

That goal was met, and it&#39;s why we&#39;re all still using it twenty years later. The syntax borrowed from conventions people had already invented for plain text email and Usenet: `=` and `-` underlines from Setext, `#` headers from atx, `&gt;` quoting from Usenet, `*` for emphasis from Textile and reStructuredText. None of it was new. That was the point.

What Markdown shipped without was a grammar. The specification was English prose describing the syntax with examples, and the tiebreaker for anything the prose didn&#39;t cover was &#34;whatever `Markdown.pl` does.&#34; A Perl script full of regular expressions became the definition of the format by default.

That works fine until someone writes a second implementation.

---

## Where the Prose Ran Out

The ambiguities weren&#39;t exotic. They were things you hit in the first week:

**How much indentation nests a list?** Two spaces? Four? One tab? The original prose didn&#39;t say clearly, and the answer interacts with the rule that four spaces means a code block.

**What happens inside raw HTML?** If you write a `&lt;div&gt;` and put Markdown inside it, does the Markdown get processed? Gruber&#39;s implementation had behavior; the prose didn&#39;t specify it.

**When does a `*` open emphasis versus just being an asterisk?** In `a * b * c`, are those multiplication signs or emphasis delimiters?

**Do underscores work inside words?** This one bites daily:

```
INPUT: &#34;snake_case_variable&#34;

Python-Markdown      &lt;p&gt;snake_case_variable&lt;/p&gt;
markdown2            &lt;p&gt;snake&lt;em&gt;case&lt;/em&gt;variable&lt;/p&gt;
mistune              &lt;p&gt;snake_case_variable&lt;/p&gt;
marko (CommonMark)   &lt;p&gt;snake_case_variable&lt;/p&gt;
cmark-gfm (GitHub)   &lt;p&gt;snake_case_variable&lt;/p&gt;
```

markdown2 italicizes your variable name. Every other parser leaves it alone. Both are defensible readings of a spec that never addressed it.

Or the heading with no space after the hash:

```
INPUT: &#34;#Heading&#34;

Python-Markdown      &lt;h1&gt;Heading&lt;/h1&gt;
markdown2            &lt;h1&gt;Heading&lt;/h1&gt;
mistune              &lt;p&gt;#Heading&lt;/p&gt;
marko (CommonMark)   &lt;p&gt;#Heading&lt;/p&gt;
cmark-gfm (GitHub)   &lt;p&gt;#Heading&lt;/p&gt;
```

Half of them give you a heading, half give you a paragraph starting with a hash. This one matters because `#hashtag` at the start of a line is a real thing people write.

---

## Everyone Wrote Their Own

With no formal spec, every implementation became a dialect, and the popular ones added features:

- **PHP Markdown Extra** (Michel Fortin, 2005) added pipe tables, definition lists, footnotes, fenced code blocks, and attribute blocks.
- **MultiMarkdown** (Fletcher Penney, 2005) added metadata frontmatter, cross-references, citations, and LaTeX export.
- **Pandoc Markdown** (John MacFarlane, 2006) built a real AST-based parser and added YAML frontmatter, TeX math, grid tables, and citations.
- **kramdown** (Thomas Leitner, 2009) added inline attribute lists and its own math support.

Each is a superset of a slightly different reading of the original. A document written for one is not guaranteed to render correctly in another, and the failure mode is silent: you don&#39;t get a parse error, you get the wrong document.

---

## CommonMark: Specify the Ambiguity Away

On 3 September 2014, Jeff Atwood announced a spec effort on Coding Horror under the name **Standard Markdown**, with John MacFarlane as primary author and people from GitHub, Reddit, Stack Exchange, and Meteor involved. The goal was not a new dialect and not a replacement for Gruber&#39;s syntax, but an unambiguous description of what the existing syntax should mean in every case.

The name lasted about a day. That night, by Atwood&#39;s account, Gruber emailed him and MacFarlane privately, called the name &#34;infuriating,&#34; and asked that the project be renamed and the domain taken down. On 4 September, Atwood published a follow-up retitling it **Common Markdown**, which shortly became the one-word **CommonMark**.

Worth being precise here, because this story gets retold badly: this was not a trademark action. Gruber holds no registered trademark on &#34;Markdown&#34; and did not invoke one. It was an objection to the name, made in private email, and the only public record of his side is Atwood&#39;s paraphrase. There is no Daring Fireball post about it.

The naming fight is a footnote. The approach is the interesting part. Rather than describing the syntax in prose and hoping, CommonMark defines a parsing *algorithm* and ships an executable test suite pairing exact input with exact expected HTML, more than 500 examples embedded in the spec document itself. Conformance is not a matter of opinion. You run the tests.

The algorithm works in two passes.

**Phase one walks the document line by line and builds block structure.** Container blocks (blockquotes, lists, list items) and leaf blocks (headings, code blocks, paragraphs, HTML blocks) get assembled into a tree. Link reference definitions get collected. No inline formatting is considered at all in this phase, which is why block structure always wins: a `&gt;` at the start of a line is a blockquote marker regardless of what emphasis you thought you were in the middle of.

**Phase two walks the text inside leaf blocks and resolves inline structure.** This is where emphasis, links, images, code spans, and inline HTML get parsed, using a delimiter stack.

That two-phase split is the single most useful thing to know about Markdown parsing, because it explains most surprising behavior. If your emphasis &#34;leaked&#34; across a list item boundary, it didn&#39;t; blocks were decided before emphasis was ever considered.

### The Emphasis Rules Are Hard

Emphasis is the hardest part of the spec, and CommonMark&#39;s solution is a set of flanking rules. A run of `*` or `_` is classified as **left-flanking** (can open emphasis) or **right-flanking** (can close it) based on the characters on either side, roughly: a delimiter can open if it&#39;s not followed by whitespace, and can close if it&#39;s not preceded by whitespace, with extra conditions around punctuation.

Then there&#39;s a special case for underscores: an `_` can open emphasis only if it&#39;s left-flanking **and not** right-flanking. That single asymmetry is what makes `snake_case_variable` safe, because the middle underscores are both left- and right-flanking and are therefore disqualified from opening anything. Asterisks don&#39;t get that rule, which is why `snake*case*variable` still italicizes.

This is what &#34;specifying the ambiguity away&#34; costs. The rule isn&#39;t elegant. It exists because real documents contain identifiers, and a spec that italicizes your variable names is wrong no matter how clean its grammar is.

You can see the payoff in the nesting case:

```
INPUT: &#34;*foo**bar**baz*&#34;

Python-Markdown      &lt;p&gt;&lt;em&gt;foo&lt;/em&gt;&lt;em&gt;bar&lt;/em&gt;&lt;em&gt;baz&lt;/em&gt;&lt;/p&gt;
everyone else        &lt;p&gt;&lt;em&gt;foo&lt;strong&gt;bar&lt;/strong&gt;baz&lt;/em&gt;&lt;/p&gt;
```

Four parsers agree, and the one that predates the delimiter-stack approach gets it wrong in a way that changes the meaning.

---

## GFM Is a Layer, Not a Fork

GitHub Flavored Markdown is CommonMark plus five extensions, and it&#39;s specified against CommonMark rather than diverging from it:

1. **Tables**, pipe-delimited with alignment colons
2. **Task lists**, `- [ ]` and `- [x]`, rendered as checkboxes
3. **Strikethrough**, `~~text~~`
4. **Autolinks**, bare URLs linkified without brackets
5. **A raw HTML filter** that neutralizes dangerous tags by escaping their opening bracket

That last one is a security control rather than a formatting feature, which tells you something about what it&#39;s like to run a Markdown renderer on user-submitted content at GitHub&#39;s scale.

The extension boundary is visible if you feed the same table to both:

```
INPUT:
| a | b |
|---|---|
| 1 | 2 |

CommonMark  &lt;p&gt;| a | b | |---|---| | 1 | 2 |&lt;/p&gt;
cmark-gfm   &lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;a&lt;/th&gt;&lt;th&gt;b&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;...
```

Tables are not Markdown. Tables are a GFM extension. CommonMark renders that input as a paragraph containing literal pipe characters, and it is correct to do so.

Tables, footnotes, task lists, strikethrough, frontmatter, math, and Mermaid diagrams are all extensions. None of them are guaranteed anywhere. 

---

## What To Do About It

The practical takeaways are short.

**Know which parser you&#39;re targeting.** &#34;It renders on GitHub&#34; tells you about cmark-gfm, and nothing about your static site generator, your docs pipeline, or someone&#39;s RSS reader.

**Prefer the constructs everyone agrees on.** Headings with a space after the hash, fenced code blocks, asterisks for emphasis, blank lines between blocks, four-space or consistent nesting. Boring Markdown survives transport.

**Don&#39;t rely on parser-specific behavior you discovered by accident.** If nesting a list at two spaces works in your tool, that&#39;s your tool, not the format.

There is even a formal way to say which dialect you mean. RFC 7763 registers `text/markdown` as a media type, and RFC 7764 defines a `variant` parameter for exactly this problem:

```
text/markdown; variant=CommonMark
text/markdown; variant=GFM
text/markdown; variant=Original
```

The standards process looked at Markdown, concluded that saying &#34;this is Markdown&#34; is not specific enough to be useful, and standardized a way to say which Markdown you meant.

That&#39;s the tradeoff Markdown made. PNG picked one answer and enforced it with a checksum. A text file refuses to answer anything. Markdown let a million answers bloom, got adopted everywhere precisely because it was easy to implement badly, and has spent the last decade trying to agree with itself.

I&#39;ll take that trade. But it&#39;s worth knowing that when you write Markdown, you are not writing in a format. You&#39;re writing in a dialect, and hoping the reader speaks it.

## Sources

- [Daring Fireball: Markdown](https://daringfireball.net/projects/markdown/syntax) — Gruber&#39;s original 2004 syntax document and design goals
- [CommonMark Specification](https://spec.commonmark.org/) — the parsing algorithm, emphasis flanking rules, and executable test suite
- [CommonMark parsing strategy appendix](https://spec.commonmark.org/0.31.2/#appendix-a-a-parsing-strategy) — the two-phase block/inline design
- [GitHub Flavored Markdown Spec](https://github.github.com/gfm/) — the five extensions, specified against CommonMark
- [RFC 7763](https://datatracker.ietf.org/doc/html/rfc7763) and [RFC 7764](https://datatracker.ietf.org/doc/html/rfc7764) — the `text/markdown` media type and the registered dialect variants, both by S. Leonard, March 2016
- [Coding Horror: Standard Flavored Markdown](https://blog.codinghorror.com/standard-flavored-markdown/) and [Standard Markdown is now Common Markdown](https://blog.codinghorror.com/standard-markdown-is-now-common-markdown/) — Atwood&#39;s announcement and the rename a day later
- [Daring Fireball: Introducing Markdown](https://daringfireball.net/2004/03/introducing_markdown) — the original 15 March 2004 announcement
- [`tagfilter.c` in cmark-gfm](https://github.com/github/cmark-gfm/blob/master/extensions/tagfilter.c) — the nine tags GFM&#39;s raw HTML filter neutralizes

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>There Is No Such Thing as a Text File</title>
      <link>https://llbbl.blog/2026/08/15/there-is-no-such-thing.html</link>
      <pubDate>Sat, 15 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/15/there-is-no-such-thing.html</guid>
      <description>&lt;p&gt;Last time I took apart PNG, which opens with eight bytes whose entire job is to announce &amp;ldquo;I am a PNG&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;A text file opens with nothing. No signature, no header, no length field, no version, no metadata. It is bytes, and then it stops.&lt;/p&gt;
&lt;p&gt;So this post is the opposite of the last one. Instead of walking a structure, we&amp;rsquo;re going to look at what happens when there isn&amp;rsquo;t one.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;posix-defines-the-standard&#34;&gt;POSIX Defines the Standard&lt;/h2&gt;
&lt;p&gt;Start with the standard, in §3.403:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A file that contains characters organized into zero or more lines. The lines do not contain NUL characters and none can exceed {LINE_MAX} bytes in length, including the &lt;code&gt;&amp;lt;newline&amp;gt;&lt;/code&gt; character. &lt;strong&gt;Although POSIX.1-2017 does not distinguish between text files and binary files&lt;/strong&gt; (see the ISO C standard), many utilities only produce predictable or meaningful output when operating on text files.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The standard defines the term and then tells you the system doesn&amp;rsquo;t enforce it.&lt;/p&gt;
&lt;p&gt;Nothing in the filesystem records &amp;ldquo;this is text.&amp;rdquo; There&amp;rsquo;s no flag on the inode, no attribute, nothing in the directory entry. The &lt;code&gt;.txt&lt;/code&gt; extension is a hint to humans and to Windows. &amp;ldquo;Text file&amp;rdquo; is not a property a file has. It&amp;rsquo;s a claim the &lt;em&gt;reader&lt;/em&gt; makes about the bytes, and every tool makes it slightly differently.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-bytes-dont-know-what-they-mean&#34;&gt;The Bytes Don&amp;rsquo;t Know What They Mean&lt;/h2&gt;
&lt;p&gt;A file stores bytes. Turning bytes into characters requires an encoding, and the encoding is not in the file.&lt;/p&gt;
&lt;p&gt;Here are the same five characters, &lt;code&gt;Héllo&lt;/code&gt;, in several encodings:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;ascii         FAILS: ordinal not in range(128)
latin-1        5 bytes  48 e9 6c 6c 6f
cp1252         5 bytes  48 e9 6c 6c 6f
utf-8          6 bytes  48 c3 a9 6c 6c 6f
utf-16        12 bytes  ff fe 48 00 e9 00 6c 00 6c 00 6f 00
utf-16-be     10 bytes  00 48 00 e9 00 6c 00 6c 00 6f
utf-32        24 bytes  ff fe 00 00 48 00 00 00 e9 00 00 00 ...
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Five characters. Anywhere from 5 to 24 bytes. Nothing in any of those files says which one you&amp;rsquo;re looking at.&lt;/p&gt;
&lt;p&gt;When you open a file in your editor and it looks right, that&amp;rsquo;s your editor guessing correctly. When you get &lt;code&gt;caf√©&lt;/code&gt; instead of &lt;code&gt;café&lt;/code&gt;, that&amp;rsquo;s your editor guessing wrong. The file never changed.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-fight-over-the-eighth-bit&#34;&gt;The Fight Over the Eighth Bit&lt;/h2&gt;
&lt;p&gt;ASCII was standardized as a 7-bit code: 128 values, &lt;code&gt;0x00&lt;/code&gt; through &lt;code&gt;0x7F&lt;/code&gt;. Thirty-three control codes, ninety-five printable characters, and that was the whole world if the world spoke English.&lt;/p&gt;
&lt;p&gt;Bytes have eight bits though, so there were another 128 values sitting there unused. Everyone grabbed them, and everyone grabbed them differently.&lt;/p&gt;
&lt;p&gt;ISO 8859-1 (Latin-1) claimed &lt;code&gt;0xA0&lt;/code&gt;–&lt;code&gt;0xFF&lt;/code&gt; for Western European letters and reserved &lt;code&gt;0x80&lt;/code&gt;–&lt;code&gt;0x9F&lt;/code&gt; for a second set of control codes nobody used. Microsoft looked at those 32 wasted slots and put printable punctuation there instead, creating Windows-1252. That&amp;rsquo;s where the curly quotes and the em dash live:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;CP1252 text  : It&amp;#39;s &amp;#34;fine&amp;#34; — really
CP1252 bytes : 49 74 27 73 20 93 66 69 6e 65 94 20 97 20 72 65 61 6c 6c 79
UTF-8 bytes  : 49 74 27 73 20 e2 80 9c 66 69 6e 65 e2 80 9d 20 e2 80 94 ...
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Byte &lt;code&gt;0x93&lt;/code&gt; is a left curly quote in CP1252 and a control character in strict Latin-1. This is why pasting from Word into a system expecting Latin-1 produces garbage: the bytes are legal, they just mean nothing there.&lt;/p&gt;
&lt;p&gt;Mojibake, Japanese for &amp;ldquo;character transformation,&amp;rdquo; is exactly this, and it&amp;rsquo;s completely deterministic:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;original text  : café
as UTF-8 bytes : 63 61 66 c3 a9
read as CP1252 : cafÃ©
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;c3 a9&lt;/code&gt; is one character in UTF-8 and two characters in CP1252. Both readings are valid. Only one is what you meant.&lt;/p&gt;
&lt;p&gt;It could have been worse. IBM&amp;rsquo;s EBCDIC, still running on mainframes, isn&amp;rsquo;t an ASCII superset at all:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;&amp;#39;A&amp;#39;  ASCII 0x41   EBCDIC 0xc1
&amp;#39;a&amp;#39;  ASCII 0x61   EBCDIC 0x81
&amp;#39; &amp;#39;  ASCII 0x20   EBCDIC 0x40
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And the letters aren&amp;rsquo;t even contiguous. &lt;code&gt;I&lt;/code&gt; is &lt;code&gt;0xc9&lt;/code&gt;, &lt;code&gt;J&lt;/code&gt; is &lt;code&gt;0xd1&lt;/code&gt;, with a gap in between. Sorting strings by byte value, which works fine in ASCII, silently produces wrong output in EBCDIC.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;why-utf-8-won&#34;&gt;Why UTF-8 Won&lt;/h2&gt;
&lt;p&gt;UTF-8 encodes a character in one to four bytes. ASCII characters keep their single-byte values, so every ASCII file is already a valid UTF-8 file. That backward compatibility gets most of the credit, but the more interesting property is the bit pattern:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;&amp;#39;A&amp;#39;  U+0041   1 byte   41           01000001
&amp;#39;é&amp;#39;  U+00E9   2 bytes  c3 a9        11000011 10101001
&amp;#39;€&amp;#39;  U+20AC   3 bytes  e2 82 ac     11100010 10000010 10101100
&amp;#39;🙂&amp;#39; U+1F642  4 bytes  f0 9f 99 82  11110000 10011111 10011001 10000010
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Look at the leading bits. A single-byte character starts with &lt;code&gt;0&lt;/code&gt;. A multi-byte character starts with &lt;code&gt;110&lt;/code&gt;, &lt;code&gt;1110&lt;/code&gt;, or &lt;code&gt;11110&lt;/code&gt;, where the number of leading 1s is the total byte count. Every continuation byte starts with &lt;code&gt;10&lt;/code&gt;, and nothing else does.&lt;/p&gt;
&lt;p&gt;That makes UTF-8 self-synchronizing. Drop into the middle of a file at a random offset and you can tell immediately whether you&amp;rsquo;re mid-character, and walk backwards a byte or two to find the boundary. You do not need to have read the file from the beginning.&lt;/p&gt;
&lt;p&gt;Compare that to UTF-16, where you must know the byte order and must have tracked whether you&amp;rsquo;re on an even or odd boundary. UTF-8 made encoding a local property instead of a global one, and that&amp;rsquo;s why it took over.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-bom&#34;&gt;The BOM&lt;/h2&gt;
&lt;p&gt;Multi-byte encodings have a byte order problem: is &lt;code&gt;00 48&lt;/code&gt; the character &lt;code&gt;U+0048&lt;/code&gt; or &lt;code&gt;U+4800&lt;/code&gt;? The Byte Order Mark solves it by putting &lt;code&gt;U+FEFF&lt;/code&gt; at the start of the file, so a reader can look at the first two bytes and work out the endianness.&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;utf-16      ff fe 68 69 ...   (little-endian)
utf-16-le   68 00 69 00       (no BOM, you&amp;#39;d better know)
utf-8-sig   ef bb bf 68 69    (UTF-8 &amp;#34;BOM&amp;#34;)
utf-8       68 69             (no BOM)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;UTF-8 has no byte order to mark, because its unit is one byte. The UTF-8 BOM is not a byte order mark at all; it&amp;rsquo;s a three-byte flag saying &amp;ldquo;this is UTF-8,&amp;rdquo; and the Unicode Consortium neither requires nor recommends it.&lt;/p&gt;
&lt;p&gt;It also actively breaks things. The kernel identifies a script by looking for &lt;code&gt;0x23 0x21&lt;/code&gt;, the characters &lt;code&gt;#!&lt;/code&gt;, at offset zero:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;no BOM  : 23 21 2f 62 69 6e 2f 73 68 0a  -&amp;gt;  #!/bin/sh
with BOM: ef bb bf 23 21 2f 62 69 6e 2f  -&amp;gt;  not a script
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Same for JSON parsers, CSV importers, and anything else that expects a specific first byte. If you have ever seen a shell script fail with a cryptic error on a line that looks correct, this is a candidate.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;lines-are-a-convention-too&#34;&gt;Lines Are a Convention Too&lt;/h2&gt;
&lt;p&gt;There is no line structure in a text file. There&amp;rsquo;s a byte that tools agree means &amp;ldquo;line break,&amp;rdquo; and even that isn&amp;rsquo;t agreed on.&lt;/p&gt;
&lt;p&gt;The split is a hardware inheritance. A teletype needed two separate mechanical actions to start a new line: &lt;strong&gt;carriage return&lt;/strong&gt; (&lt;code&gt;0x0D&lt;/code&gt;) moved the print head back to the left margin, and &lt;strong&gt;line feed&lt;/strong&gt; (&lt;code&gt;0x0A&lt;/code&gt;) advanced the paper by one row. Two actions, two control codes.&lt;/p&gt;
&lt;p&gt;Then everyone picked differently. Unix chose LF alone. MS-DOS, and Windows after it, kept both as CRLF. Classic Mac OS used CR alone. Those choices are still with us thirty years later, and they&amp;rsquo;re the reason &lt;code&gt;.gitattributes&lt;/code&gt; exists.&lt;/p&gt;
&lt;p&gt;And then there&amp;rsquo;s the trailing newline, which people argue about without realizing the standard already answered it. POSIX §3.206:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A line is a sequence of zero or more non-&lt;code&gt;&amp;lt;newline&amp;gt;&lt;/code&gt; characters &lt;strong&gt;plus a terminating &lt;code&gt;&amp;lt;newline&amp;gt;&lt;/code&gt; character&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The newline is part of the line, not a separator between lines. A file whose last byte isn&amp;rsquo;t a newline doesn&amp;rsquo;t have a final line. POSIX §3.195 has a name for what it has instead: an &lt;em&gt;incomplete line&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;That definition has teeth:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ wc -l lf.txt nofinal.txt
       2 lf.txt
       1 nofinal.txt
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Both files contain the text &lt;code&gt;one&lt;/code&gt; and &lt;code&gt;two&lt;/code&gt;. The first ends with a newline, the second doesn&amp;rsquo;t. &lt;code&gt;wc -l&lt;/code&gt; counts newline bytes, so the second file reports one line despite visibly having two.&lt;/p&gt;
&lt;p&gt;This is also what git&amp;rsquo;s &lt;code&gt;\ No newline at end of file&lt;/code&gt; marker means. It isn&amp;rsquo;t a style complaint. Git is telling you the last line is incomplete by the POSIX definition, which matters because otherwise appending a line would silently modify the existing last line rather than adding a new one.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;how-tools-guess&#34;&gt;How Tools Guess&lt;/h2&gt;
&lt;p&gt;Since nothing declares itself, every tool that needs to know applies a heuristic. The dominant one is: &lt;strong&gt;does it contain a NUL byte?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That test exists because C strings are NUL-terminated, so a NUL in the middle of what claims to be text means something is off. It&amp;rsquo;s a good heuristic. It&amp;rsquo;s also wrong in two ways worth knowing about.&lt;/p&gt;
&lt;p&gt;Git&amp;rsquo;s version is &lt;code&gt;buffer_is_binary()&lt;/code&gt; in &lt;code&gt;xdiff-interface.c&lt;/code&gt;, and it doesn&amp;rsquo;t scan the whole file. It caps at a constant:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-c&#34; data-lang=&#34;c&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;#define FIRST_FEW_BYTES 8000
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So the check is &amp;ldquo;is there a NUL in the first 8000 bytes.&amp;rdquo; A file with clean text for 10KB and a NUL after that is text as far as git is concerned. The cutoff is a performance tradeoff, and it means binary-ness is decided by a sample, not a proof.&lt;/p&gt;
&lt;p&gt;The second problem is bigger.&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;plain ascii          NUL present: False  -&amp;gt; text
utf-8 with emoji     NUL present: False  -&amp;gt; text
has a NUL byte       NUL present: True   -&amp;gt; BINARY
utf-16 text          NUL present: True   -&amp;gt; BINARY
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;UTF-16 encodes ASCII characters as the character byte plus a NUL. Any UTF-16 file that&amp;rsquo;s mostly English is roughly half NUL bytes. So git does this to a perfectly valid text file:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ git diff --cached --stat
 lf.txt      |   2 ++
 utf16.txt   | Bin 0 -&amp;gt; 24 bytes
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;Bin&lt;/code&gt;. Git will not diff it, will not merge it, and will not show it in review. The heuristic isn&amp;rsquo;t detecting text, it&amp;rsquo;s detecting C-string-safety, and those aren&amp;rsquo;t the same question.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;file(1)&lt;/code&gt; is more thorough, and it shows how much the BOM is doing:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ file utf16.txt utf16_bom.txt lf.txt
utf16.txt:     data
utf16_bom.txt: Unicode text, UTF-16, little-endian text
lf.txt:        ASCII text
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Identical text content in the first two files. The only difference is two leading bytes. Without them &lt;code&gt;file&lt;/code&gt; gives up and calls it &lt;code&gt;data&lt;/code&gt;; with them it identifies the encoding exactly. For a format with no header, a BOM is the closest thing to one that exists.&lt;/p&gt;
&lt;p&gt;Under the hood &lt;code&gt;file&lt;/code&gt; is doing real work rather than one heuristic. &lt;code&gt;src/encoding.c&lt;/code&gt; carries a 256-entry table classifying every byte value as never-valid-in-text, ASCII, ISO-8859, or extended ASCII, plus a dedicated UTF-8 state machine that rejects invalid sequences. It then tries candidate encodings in order: ASCII, UTF-7, UTF-8 with BOM, UTF-8, UTF-32, UTF-16, Latin-1, extended ASCII, and finally EBCDIC. That ordering is a nice fossil record of which encodings are still worth guessing first.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;why-this-matters&#34;&gt;Why This Matters&lt;/h2&gt;
&lt;p&gt;Nearly every format developers work in daily is a convention layered on this substrate. Source code, JSON, YAML, TOML, CSV, Markdown, config files, logs. All of them inherit these problems, and none of them can fully escape them, because the layer underneath has no way to describe itself.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the tradeoff. A format with no header can&amp;rsquo;t tell you anything about itself, which is exactly why it has outlived every format that could. PNG will be readable as long as someone maintains a PNG decoder. A text file is readable as long as someone remembers what bytes are.&lt;/p&gt;
&lt;p&gt;Next in the series: Markdown, which is a text file plus a set of conventions that nobody fully agrees on.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html&#34;&gt;POSIX.1-2017 Base Definitions, Chapter 3&lt;/a&gt; — §3.206 Line, §3.195 Incomplete Line, §3.403 Text File&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://datatracker.ietf.org/doc/html/rfc3629&#34;&gt;RFC 3629&lt;/a&gt; — the UTF-8 specification and its byte patterns&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://datatracker.ietf.org/doc/html/rfc2046#section-4.1&#34;&gt;RFC 2046 §4.1&lt;/a&gt; — the &lt;code&gt;text/plain&lt;/code&gt; media type&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.unicode.org/faq/utf_bom.html&#34;&gt;Unicode FAQ on UTF-8, UTF-16, and the BOM&lt;/a&gt; — the Consortium&amp;rsquo;s own guidance on why not to use a UTF-8 BOM&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/git/git/blob/master/xdiff-interface.c&#34;&gt;&lt;code&gt;buffer_is_binary()&lt;/code&gt; in git&amp;rsquo;s &lt;code&gt;xdiff-interface.c&lt;/code&gt;&lt;/a&gt; — the NUL check and the &lt;code&gt;FIRST_FEW_BYTES&lt;/code&gt; cutoff&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/file/file/blob/master/src/encoding.c&#34;&gt;&lt;code&gt;src/encoding.c&lt;/code&gt; in the &lt;code&gt;file&lt;/code&gt; project&lt;/a&gt; — the text-character table and encoding-guessing order behind &lt;code&gt;file(1)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>Last time I took apart PNG, which opens with eight bytes whose entire job is to announce &#34;I am a PNG&#34;.

A text file opens with nothing. No signature, no header, no length field, no version, no metadata. It is bytes, and then it stops.

So this post is the opposite of the last one. Instead of walking a structure, we&#39;re going to look at what happens when there isn&#39;t one.

---

## POSIX Defines the Standard

Start with the standard, in §3.403:

&gt; A file that contains characters organized into zero or more lines. The lines do not contain NUL characters and none can exceed {LINE_MAX} bytes in length, including the `&lt;newline&gt;` character. **Although POSIX.1-2017 does not distinguish between text files and binary files** (see the ISO C standard), many utilities only produce predictable or meaningful output when operating on text files.

The standard defines the term and then tells you the system doesn&#39;t enforce it.

Nothing in the filesystem records &#34;this is text.&#34; There&#39;s no flag on the inode, no attribute, nothing in the directory entry. The `.txt` extension is a hint to humans and to Windows. &#34;Text file&#34; is not a property a file has. It&#39;s a claim the *reader* makes about the bytes, and every tool makes it slightly differently.

---

## The Bytes Don&#39;t Know What They Mean

A file stores bytes. Turning bytes into characters requires an encoding, and the encoding is not in the file.

Here are the same five characters, `Héllo`, in several encodings:

```
ascii         FAILS: ordinal not in range(128)
latin-1        5 bytes  48 e9 6c 6c 6f
cp1252         5 bytes  48 e9 6c 6c 6f
utf-8          6 bytes  48 c3 a9 6c 6c 6f
utf-16        12 bytes  ff fe 48 00 e9 00 6c 00 6c 00 6f 00
utf-16-be     10 bytes  00 48 00 e9 00 6c 00 6c 00 6f
utf-32        24 bytes  ff fe 00 00 48 00 00 00 e9 00 00 00 ...
```

Five characters. Anywhere from 5 to 24 bytes. Nothing in any of those files says which one you&#39;re looking at.

When you open a file in your editor and it looks right, that&#39;s your editor guessing correctly. When you get `caf√©` instead of `café`, that&#39;s your editor guessing wrong. The file never changed.

---

## The Fight Over the Eighth Bit

ASCII was standardized as a 7-bit code: 128 values, `0x00` through `0x7F`. Thirty-three control codes, ninety-five printable characters, and that was the whole world if the world spoke English.

Bytes have eight bits though, so there were another 128 values sitting there unused. Everyone grabbed them, and everyone grabbed them differently.

ISO 8859-1 (Latin-1) claimed `0xA0`–`0xFF` for Western European letters and reserved `0x80`–`0x9F` for a second set of control codes nobody used. Microsoft looked at those 32 wasted slots and put printable punctuation there instead, creating Windows-1252. That&#39;s where the curly quotes and the em dash live:

```
CP1252 text  : It&#39;s &#34;fine&#34; — really
CP1252 bytes : 49 74 27 73 20 93 66 69 6e 65 94 20 97 20 72 65 61 6c 6c 79
UTF-8 bytes  : 49 74 27 73 20 e2 80 9c 66 69 6e 65 e2 80 9d 20 e2 80 94 ...
```

Byte `0x93` is a left curly quote in CP1252 and a control character in strict Latin-1. This is why pasting from Word into a system expecting Latin-1 produces garbage: the bytes are legal, they just mean nothing there.

Mojibake, Japanese for &#34;character transformation,&#34; is exactly this, and it&#39;s completely deterministic:

```
original text  : café
as UTF-8 bytes : 63 61 66 c3 a9
read as CP1252 : cafÃ©
```

`c3 a9` is one character in UTF-8 and two characters in CP1252. Both readings are valid. Only one is what you meant.

It could have been worse. IBM&#39;s EBCDIC, still running on mainframes, isn&#39;t an ASCII superset at all:

```
&#39;A&#39;  ASCII 0x41   EBCDIC 0xc1
&#39;a&#39;  ASCII 0x61   EBCDIC 0x81
&#39; &#39;  ASCII 0x20   EBCDIC 0x40
```

And the letters aren&#39;t even contiguous. `I` is `0xc9`, `J` is `0xd1`, with a gap in between. Sorting strings by byte value, which works fine in ASCII, silently produces wrong output in EBCDIC.

---

## Why UTF-8 Won

UTF-8 encodes a character in one to four bytes. ASCII characters keep their single-byte values, so every ASCII file is already a valid UTF-8 file. That backward compatibility gets most of the credit, but the more interesting property is the bit pattern:

```
&#39;A&#39;  U+0041   1 byte   41           01000001
&#39;é&#39;  U+00E9   2 bytes  c3 a9        11000011 10101001
&#39;€&#39;  U+20AC   3 bytes  e2 82 ac     11100010 10000010 10101100
&#39;🙂&#39; U+1F642  4 bytes  f0 9f 99 82  11110000 10011111 10011001 10000010
```

Look at the leading bits. A single-byte character starts with `0`. A multi-byte character starts with `110`, `1110`, or `11110`, where the number of leading 1s is the total byte count. Every continuation byte starts with `10`, and nothing else does.

That makes UTF-8 self-synchronizing. Drop into the middle of a file at a random offset and you can tell immediately whether you&#39;re mid-character, and walk backwards a byte or two to find the boundary. You do not need to have read the file from the beginning.

Compare that to UTF-16, where you must know the byte order and must have tracked whether you&#39;re on an even or odd boundary. UTF-8 made encoding a local property instead of a global one, and that&#39;s why it took over.

---

## The BOM

Multi-byte encodings have a byte order problem: is `00 48` the character `U+0048` or `U+4800`? The Byte Order Mark solves it by putting `U+FEFF` at the start of the file, so a reader can look at the first two bytes and work out the endianness.

```
utf-16      ff fe 68 69 ...   (little-endian)
utf-16-le   68 00 69 00       (no BOM, you&#39;d better know)
utf-8-sig   ef bb bf 68 69    (UTF-8 &#34;BOM&#34;)
utf-8       68 69             (no BOM)
```

UTF-8 has no byte order to mark, because its unit is one byte. The UTF-8 BOM is not a byte order mark at all; it&#39;s a three-byte flag saying &#34;this is UTF-8,&#34; and the Unicode Consortium neither requires nor recommends it.

It also actively breaks things. The kernel identifies a script by looking for `0x23 0x21`, the characters `#!`, at offset zero:

```
no BOM  : 23 21 2f 62 69 6e 2f 73 68 0a  -&gt;  #!/bin/sh
with BOM: ef bb bf 23 21 2f 62 69 6e 2f  -&gt;  not a script
```

Same for JSON parsers, CSV importers, and anything else that expects a specific first byte. If you have ever seen a shell script fail with a cryptic error on a line that looks correct, this is a candidate.

---

## Lines Are a Convention Too

There is no line structure in a text file. There&#39;s a byte that tools agree means &#34;line break,&#34; and even that isn&#39;t agreed on.

The split is a hardware inheritance. A teletype needed two separate mechanical actions to start a new line: **carriage return** (`0x0D`) moved the print head back to the left margin, and **line feed** (`0x0A`) advanced the paper by one row. Two actions, two control codes.

Then everyone picked differently. Unix chose LF alone. MS-DOS, and Windows after it, kept both as CRLF. Classic Mac OS used CR alone. Those choices are still with us thirty years later, and they&#39;re the reason `.gitattributes` exists.

And then there&#39;s the trailing newline, which people argue about without realizing the standard already answered it. POSIX §3.206:

&gt; A line is a sequence of zero or more non-`&lt;newline&gt;` characters **plus a terminating `&lt;newline&gt;` character**.

The newline is part of the line, not a separator between lines. A file whose last byte isn&#39;t a newline doesn&#39;t have a final line. POSIX §3.195 has a name for what it has instead: an *incomplete line*.

That definition has teeth:

```
$ wc -l lf.txt nofinal.txt
       2 lf.txt
       1 nofinal.txt
```

Both files contain the text `one` and `two`. The first ends with a newline, the second doesn&#39;t. `wc -l` counts newline bytes, so the second file reports one line despite visibly having two.

This is also what git&#39;s `\ No newline at end of file` marker means. It isn&#39;t a style complaint. Git is telling you the last line is incomplete by the POSIX definition, which matters because otherwise appending a line would silently modify the existing last line rather than adding a new one.

---

## How Tools Guess

Since nothing declares itself, every tool that needs to know applies a heuristic. The dominant one is: **does it contain a NUL byte?**

That test exists because C strings are NUL-terminated, so a NUL in the middle of what claims to be text means something is off. It&#39;s a good heuristic. It&#39;s also wrong in two ways worth knowing about.

Git&#39;s version is `buffer_is_binary()` in `xdiff-interface.c`, and it doesn&#39;t scan the whole file. It caps at a constant:

```c
#define FIRST_FEW_BYTES 8000
```

So the check is &#34;is there a NUL in the first 8000 bytes.&#34; A file with clean text for 10KB and a NUL after that is text as far as git is concerned. The cutoff is a performance tradeoff, and it means binary-ness is decided by a sample, not a proof.

The second problem is bigger.

```
plain ascii          NUL present: False  -&gt; text
utf-8 with emoji     NUL present: False  -&gt; text
has a NUL byte       NUL present: True   -&gt; BINARY
utf-16 text          NUL present: True   -&gt; BINARY
```

UTF-16 encodes ASCII characters as the character byte plus a NUL. Any UTF-16 file that&#39;s mostly English is roughly half NUL bytes. So git does this to a perfectly valid text file:

```
$ git diff --cached --stat
 lf.txt      |   2 ++
 utf16.txt   | Bin 0 -&gt; 24 bytes
```

`Bin`. Git will not diff it, will not merge it, and will not show it in review. The heuristic isn&#39;t detecting text, it&#39;s detecting C-string-safety, and those aren&#39;t the same question.

`file(1)` is more thorough, and it shows how much the BOM is doing:

```
$ file utf16.txt utf16_bom.txt lf.txt
utf16.txt:     data
utf16_bom.txt: Unicode text, UTF-16, little-endian text
lf.txt:        ASCII text
```

Identical text content in the first two files. The only difference is two leading bytes. Without them `file` gives up and calls it `data`; with them it identifies the encoding exactly. For a format with no header, a BOM is the closest thing to one that exists.

Under the hood `file` is doing real work rather than one heuristic. `src/encoding.c` carries a 256-entry table classifying every byte value as never-valid-in-text, ASCII, ISO-8859, or extended ASCII, plus a dedicated UTF-8 state machine that rejects invalid sequences. It then tries candidate encodings in order: ASCII, UTF-7, UTF-8 with BOM, UTF-8, UTF-32, UTF-16, Latin-1, extended ASCII, and finally EBCDIC. That ordering is a nice fossil record of which encodings are still worth guessing first.

---

## Why This Matters

Nearly every format developers work in daily is a convention layered on this substrate. Source code, JSON, YAML, TOML, CSV, Markdown, config files, logs. All of them inherit these problems, and none of them can fully escape them, because the layer underneath has no way to describe itself.

That&#39;s the tradeoff. A format with no header can&#39;t tell you anything about itself, which is exactly why it has outlived every format that could. PNG will be readable as long as someone maintains a PNG decoder. A text file is readable as long as someone remembers what bytes are.

Next in the series: Markdown, which is a text file plus a set of conventions that nobody fully agrees on.

## Sources

- [POSIX.1-2017 Base Definitions, Chapter 3](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html) — §3.206 Line, §3.195 Incomplete Line, §3.403 Text File
- [RFC 3629](https://datatracker.ietf.org/doc/html/rfc3629) — the UTF-8 specification and its byte patterns
- [RFC 2046 §4.1](https://datatracker.ietf.org/doc/html/rfc2046#section-4.1) — the `text/plain` media type
- [Unicode FAQ on UTF-8, UTF-16, and the BOM](https://www.unicode.org/faq/utf_bom.html) — the Consortium&#39;s own guidance on why not to use a UTF-8 BOM
- [`buffer_is_binary()` in git&#39;s `xdiff-interface.c`](https://github.com/git/git/blob/master/xdiff-interface.c) — the NUL check and the `FIRST_FEW_BYTES` cutoff
- [`src/encoding.c` in the `file` project](https://github.com/file/file/blob/master/src/encoding.c) — the text-character table and encoding-guessing order behind `file(1)`

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>How PNG Actually Stores Your Pixels</title>
      <link>https://llbbl.blog/2026/08/14/how-png-actually-stores-your.html</link>
      <pubDate>Fri, 14 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/14/how-png-actually-stores-your.html</guid>
      <description>&lt;p&gt;I&amp;rsquo;m starting a series on file formats. Not &amp;ldquo;here are the ten image formats you should know,&amp;rdquo; but the actual bytes: what&amp;rsquo;s in the file, in what order, and why someone decided it should be that way.&lt;/p&gt;
&lt;p&gt;Starting with PNG, because it&amp;rsquo;s the format most developers touch every day and almost nobody has looked inside.&lt;/p&gt;
&lt;p&gt;I am likely to cover a few things that other explainer documents have covered, such as chunk structure and chunk types. However, I&amp;rsquo;d like to dig into some details that are not often mentioned, such as where your pixels went.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;a-format-born-from-a-patent-fight&#34;&gt;A Format Born From a Patent Fight&lt;/h2&gt;
&lt;p&gt;PNG exists because of a licensing ambush. On 28 December 1994, right in the middle of the holidays, Unisys announced an agreement to start collecting royalties from authors of GIF-supporting software, on the strength of its patent on the LZW compression algorithm that GIF used.&lt;/p&gt;
&lt;p&gt;The response was fast. A draft for a replacement format was posted to &lt;code&gt;comp.graphics&lt;/code&gt; on 4 January 1995, one week later. It was originally called PBF, for Portable Bitmap Format, and got renamed to PNG two days after that. The format shipped as a W3C Recommendation in October 1996.&lt;/p&gt;
&lt;p&gt;Two things about that origin still show in the bytes. The format is aggressively defensive, because it was designed by people who expected files to be mangled in transit. And it is aggressively extensible, because they had just watched a format become unusable for reasons that had nothing to do with its technical design.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;eight-bytes-of-paranoia&#34;&gt;Eight Bytes of Paranoia&lt;/h2&gt;
&lt;p&gt;Every PNG starts with the same eight bytes:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;Hexadecimal:  89  50  4E  47  0D  0A  1A  0A
ASCII/Ctrl: \x89  P   N   G  \r  \n \x1A \n
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;P N G&lt;/code&gt; in the middle is obvious. The other five bytes are a booby trap for 1995-era file transfer, and each one catches a specific failure:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;0x89&lt;/code&gt; has the high bit set. Some 7-bit transfer paths stripped bit 7 from every byte. If that happened, this byte arrives as &lt;code&gt;0x09&lt;/code&gt; and the file is detectably wrong on byte one.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0x0D 0x0A&lt;/code&gt; is a DOS line ending. A text-mode FTP transfer that &amp;ldquo;helpfully&amp;rdquo; converts CRLF to LF mangles it.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0x1A&lt;/code&gt; is Ctrl-Z, the MS-DOS end-of-file marker. If you &lt;code&gt;TYPE&lt;/code&gt; a PNG at a DOS prompt, output stops here instead of spraying binary at your terminal and leaving it in a weird state.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0x0A&lt;/code&gt; is a bare LF, catching the opposite conversion: LF silently expanded to CRLF.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Who would have thought that so many bits were used just to account for line endings in different operating systems? I suppose it&amp;rsquo;s good to plan ahead when designing a file format.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;everything-is-a-chunk&#34;&gt;Everything Is a Chunk&lt;/h2&gt;
&lt;p&gt;After the signature, a PNG is a flat sequence of chunks. No central directory, no offset table. You read them in order.&lt;/p&gt;
&lt;p&gt;Every chunk has the same four-field shape:&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Field&lt;/th&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Size&lt;/th&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Notes&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Length&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;4 bytes&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Big-endian, counts &lt;strong&gt;only&lt;/strong&gt; the data field&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Chunk Type&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;4 bytes&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Four ASCII letters&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Chunk Data&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Length bytes&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Can be zero-length&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;CRC-32&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;4 bytes&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Computed over type &lt;strong&gt;and&lt;/strong&gt; data, not over length&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two details worth keeping. The length field is 32 bits but the spec caps values at 2³¹−1, so the high bit is always clear. And the CRC covers the type plus the data but skips the length, which means a corrupted length field is not detected by the chunk&amp;rsquo;s own checksum.&lt;/p&gt;
&lt;p&gt;The chunk type is where PNG does something clever. Those four letters are ASCII, and bit 5 of an ASCII letter is what distinguishes uppercase from lowercase (&lt;code&gt;A&lt;/code&gt; is &lt;code&gt;0x41&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt; is &lt;code&gt;0x61&lt;/code&gt;). PNG uses that bit in each of the four positions as a flag:&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Position&lt;/th&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Uppercase means&lt;/th&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Lowercase means&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;1st&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Critical: decoder must understand it&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Ancillary: safe to ignore&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;2nd&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Public, registered in the spec&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Private, vendor-specific&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;3rd&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Reserved, must be uppercase today&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;(reserved for future use)&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;4th&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Unsafe to copy if pixels changed&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Safe to copy blindly&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;So a decoder that has never heard of &lt;code&gt;tEXt&lt;/code&gt; can tell from the lowercase &lt;code&gt;t&lt;/code&gt; that skipping it is fine. A decoder hitting &lt;code&gt;IDAT&lt;/code&gt; sees the uppercase &lt;code&gt;I&lt;/code&gt; and knows it cannot skip it. The capability negotiation is encoded in the name itself, which means you can add chunk types decades later without breaking old readers. This is why APNG could bolt animation onto PNG without a version bump.&lt;/p&gt;
&lt;p&gt;Four chunk types are critical: &lt;code&gt;IHDR&lt;/code&gt; (header, always first), &lt;code&gt;PLTE&lt;/code&gt; (palette), &lt;code&gt;IDAT&lt;/code&gt; (the pixels), and &lt;code&gt;IEND&lt;/code&gt; (a zero-length terminator).&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;ihdr-is-the-decode-key&#34;&gt;IHDR Is the Decode Key&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;IHDR&lt;/code&gt; is exactly 13 bytes and it comes first because nothing else can be interpreted without it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Width&lt;/strong&gt; (4 bytes) and &lt;strong&gt;Height&lt;/strong&gt; (4 bytes), big-endian&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bit depth&lt;/strong&gt; (1 byte): bits per &lt;em&gt;sample&lt;/em&gt;, one of 1, 2, 4, 8, 16&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Color type&lt;/strong&gt; (1 byte): what a pixel is made of&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compression method&lt;/strong&gt; (1 byte): always 0&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Filter method&lt;/strong&gt; (1 byte): always 0&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Interlace method&lt;/strong&gt; (1 byte): 0 for none, 1 for Adam7&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Bit depth and color type together determine everything about the pixel layout, and only certain combinations are legal:&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Color type&lt;/th&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Name&lt;/th&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Samples per pixel&lt;/th&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Legal bit depths&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;0&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Greyscale&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;1&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;1, 2, 4, 8, 16&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;2&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Truecolor (RGB)&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;3&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;8, 16&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;3&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Indexed&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;1 (a palette index)&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;1, 2, 4, 8&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;4&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Greyscale + alpha&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;2&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;8, 16&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;6&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Truecolor + alpha (RGBA)&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;4&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;8, 16&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Note the gaps. You cannot have 16-bit indexed color, because a palette holds at most 256 entries and 8 bits already addresses all of them. You cannot have 1-bit RGB, because a &amp;ldquo;1-bit red sample&amp;rdquo; isn&amp;rsquo;t a useful thing. The table isn&amp;rsquo;t arbitrary; each missing cell is a combination that would be incoherent.&lt;/p&gt;
&lt;p&gt;Also note that bit depth is per &lt;em&gt;sample&lt;/em&gt;, not per pixel. A bit depth of 16 with color type 6 means 16 bits each for R, G, B, and A: 64 bits per pixel. That&amp;rsquo;s the &amp;ldquo;64-bit RGBA&amp;rdquo; you see in PNG marketing.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;where-the-pixels-actually-live&#34;&gt;Where the Pixels Actually Live&lt;/h2&gt;
&lt;p&gt;Uncompress all the &lt;code&gt;IDAT&lt;/code&gt; data and concatenate it, and you get a byte stream. That stream is not a grid. It&amp;rsquo;s a sequence of &lt;strong&gt;scanlines&lt;/strong&gt;, one per image row, top to bottom. And each scanline is:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;[1 filter type byte][packed sample data for the whole row]
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That leading byte is not pixel data. It&amp;rsquo;s a number from 0 to 4 saying which filter was applied to this row.&lt;/p&gt;
&lt;p&gt;The sample data is packed with no padding between pixels and no separators. Samples appear in a fixed order within each pixel:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Greyscale: &lt;code&gt;grey&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Truecolor: &lt;code&gt;red, green, blue&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Indexed: &lt;code&gt;palette index&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Greyscale + alpha: &lt;code&gt;grey, alpha&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Truecolor + alpha: &lt;code&gt;red, green, blue, alpha&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here is an example PNG, filter type 0 (no filtering) on both rows:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;scanline 0: 00 ff 00 00 00 ff 00 00 00 ff ff ff 00
            ^^ filter byte
            ^^^^^^^^ red pixel (ff,00,00)
                     ^^^^^^^^ green pixel (00,ff,00)

scanline 1: 00 00 00 00 80 80 80 ff ff ff ff 00 ff
            ^^ filter byte
               ^^^^^^^^ black    ^^^^^^^^ white
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Twelve bytes of pixel data per row (4 pixels × 3 samples), each prefixed by one filter byte, for 26 bytes of raw stream. The complete file, signature and all four chunks included, is &lt;strong&gt;83 bytes&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The whole model at 8-bit depth: walk the row, emit samples in order, move on. No alignment, no padding, no per-pixel headers.&lt;/p&gt;
&lt;h3 id=&#34;below-8-bits-pixels-share-bytes&#34;&gt;Below 8 Bits, Pixels Share Bytes&lt;/h3&gt;
&lt;p&gt;Bit depths of 1, 2, and 4 only apply to greyscale and indexed images. Multiple pixels get packed into a single byte.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;These samples are packed into bytes with the leftmost sample in the high-order bits of a byte followed by the other samples for the scanline.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Leftmost pixel goes in the &lt;strong&gt;high&lt;/strong&gt; bits. So for a 12-pixel-wide 1-bit greyscale image:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;pixels       : 1 1 0 1 0 0 0 1  1 0 1 1
packed bytes : 0xd1 0xb0
               11010001 10110000
                              ^^^^ unused
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Twelve pixels need 12 bits, which rounds up to 2 bytes, leaving 4 bits spare at the end. The spec&amp;rsquo;s language on those leftover bits:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;When there are multiple pixels per byte, some low-order bits of the last byte of a scanline may go unused. The contents of these unused bits are not specified.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Scanlines always start on a byte boundary. Row 2 never continues in the leftover bits of row 1&amp;rsquo;s last byte.&lt;/p&gt;
&lt;p&gt;At bit depth 16, each sample is two bytes, most significant byte first. The spec calls it network byte order. On x86 and ARM, which are little-endian, that means every 16-bit sample needs a byte swap on read and on write.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-filter-byte-is-the-whole-trick&#34;&gt;The Filter Byte Is the Whole Trick&lt;/h2&gt;
&lt;p&gt;Now back to that leading byte on every scanline.&lt;/p&gt;
&lt;p&gt;PNG uses DEFLATE, the same algorithm as gzip and zip. If you just DEFLATE&amp;rsquo;d raw pixels, PNG would compress about as well as gzipping a bitmap, which is to say barely at all. Photographs and gradients don&amp;rsquo;t repeat exact byte sequences, and LZ77 needs exact repeats.&lt;/p&gt;
&lt;p&gt;So before compressing, PNG transforms each scanline into differences from its neighbors. Five filters are available, chosen &lt;strong&gt;per scanline&lt;/strong&gt;:&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Type&lt;/th&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Name&lt;/th&gt;
          &lt;th style=&#34;text-align: left&#34;&gt;Transform&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;0&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;None&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;store the byte as-is&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;1&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Sub&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;subtract the byte from the pixel to the left&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;2&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Up&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;subtract the byte from the pixel above&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;3&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Average&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;subtract the average of left and above&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;4&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;Paeth&lt;/td&gt;
          &lt;td style=&#34;text-align: left&#34;&gt;subtract whichever of left/above/upper-left is the best predictor&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;All arithmetic is mod 256, which is what makes it reversible without storing a sign. And &amp;ldquo;the pixel to the left&amp;rdquo; means the byte at the same position in the previous pixel, so for RGB the red sample is compared against the previous red sample, not against the previous blue.&lt;/p&gt;
&lt;p&gt;Take a 16-pixel greyscale gradient stepping by 10:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;raw scanline     : 00 0a 14 1e 28 32 3c 46 50 5a 64 6e 78 82 8c 96
after Sub filter : 00 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Sixteen distinct byte values become two. The image is unchanged and the transform is exactly reversible, but LZ77 now sees a run it can encode in almost nothing. On this toy row, DEFLATE produces 28 bytes for the raw version and 15 for the filtered one.&lt;/p&gt;
&lt;p&gt;Sixteen bytes is far too small for DEFLATE to stretch its legs, so don&amp;rsquo;t read that ratio as typical. The point is the entropy collapse: filtering doesn&amp;rsquo;t compress anything, it rearranges the data so the compressor has something to find.&lt;/p&gt;
&lt;p&gt;That per-scanline choice is also why two encoders produce different-sized files from identical pixels. libpng, ImageMagick, &lt;code&gt;oxipng&lt;/code&gt;, and &lt;code&gt;zopflipng&lt;/code&gt; all ship different filter-selection heuristics. Same spec, same decoded output, different bytes on disk. Most PNG optimizers are search algorithms over filter choices, not better compressors.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-compression-pipeline-end-to-end&#34;&gt;The Compression Pipeline, End to End&lt;/h2&gt;
&lt;p&gt;Putting it together:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;raw pixels
  -&amp;gt; pack into scanlines (samples in order, sub-byte packing if needed)
  -&amp;gt; prepend a filter byte per scanline, apply the filter
  -&amp;gt; DEFLATE the whole concatenated stream (LZ77 + Huffman)
  -&amp;gt; wrap in a zlib container (RFC 1950)
  -&amp;gt; split across one or more IDAT chunks
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;A few consequences fall out of that ordering:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The zlib stream spans chunks.&lt;/strong&gt; &lt;code&gt;IDAT&lt;/code&gt; boundaries are arbitrary. A decoder must concatenate every &lt;code&gt;IDAT&lt;/code&gt; payload and then decompress; decompressing them individually fails. Encoders split them for streaming, not for structure.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Adler-32 checksum in the zlib wrapper covers filtered bytes&lt;/strong&gt;, not your original pixels. It validates decompression, not image fidelity. The per-chunk CRC-32 is what protects against transmission corruption.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Compression is global across the image.&lt;/strong&gt; LZ77&amp;rsquo;s 32KB sliding window means row 400 can match against row 380 if they&amp;rsquo;re similar. This is why a 64×64 solid color block compresses to &lt;strong&gt;136 bytes&lt;/strong&gt; while a 64×64 gradient of the same dimensions takes &lt;strong&gt;10,362 bytes&lt;/strong&gt;, against 12,288 bytes raw. Uniformity compresses; novelty doesn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;And a practical one: for that solid-color block, encoding as &lt;strong&gt;indexed&lt;/strong&gt; color with a one-entry palette produces a &lt;strong&gt;99-byte&lt;/strong&gt; file instead of 136, because each pixel is one index byte instead of three samples. If your image has few colors, color type 3 usually beats truecolor even after DEFLATE gets its turn.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;interlacing-briefly&#34;&gt;Interlacing, Briefly&lt;/h2&gt;
&lt;p&gt;If the interlace byte in &lt;code&gt;IHDR&lt;/code&gt; is 1, the image uses Adam7: the pixels are transmitted in seven passes over an 8×8 grid, coarse to fine, so a partially-downloaded image renders as a low-resolution preview that sharpens.&lt;/p&gt;
&lt;p&gt;Two things to know. Each pass is filtered and encoded as an independent sub-image with its own scanlines and filter bytes, so a decoder can&amp;rsquo;t treat the stream as one grid. And Adam7 typically makes files &lt;em&gt;larger&lt;/em&gt;, because breaking the image into seven sparse sub-images destroys exactly the local coherence that filtering and LZ77 depend on. It was a good trade on a 28.8k modem. On any modern connection it costs size and complexity for a progressive render nobody waits around to see.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-this-buys-you&#34;&gt;What This Buys You&lt;/h2&gt;
&lt;p&gt;The design decisions hold up well for a 1996 format:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Unknown chunks are safe by construction&lt;/strong&gt;, so the format extended to EXIF metadata, ICC profiles, and animation without ever breaking old decoders.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every chunk is individually checksummed&lt;/strong&gt;, so corruption is localized and detectable rather than silently rendering garbage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Filtering is a preprocessing step, not a compression format&lt;/strong&gt;, which means encoders can get better forever without touching the spec. A file written by &lt;code&gt;zopflipng&lt;/code&gt; today decodes fine in a 1997 reader.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That extensibility is not just historical. PNG got a Third Edition as a W3C Recommendation on 24 June 2025, which finally standardized APNG, added an &lt;code&gt;eXIf&lt;/code&gt; chunk for camera metadata, and brought in HDR through three new chunks (&lt;code&gt;cICP&lt;/code&gt;, &lt;code&gt;mDCV&lt;/code&gt;, &lt;code&gt;cLLI&lt;/code&gt;). Thirty years on, the container still had room.&lt;/p&gt;
&lt;p&gt;Where it shows its age is DEFLATE, which is a 1990s compressor. Lossless WebP does beat it: Google&amp;rsquo;s own study puts WebP lossless at 23% smaller than PNGs already optimized with ZopfliPNG, and 42% smaller than default libpng output. Worth noting the baseline matters enormously there, and Google&amp;rsquo;s WebP FAQ quotes a different figure (26%) than the study it links to.&lt;/p&gt;
&lt;p&gt;Lossless AVIF is a murkier story than the marketing suggests. AOMedia publishes no general lossless-AVIF-versus-PNG number at all; its quantified claims (50% versus JPEG, 30% versus WebP) are all about lossy encoding. The only primary figure available is 10% versus a 16-bit PNG for a single demo image using a new v1.2.0 feature. Independent testing regularly finds lossless AVIF producing &lt;em&gt;larger&lt;/em&gt; files than PNG for flat synthetic images like icons, UI, and charts. If you&amp;rsquo;re picking a format for screenshots and diagrams, test on your own images rather than trusting a general ranking.&lt;/p&gt;
&lt;p&gt;Next in the series: the opposite of all this. A text file, which announces nothing about itself at all.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://www.w3.org/TR/png-3/&#34;&gt;W3C PNG Specification, Third Edition&lt;/a&gt; — the current standard; §7.2 covers scanlines and sample packing, §9 covers filtering&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://datatracker.ietf.org/doc/html/rfc2083&#34;&gt;RFC 2083&lt;/a&gt; — the original 1997 IETF PNG specification&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://datatracker.ietf.org/doc/html/rfc1950&#34;&gt;RFC 1950 (zlib)&lt;/a&gt; and &lt;a href=&#34;https://datatracker.ietf.org/doc/html/rfc1951&#34;&gt;RFC 1951 (DEFLATE)&lt;/a&gt; — the compression layer&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;http://www.libpng.org/pub/png/pnghist.html&#34;&gt;libpng PNG history&lt;/a&gt; — the Unisys announcement, the PBF name, and the January 1995 timeline&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://developers.google.com/speed/webp/docs/webp_lossless_alpha_study&#34;&gt;WebP Lossless and Alpha Study&lt;/a&gt; — Google&amp;rsquo;s 23%/42% lossless figures and their baselines&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;http://aomedia.org/blog%20posts/AV1-Image-File-Format-Specification-Gets-an-Upgrade-with-AVIF/&#34;&gt;AOMedia on AVIF v1.2.0&lt;/a&gt; — the 10% lossless figure, and its narrow scope&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>I&#39;m starting a series on file formats. Not &#34;here are the ten image formats you should know,&#34; but the actual bytes: what&#39;s in the file, in what order, and why someone decided it should be that way.

Starting with PNG, because it&#39;s the format most developers touch every day and almost nobody has looked inside.

I am likely to cover a few things that other explainer documents have covered, such as chunk structure and chunk types. However, I&#39;d like to dig into some details that are not often mentioned, such as where your pixels went.

---

## A Format Born From a Patent Fight

PNG exists because of a licensing ambush. On 28 December 1994, right in the middle of the holidays, Unisys announced an agreement to start collecting royalties from authors of GIF-supporting software, on the strength of its patent on the LZW compression algorithm that GIF used.

The response was fast. A draft for a replacement format was posted to `comp.graphics` on 4 January 1995, one week later. It was originally called PBF, for Portable Bitmap Format, and got renamed to PNG two days after that. The format shipped as a W3C Recommendation in October 1996.

Two things about that origin still show in the bytes. The format is aggressively defensive, because it was designed by people who expected files to be mangled in transit. And it is aggressively extensible, because they had just watched a format become unusable for reasons that had nothing to do with its technical design.

---

## Eight Bytes of Paranoia

Every PNG starts with the same eight bytes:

```
Hexadecimal:  89  50  4E  47  0D  0A  1A  0A
ASCII/Ctrl: \x89  P   N   G  \r  \n \x1A \n
```

`P N G` in the middle is obvious. The other five bytes are a booby trap for 1995-era file transfer, and each one catches a specific failure:

1. `0x89` has the high bit set. Some 7-bit transfer paths stripped bit 7 from every byte. If that happened, this byte arrives as `0x09` and the file is detectably wrong on byte one.
2. `0x0D 0x0A` is a DOS line ending. A text-mode FTP transfer that &#34;helpfully&#34; converts CRLF to LF mangles it.
3. `0x1A` is Ctrl-Z, the MS-DOS end-of-file marker. If you `TYPE` a PNG at a DOS prompt, output stops here instead of spraying binary at your terminal and leaving it in a weird state.
4. `0x0A` is a bare LF, catching the opposite conversion: LF silently expanded to CRLF.

Who would have thought that so many bits were used just to account for line endings in different operating systems? I suppose it&#39;s good to plan ahead when designing a file format.

---

## Everything Is a Chunk

After the signature, a PNG is a flat sequence of chunks. No central directory, no offset table. You read them in order.

Every chunk has the same four-field shape:

| Field | Size | Notes |
| :--- | :--- | :--- |
| Length | 4 bytes | Big-endian, counts **only** the data field |
| Chunk Type | 4 bytes | Four ASCII letters |
| Chunk Data | Length bytes | Can be zero-length |
| CRC-32 | 4 bytes | Computed over type **and** data, not over length |

Two details worth keeping. The length field is 32 bits but the spec caps values at 2³¹−1, so the high bit is always clear. And the CRC covers the type plus the data but skips the length, which means a corrupted length field is not detected by the chunk&#39;s own checksum.

The chunk type is where PNG does something clever. Those four letters are ASCII, and bit 5 of an ASCII letter is what distinguishes uppercase from lowercase (`A` is `0x41`, `a` is `0x61`). PNG uses that bit in each of the four positions as a flag:

| Position | Uppercase means | Lowercase means |
| :--- | :--- | :--- |
| 1st | Critical: decoder must understand it | Ancillary: safe to ignore |
| 2nd | Public, registered in the spec | Private, vendor-specific |
| 3rd | Reserved, must be uppercase today | (reserved for future use) |
| 4th | Unsafe to copy if pixels changed | Safe to copy blindly |

So a decoder that has never heard of `tEXt` can tell from the lowercase `t` that skipping it is fine. A decoder hitting `IDAT` sees the uppercase `I` and knows it cannot skip it. The capability negotiation is encoded in the name itself, which means you can add chunk types decades later without breaking old readers. This is why APNG could bolt animation onto PNG without a version bump.

Four chunk types are critical: `IHDR` (header, always first), `PLTE` (palette), `IDAT` (the pixels), and `IEND` (a zero-length terminator).

---

## IHDR Is the Decode Key

`IHDR` is exactly 13 bytes and it comes first because nothing else can be interpreted without it:

- **Width** (4 bytes) and **Height** (4 bytes), big-endian
- **Bit depth** (1 byte): bits per *sample*, one of 1, 2, 4, 8, 16
- **Color type** (1 byte): what a pixel is made of
- **Compression method** (1 byte): always 0
- **Filter method** (1 byte): always 0
- **Interlace method** (1 byte): 0 for none, 1 for Adam7

Bit depth and color type together determine everything about the pixel layout, and only certain combinations are legal:

| Color type | Name | Samples per pixel | Legal bit depths |
| :--- | :--- | :--- | :--- |
| 0 | Greyscale | 1 | 1, 2, 4, 8, 16 |
| 2 | Truecolor (RGB) | 3 | 8, 16 |
| 3 | Indexed | 1 (a palette index) | 1, 2, 4, 8 |
| 4 | Greyscale + alpha | 2 | 8, 16 |
| 6 | Truecolor + alpha (RGBA) | 4 | 8, 16 |

Note the gaps. You cannot have 16-bit indexed color, because a palette holds at most 256 entries and 8 bits already addresses all of them. You cannot have 1-bit RGB, because a &#34;1-bit red sample&#34; isn&#39;t a useful thing. The table isn&#39;t arbitrary; each missing cell is a combination that would be incoherent.

Also note that bit depth is per *sample*, not per pixel. A bit depth of 16 with color type 6 means 16 bits each for R, G, B, and A: 64 bits per pixel. That&#39;s the &#34;64-bit RGBA&#34; you see in PNG marketing.

---

## Where the Pixels Actually Live

Uncompress all the `IDAT` data and concatenate it, and you get a byte stream. That stream is not a grid. It&#39;s a sequence of **scanlines**, one per image row, top to bottom. And each scanline is:

```
[1 filter type byte][packed sample data for the whole row]
```

That leading byte is not pixel data. It&#39;s a number from 0 to 4 saying which filter was applied to this row.

The sample data is packed with no padding between pixels and no separators. Samples appear in a fixed order within each pixel:

- Greyscale: `grey`
- Truecolor: `red, green, blue`
- Indexed: `palette index`
- Greyscale + alpha: `grey, alpha`
- Truecolor + alpha: `red, green, blue, alpha`

Here is an example PNG, filter type 0 (no filtering) on both rows:

```
scanline 0: 00 ff 00 00 00 ff 00 00 00 ff ff ff 00
            ^^ filter byte
            ^^^^^^^^ red pixel (ff,00,00)
                     ^^^^^^^^ green pixel (00,ff,00)

scanline 1: 00 00 00 00 80 80 80 ff ff ff ff 00 ff
            ^^ filter byte
               ^^^^^^^^ black    ^^^^^^^^ white
```

Twelve bytes of pixel data per row (4 pixels × 3 samples), each prefixed by one filter byte, for 26 bytes of raw stream. The complete file, signature and all four chunks included, is **83 bytes**.

The whole model at 8-bit depth: walk the row, emit samples in order, move on. No alignment, no padding, no per-pixel headers.

### Below 8 Bits, Pixels Share Bytes

Bit depths of 1, 2, and 4 only apply to greyscale and indexed images. Multiple pixels get packed into a single byte.

&gt; These samples are packed into bytes with the leftmost sample in the high-order bits of a byte followed by the other samples for the scanline.

Leftmost pixel goes in the **high** bits. So for a 12-pixel-wide 1-bit greyscale image:

```
pixels       : 1 1 0 1 0 0 0 1  1 0 1 1
packed bytes : 0xd1 0xb0
               11010001 10110000
                              ^^^^ unused
```

Twelve pixels need 12 bits, which rounds up to 2 bytes, leaving 4 bits spare at the end. The spec&#39;s language on those leftover bits:

&gt; When there are multiple pixels per byte, some low-order bits of the last byte of a scanline may go unused. The contents of these unused bits are not specified.

Scanlines always start on a byte boundary. Row 2 never continues in the leftover bits of row 1&#39;s last byte. 

At bit depth 16, each sample is two bytes, most significant byte first. The spec calls it network byte order. On x86 and ARM, which are little-endian, that means every 16-bit sample needs a byte swap on read and on write. 

---

## The Filter Byte Is the Whole Trick

Now back to that leading byte on every scanline.

PNG uses DEFLATE, the same algorithm as gzip and zip. If you just DEFLATE&#39;d raw pixels, PNG would compress about as well as gzipping a bitmap, which is to say barely at all. Photographs and gradients don&#39;t repeat exact byte sequences, and LZ77 needs exact repeats.

So before compressing, PNG transforms each scanline into differences from its neighbors. Five filters are available, chosen **per scanline**:

| Type | Name | Transform |
| :--- | :--- | :--- |
| 0 | None | store the byte as-is |
| 1 | Sub | subtract the byte from the pixel to the left |
| 2 | Up | subtract the byte from the pixel above |
| 3 | Average | subtract the average of left and above |
| 4 | Paeth | subtract whichever of left/above/upper-left is the best predictor |

All arithmetic is mod 256, which is what makes it reversible without storing a sign. And &#34;the pixel to the left&#34; means the byte at the same position in the previous pixel, so for RGB the red sample is compared against the previous red sample, not against the previous blue.

Take a 16-pixel greyscale gradient stepping by 10:

```
raw scanline     : 00 0a 14 1e 28 32 3c 46 50 5a 64 6e 78 82 8c 96
after Sub filter : 00 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a
```

Sixteen distinct byte values become two. The image is unchanged and the transform is exactly reversible, but LZ77 now sees a run it can encode in almost nothing. On this toy row, DEFLATE produces 28 bytes for the raw version and 15 for the filtered one.

Sixteen bytes is far too small for DEFLATE to stretch its legs, so don&#39;t read that ratio as typical. The point is the entropy collapse: filtering doesn&#39;t compress anything, it rearranges the data so the compressor has something to find.

That per-scanline choice is also why two encoders produce different-sized files from identical pixels. libpng, ImageMagick, `oxipng`, and `zopflipng` all ship different filter-selection heuristics. Same spec, same decoded output, different bytes on disk. Most PNG optimizers are search algorithms over filter choices, not better compressors.

---

## The Compression Pipeline, End to End

Putting it together:

```
raw pixels
  -&gt; pack into scanlines (samples in order, sub-byte packing if needed)
  -&gt; prepend a filter byte per scanline, apply the filter
  -&gt; DEFLATE the whole concatenated stream (LZ77 + Huffman)
  -&gt; wrap in a zlib container (RFC 1950)
  -&gt; split across one or more IDAT chunks
```

A few consequences fall out of that ordering:

**The zlib stream spans chunks.** `IDAT` boundaries are arbitrary. A decoder must concatenate every `IDAT` payload and then decompress; decompressing them individually fails. Encoders split them for streaming, not for structure.

**The Adler-32 checksum in the zlib wrapper covers filtered bytes**, not your original pixels. It validates decompression, not image fidelity. The per-chunk CRC-32 is what protects against transmission corruption.

**Compression is global across the image.** LZ77&#39;s 32KB sliding window means row 400 can match against row 380 if they&#39;re similar. This is why a 64×64 solid color block compresses to **136 bytes** while a 64×64 gradient of the same dimensions takes **10,362 bytes**, against 12,288 bytes raw. Uniformity compresses; novelty doesn&#39;t.

And a practical one: for that solid-color block, encoding as **indexed** color with a one-entry palette produces a **99-byte** file instead of 136, because each pixel is one index byte instead of three samples. If your image has few colors, color type 3 usually beats truecolor even after DEFLATE gets its turn.

---

## Interlacing, Briefly

If the interlace byte in `IHDR` is 1, the image uses Adam7: the pixels are transmitted in seven passes over an 8×8 grid, coarse to fine, so a partially-downloaded image renders as a low-resolution preview that sharpens.

Two things to know. Each pass is filtered and encoded as an independent sub-image with its own scanlines and filter bytes, so a decoder can&#39;t treat the stream as one grid. And Adam7 typically makes files *larger*, because breaking the image into seven sparse sub-images destroys exactly the local coherence that filtering and LZ77 depend on. It was a good trade on a 28.8k modem. On any modern connection it costs size and complexity for a progressive render nobody waits around to see.

---

## What This Buys You

The design decisions hold up well for a 1996 format:

- **Unknown chunks are safe by construction**, so the format extended to EXIF metadata, ICC profiles, and animation without ever breaking old decoders.
- **Every chunk is individually checksummed**, so corruption is localized and detectable rather than silently rendering garbage.
- **Filtering is a preprocessing step, not a compression format**, which means encoders can get better forever without touching the spec. A file written by `zopflipng` today decodes fine in a 1997 reader.

That extensibility is not just historical. PNG got a Third Edition as a W3C Recommendation on 24 June 2025, which finally standardized APNG, added an `eXIf` chunk for camera metadata, and brought in HDR through three new chunks (`cICP`, `mDCV`, `cLLI`). Thirty years on, the container still had room.

Where it shows its age is DEFLATE, which is a 1990s compressor. Lossless WebP does beat it: Google&#39;s own study puts WebP lossless at 23% smaller than PNGs already optimized with ZopfliPNG, and 42% smaller than default libpng output. Worth noting the baseline matters enormously there, and Google&#39;s WebP FAQ quotes a different figure (26%) than the study it links to.

Lossless AVIF is a murkier story than the marketing suggests. AOMedia publishes no general lossless-AVIF-versus-PNG number at all; its quantified claims (50% versus JPEG, 30% versus WebP) are all about lossy encoding. The only primary figure available is 10% versus a 16-bit PNG for a single demo image using a new v1.2.0 feature. Independent testing regularly finds lossless AVIF producing *larger* files than PNG for flat synthetic images like icons, UI, and charts. If you&#39;re picking a format for screenshots and diagrams, test on your own images rather than trusting a general ranking.

Next in the series: the opposite of all this. A text file, which announces nothing about itself at all. 

## Sources

- [W3C PNG Specification, Third Edition](https://www.w3.org/TR/png-3/) — the current standard; §7.2 covers scanlines and sample packing, §9 covers filtering
- [RFC 2083](https://datatracker.ietf.org/doc/html/rfc2083) — the original 1997 IETF PNG specification
- [RFC 1950 (zlib)](https://datatracker.ietf.org/doc/html/rfc1950) and [RFC 1951 (DEFLATE)](https://datatracker.ietf.org/doc/html/rfc1951) — the compression layer
- [libpng PNG history](http://www.libpng.org/pub/png/pnghist.html) — the Unisys announcement, the PBF name, and the January 1995 timeline
- [WebP Lossless and Alpha Study](https://developers.google.com/speed/webp/docs/webp_lossless_alpha_study) — Google&#39;s 23%/42% lossless figures and their baselines
- [AOMedia on AVIF v1.2.0](http://aomedia.org/blog%20posts/AV1-Image-File-Format-Specification-Gets-an-Upgrade-with-AVIF/) — the 10% lossless figure, and its narrow scope

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>Deploying Hermes Agent With Ansible Without Creating a Snowflake</title>
      <link>https://llbbl.blog/2026/08/13/deploying-hermes-agent-with-ansible.html</link>
      <pubDate>Thu, 13 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/13/deploying-hermes-agent-with-ansible.html</guid>
      <description>&lt;p&gt;I have a home server with Plenty of RAM and no useful GPU, so running a local model was never the interesting part of deploying &lt;a href=&#34;https://hermes-agent.nousresearch.com/docs/&#34;&gt;Hermes Agent&lt;/a&gt;. The interesting part was making the agent setup repeatable.&lt;/p&gt;
&lt;p&gt;I could have pasted a &lt;code&gt;docker run&lt;/code&gt; command over SSH and called it finished. It would have worked. But &amp;ldquo;it works&amp;rdquo; and &amp;ldquo;I can rebuild this server six months from now&amp;rdquo; are two very different things.&lt;/p&gt;
&lt;p&gt;So I built an Ansible role around the official Hermes container, authenticated with a ChatGPT subscription through the &lt;code&gt;openai-codex&lt;/code&gt; provider, and left the one interactive step, OAuth, outside Ansible. That split is the whole idea. Ansible owns the infrastructure. Hermes owns its refresh token. I own the browser login.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;start-with-the-deployment-contract&#34;&gt;Start With the Deployment Contract&lt;/h2&gt;
&lt;p&gt;Before writing a single task, decide what the role is promising. Mine had five rules:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Pin the image by version and digest.&lt;/strong&gt; A mutable &lt;code&gt;latest&lt;/code&gt; tag is not a deployment plan.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Persist &lt;code&gt;/opt/data&lt;/code&gt;.&lt;/strong&gt; Hermes keeps configuration, sessions, skills, memories, and authentication state there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Publish the API on &lt;code&gt;127.0.0.1&lt;/code&gt; only.&lt;/strong&gt; A private Docker network handles future service-to-service access.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bound the process.&lt;/strong&gt; 4 GiB of RAM, 2 CPUs, 256 PIDs, bounded logs, and agent loop limits.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep OAuth manual.&lt;/strong&gt; Ansible creates the service, then the operator completes the device-code login over SSH.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The role layout is conventional: &lt;code&gt;defaults/main.yml&lt;/code&gt;, &lt;code&gt;handlers/main.yml&lt;/code&gt;, &lt;code&gt;tasks/main.yml&lt;/code&gt;, and templates for the Compose file, the Hermes config, the env file, and a host wrapper script.&lt;/p&gt;
&lt;p&gt;Put every value you expect to tune in &lt;code&gt;defaults/main.yml&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;hermes_agent_home&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;/home/youruser/Web/hermes-agent&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;hermes_agent_data_dir&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;{{ hermes_agent_home }}/data&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;hermes_agent_image&lt;/span&gt;: &amp;gt;-&lt;span style=&#34;color:#e6db74&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;  nousresearch/hermes-agent:v2026.8.3@sha256:&amp;lt;tag&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;hermes_agent_api_port&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;8642&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;hermes_agent_publish_host&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;127.0.0.1&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;hermes_agent_model_provider&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;openai-codex&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;hermes_agent_model_name&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;gpt-5.6-terra&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;hermes_agent_memory_limit&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;4g&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;hermes_agent_cpu_limit&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;2.0&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;hermes_agent_pids_limit&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;256&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Use a release and digest &lt;em&gt;you&lt;/em&gt; have reviewed. The version above is what I deployed, not a promise that it is still the right one when you read this.&lt;/p&gt;
&lt;p&gt;The Compose template turns those defaults into an enforceable boundary:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;services&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;hermes&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;image&lt;/span&gt;: {{ &lt;span style=&#34;color:#ae81ff&#34;&gt;hermes_agent_image }}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;command&lt;/span&gt;: [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;gateway&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;run&amp;#34;&lt;/span&gt;]
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;restart&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;unless-stopped&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;env_file&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - &lt;span style=&#34;color:#ae81ff&#34;&gt;./hermes.env&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;volumes&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - {{ &lt;span style=&#34;color:#ae81ff&#34;&gt;hermes_agent_data_dir }}:/opt/data&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;ports&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;127.0.0.1:8642:8642&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;mem_limit&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;4g&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;cpus&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;2.0&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;pids_limit&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;256&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;security_opt&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - &lt;span style=&#34;color:#66d9ef&#34;&gt;no&lt;/span&gt;-&lt;span style=&#34;color:#ae81ff&#34;&gt;new-privileges:true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;healthcheck&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;test&lt;/span&gt;: [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;CMD&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;curl&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;-fsS&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;http://127.0.0.1:8642/health&amp;#34;&lt;/span&gt;]
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;interval&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;10s&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;retries&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;12&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;logging&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;options&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#f92672&#34;&gt;max-size&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;10m&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#f92672&#34;&gt;max-file&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;5&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The loopback bind matters. The official Hermes Docker docs recommend authenticated access for exposed services, and specifically call out SSH tunnels or private networking as the safer way to reach a loopback-bound dashboard. I don&amp;rsquo;t need the API listening on every interface just because Docker makes that easy.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;make-ansible-own-setup-and-proof&#34;&gt;Make Ansible Own Setup &lt;em&gt;and&lt;/em&gt; Proof&lt;/h2&gt;
&lt;p&gt;The role should do more than render YAML. It should reject unsafe input before it mutates anything, create persistent directories with deliberate ownership, reconcile Compose, and prove the service came back.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;- &lt;span style=&#34;color:#f92672&#34;&gt;name&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;Validate Hermes configuration&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;ansible.builtin.assert&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;that&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - &lt;span style=&#34;color:#ae81ff&#34;&gt;hermes_agent_api_key | length &amp;gt;= 32&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - &lt;span style=&#34;color:#ae81ff&#34;&gt;hermes_agent_publish_host in [&amp;#39;127.0.0.1&amp;#39;, &amp;#39;::1&amp;#39;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - &lt;span style=&#34;color:#ae81ff&#34;&gt;hermes_agent_model_provider in [&amp;#39;openai-codex&amp;#39;, &amp;#39;openai-api&amp;#39;, &amp;#39;openrouter&amp;#39;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;no_log&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;- &lt;span style=&#34;color:#f92672&#34;&gt;name&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;Render protected environment&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;ansible.builtin.template&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;src&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;hermes.env.j2&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;dest&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;{{ hermes_agent_home }}/hermes.env&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;mode&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;0600&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;no_log&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;- &lt;span style=&#34;color:#f92672&#34;&gt;name&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;Reconcile Hermes Compose project&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;community.docker.docker_compose_v2&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;project_src&lt;/span&gt;: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;{{ hermes_agent_home }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;state&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;present&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;pull&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;missing&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Notice the two uses of &lt;code&gt;no_log&lt;/code&gt;. An encrypted variable is protected at rest, but Ansible will happily reveal the decrypted value in task output or &lt;code&gt;--diff&lt;/code&gt;. Secret-bearing template and validation tasks should not print their inputs.&lt;/p&gt;
&lt;p&gt;I encrypted only the API bearer key, not the whole variables file:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;ansible-vault encrypt_string &lt;span style=&#34;color:#ae81ff&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  --vault-password-file vault_password_file &lt;span style=&#34;color:#ae81ff&#34;&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  --stdin-name vault_hermes_agent_api_key
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Type the value, press Ctrl-D, paste the resulting &lt;code&gt;!vault&lt;/code&gt; block into your group vars, and let a &lt;code&gt;no_log&lt;/code&gt; assertion check its length. You don&amp;rsquo;t need to print the plaintext back into your terminal to prove Ansible can decrypt it.&lt;/p&gt;
&lt;p&gt;I also wanted keyless web search, so the role installs a pinned DDGS package into persistent storage with &lt;code&gt;uv&lt;/code&gt;, not &lt;code&gt;pip&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;- &lt;span style=&#34;color:#f92672&#34;&gt;name&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;Install pinned DDGS&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;community.docker.docker_container_exec&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;container&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;hermes-agent&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;user&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;hermes&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#f92672&#34;&gt;argv&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - &lt;span style=&#34;color:#ae81ff&#34;&gt;/usr/local/bin/uv&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - &lt;span style=&#34;color:#ae81ff&#34;&gt;pip&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - &lt;span style=&#34;color:#ae81ff&#34;&gt;install&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - --&lt;span style=&#34;color:#ae81ff&#34;&gt;python&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - &lt;span style=&#34;color:#ae81ff&#34;&gt;/opt/hermes/.venv/bin/python&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - --&lt;span style=&#34;color:#ae81ff&#34;&gt;target&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - &lt;span style=&#34;color:#ae81ff&#34;&gt;/opt/data/lazy-packages&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - --&lt;span style=&#34;color:#ae81ff&#34;&gt;reinstall&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      - &lt;span style=&#34;color:#ae81ff&#34;&gt;ddgs==9.14.4&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The follow-up check imports &lt;code&gt;DDGS&lt;/code&gt;, verifies the version, and confirms &lt;code&gt;/opt/data/lazy-packages&lt;/code&gt; is on &lt;code&gt;sys.path&lt;/code&gt;. Checking for a metadata directory is not enough.&lt;/p&gt;
&lt;p&gt;One gotcha from the release I deployed: do not render &lt;code&gt;HERMES_YOLO_MODE=0&lt;/code&gt;. Its mere presence still triggered the YOLO banner. If you want manual approvals, omit the variable entirely and set the approval mode in the Hermes config instead.&lt;/p&gt;
&lt;p&gt;OAuth stays manual, because browser authentication is an operator action, not configuration management:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;ssh user@homelab
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;docker exec -it hermes-agent hermes auth add openai-codex --no-browser
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;docker exec hermes-agent hermes auth status openai-codex
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The refreshable credential lands under the persisted &lt;code&gt;/opt/data&lt;/code&gt; directory. Do not copy your laptop&amp;rsquo;s &lt;code&gt;~/.codex&lt;/code&gt; directory into the container just to skip one login.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;make-daily-use-boring-too&#34;&gt;Make Daily Use Boring Too&lt;/h2&gt;
&lt;p&gt;The last piece was a host command. I wanted to SSH into the server, type &lt;code&gt;hermes&lt;/code&gt;, and resume the last session without remembering a Docker incantation.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-sh&#34; data-lang=&#34;sh&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;#!/bin/sh
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;set -eu
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;[&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;&lt;/span&gt;$#&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;&lt;/span&gt; -eq &lt;span style=&#34;color:#ae81ff&#34;&gt;0&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;]&lt;/span&gt;; &lt;span style=&#34;color:#66d9ef&#34;&gt;then&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  set -- --continue
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;fi&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;[&lt;/span&gt; -t &lt;span style=&#34;color:#ae81ff&#34;&gt;0&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;]&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;[&lt;/span&gt; -t &lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;]&lt;/span&gt;; &lt;span style=&#34;color:#66d9ef&#34;&gt;then&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  exec docker exec -it -w /opt/data/workspace hermes-agent hermes &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;&lt;/span&gt;$@&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;fi&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;exec docker exec -i -w /opt/data/workspace hermes-agent hermes &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;&lt;/span&gt;$@&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Install that template as &lt;code&gt;/usr/local/bin/hermes&lt;/code&gt; with mode &lt;code&gt;0755&lt;/code&gt;. Arguments pass through, so &lt;code&gt;hermes --help&lt;/code&gt; and &lt;code&gt;hermes auth status openai-codex&lt;/code&gt; work from the host too.&lt;/p&gt;
&lt;p&gt;Then run the play three times: once with &lt;code&gt;--check --diff&lt;/code&gt; to preview, once to deploy, and once more to confirm it reports &lt;code&gt;changed=0&lt;/code&gt;. Idempotence you haven&amp;rsquo;t observed is idempotence you&amp;rsquo;re guessing at. Finish by checking the container is healthy and the port is where you think it is:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;docker inspect --format &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;{{.State.Health.Status}}&amp;#39;&lt;/span&gt; hermes-agent
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;docker port hermes-agent
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That is the difference between a container I happen to have running and a service I know how to rebuild. The manual OAuth step isn&amp;rsquo;t a failure of automation, it&amp;rsquo;s a clean boundary around a human credential flow.&lt;/p&gt;
&lt;p&gt;Exactly what I want from Ansible.&lt;/p&gt;
&lt;h2 id=&#34;sources--references&#34;&gt;Sources &amp;amp; References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://hermes-agent.nousresearch.com/docs/user-guide/docker/&#34;&gt;Hermes Agent Docker guide&lt;/a&gt; — official container deployment docs&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://hermes-agent.nousresearch.com/docs/integrations/providers&#34;&gt;Hermes Agent provider documentation&lt;/a&gt; — including &lt;code&gt;openai-codex&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://docs.ansible.com/projects/ansible-core/2.17/vault_guide/vault_encrypting_content.html&#34;&gt;Ansible Vault: encrypting individual variables&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://docs.ansible.com/projects/ansible/latest/collections/community/docker/docker_compose_v2_module.html&#34;&gt;community.docker.docker_compose_v2&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>I have a home server with Plenty of RAM and no useful GPU, so running a local model was never the interesting part of deploying [Hermes Agent](https://hermes-agent.nousresearch.com/docs/). The interesting part was making the agent setup repeatable.

I could have pasted a `docker run` command over SSH and called it finished. It would have worked. But &#34;it works&#34; and &#34;I can rebuild this server six months from now&#34; are two very different things.

So I built an Ansible role around the official Hermes container, authenticated with a ChatGPT subscription through the `openai-codex` provider, and left the one interactive step, OAuth, outside Ansible. That split is the whole idea. Ansible owns the infrastructure. Hermes owns its refresh token. I own the browser login.

---

## Start With the Deployment Contract

Before writing a single task, decide what the role is promising. Mine had five rules:

1. **Pin the image by version and digest.** A mutable `latest` tag is not a deployment plan.
2. **Persist `/opt/data`.** Hermes keeps configuration, sessions, skills, memories, and authentication state there.
3. **Publish the API on `127.0.0.1` only.** A private Docker network handles future service-to-service access.
4. **Bound the process.** 4 GiB of RAM, 2 CPUs, 256 PIDs, bounded logs, and agent loop limits.
5. **Keep OAuth manual.** Ansible creates the service, then the operator completes the device-code login over SSH.

The role layout is conventional: `defaults/main.yml`, `handlers/main.yml`, `tasks/main.yml`, and templates for the Compose file, the Hermes config, the env file, and a host wrapper script.

Put every value you expect to tune in `defaults/main.yml`:

```yaml
hermes_agent_home: /home/youruser/Web/hermes-agent
hermes_agent_data_dir: &#34;{{ hermes_agent_home }}/data&#34;

hermes_agent_image: &gt;-
  nousresearch/hermes-agent:v2026.8.3@sha256:&lt;tag&gt;
hermes_agent_api_port: 8642
hermes_agent_publish_host: &#34;127.0.0.1&#34;

hermes_agent_model_provider: openai-codex
hermes_agent_model_name: gpt-5.6-terra
hermes_agent_memory_limit: 4g
hermes_agent_cpu_limit: &#34;2.0&#34;
hermes_agent_pids_limit: 256
```

Use a release and digest *you* have reviewed. The version above is what I deployed, not a promise that it is still the right one when you read this.

The Compose template turns those defaults into an enforceable boundary:

```yaml
services:
  hermes:
    image: {{ hermes_agent_image }}
    command: [&#34;gateway&#34;, &#34;run&#34;]
    restart: unless-stopped
    env_file:
      - ./hermes.env
    volumes:
      - {{ hermes_agent_data_dir }}:/opt/data
    ports:
      - &#34;127.0.0.1:8642:8642&#34;
    mem_limit: 4g
    cpus: &#34;2.0&#34;
    pids_limit: 256
    security_opt:
      - no-new-privileges:true
    healthcheck:
      test: [&#34;CMD&#34;, &#34;curl&#34;, &#34;-fsS&#34;, &#34;http://127.0.0.1:8642/health&#34;]
      interval: 10s
      retries: 12
    logging:
      options:
        max-size: &#34;10m&#34;
        max-file: &#34;5&#34;
```

The loopback bind matters. The official Hermes Docker docs recommend authenticated access for exposed services, and specifically call out SSH tunnels or private networking as the safer way to reach a loopback-bound dashboard. I don&#39;t need the API listening on every interface just because Docker makes that easy.

---

## Make Ansible Own Setup *and* Proof

The role should do more than render YAML. It should reject unsafe input before it mutates anything, create persistent directories with deliberate ownership, reconcile Compose, and prove the service came back.

```yaml
- name: Validate Hermes configuration
  ansible.builtin.assert:
    that:
      - hermes_agent_api_key | length &gt;= 32
      - hermes_agent_publish_host in [&#39;127.0.0.1&#39;, &#39;::1&#39;]
      - hermes_agent_model_provider in [&#39;openai-codex&#39;, &#39;openai-api&#39;, &#39;openrouter&#39;]
  no_log: true

- name: Render protected environment
  ansible.builtin.template:
    src: hermes.env.j2
    dest: &#34;{{ hermes_agent_home }}/hermes.env&#34;
    mode: &#34;0600&#34;
  no_log: true

- name: Reconcile Hermes Compose project
  community.docker.docker_compose_v2:
    project_src: &#34;{{ hermes_agent_home }}&#34;
    state: present
    pull: missing
```

Notice the two uses of `no_log`. An encrypted variable is protected at rest, but Ansible will happily reveal the decrypted value in task output or `--diff`. Secret-bearing template and validation tasks should not print their inputs.

I encrypted only the API bearer key, not the whole variables file:

```bash
ansible-vault encrypt_string \
  --vault-password-file vault_password_file \
  --stdin-name vault_hermes_agent_api_key
```

Type the value, press Ctrl-D, paste the resulting `!vault` block into your group vars, and let a `no_log` assertion check its length. You don&#39;t need to print the plaintext back into your terminal to prove Ansible can decrypt it.

I also wanted keyless web search, so the role installs a pinned DDGS package into persistent storage with `uv`, not `pip`:

```yaml
- name: Install pinned DDGS
  community.docker.docker_container_exec:
    container: hermes-agent
    user: hermes
    argv:
      - /usr/local/bin/uv
      - pip
      - install
      - --python
      - /opt/hermes/.venv/bin/python
      - --target
      - /opt/data/lazy-packages
      - --reinstall
      - ddgs==9.14.4
```

The follow-up check imports `DDGS`, verifies the version, and confirms `/opt/data/lazy-packages` is on `sys.path`. Checking for a metadata directory is not enough.

One gotcha from the release I deployed: do not render `HERMES_YOLO_MODE=0`. Its mere presence still triggered the YOLO banner. If you want manual approvals, omit the variable entirely and set the approval mode in the Hermes config instead.

OAuth stays manual, because browser authentication is an operator action, not configuration management:

```bash
ssh user@homelab
docker exec -it hermes-agent hermes auth add openai-codex --no-browser
docker exec hermes-agent hermes auth status openai-codex
```

The refreshable credential lands under the persisted `/opt/data` directory. Do not copy your laptop&#39;s `~/.codex` directory into the container just to skip one login.

---

## Make Daily Use Boring Too

The last piece was a host command. I wanted to SSH into the server, type `hermes`, and resume the last session without remembering a Docker incantation.

```sh
#!/bin/sh
set -eu

if [ &#34;$#&#34; -eq 0 ]; then
  set -- --continue
fi

if [ -t 0 ] &amp;&amp; [ -t 1 ]; then
  exec docker exec -it -w /opt/data/workspace hermes-agent hermes &#34;$@&#34;
fi

exec docker exec -i -w /opt/data/workspace hermes-agent hermes &#34;$@&#34;
```

Install that template as `/usr/local/bin/hermes` with mode `0755`. Arguments pass through, so `hermes --help` and `hermes auth status openai-codex` work from the host too.

Then run the play three times: once with `--check --diff` to preview, once to deploy, and once more to confirm it reports `changed=0`. Idempotence you haven&#39;t observed is idempotence you&#39;re guessing at. Finish by checking the container is healthy and the port is where you think it is:

```bash
docker inspect --format &#39;{{.State.Health.Status}}&#39; hermes-agent
docker port hermes-agent
```

That is the difference between a container I happen to have running and a service I know how to rebuild. The manual OAuth step isn&#39;t a failure of automation, it&#39;s a clean boundary around a human credential flow.

Exactly what I want from Ansible.

## Sources &amp; References

- [Hermes Agent Docker guide](https://hermes-agent.nousresearch.com/docs/user-guide/docker/) — official container deployment docs
- [Hermes Agent provider documentation](https://hermes-agent.nousresearch.com/docs/integrations/providers) — including `openai-codex`
- [Ansible Vault: encrypting individual variables](https://docs.ansible.com/projects/ansible-core/2.17/vault_guide/vault_encrypting_content.html)
- [community.docker.docker_compose_v2](https://docs.ansible.com/projects/ansible/latest/collections/community/docker/docker_compose_v2_module.html)

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>pnpm 11 Made the Safe Thing the Default</title>
      <link>https://llbbl.blog/2026/08/12/pnpm-made-the-safe-thing.html</link>
      <pubDate>Wed, 12 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/12/pnpm-made-the-safe-thing.html</guid>
      <description>&lt;p&gt;Protecting against supply chain attacks requires vigilance. You have to audit your dependencies. You have to pin your versions. You have to review your install scripts. All of these are great things to do, but they require sustained effort.&lt;/p&gt;
&lt;p&gt;pnpm 11 took the obvious thing and made it the default. They changed the waiting period. &lt;code&gt;minimumReleaseAge&lt;/code&gt; defines the minimum number of minutes that must pass after a version is published before pnpm will install it.&lt;/p&gt;
&lt;p&gt;Before version 11 the default was &lt;strong&gt;0&lt;/strong&gt;. In version 11 the default is &lt;strong&gt;1440 minutes&lt;/strong&gt;, which is 24 hours, and it applies to everything.&lt;/p&gt;
&lt;p&gt;Most malicious packages get discovered and pulled from the registry within minutes, or at most an hour. So what a day of patience buys you is that you&amp;rsquo;ve eliminated the potential for dependencies sneaking in that haven&amp;rsquo;t been fully vetted.&lt;/p&gt;
&lt;p&gt;npm already followed suit, and so did everyone else. This is now table stakes across the ecosystem:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;pnpm&lt;/strong&gt; got there first with &lt;code&gt;minimumReleaseAge&lt;/code&gt;, measured in minutes, back in 10.16 in September 2025.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Yarn&lt;/strong&gt; shipped &lt;code&gt;npmMinimalAgeGate&lt;/code&gt;, also minutes, in 4.10.0 that same month.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bun&lt;/strong&gt; added &lt;code&gt;minimumReleaseAge&lt;/code&gt; in 1.3 in October 2025, measured in seconds, plus a &lt;code&gt;minimumReleaseAgeExcludes&lt;/code&gt; list for packages you trust.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;npm&lt;/strong&gt; landed &lt;code&gt;min-release-age&lt;/code&gt; in 11.10.0 in February 2026, measured in days.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I guess they couldn&amp;rsquo;t agree on the unit of time for their release age setting.&lt;/p&gt;
&lt;p&gt;As far as I know, in all of them besides pnpm, the cooldown is opt-in. It&amp;rsquo;s not the default. So you have to know the setting exists and you have to go turn it on.&lt;/p&gt;
&lt;p&gt;Here are some other things that changed in pnpm 11:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;allowBuilds&lt;/code&gt;&lt;/strong&gt; replaces &lt;code&gt;onlyBuiltDependencies&lt;/code&gt;, which was removed in v11. It&amp;rsquo;s a map of which packages may run build scripts. Anything not listed is disallowed and treated as unreviewed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;strictDepBuilds&lt;/code&gt;&lt;/strong&gt; defaults to &lt;code&gt;true&lt;/code&gt;. Installation exits with a non-zero code if any dependency has unreviewed build scripts, so this fails your CI rather than printing a warning nobody reads.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;verifyDepsBeforeRun&lt;/code&gt;&lt;/strong&gt; defaults to &lt;code&gt;install&lt;/code&gt;. Before &lt;code&gt;pnpm run&lt;/code&gt; or &lt;code&gt;pnpm exec&lt;/code&gt;, it checks whether your dependency state matches the lockfile. Other options are &lt;code&gt;warn&lt;/code&gt;, &lt;code&gt;error&lt;/code&gt;, &lt;code&gt;prompt&lt;/code&gt;, and &lt;code&gt;false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;dangerouslyAllowAllBuilds&lt;/code&gt;&lt;/strong&gt; defaults to &lt;code&gt;false&lt;/code&gt;, and the name is doing exactly the work it should. Setting it true lets every dependency, transitive ones included, run install scripts now and in the future.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The clear pattern here is that an automated or unintentional action should be blocked, not permitted with a warning.&lt;/p&gt;
&lt;p&gt;Sure, there is somewhat of a cost here. The delay means you can&amp;rsquo;t immediately install a new version that was just published unless you flip the flag. I can see the &lt;code&gt;allowBuilds&lt;/code&gt; migration being somewhat of a hassle, because the first time you install after upgrading you&amp;rsquo;re going to get a list of packages that want to run build scripts. It&amp;rsquo;s easy to be lazy and approve all of them without thinking.&lt;/p&gt;
&lt;p&gt;With the tools we have available to us these days, we can ask an agent to review the build scripts. This is the right thing to do. Find a way to pin the dependency to fix transitive version issues. The inner engineer in all of us needs to understand why build scripts are dangerous, and what to be careful of, so that you can ask your subagent to go and see if that&amp;rsquo;s a problem, or if that problem has been fixed with the new version. It&amp;rsquo;s up to the human in the loop to ensure that the agents are doing their due diligence.&lt;/p&gt;
&lt;p&gt;When building software, we should also look for other paths to optimize, and make the lazy path the safest one, because chances are that&amp;rsquo;s going to become the default.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>Protecting against supply chain attacks requires vigilance. You have to audit your dependencies. You have to pin your versions. You have to review your install scripts. All of these are great things to do, but they require sustained effort.

pnpm 11 took the obvious thing and made it the default. They changed the waiting period. `minimumReleaseAge` defines the minimum number of minutes that must pass after a version is published before pnpm will install it.

Before version 11 the default was **0**. In version 11 the default is **1440 minutes**, which is 24 hours, and it applies to everything.

Most malicious packages get discovered and pulled from the registry within minutes, or at most an hour. So what a day of patience buys you is that you&#39;ve eliminated the potential for dependencies sneaking in that haven&#39;t been fully vetted.

npm already followed suit, and so did everyone else. This is now table stakes across the ecosystem:

- **pnpm** got there first with `minimumReleaseAge`, measured in minutes, back in 10.16 in September 2025.
- **Yarn** shipped `npmMinimalAgeGate`, also minutes, in 4.10.0 that same month.
- **Bun** added `minimumReleaseAge` in 1.3 in October 2025, measured in seconds, plus a `minimumReleaseAgeExcludes` list for packages you trust.
- **npm** landed `min-release-age` in 11.10.0 in February 2026, measured in days.

I guess they couldn&#39;t agree on the unit of time for their release age setting.

As far as I know, in all of them besides pnpm, the cooldown is opt-in. It&#39;s not the default. So you have to know the setting exists and you have to go turn it on.

Here are some other things that changed in pnpm 11:

- **`allowBuilds`** replaces `onlyBuiltDependencies`, which was removed in v11. It&#39;s a map of which packages may run build scripts. Anything not listed is disallowed and treated as unreviewed.
- **`strictDepBuilds`** defaults to `true`. Installation exits with a non-zero code if any dependency has unreviewed build scripts, so this fails your CI rather than printing a warning nobody reads.
- **`verifyDepsBeforeRun`** defaults to `install`. Before `pnpm run` or `pnpm exec`, it checks whether your dependency state matches the lockfile. Other options are `warn`, `error`, `prompt`, and `false`.
- **`dangerouslyAllowAllBuilds`** defaults to `false`, and the name is doing exactly the work it should. Setting it true lets every dependency, transitive ones included, run install scripts now and in the future.

The clear pattern here is that an automated or unintentional action should be blocked, not permitted with a warning.

Sure, there is somewhat of a cost here. The delay means you can&#39;t immediately install a new version that was just published unless you flip the flag. I can see the `allowBuilds` migration being somewhat of a hassle, because the first time you install after upgrading you&#39;re going to get a list of packages that want to run build scripts. It&#39;s easy to be lazy and approve all of them without thinking.

With the tools we have available to us these days, we can ask an agent to review the build scripts. This is the right thing to do. Find a way to pin the dependency to fix transitive version issues. The inner engineer in all of us needs to understand why build scripts are dangerous, and what to be careful of, so that you can ask your subagent to go and see if that&#39;s a problem, or if that problem has been fixed with the new version. It&#39;s up to the human in the loop to ensure that the agents are doing their due diligence.

When building software, we should also look for other paths to optimize, and make the lazy path the safest one, because chances are that&#39;s going to become the default.

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>Onboarding a Second Engineer to an Agent-Heavy Repo</title>
      <link>https://llbbl.blog/2026/08/11/onboarding-a-second-engineer-to.html</link>
      <pubDate>Tue, 11 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/11/onboarding-a-second-engineer-to.html</guid>
      <description>&lt;p&gt;How do you onboard a new engineer to a project when most of what they need to know is not checked into the code at all? It&amp;rsquo;s the agent configuration. It&amp;rsquo;s the rules about commands, the conventions the agent applies automatically because it&amp;rsquo;s stored in your memory. That isn&amp;rsquo;t shared.&lt;/p&gt;
&lt;p&gt;I think we&amp;rsquo;re still figuring out the answers to this. I&amp;rsquo;m not sure that we should be sharing the context file. I don&amp;rsquo;t think we should be sharing subagents. I could see skills being a shared resource.&lt;/p&gt;
&lt;p&gt;I think it&amp;rsquo;s more important that you don&amp;rsquo;t share everything. The things that should be shared, share cleanly. Set it up like you would set up linting rules on your pipeline.&lt;/p&gt;
&lt;p&gt;Understanding how the other person intends to be productive will impact what items need to be shared. Having access to a good memory system is way more important than checking in your context file.&lt;/p&gt;
&lt;p&gt;When a new person joins, of course, give them access to the code. Of course, give them access to things that you&amp;rsquo;ve agreed upon should be shared. But you should also talk with them and understand what their expectations are of a real workflow from end to end. Do they have the tools that they need in order to build reliable and repeatable workflows that will get results?&lt;/p&gt;
&lt;p&gt;Access to the tools that they need is more important than sharing every single tiny detail.&lt;/p&gt;
&lt;p&gt;People responsible for making changes to a system can learn a lot by just doing. You need to understand the tools before you can understand the system.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>How do you onboard a new engineer to a project when most of what they need to know is not checked into the code at all? It&#39;s the agent configuration. It&#39;s the rules about commands, the conventions the agent applies automatically because it&#39;s stored in your memory. That isn&#39;t shared.

I think we&#39;re still figuring out the answers to this. I&#39;m not sure that we should be sharing the context file. I don&#39;t think we should be sharing subagents. I could see skills being a shared resource.

I think it&#39;s more important that you don&#39;t share everything. The things that should be shared, share cleanly. Set it up like you would set up linting rules on your pipeline.

Understanding how the other person intends to be productive will impact what items need to be shared. Having access to a good memory system is way more important than checking in your context file.

When a new person joins, of course, give them access to the code. Of course, give them access to things that you&#39;ve agreed upon should be shared. But you should also talk with them and understand what their expectations are of a real workflow from end to end. Do they have the tools that they need in order to build reliable and repeatable workflows that will get results?

Access to the tools that they need is more important than sharing every single tiny detail.

People responsible for making changes to a system can learn a lot by just doing. You need to understand the tools before you can understand the system.

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>Boring Is a Feature</title>
      <link>https://llbbl.blog/2026/08/10/boring-is-a-feature.html</link>
      <pubDate>Mon, 10 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/10/boring-is-a-feature.html</guid>
      <description>&lt;p&gt;What does boring look like in the age of AI? And I&amp;rsquo;m not talking about uninteresting. I&amp;rsquo;m talking about highly maintainable.&lt;/p&gt;
&lt;p&gt;JavaScript?&lt;/p&gt;
&lt;p&gt;I mean I guess models are good at it. Everybody knows it. It runs everywhere. The biggest problem with JavaScript is that TypeScript is better.&lt;/p&gt;
&lt;p&gt;Certainly it&amp;rsquo;s better than picking a novelty language that you haven&amp;rsquo;t built anything with before. Does anybody on the team actually know Haskell? And how long ago did they know Haskell? You need to evaluate the cost of adopting it just as you would evaluate how long it would take to learn it and train the team on it.&lt;/p&gt;
&lt;p&gt;The upfront cost can be easier to measure. But the recurring ones are much harder to predict. What happens when a maintainer moves on from a package that everyone uses, and the speed it takes to find a new maintainer is not as fast as you need it to be?&lt;/p&gt;
&lt;p&gt;Boring tools are ones where the recurring cost of maintenance is as close to zero as you can get it. If you come back to it in eight months, it should work the way you remember it. This is a fairy tale that we tell ourselves, that nothing ever changes and we can control that change.&lt;/p&gt;
&lt;p&gt;Is boring even possible in the age of AI, when it feels like everyone has their own particle beam cannon that they can point at your codebase?&lt;/p&gt;
&lt;p&gt;I think it&amp;rsquo;s worth talking about what I mean by boring, because it can be used as a synonym for old, but that&amp;rsquo;s not what I mean. Boring means predictability.&lt;/p&gt;
&lt;p&gt;Take all your npm packages. Can you answer these questions about all of them? Probably not.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;How often do the release notes contain the word &amp;ldquo;breaking&amp;rdquo;?&lt;/strong&gt; Skim a year of them. This is the single best signal available and it takes ten minutes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How many people can merge?&lt;/strong&gt; One is a risk regardless of how good that one person is. People change jobs, burn out, and lose interest.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What happens to old versions?&lt;/strong&gt; A project that supports the previous major for a while is telling you something about how it thinks about your time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Can you read the source?&lt;/strong&gt; Not all of it. Enough to fix something yourself when you&amp;rsquo;re blocked and nobody&amp;rsquo;s answering.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Learn the new tool. Experiment. Try new things. Stay passionate about software. Just because you can use the new thing doesn&amp;rsquo;t mean you should.&lt;/p&gt;
&lt;p&gt;Don&amp;rsquo;t always pick the boring option, just like you don&amp;rsquo;t always pick the new option. It takes wisdom to know what the right answer is.&lt;/p&gt;
&lt;p&gt;You have to understand your failure modes, and when it&amp;rsquo;s an appropriate time to take a risk, and the scale of the risk.&lt;/p&gt;
&lt;p&gt;Pick boring for the parts you don&amp;rsquo;t want to think about. Save the interesting decisions for the places where being interesting is the point.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>What does boring look like in the age of AI? And I&#39;m not talking about uninteresting. I&#39;m talking about highly maintainable.

JavaScript?

I mean I guess models are good at it. Everybody knows it. It runs everywhere. The biggest problem with JavaScript is that TypeScript is better.

Certainly it&#39;s better than picking a novelty language that you haven&#39;t built anything with before. Does anybody on the team actually know Haskell? And how long ago did they know Haskell? You need to evaluate the cost of adopting it just as you would evaluate how long it would take to learn it and train the team on it.

The upfront cost can be easier to measure. But the recurring ones are much harder to predict. What happens when a maintainer moves on from a package that everyone uses, and the speed it takes to find a new maintainer is not as fast as you need it to be?

Boring tools are ones where the recurring cost of maintenance is as close to zero as you can get it. If you come back to it in eight months, it should work the way you remember it. This is a fairy tale that we tell ourselves, that nothing ever changes and we can control that change.

Is boring even possible in the age of AI, when it feels like everyone has their own particle beam cannon that they can point at your codebase?

I think it&#39;s worth talking about what I mean by boring, because it can be used as a synonym for old, but that&#39;s not what I mean. Boring means predictability.

Take all your npm packages. Can you answer these questions about all of them? Probably not.

- **How often do the release notes contain the word &#34;breaking&#34;?** Skim a year of them. This is the single best signal available and it takes ten minutes.
- **How many people can merge?** One is a risk regardless of how good that one person is. People change jobs, burn out, and lose interest.
- **What happens to old versions?** A project that supports the previous major for a while is telling you something about how it thinks about your time.
- **Can you read the source?** Not all of it. Enough to fix something yourself when you&#39;re blocked and nobody&#39;s answering.

Learn the new tool. Experiment. Try new things. Stay passionate about software. Just because you can use the new thing doesn&#39;t mean you should.

Don&#39;t always pick the boring option, just like you don&#39;t always pick the new option. It takes wisdom to know what the right answer is.

You have to understand your failure modes, and when it&#39;s an appropriate time to take a risk, and the scale of the risk.

Pick boring for the parts you don&#39;t want to think about. Save the interesting decisions for the places where being interesting is the point.

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>The Best Automation Has a Manual Escape Hatch</title>
      <link>https://llbbl.blog/2026/08/09/the-best-automation-has-a.html</link>
      <pubDate>Sun, 09 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/09/the-best-automation-has-a.html</guid>
      <description>&lt;p&gt;Automation earns trust by being easy to override, not by being impossible to question.&lt;/p&gt;
&lt;p&gt;That sounds backwards. The pitch for automating something is usually that it removes the human, and a system you keep reaching into feels like a system that didn&amp;rsquo;t finish the job. But the automation you actually trust, over years, is the one you know you can stop.&lt;/p&gt;
&lt;p&gt;Most automation that you set up is enforcing some sort of policy, and that&amp;rsquo;s right most of the time, but not always.&lt;/p&gt;
&lt;p&gt;The mistake isn&amp;rsquo;t automating a default way of working. It&amp;rsquo;s building a system where the default is ingrained so deeply that there&amp;rsquo;s no way out of it.&lt;/p&gt;
&lt;p&gt;The automation must be flexible. You must be able to adapt the automation as the requirements change.&lt;/p&gt;
&lt;p&gt;Do you have contingency plans on what to do if the automation fails?&lt;/p&gt;
&lt;p&gt;Now I&amp;rsquo;m not talking about how to get around the automation, or always forcing an outcome that disables the automation. Instead, I&amp;rsquo;m talking about what a real escape hatch looks like.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It&amp;rsquo;s one operation.&lt;/strong&gt; You run a command. You don&amp;rsquo;t perform a sequence of five steps where forgetting the third leaves things inconsistent.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It maintains the invariants.&lt;/strong&gt; This is the big one. When I override a post&amp;rsquo;s date, the file and the database both get updated. If the override only touched one of them, I&amp;rsquo;d have created a split-brain problem in the name of fixing a scheduling problem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It&amp;rsquo;s discoverable.&lt;/strong&gt; It shows up in the help output next to everything else. An escape hatch nobody knows about is not a feature, it&amp;rsquo;s trivia.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It&amp;rsquo;s supported, not tolerated.&lt;/strong&gt; It has tests. It survives refactors. Nobody has to feel clever for using it.&lt;/p&gt;
&lt;p&gt;If your answer to &amp;ldquo;what if the automation is wrong&amp;rdquo; is &amp;ldquo;go around it manually,&amp;rdquo; you don&amp;rsquo;t have a hatch. You have a hazard with a tradition attached.&lt;/p&gt;
&lt;p&gt;If you design the escape hatch first, it forces a question that&amp;rsquo;s worth thinking about. At least what happens when the automation is wrong. What are your plans to do something about it?&lt;/p&gt;
&lt;h2 id=&#34;log-when-the-hatch-gets-used&#34;&gt;Log When the Hatch Gets Used&lt;/h2&gt;
&lt;p&gt;Don&amp;rsquo;t forget about the log. It&amp;rsquo;s not one that you should skip over. You should be logging when your escape hatch gets used, even if it only happens once a quarter.&lt;/p&gt;
&lt;p&gt;You probably don&amp;rsquo;t need to update your policy every time. But your escape hatch log is a good indication of when you might consider updating the policy.&lt;/p&gt;
&lt;p&gt;Building an escape hatch changes the risk. The worst case is not that the tool did something irreversible, but rather that the tool did something I fixed in one command.&lt;/p&gt;
&lt;p&gt;So build the hatch. Make it one command, make it maintain your invariants, put it in the help text, and count how often it gets pulled.&lt;/p&gt;
&lt;p&gt;The automation you trust isn&amp;rsquo;t the one that&amp;rsquo;s always right. It&amp;rsquo;s the one you know you can overrule.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>Automation earns trust by being easy to override, not by being impossible to question.

That sounds backwards. The pitch for automating something is usually that it removes the human, and a system you keep reaching into feels like a system that didn&#39;t finish the job. But the automation you actually trust, over years, is the one you know you can stop.

Most automation that you set up is enforcing some sort of policy, and that&#39;s right most of the time, but not always.

The mistake isn&#39;t automating a default way of working. It&#39;s building a system where the default is ingrained so deeply that there&#39;s no way out of it.

The automation must be flexible. You must be able to adapt the automation as the requirements change.

Do you have contingency plans on what to do if the automation fails?

Now I&#39;m not talking about how to get around the automation, or always forcing an outcome that disables the automation. Instead, I&#39;m talking about what a real escape hatch looks like.

**It&#39;s one operation.** You run a command. You don&#39;t perform a sequence of five steps where forgetting the third leaves things inconsistent.

**It maintains the invariants.** This is the big one. When I override a post&#39;s date, the file and the database both get updated. If the override only touched one of them, I&#39;d have created a split-brain problem in the name of fixing a scheduling problem.

**It&#39;s discoverable.** It shows up in the help output next to everything else. An escape hatch nobody knows about is not a feature, it&#39;s trivia.

**It&#39;s supported, not tolerated.** It has tests. It survives refactors. Nobody has to feel clever for using it.

If your answer to &#34;what if the automation is wrong&#34; is &#34;go around it manually,&#34; you don&#39;t have a hatch. You have a hazard with a tradition attached.

If you design the escape hatch first, it forces a question that&#39;s worth thinking about. At least what happens when the automation is wrong. What are your plans to do something about it?

## Log When the Hatch Gets Used

Don&#39;t forget about the log. It&#39;s not one that you should skip over. You should be logging when your escape hatch gets used, even if it only happens once a quarter.

You probably don&#39;t need to update your policy every time. But your escape hatch log is a good indication of when you might consider updating the policy.

Building an escape hatch changes the risk. The worst case is not that the tool did something irreversible, but rather that the tool did something I fixed in one command.

So build the hatch. Make it one command, make it maintain your invariants, put it in the help text, and count how often it gets pulled.

The automation you trust isn&#39;t the one that&#39;s always right. It&#39;s the one you know you can overrule.

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>Cost and Latency Belong in the Score</title>
      <link>https://llbbl.blog/2026/08/08/cost-and-latency-belong-in.html</link>
      <pubDate>Sat, 08 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/08/cost-and-latency-belong-in.html</guid>
      <description>&lt;p&gt;Congratulations, the best model available passed your eval. That&amp;rsquo;s not the question you should be answering. What other models could have achieved equivalent results? How much did it cost to run those other models? How long did it take for those other models to achieve equivalent results? All of these are the questions that you should be asking yourself.&lt;/p&gt;
&lt;p&gt;If the only thing you&amp;rsquo;re measuring is if the output is correct or not, then we already know the ranking. The bigger models are going to score higher on the majority of tasks. You don&amp;rsquo;t need an eval harness to answer that question. Quality scoring is only part of the answer that you actually care about. On every eval run, you should be recording at least three things per task.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Quality.&lt;/strong&gt; Whatever your pass or fail criteria are. This is the part you already have.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost.&lt;/strong&gt; What the run consumed. Tokens in, tokens out, and the price attached to them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Latency.&lt;/strong&gt; Wall clock, start to finish.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These aren&amp;rsquo;t particularly hard to capture. Cost and latency are usually right there in the response and you&amp;rsquo;re probably throwing them away. Latency is a hill to climb in its own right, given that it can vary depending on the reliability of the models. Latency at different times of day also matters more than at others. If the human operator is asleep, then latency matters less.&lt;/p&gt;
&lt;p&gt;Once you start recording these three dimensions, the promise is you&amp;rsquo;ll be able to more accurately answer the question: which model is best?&lt;/p&gt;
&lt;p&gt;I mean that&amp;rsquo;s the whole point of all this, right?&lt;/p&gt;
&lt;p&gt;What you&amp;rsquo;re really building is a system, or an attempt at a system, for predicting the output of a non-deterministic system.&lt;/p&gt;
&lt;p&gt;Good luck.&lt;/p&gt;
&lt;p&gt;A global spending cap for the month doesn&amp;rsquo;t answer the question: are you using the wrong model for particular tasks?&lt;/p&gt;
&lt;p&gt;Task routing is complicated. I can see it being a complex problem to try to solve.&lt;/p&gt;
&lt;p&gt;You&amp;rsquo;re going to find that cheap models win more often than you&amp;rsquo;d think. There&amp;rsquo;s a whole bunch of types of work where you don&amp;rsquo;t need a frontier model, where you&amp;rsquo;re just using the wrong tool for the task at hand. Think about reformatting, or extracting data from structured text, or classifying something into a bucket, or summarizing a document, or renaming things. All of these are narrow tasks that don&amp;rsquo;t require a lot of reasoning between the input and the output.&lt;/p&gt;
&lt;p&gt;One thing to try is to do it with the cheap one first and then escalate to a larger model if it fails. You&amp;rsquo;ll often find that scaling up is a lot easier than scaling down in terms of model intelligence.&lt;/p&gt;
&lt;p&gt;The takeaway here is that cost and latency belong in or alongside quality in an eval harness.&lt;/p&gt;
&lt;p&gt;Once you start recording all three, then you can start measuring and deciding between models.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>Congratulations, the best model available passed your eval. That&#39;s not the question you should be answering. What other models could have achieved equivalent results? How much did it cost to run those other models? How long did it take for those other models to achieve equivalent results? All of these are the questions that you should be asking yourself.

If the only thing you&#39;re measuring is if the output is correct or not, then we already know the ranking. The bigger models are going to score higher on the majority of tasks. You don&#39;t need an eval harness to answer that question. Quality scoring is only part of the answer that you actually care about. On every eval run, you should be recording at least three things per task.

- **Quality.** Whatever your pass or fail criteria are. This is the part you already have.
- **Cost.** What the run consumed. Tokens in, tokens out, and the price attached to them.
- **Latency.** Wall clock, start to finish.

These aren&#39;t particularly hard to capture. Cost and latency are usually right there in the response and you&#39;re probably throwing them away. Latency is a hill to climb in its own right, given that it can vary depending on the reliability of the models. Latency at different times of day also matters more than at others. If the human operator is asleep, then latency matters less.

Once you start recording these three dimensions, the promise is you&#39;ll be able to more accurately answer the question: which model is best?

I mean that&#39;s the whole point of all this, right?

What you&#39;re really building is a system, or an attempt at a system, for predicting the output of a non-deterministic system.

Good luck.

A global spending cap for the month doesn&#39;t answer the question: are you using the wrong model for particular tasks?

Task routing is complicated. I can see it being a complex problem to try to solve.

You&#39;re going to find that cheap models win more often than you&#39;d think. There&#39;s a whole bunch of types of work where you don&#39;t need a frontier model, where you&#39;re just using the wrong tool for the task at hand. Think about reformatting, or extracting data from structured text, or classifying something into a bucket, or summarizing a document, or renaming things. All of these are narrow tasks that don&#39;t require a lot of reasoning between the input and the output.

One thing to try is to do it with the cheap one first and then escalate to a larger model if it fails. You&#39;ll often find that scaling up is a lot easier than scaling down in terms of model intelligence.

The takeaway here is that cost and latency belong in or alongside quality in an eval harness.

Once you start recording all three, then you can start measuring and deciding between models.

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>Your Vibes Are Not an Agent Eval</title>
      <link>https://llbbl.blog/2026/08/07/your-vibes-are-not-an.html</link>
      <pubDate>Fri, 07 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/07/your-vibes-are-not-an.html</guid>
      <description>&lt;p&gt;Do you really know what you&amp;rsquo;re doing? You swapped a model, you tuned an agentic workflow, you had the agent rewrite a chunk of a system prompt. You added a skill. Now the output feels sharper. Is that feeling a measurement? No, it&amp;rsquo;s an impression. This is when the vibes start seeping into your agentic engineering world view.&lt;/p&gt;
&lt;h2 id=&#34;how-impressions-fail&#34;&gt;How Impressions Fail&lt;/h2&gt;
&lt;p&gt;A subjective assessment isn&amp;rsquo;t necessarily useless, since it&amp;rsquo;s how you can notice something is wrong in the first place, and it can be a good signal, or an early signal that leads to a corrective action.&lt;/p&gt;
&lt;p&gt;A few things here conspire to work against you.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Recency.&lt;/strong&gt; You remember the last three runs vividly and the forty before them not at all. If the last three happened to be easy tasks, the model got better. If they were gnarly, it got worse.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Confirmation.&lt;/strong&gt; You just spent an hour rewriting a prompt. You are not a neutral judge of whether that hour helped. Nobody is.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prompt drift.&lt;/strong&gt; This one is sneakier. You&amp;rsquo;re not asking the same thing you asked last month. Your prompts got better because &lt;em&gt;you&lt;/em&gt; got better at prompting, and that improvement gets silently credited to the model.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Task drift.&lt;/strong&gt; The work changed. You were doing greenfield scaffolding in June and you&amp;rsquo;re doing debugging in August. Those exercise completely different capabilities, and comparing across them tells you nothing.&lt;/p&gt;
&lt;p&gt;All of these will sneak up and bite you in the ass. A decent working knowledge of the system is not a measurement.&lt;/p&gt;
&lt;p&gt;The actual risk with an agentic workflow isn&amp;rsquo;t a sharp and dramatic decline in quality. It&amp;rsquo;s a slow regression over time as you start missing things that slip through the cracks when you&amp;rsquo;re not paying attention as closely as you should on that day.&lt;/p&gt;
&lt;p&gt;I talked about evals that are worth building in previous posts. You should go have a look at some examples there on how to get started.&lt;/p&gt;
&lt;p&gt;How do you test a harness? You need to separate the model from the harness. It turns out the harness changes frequently along with the model. Is it even worth testing the harness?&lt;/p&gt;
&lt;p&gt;A model swap tripwire is a good place to get started. A tripwire asks whether this specific change made things worse. It&amp;rsquo;s a binary operation. You run it before the swap, save the results, and run it again after the swap, and compare the results. Same task, same prompt, only the model changed.&lt;/p&gt;
&lt;p&gt;If you keep going on vibes, they will keep telling you things. That may or may not matter. At the end of the day, vibes are a decent smoke alarm, but make for a terrible way to measure quality.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>Do you really know what you&#39;re doing? You swapped a model, you tuned an agentic workflow, you had the agent rewrite a chunk of a system prompt. You added a skill. Now the output feels sharper. Is that feeling a measurement? No, it&#39;s an impression. This is when the vibes start seeping into your agentic engineering world view.

## How Impressions Fail

A subjective assessment isn&#39;t necessarily useless, since it&#39;s how you can notice something is wrong in the first place, and it can be a good signal, or an early signal that leads to a corrective action.

A few things here conspire to work against you.

**Recency.** You remember the last three runs vividly and the forty before them not at all. If the last three happened to be easy tasks, the model got better. If they were gnarly, it got worse.

**Confirmation.** You just spent an hour rewriting a prompt. You are not a neutral judge of whether that hour helped. Nobody is.

**Prompt drift.** This one is sneakier. You&#39;re not asking the same thing you asked last month. Your prompts got better because *you* got better at prompting, and that improvement gets silently credited to the model.

**Task drift.** The work changed. You were doing greenfield scaffolding in June and you&#39;re doing debugging in August. Those exercise completely different capabilities, and comparing across them tells you nothing.

All of these will sneak up and bite you in the ass. A decent working knowledge of the system is not a measurement.

The actual risk with an agentic workflow isn&#39;t a sharp and dramatic decline in quality. It&#39;s a slow regression over time as you start missing things that slip through the cracks when you&#39;re not paying attention as closely as you should on that day.

I talked about evals that are worth building in previous posts. You should go have a look at some examples there on how to get started.

How do you test a harness? You need to separate the model from the harness. It turns out the harness changes frequently along with the model. Is it even worth testing the harness?

A model swap tripwire is a good place to get started. A tripwire asks whether this specific change made things worse. It&#39;s a binary operation. You run it before the swap, save the results, and run it again after the swap, and compare the results. Same task, same prompt, only the model changed.

If you keep going on vibes, they will keep telling you things. That may or may not matter. At the end of the day, vibes are a decent smoke alarm, but make for a terrible way to measure quality.

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>Start With Ten Tasks You Actually Do</title>
      <link>https://llbbl.blog/2026/08/06/start-with-ten-tasks-you.html</link>
      <pubDate>Thu, 06 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/06/start-with-ten-tasks-you.html</guid>
      <description>&lt;p&gt;Public benchmarks of large language models are a fine way to compare models in the abstract, but they&amp;rsquo;re close to useless for answering questions about things that actually matter. Generally, it&amp;rsquo;s helpful to know which model is the best in general at a specific benchmark, but it doesn&amp;rsquo;t answer the question of which model is the best at that specific thing you ask it to do all day long.&lt;/p&gt;
&lt;p&gt;You need to build a golden set. Ten tasks where you understand the input and the output.&lt;/p&gt;
&lt;p&gt;Scoring the code is not the hard part. Picking the ten tasks is going to be the hardest thing. How do you pick something that has a true pass or fail, but also applies to your specific problem?&lt;/p&gt;
&lt;p&gt;The temptation is to sit down and try to come up with representative tasks. Chances are you&amp;rsquo;re going to waste a ton of time and not produce any better results.&lt;/p&gt;
&lt;p&gt;Instead, you should be harvesting your tasks from a variety of sources.&lt;/p&gt;
&lt;p&gt;What happens when the benchmark that matters is your last ten pull requests?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Git history.&lt;/strong&gt; What have you actually been changing? A month of commits will show you the shape of your work faster than introspection will.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Issues and TODOs.&lt;/strong&gt; These are tasks somebody already wrote down in task-shaped language.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prior agent sessions.&lt;/strong&gt; If you have logs, this is the best source, because it&amp;rsquo;s literally the distribution you&amp;rsquo;re trying to measure.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The things you retry.&lt;/strong&gt; Anything you&amp;rsquo;ve asked an agent twice because the first answer was wrong is a high-value task. It&amp;rsquo;s already demonstrated it can discriminate.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A good eval task is one where you already know what the failure is and that it&amp;rsquo;s possible, because you&amp;rsquo;ve seen it fail.&lt;/p&gt;
&lt;h2 id=&#34;scoreable-means-checkable&#34;&gt;Scoreable Means Checkable&lt;/h2&gt;
&lt;p&gt;So now you have your tasks, and it&amp;rsquo;s a different problem. You need to decide how to score the task and whether or not the agent got it right. This doesn&amp;rsquo;t necessarily mean you have to build automation from day one. Some good starting points that would qualify are the following:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tests pass or don&amp;rsquo;t&lt;/li&gt;
&lt;li&gt;Output parses or doesn&amp;rsquo;t&lt;/li&gt;
&lt;li&gt;The right files changed and no others&lt;/li&gt;
&lt;li&gt;A required field is present and well-formed&lt;/li&gt;
&lt;li&gt;The result matches a known-good output you saved earlier&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Keep the amount of comparisons small. Don&amp;rsquo;t expand and keep evaluating. You can keep your old evaluations, but they shouldn&amp;rsquo;t impact future decisions forever.&lt;/p&gt;
&lt;p&gt;Some amount of change in passing or failing over time is representative of a healthy set.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s how you can get started.&lt;/p&gt;
&lt;p&gt;Open your git log. Find things that the agent did well and things the agent could have done better. Write a definition of done.&lt;/p&gt;
&lt;p&gt;You now have a small golden set that&amp;rsquo;s going to be more relevant and useful than any leaderboard online, because it was built from the results of your actual work.&lt;/p&gt;
&lt;p&gt;The leaderboard tells you which model wins on average. You are not the average.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>Public benchmarks of large language models are a fine way to compare models in the abstract, but they&#39;re close to useless for answering questions about things that actually matter. Generally, it&#39;s helpful to know which model is the best in general at a specific benchmark, but it doesn&#39;t answer the question of which model is the best at that specific thing you ask it to do all day long.

You need to build a golden set. Ten tasks where you understand the input and the output.

Scoring the code is not the hard part. Picking the ten tasks is going to be the hardest thing. How do you pick something that has a true pass or fail, but also applies to your specific problem?

The temptation is to sit down and try to come up with representative tasks. Chances are you&#39;re going to waste a ton of time and not produce any better results.

Instead, you should be harvesting your tasks from a variety of sources.

What happens when the benchmark that matters is your last ten pull requests?

- **Git history.** What have you actually been changing? A month of commits will show you the shape of your work faster than introspection will.
- **Issues and TODOs.** These are tasks somebody already wrote down in task-shaped language.
- **Prior agent sessions.** If you have logs, this is the best source, because it&#39;s literally the distribution you&#39;re trying to measure.
- **The things you retry.** Anything you&#39;ve asked an agent twice because the first answer was wrong is a high-value task. It&#39;s already demonstrated it can discriminate.

A good eval task is one where you already know what the failure is and that it&#39;s possible, because you&#39;ve seen it fail.

## Scoreable Means Checkable

So now you have your tasks, and it&#39;s a different problem. You need to decide how to score the task and whether or not the agent got it right. This doesn&#39;t necessarily mean you have to build automation from day one. Some good starting points that would qualify are the following:

- Tests pass or don&#39;t
- Output parses or doesn&#39;t
- The right files changed and no others
- A required field is present and well-formed
- The result matches a known-good output you saved earlier

Keep the amount of comparisons small. Don&#39;t expand and keep evaluating. You can keep your old evaluations, but they shouldn&#39;t impact future decisions forever.

Some amount of change in passing or failing over time is representative of a healthy set.

Here&#39;s how you can get started.

Open your git log. Find things that the agent did well and things the agent could have done better. Write a definition of done.

You now have a small golden set that&#39;s going to be more relevant and useful than any leaderboard online, because it was built from the results of your actual work.

The leaderboard tells you which model wins on average. You are not the average.

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>`updated_at` Is Not a Conflict-Resolution Strategy</title>
      <link>https://llbbl.blog/2026/08/05/updatedat-is-not-a-conflictresolution.html</link>
      <pubDate>Wed, 05 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/05/updatedat-is-not-a-conflictresolution.html</guid>
      <description>&lt;p&gt;In the last post we talked about the problems with a distributed system, and touched on the fact that timestamps are not as reliable as you think they are.&lt;/p&gt;
&lt;p&gt;If you have two &lt;code&gt;updated_at&lt;/code&gt; fields and you compare them, how do you decide which side is the correct one?&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;updated_at&lt;/code&gt; field only tells you that a write happened. It doesn&amp;rsquo;t tell you the meaning, or if it was intentional. Conflict resolution is fundamentally a question about causality. Did side A intend for this change to happen? A wall clock timestamp can&amp;rsquo;t tell you the answer to that.&lt;/p&gt;
&lt;p&gt;Two independent clocks can drift, and will drift. Yes, it will get corrected by NTP occasionally. But you can&amp;rsquo;t always rely on their NTP service working. Timestamps are a fine signal that something occurred, and they&amp;rsquo;re a reasonable way for a human to sort a list and answer roughly when we think a change occurred. But if you use them as a foundation to decide what data to keep, you&amp;rsquo;re gonna end up destroying and losing data.&lt;/p&gt;
&lt;h2 id=&#34;things-that-actually-work&#34;&gt;Things That Actually Work&lt;/h2&gt;
&lt;p&gt;The good news is the alternatives are not exotic, and you don&amp;rsquo;t need all of them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Content hashes.&lt;/strong&gt; Hash the meaningful content and compare hashes instead of times. This kills the metadata-edit problem outright: if the hash matches, nothing changed, no matter what the timestamp claims. It&amp;rsquo;s the highest-value change on this list and usually the easiest, because it&amp;rsquo;s a pure function of data you already have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Version counters.&lt;/strong&gt; A monotonic integer per record, incremented on every meaningful write. Immune to clock skew entirely, because it isn&amp;rsquo;t a clock. The cost is that somebody has to own the increment, which is straightforward with a single authority and gets harder without one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sync checkpoints.&lt;/strong&gt; Record what was confirmed at the last successful sync, not just when it happened. Then the question becomes &amp;ldquo;has this changed since the last agreed state,&amp;rdquo; which is answerable, instead of &amp;ldquo;is this newer,&amp;rdquo; which is a guess.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Operation logs.&lt;/strong&gt; Store what happened rather than only the result. Heavier, but it&amp;rsquo;s the only option that lets you reconstruct intent after the fact, and it turns &amp;ldquo;which one wins&amp;rdquo; into a question you can actually audit.&lt;/p&gt;
&lt;p&gt;You can get most of the benefit from the first one. Hash the content, and let the timestamp go back to being a display field.&lt;/p&gt;
&lt;h2 id=&#34;when-last-write-wins-is-fine&#34;&gt;When Last-Write-Wins Is Fine&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;m not gonna lie, last write wins is often the correct engineering choice, and replacing it with something more complicated can be its own mistake. Sometimes it&amp;rsquo;s fine. If a write gets lost and the data is recoverable, that&amp;rsquo;s a trade you can live with.&lt;/p&gt;
&lt;p&gt;If it&amp;rsquo;s a simple tool without a ton of users, adding a lot of complexity is not the way to go.&lt;/p&gt;
&lt;p&gt;If the data is just a cache or a projection, then who cares? You can rebuild it from the authoritative source anyway.&lt;/p&gt;
&lt;h2 id=&#34;what-id-actually-do&#34;&gt;What I&amp;rsquo;d Actually Do&lt;/h2&gt;
&lt;p&gt;Keep &lt;code&gt;updated_at&lt;/code&gt;. It&amp;rsquo;s useful. Sort by it, display it, log it.&lt;/p&gt;
&lt;p&gt;Just stop letting it decide things. Add a content hash and check that first, so a no-op edit stays a no-op. If a field can be written from two sides independently, give it a version counter or an explicit authority rule, and write the rule down somewhere the next person will find it.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>In the last post we talked about the problems with a distributed system, and touched on the fact that timestamps are not as reliable as you think they are.

If you have two `updated_at` fields and you compare them, how do you decide which side is the correct one?

The `updated_at` field only tells you that a write happened. It doesn&#39;t tell you the meaning, or if it was intentional. Conflict resolution is fundamentally a question about causality. Did side A intend for this change to happen? A wall clock timestamp can&#39;t tell you the answer to that.

Two independent clocks can drift, and will drift. Yes, it will get corrected by NTP occasionally. But you can&#39;t always rely on their NTP service working. Timestamps are a fine signal that something occurred, and they&#39;re a reasonable way for a human to sort a list and answer roughly when we think a change occurred. But if you use them as a foundation to decide what data to keep, you&#39;re gonna end up destroying and losing data.

## Things That Actually Work

The good news is the alternatives are not exotic, and you don&#39;t need all of them.

**Content hashes.** Hash the meaningful content and compare hashes instead of times. This kills the metadata-edit problem outright: if the hash matches, nothing changed, no matter what the timestamp claims. It&#39;s the highest-value change on this list and usually the easiest, because it&#39;s a pure function of data you already have.

**Version counters.** A monotonic integer per record, incremented on every meaningful write. Immune to clock skew entirely, because it isn&#39;t a clock. The cost is that somebody has to own the increment, which is straightforward with a single authority and gets harder without one.

**Sync checkpoints.** Record what was confirmed at the last successful sync, not just when it happened. Then the question becomes &#34;has this changed since the last agreed state,&#34; which is answerable, instead of &#34;is this newer,&#34; which is a guess.

**Operation logs.** Store what happened rather than only the result. Heavier, but it&#39;s the only option that lets you reconstruct intent after the fact, and it turns &#34;which one wins&#34; into a question you can actually audit.

You can get most of the benefit from the first one. Hash the content, and let the timestamp go back to being a display field.

## When Last-Write-Wins Is Fine

I&#39;m not gonna lie, last write wins is often the correct engineering choice, and replacing it with something more complicated can be its own mistake. Sometimes it&#39;s fine. If a write gets lost and the data is recoverable, that&#39;s a trade you can live with.

If it&#39;s a simple tool without a ton of users, adding a lot of complexity is not the way to go.

If the data is just a cache or a projection, then who cares? You can rebuild it from the authoritative source anyway.

## What I&#39;d Actually Do

Keep `updated_at`. It&#39;s useful. Sort by it, display it, log it.

Just stop letting it decide things. Add a content hash and check that first, so a no-op edit stays a no-op. If a field can be written from two sides independently, give it a version counter or an explicit authority rule, and write the rule down somewhere the next person will find it.

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>The Moment You Add Sync, You Have a Distributed System</title>
      <link>https://llbbl.blog/2026/08/04/the-moment-you-add-sync.html</link>
      <pubDate>Tue, 04 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/04/the-moment-you-add-sync.html</guid>
      <description>&lt;p&gt;How do you keep two sets of data in sync? Like, by definition, you now have a distributed system.&lt;/p&gt;
&lt;p&gt;It could be something simple, syncing files or talking with a remote service somewhere. Maybe it&amp;rsquo;s not a lot of code. Initially, it might not feel like a distributed system, because there&amp;rsquo;s no cluster or consensus protocol. There&amp;rsquo;s no leader election system. You have multiple leaders that need to stay in sync.&lt;/p&gt;
&lt;p&gt;How do you maintain state between two independent systems, when their only connection is an over-the-network connection that&amp;rsquo;s allowed to fail?&lt;/p&gt;
&lt;p&gt;Sync can be a verb that you apply to the data on one side, but it&amp;rsquo;s also describing the negotiation that happens between two distributed systems.&lt;/p&gt;
&lt;p&gt;Here is the set of questions you have to answer if you are trying to build a distributed system that maintains sync.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What&amp;rsquo;s new here that isn&amp;rsquo;t there?&lt;/li&gt;
&lt;li&gt;What&amp;rsquo;s new there that isn&amp;rsquo;t here?&lt;/li&gt;
&lt;li&gt;What changed in both places since we last talked?&lt;/li&gt;
&lt;li&gt;What happens if we get halfway through and the connection dies?&lt;/li&gt;
&lt;li&gt;If I retry, do I create a duplicate?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All of these sound like problems from a paper you read about replicated state machines. Congratulations, they&amp;rsquo;re now your problem too.&lt;/p&gt;
&lt;p&gt;Just because the request succeeded doesn&amp;rsquo;t mean that the two systems now agree.&lt;/p&gt;
&lt;p&gt;The problems that you&amp;rsquo;re going to run into are either caused by or solved by a timestamp.&lt;/p&gt;
&lt;p&gt;Recovering from an error state is crucial for building a durable system.&lt;/p&gt;
&lt;h2 id=&#34;idempotency-is-the-cheapest-insurance-you-can-buy&#34;&gt;Idempotency Is the Cheapest Insurance You Can Buy&lt;/h2&gt;
&lt;p&gt;It is a guarantee, or pretty much a guarantee, that if your sync can be interrupted, it will be. Idempotency is how you ensure that the same request can be retried safely. If you run the same request twice, you either need to produce the same result or no result.&lt;/p&gt;
&lt;p&gt;Every item on both sides of the system needs its own stable identity that each side agrees on. The create needs to always be create-if-absent, basically an upsert.&lt;/p&gt;
&lt;p&gt;How do you decide which copy of the data has authority?&lt;/p&gt;
&lt;p&gt;When you start needing to do resolution logic, this is where your subtle data loss can occur. Your point-in-time recovery window is likely 30 days or less, and chances are you aren&amp;rsquo;t going to go back and check the old copy that is about to expire.&lt;/p&gt;
&lt;p&gt;Last write wins is the default because it&amp;rsquo;s easy, and it&amp;rsquo;s what everybody assumes. It works when there aren&amp;rsquo;t a whole lot of writes and when one side is clearly the primary. It breaks down when the data can&amp;rsquo;t be replayed safely.&lt;/p&gt;
&lt;p&gt;Do your timestamps actually mean what you think they mean? Can you trust time? It&amp;rsquo;s complicated to get correct. And if the difference between two timestamps is very small, and the drift is larger than the difference, problems occur.&lt;/p&gt;
&lt;p&gt;Things to look into for later: CRDTs, vector clocks, operational transforms.&lt;/p&gt;
&lt;p&gt;Chances are these are not the right answer for a personal tool that you&amp;rsquo;re building on the weekends. It&amp;rsquo;s good to have discipline and understand the solutions we&amp;rsquo;ve come up with for resolving synchronization problems. At the end of the day, you&amp;rsquo;re just gonna want something that works.&lt;/p&gt;
&lt;p&gt;You have a distributed system. It has one user and it runs on a laptop, but it has all the failure modes, and it doesn&amp;rsquo;t care that you didn&amp;rsquo;t mean to build one.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>How do you keep two sets of data in sync? Like, by definition, you now have a distributed system.

It could be something simple, syncing files or talking with a remote service somewhere. Maybe it&#39;s not a lot of code. Initially, it might not feel like a distributed system, because there&#39;s no cluster or consensus protocol. There&#39;s no leader election system. You have multiple leaders that need to stay in sync.

How do you maintain state between two independent systems, when their only connection is an over-the-network connection that&#39;s allowed to fail?

Sync can be a verb that you apply to the data on one side, but it&#39;s also describing the negotiation that happens between two distributed systems.

Here is the set of questions you have to answer if you are trying to build a distributed system that maintains sync.

- What&#39;s new here that isn&#39;t there?
- What&#39;s new there that isn&#39;t here?
- What changed in both places since we last talked?
- What happens if we get halfway through and the connection dies?
- If I retry, do I create a duplicate?

All of these sound like problems from a paper you read about replicated state machines. Congratulations, they&#39;re now your problem too.

Just because the request succeeded doesn&#39;t mean that the two systems now agree.

The problems that you&#39;re going to run into are either caused by or solved by a timestamp.

Recovering from an error state is crucial for building a durable system.

## Idempotency Is the Cheapest Insurance You Can Buy

It is a guarantee, or pretty much a guarantee, that if your sync can be interrupted, it will be. Idempotency is how you ensure that the same request can be retried safely. If you run the same request twice, you either need to produce the same result or no result.

Every item on both sides of the system needs its own stable identity that each side agrees on. The create needs to always be create-if-absent, basically an upsert.

How do you decide which copy of the data has authority?

When you start needing to do resolution logic, this is where your subtle data loss can occur. Your point-in-time recovery window is likely 30 days or less, and chances are you aren&#39;t going to go back and check the old copy that is about to expire.

Last write wins is the default because it&#39;s easy, and it&#39;s what everybody assumes. It works when there aren&#39;t a whole lot of writes and when one side is clearly the primary. It breaks down when the data can&#39;t be replayed safely.

Do your timestamps actually mean what you think they mean? Can you trust time? It&#39;s complicated to get correct. And if the difference between two timestamps is very small, and the drift is larger than the difference, problems occur.

Things to look into for later: CRDTs, vector clocks, operational transforms.

Chances are these are not the right answer for a personal tool that you&#39;re building on the weekends. It&#39;s good to have discipline and understand the solutions we&#39;ve come up with for resolving synchronization problems. At the end of the day, you&#39;re just gonna want something that works.

You have a distributed system. It has one user and it runs on a laptop, but it has all the failure modes, and it doesn&#39;t care that you didn&#39;t mean to build one.

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>Your Local File Should Not Have to Argue With Your Database</title>
      <link>https://llbbl.blog/2026/08/03/your-local-file-should-not.html</link>
      <pubDate>Mon, 03 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/03/your-local-file-should-not.html</guid>
      <description>&lt;p&gt;Sync bugs usually all start the same way. Two copies of something, both of them &lt;em&gt;mostly&lt;/em&gt; right, and no written rule about which one wins.&lt;/p&gt;
&lt;p&gt;The problems occur when you don&amp;rsquo;t notice The bug. When the file says one thing and the database says another. It&amp;rsquo;s not a problem until it is. And then you have to spend time figuring the why and when&amp;rsquo;s of the drift.&lt;/p&gt;
&lt;p&gt;So let&amp;rsquo;s talk about authority. Not storage, not sync, not &amp;ldquo;where does the data live.&amp;rdquo; Authority. Which copy is allowed to be right when two copies disagree.&lt;/p&gt;
&lt;h2 id=&#34;the-question-nobody-writes-down&#34;&gt;The Question Nobody Writes Down&lt;/h2&gt;
&lt;p&gt;Most systems that hold the same data in two places never actually decide this. The decision gets made accidentally, by whichever code path happened to run last, and then it gets re-made differently by the next feature.&lt;/p&gt;
&lt;p&gt;The failure is duplication without a stated rule.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s a concrete version. I run a content pipeline for this blog. Posts are Markdown files with YAML frontmatter sitting in a directory. There&amp;rsquo;s also a Turso database holding metadata about those same posts. Two copies of what looks like the same information.&lt;/p&gt;
&lt;p&gt;Ask the naive question, &amp;ldquo;which one is the source of truth,&amp;rdquo; and you get a bad answer, because the honest answer is &lt;em&gt;neither, and both, depending on the field&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id=&#34;split-authority-by-field-not-by-store&#34;&gt;Split Authority by Field, Not by Store&lt;/h2&gt;
&lt;p&gt;You might try to pick an authoritative source based on &lt;em&gt;store&lt;/em&gt;. Files win, or the database wins. But the useful granularity is usually the field.&lt;/p&gt;
&lt;p&gt;In my pipeline it breaks down like this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Post content and tags: the Markdown file wins.&lt;/strong&gt; The frontmatter is authoritative. If the database has a different tag list, the database is wrong, and it gets rebuilt from the file.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scheduling: the database wins.&lt;/strong&gt; What time a post goes out, what slot it holds, whether it&amp;rsquo;s been claimed. The file does not get a vote.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those are different answers for the same post, and that&amp;rsquo;s fine, because each one is written down and each one has a reason.&lt;/p&gt;
&lt;p&gt;The content lives in the file because content is the thing I edit by hand, in an editor, with Git history behind it. I want &lt;code&gt;git log&lt;/code&gt; to be the real record of what changed. Putting that in a database would mean my writing history lives somewhere that is harder to access.&lt;/p&gt;
&lt;p&gt;The schedule lives in the database because scheduling is a &lt;em&gt;coordination&lt;/em&gt; problem. It needs uniqueness constraints, it needs to answer &amp;ldquo;what&amp;rsquo;s in the 10am slot on Tuesday,&amp;rdquo; and it needs to do that without me parsing 241 files. A database is genuinely better at that. It just isn&amp;rsquo;t better at holding prose.&lt;/p&gt;
&lt;h2 id=&#34;a-database-can-be-useful-without-being-authoritative&#34;&gt;A Database Can Be Useful Without Being Authoritative&lt;/h2&gt;
&lt;p&gt;I think there&amp;rsquo;s a reflex where adding a database feels like promoting the data into it. You put the posts in Postgres and now Postgres is where posts &lt;em&gt;are&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;It doesn&amp;rsquo;t have to work that way. A database can be a query layer over data that lives somewhere else, and that&amp;rsquo;s a completely respectable job. Indexes, joins, counts, &amp;ldquo;show me every post tagged local-first published before June.&amp;rdquo; All of that is worth having, and none of it requires the database to be the authority.&lt;/p&gt;
&lt;p&gt;The test I use: &lt;strong&gt;if I deleted the database right now, what would I lose forever?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For me, it would be the scheduling state because that&amp;rsquo;s what I put in the database. The important thing is I wouldn&amp;rsquo;t lose a single word that I&amp;rsquo;ve written. Every post would still be in a directory. This choice is deliberate.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s easy for the database to become a Cache and not an authoritative source.&lt;/p&gt;
&lt;h2 id=&#34;what-should-happen-when-they-disagree&#34;&gt;What Should Happen When They Disagree&lt;/h2&gt;
&lt;p&gt;If you have documented your authoritative source, then the disagreements stops becoming a crisis, and it just is a routine. Resolution event&lt;/p&gt;
&lt;p&gt;You should be able to rebuild it. There should be nothing to decide. The decision is documented and how you resolve conflicts. Just depends on. Which authoritative source owns Which s segment of your data?&lt;/p&gt;
&lt;p&gt;In my case, there&amp;rsquo;s actually a third authoritative source, and that&amp;rsquo;s the remote blog system that hands back an ID every time I schedule a new post.&lt;/p&gt;
&lt;p&gt;So, this is totally fine if you pick the authority at the field level and Document that decision to prevent trip-ups in the future.&lt;/p&gt;
&lt;p&gt;Your files and your database shouldn&amp;rsquo;t be arguing, all it requires is a bit of planning.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>Sync bugs usually all start the same way. Two copies of something, both of them *mostly* right, and no written rule about which one wins.

The problems occur when you don&#39;t notice The bug. When the file says one thing and the database says another. It&#39;s not a problem until it is. And then you have to spend time figuring the why and when&#39;s of the drift.

So let&#39;s talk about authority. Not storage, not sync, not &#34;where does the data live.&#34; Authority. Which copy is allowed to be right when two copies disagree.

## The Question Nobody Writes Down

Most systems that hold the same data in two places never actually decide this. The decision gets made accidentally, by whichever code path happened to run last, and then it gets re-made differently by the next feature.

The failure is duplication without a stated rule.

Here&#39;s a concrete version. I run a content pipeline for this blog. Posts are Markdown files with YAML frontmatter sitting in a directory. There&#39;s also a Turso database holding metadata about those same posts. Two copies of what looks like the same information.

Ask the naive question, &#34;which one is the source of truth,&#34; and you get a bad answer, because the honest answer is *neither, and both, depending on the field*.

## Split Authority by Field, Not by Store

You might try to pick an authoritative source based on *store*. Files win, or the database wins. But the useful granularity is usually the field.

In my pipeline it breaks down like this:

- **Post content and tags: the Markdown file wins.** The frontmatter is authoritative. If the database has a different tag list, the database is wrong, and it gets rebuilt from the file.
- **Scheduling: the database wins.** What time a post goes out, what slot it holds, whether it&#39;s been claimed. The file does not get a vote.

Those are different answers for the same post, and that&#39;s fine, because each one is written down and each one has a reason.

The content lives in the file because content is the thing I edit by hand, in an editor, with Git history behind it. I want `git log` to be the real record of what changed. Putting that in a database would mean my writing history lives somewhere that is harder to access.

The schedule lives in the database because scheduling is a *coordination* problem. It needs uniqueness constraints, it needs to answer &#34;what&#39;s in the 10am slot on Tuesday,&#34; and it needs to do that without me parsing 241 files. A database is genuinely better at that. It just isn&#39;t better at holding prose.

## A Database Can Be Useful Without Being Authoritative

I think there&#39;s a reflex where adding a database feels like promoting the data into it. You put the posts in Postgres and now Postgres is where posts *are*.

It doesn&#39;t have to work that way. A database can be a query layer over data that lives somewhere else, and that&#39;s a completely respectable job. Indexes, joins, counts, &#34;show me every post tagged local-first published before June.&#34; All of that is worth having, and none of it requires the database to be the authority.

The test I use: **if I deleted the database right now, what would I lose forever?**

For me, it would be the scheduling state because that&#39;s what I put in the database. The important thing is I wouldn&#39;t lose a single word that I&#39;ve written. Every post would still be in a directory. This choice is deliberate.

It&#39;s easy for the database to become a Cache and not an authoritative source.

## What Should Happen When They Disagree

If you have documented your authoritative source, then the disagreements stops becoming a crisis, and it just is a routine. Resolution event

You should be able to rebuild it. There should be nothing to decide. The decision is documented and how you resolve conflicts. Just depends on. Which authoritative source owns Which s segment of your data?

In my case, there&#39;s actually a third authoritative source, and that&#39;s the remote blog system that hands back an ID every time I schedule a new post.

So, this is totally fine if you pick the authority at the field level and Document that decision to prevent trip-ups in the future.

Your files and your database shouldn&#39;t be arguing, all it requires is a bit of planning.

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>The Decision Log: A Lightweight Artifact for Agentic Coding</title>
      <link>https://llbbl.blog/2026/08/02/the-decision-log-a-lightweight.html</link>
      <pubDate>Sun, 02 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/02/the-decision-log-a-lightweight.html</guid>
      <description>&lt;p&gt;Coding agents are remarkably good at reopening decisions you already made.&lt;/p&gt;
&lt;p&gt;Imagine a content pipeline where posts live as local Markdown files and a database holds the scheduling metadata. You open a fresh session. Which one does the agent think is authoritative?&lt;/p&gt;
&lt;p&gt;It has to guess. And the database looks like the better answer, because databases usually are. So it proposes the obvious cleanup: make the database the source of truth and treat the files as an export format.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s wrong, for a reason the code never states. You can rebuild the database from the files. You cannot rebuild the files from the database. Drop the database and you re-index from markdown, losing some operational history on the way. Lose the markdown and the posts are gone.&lt;/p&gt;
&lt;p&gt;Some questions are still better answered by the database, scheduling among them. But the authoritative copy is whichever one you could regenerate the other from, and that asymmetry is the entire argument. It appears nowhere in the schema, nowhere in the file layout, and nowhere in the diff.&lt;/p&gt;
&lt;p&gt;So you explain it. The agent takes the point immediately, drops the idea, and gets on with the actual work. Good outcome.&lt;/p&gt;
&lt;p&gt;Then the session ends, and the next one arrives with the same instincts and the same blank slate. Nothing&amp;rsquo;s wrong with its reasoning. It&amp;rsquo;s missing the one piece of context the repository doesn&amp;rsquo;t contain: &lt;strong&gt;that road has already been walked.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s what a decision log is for.&lt;/p&gt;
&lt;h2 id=&#34;issues-and-skills-leave-a-gap&#34;&gt;Issues and Skills Leave a Gap&lt;/h2&gt;
&lt;p&gt;Agentic projects tend to accumulate two useful artifacts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Issues&lt;/strong&gt; describe work. Add a field, fix the retry logic, move the cache, update the API client. They tell an agent what needs to change and, if the issue is any good, what done looks like.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Skills and project instructions&lt;/strong&gt; describe process. Use this package manager. Run these tests. Never call this destructive command. They tell an agent how work should happen, repeatedly.&lt;/p&gt;
&lt;p&gt;Both matter. Neither is a natural home for, &amp;ldquo;We considered Redis, rejected it because this service has to run without another dependency, and we&amp;rsquo;ll reconsider if the process moves to multiple instances.&amp;rdquo;&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;Artifact&lt;/th&gt;
          &lt;th&gt;The question it answers&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;Issue&lt;/td&gt;
          &lt;td&gt;What needs to change?&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Skill or instruction&lt;/td&gt;
          &lt;td&gt;How should work be done?&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Decision log&lt;/td&gt;
          &lt;td&gt;Why this path instead of another one?&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The code records the result. Git records the diff. A closed issue might contain the discussion, if somebody thinks to go looking for it. The decision log keeps the conclusion and the rejected alternatives somewhere an agent can find them &lt;em&gt;before&lt;/em&gt; it starts planning.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-rejected-option-is-the-important-part&#34;&gt;The Rejected Option Is the Important Part&lt;/h2&gt;
&lt;p&gt;I wrote recently about &lt;a href=&#34;https://llbbl.blog/2026/07/21/code-is-cheap-now-decisions.html&#34;&gt;decision debt&lt;/a&gt;, the gap created when code gets produced faster than anyone records what it means or why it exists. A decision log is one small way to pay that debt as you go.&lt;/p&gt;
&lt;p&gt;The useful part isn&amp;rsquo;t &amp;ldquo;We chose SQLite.&amp;rdquo; The repository already contains a SQLite database. The useful part is why SQLite won, which alternatives lost, and what would have to change before the decision should be reopened.&lt;/p&gt;
&lt;p&gt;Without that negative history, an agent sees an absence and treats it as an oversight. No Redis? Add Redis. No abstraction around this HTTP client? Generate one. No Kubernetes deployment? Surely the project just hasn&amp;rsquo;t matured enough yet.&lt;/p&gt;
&lt;p&gt;Sometimes the missing thing is missing on purpose.&lt;/p&gt;
&lt;p&gt;Humans do this too, of course. We reopen old arguments when the people who remember them leave, or when the conclusion is buried in a meeting recording. Agents just compress the cycle. Every fresh session is a new developer joining the project with excellent technical instincts and absolutely no institutional memory.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;keep-the-entry-small&#34;&gt;Keep the Entry Small&lt;/h2&gt;
&lt;p&gt;Architecture Decision Records have been around since Michael Nygard described the pattern in 2011. They preserve the status, context, decision, and consequences of an important architectural choice. Lightweight templates like MADR also capture the options considered, the decision drivers, and why one option won.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the right idea. I just don&amp;rsquo;t need a formal architecture record for every consequential choice in a personal project.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;d start with some sort of log file, holding entries like this:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-markdown&#34; data-lang=&#34;markdown&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;## 2026-07-26: Keep Markdown as the publishing source of truth
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;font-weight:bold&#34;&gt;**Context:**&lt;/span&gt; Posts exist as local files and as database records.
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;font-weight:bold&#34;&gt;**Decision:**&lt;/span&gt; Frontmatter controls publish state. The database owns
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;scheduling metadata and is not authoritative for publishing.
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;font-weight:bold&#34;&gt;**Rejected:**&lt;/span&gt; Making the database authoritative, or newest-write-wins.
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;font-weight:bold&#34;&gt;**Why:**&lt;/span&gt; The database can be rebuilt from the files. The files cannot be
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;rebuilt from the database.
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;font-weight:bold&#34;&gt;**Revisit when:**&lt;/span&gt; Editing moves to a multi-user hosted application.
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;font-weight:bold&#34;&gt;**Links:**&lt;/span&gt; the repository module, the publishing documentation.
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s the whole artifact.&lt;/p&gt;
&lt;p&gt;An agent that reads that before it starts planning knows the database-as-source-of-truth idea isn&amp;rsquo;t a fresh insight. It also knows exactly what would have to change before it becomes one again.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ll be honest that I haven&amp;rsquo;t started doing this yet, but it sounds like a good idea right?&lt;/p&gt;
&lt;p&gt;The entry doesn&amp;rsquo;t need a transcript of the debate. It needs enough context for a future person or agent to understand that the alternative was considered, why it lost, and which changed condition would make it worth discussing again.&lt;/p&gt;
&lt;h2 id=&#34;what-belongs-in-the-log&#34;&gt;What Belongs in the Log&lt;/h2&gt;
&lt;p&gt;If every choice becomes an entry, the log turns into another file nobody reads. I&amp;rsquo;d record a decision when at least one of these is true:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Two or more reasonable approaches existed&lt;/li&gt;
&lt;li&gt;A future agent is likely to propose the rejected option again&lt;/li&gt;
&lt;li&gt;The choice establishes a source of truth, security boundary, schema, dependency, or workflow&lt;/li&gt;
&lt;li&gt;Reversing it later would be expensive or dangerous&lt;/li&gt;
&lt;li&gt;The reason isn&amp;rsquo;t obvious from the code&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Don&amp;rsquo;t log naming arguments, routine implementation details, or every library function you picked. &amp;ldquo;Used a dictionary here&amp;rdquo; isn&amp;rsquo;t institutional knowledge. &amp;ldquo;Kept provider integrations on raw HTTP because the SDK doesn&amp;rsquo;t support the endpoint we need&amp;rdquo; might be.&lt;/p&gt;
&lt;p&gt;Major decisions can still become full ADRs in &lt;code&gt;docs/decisions/&lt;/code&gt; or whatever. The lightweight log is for the big middle ground between an architectural record and a comment somebody vaguely remembers leaving on a pull request.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;make-agents-read-it-at-the-right-time&#34;&gt;Make Agents Read It at the Right Time&lt;/h2&gt;
&lt;p&gt;Creating the file isn&amp;rsquo;t enough. The agent needs a retrieval rule. One sentence in the project instructions:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-markdown&#34; data-lang=&#34;markdown&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;Before proposing changes to architecture, dependencies, data ownership,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;security boundaries, or core workflows, search &lt;span style=&#34;color:#e6db74&#34;&gt;`docs/decisions.md`&lt;/span&gt; for
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;related decisions and revisit conditions.
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then the other half:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-markdown&#34; data-lang=&#34;markdown&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;After a consequential decision is approved, propose a short decision-log
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;entry. Do not record a new project policy without human confirmation.
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The instruction defines the recurring behavior. The log supplies the project-specific facts. That keeps settled choices out of a giant instruction file while still putting them in the agent&amp;rsquo;s path when they matter.&lt;/p&gt;
&lt;p&gt;When a decision changes, don&amp;rsquo;t quietly rewrite history. Add a new entry that supersedes the old one and say which revisit condition actually showed up. Version control preserves the edit, but the document should make the change legible without requiring repository archaeology.&lt;/p&gt;
&lt;h2 id=&#34;not-another-memory-system&#34;&gt;Not Another Memory System&lt;/h2&gt;
&lt;p&gt;A chat transcript contains every false start, tool result, and half-formed idea. It&amp;rsquo;s too noisy to act as a project constitution. Agent memory can help retrieve past context, but it might be private to one tool, unavailable to a collaborator, or hard to review in a pull request.&lt;/p&gt;
&lt;p&gt;A decision log needs to be deliberately boring. Plain text. Searchable. Reviewable. Stored next to the code, or accessible by it. Any human or agent can read the same entry and argue with it in the open.&lt;/p&gt;
&lt;p&gt;None of this guarantees an agent will make the right call. It removes one wasteful failure mode: spending another session rediscovering a settled tradeoff and confidently proposing the option the project already rejected.&lt;/p&gt;
&lt;p&gt;Issues tell the agent where to go. Skills tell it how to move. The decision log marks the roads we already closed, why we closed them, and when they might be worth opening again.&lt;/p&gt;
&lt;p&gt;That feels like context worth keeping.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://www.cognitect.com/blog/2011/11/15/documenting-architecture-decisions&#34;&gt;Documenting Architecture Decisions&lt;/a&gt; — Michael Nygard&amp;rsquo;s original 2011 ADR proposal.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://adr.github.io/madr/&#34;&gt;MADR&lt;/a&gt; — a lightweight decision-record format covering options, rationale, consequences, and revisit conditions.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://gds-way.digital.cabinet-office.gov.uk/standards/architecture-decisions.html&#34;&gt;The GDS Way: Documenting architecture decisions&lt;/a&gt; — keeping decision rationale in the repository while using issues to track implementation.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://code.claude.com/docs/en/how-claude-code-works&#34;&gt;How Claude Code works&lt;/a&gt; — fresh session context, compaction, project instructions, and persistent memory.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>Coding agents are remarkably good at reopening decisions you already made.

Imagine a content pipeline where posts live as local Markdown files and a database holds the scheduling metadata. You open a fresh session. Which one does the agent think is authoritative?

It has to guess. And the database looks like the better answer, because databases usually are. So it proposes the obvious cleanup: make the database the source of truth and treat the files as an export format.

It&#39;s wrong, for a reason the code never states. You can rebuild the database from the files. You cannot rebuild the files from the database. Drop the database and you re-index from markdown, losing some operational history on the way. Lose the markdown and the posts are gone.

Some questions are still better answered by the database, scheduling among them. But the authoritative copy is whichever one you could regenerate the other from, and that asymmetry is the entire argument. It appears nowhere in the schema, nowhere in the file layout, and nowhere in the diff.

So you explain it. The agent takes the point immediately, drops the idea, and gets on with the actual work. Good outcome.

Then the session ends, and the next one arrives with the same instincts and the same blank slate. Nothing&#39;s wrong with its reasoning. It&#39;s missing the one piece of context the repository doesn&#39;t contain: **that road has already been walked.**

That&#39;s what a decision log is for.

## Issues and Skills Leave a Gap

Agentic projects tend to accumulate two useful artifacts.

**Issues** describe work. Add a field, fix the retry logic, move the cache, update the API client. They tell an agent what needs to change and, if the issue is any good, what done looks like.

**Skills and project instructions** describe process. Use this package manager. Run these tests. Never call this destructive command. They tell an agent how work should happen, repeatedly.

Both matter. Neither is a natural home for, &#34;We considered Redis, rejected it because this service has to run without another dependency, and we&#39;ll reconsider if the process moves to multiple instances.&#34;

| Artifact | The question it answers |
| --- | --- |
| Issue | What needs to change? |
| Skill or instruction | How should work be done? |
| Decision log | Why this path instead of another one? |

The code records the result. Git records the diff. A closed issue might contain the discussion, if somebody thinks to go looking for it. The decision log keeps the conclusion and the rejected alternatives somewhere an agent can find them *before* it starts planning.

---

## The Rejected Option Is the Important Part

I wrote recently about [decision debt](https://llbbl.blog/2026/07/21/code-is-cheap-now-decisions.html), the gap created when code gets produced faster than anyone records what it means or why it exists. A decision log is one small way to pay that debt as you go.

The useful part isn&#39;t &#34;We chose SQLite.&#34; The repository already contains a SQLite database. The useful part is why SQLite won, which alternatives lost, and what would have to change before the decision should be reopened.

Without that negative history, an agent sees an absence and treats it as an oversight. No Redis? Add Redis. No abstraction around this HTTP client? Generate one. No Kubernetes deployment? Surely the project just hasn&#39;t matured enough yet.

Sometimes the missing thing is missing on purpose.

Humans do this too, of course. We reopen old arguments when the people who remember them leave, or when the conclusion is buried in a meeting recording. Agents just compress the cycle. Every fresh session is a new developer joining the project with excellent technical instincts and absolutely no institutional memory.

---

## Keep the Entry Small

Architecture Decision Records have been around since Michael Nygard described the pattern in 2011. They preserve the status, context, decision, and consequences of an important architectural choice. Lightweight templates like MADR also capture the options considered, the decision drivers, and why one option won.

That&#39;s the right idea. I just don&#39;t need a formal architecture record for every consequential choice in a personal project.

I&#39;d start with some sort of log file, holding entries like this:

```markdown
## 2026-07-26: Keep Markdown as the publishing source of truth

**Context:** Posts exist as local files and as database records.
**Decision:** Frontmatter controls publish state. The database owns
scheduling metadata and is not authoritative for publishing.
**Rejected:** Making the database authoritative, or newest-write-wins.
**Why:** The database can be rebuilt from the files. The files cannot be
rebuilt from the database.
**Revisit when:** Editing moves to a multi-user hosted application.
**Links:** the repository module, the publishing documentation.
```

That&#39;s the whole artifact.

An agent that reads that before it starts planning knows the database-as-source-of-truth idea isn&#39;t a fresh insight. It also knows exactly what would have to change before it becomes one again.

I&#39;ll be honest that I haven&#39;t started doing this yet, but it sounds like a good idea right?

The entry doesn&#39;t need a transcript of the debate. It needs enough context for a future person or agent to understand that the alternative was considered, why it lost, and which changed condition would make it worth discussing again.

## What Belongs in the Log

If every choice becomes an entry, the log turns into another file nobody reads. I&#39;d record a decision when at least one of these is true:

- Two or more reasonable approaches existed
- A future agent is likely to propose the rejected option again
- The choice establishes a source of truth, security boundary, schema, dependency, or workflow
- Reversing it later would be expensive or dangerous
- The reason isn&#39;t obvious from the code

Don&#39;t log naming arguments, routine implementation details, or every library function you picked. &#34;Used a dictionary here&#34; isn&#39;t institutional knowledge. &#34;Kept provider integrations on raw HTTP because the SDK doesn&#39;t support the endpoint we need&#34; might be.

Major decisions can still become full ADRs in `docs/decisions/` or whatever. The lightweight log is for the big middle ground between an architectural record and a comment somebody vaguely remembers leaving on a pull request.

---

## Make Agents Read It at the Right Time

Creating the file isn&#39;t enough. The agent needs a retrieval rule. One sentence in the project instructions:

```markdown
Before proposing changes to architecture, dependencies, data ownership,
security boundaries, or core workflows, search `docs/decisions.md` for
related decisions and revisit conditions.
```

Then the other half:

```markdown
After a consequential decision is approved, propose a short decision-log
entry. Do not record a new project policy without human confirmation.
```

The instruction defines the recurring behavior. The log supplies the project-specific facts. That keeps settled choices out of a giant instruction file while still putting them in the agent&#39;s path when they matter.

When a decision changes, don&#39;t quietly rewrite history. Add a new entry that supersedes the old one and say which revisit condition actually showed up. Version control preserves the edit, but the document should make the change legible without requiring repository archaeology.

## Not Another Memory System

A chat transcript contains every false start, tool result, and half-formed idea. It&#39;s too noisy to act as a project constitution. Agent memory can help retrieve past context, but it might be private to one tool, unavailable to a collaborator, or hard to review in a pull request.

A decision log needs to be deliberately boring. Plain text. Searchable. Reviewable. Stored next to the code, or accessible by it. Any human or agent can read the same entry and argue with it in the open.

None of this guarantees an agent will make the right call. It removes one wasteful failure mode: spending another session rediscovering a settled tradeoff and confidently proposing the option the project already rejected.

Issues tell the agent where to go. Skills tell it how to move. The decision log marks the roads we already closed, why we closed them, and when they might be worth opening again.

That feels like context worth keeping.

## Sources

- [Documenting Architecture Decisions](https://www.cognitect.com/blog/2011/11/15/documenting-architecture-decisions) — Michael Nygard&#39;s original 2011 ADR proposal.
- [MADR](https://adr.github.io/madr/) — a lightweight decision-record format covering options, rationale, consequences, and revisit conditions.
- [The GDS Way: Documenting architecture decisions](https://gds-way.digital.cabinet-office.gov.uk/standards/architecture-decisions.html) — keeping decision rationale in the repository while using issues to track implementation.
- [How Claude Code works](https://code.claude.com/docs/en/how-claude-code-works) — fresh session context, compaction, project instructions, and persistent memory.

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
    <item>
      <title>Run Your Whole Agent Stack on a $5 Box</title>
      <link>https://llbbl.blog/2026/08/01/run-your-whole-agent-stack.html</link>
      <pubDate>Sat, 01 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/01/run-your-whole-agent-stack.html</guid>
      <description>&lt;p&gt;I SSH&amp;rsquo;d into my home server this afternoon and ran &lt;code&gt;docker stats&lt;/code&gt; on the memory layer that every one of my coding agent sessions talks to. Here&amp;rsquo;s what came back:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;mem0-qdrant    28.09MiB / 60.75GiB    2.13%
mem0-neo4j     612.7MiB / 60.75GiB    0.77%
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;640 megabytes.&lt;/strong&gt; Vector store and graph store, both up for three weeks straight, serving every &lt;code&gt;remember&lt;/code&gt; and &lt;code&gt;recall&lt;/code&gt; call my agents make. The entire persistent memory for my AI tooling uses less RAM than one Chrome tab with Figma open.&lt;/p&gt;
&lt;p&gt;So let&amp;rsquo;s talk about why you&amp;rsquo;re paying a monthly subscription for this.&lt;/p&gt;
&lt;h2 id=&#34;local-first-not-local-only&#34;&gt;Local-First, Not Local-Only&lt;/h2&gt;
&lt;p&gt;I want to be precise here, because &amp;ldquo;self-hosted AI&amp;rdquo; has become a phrase people use to mean nine different things.&lt;/p&gt;
&lt;p&gt;My setup is local-first, not local-only. The &lt;em&gt;state&lt;/em&gt; lives on my hardware. The memories, the embeddings, the graph relationships, everything my agents have learned about my projects, all of it sits on a box I own, in a Docker volume I can &lt;code&gt;tar&lt;/code&gt; and carry away. Nobody can deprecate it, price-hike it, or sunset it.&lt;/p&gt;
&lt;p&gt;The inference does not. My embedder points at Mistral&amp;rsquo;s managed API. I&amp;rsquo;ll get to why, and how to swap it, but I&amp;rsquo;m not going to pretend otherwise in a post about self-hosting.&lt;/p&gt;
&lt;p&gt;That embedder is the only thing that leaves my network, and only when something actually gets embedded, so when writing a memory and searching for one. Listing, deleting, and every graph operation are local with zero API calls.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;State is what you can&amp;rsquo;t get back. Compute is a commodity you rent by the token.&lt;/strong&gt; Losing access to an API means switching providers. Losing two years of accumulated project context means starting over.&lt;/p&gt;
&lt;h2 id=&#34;the-four-pieces&#34;&gt;The Four Pieces&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Qdrant&lt;/strong&gt; is semantic search. When I ask what it remembers about my package manager preferences, Qdrant turns that into a similarity query and hands back the relevant memories. It&amp;rsquo;s Rust, it&amp;rsquo;s fast, and at &lt;strong&gt;28MB resident&lt;/strong&gt; it&amp;rsquo;s essentially free to run. One gotcha: vector dimensions are fixed when the collection is created. Swap embedding models and you need a new collection, not a migration. I learned that the annoying way.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Neo4j&lt;/strong&gt; is the graph store. Vectors are great at &amp;ldquo;find me things that sound like this&amp;rdquo; and bad at &amp;ldquo;what depends on what.&amp;rdquo; The graph holds explicit subject-predicate-object facts, so &lt;code&gt;project X&lt;/code&gt; &lt;code&gt;built_with&lt;/code&gt; &lt;code&gt;Python 3.13&lt;/code&gt; is a traversable edge instead of a fuzzy match. It&amp;rsquo;s the heavy one at &lt;strong&gt;613MB&lt;/strong&gt;, but it&amp;rsquo;s a JVM, so that&amp;rsquo;s mostly heap floor rather than working set. If you&amp;rsquo;re squeezing onto the smallest possible VPS, interrogate this one first.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;mem0&lt;/strong&gt; is the orchestration on top: what gets extracted from a conversation, what gets deduped against existing memories, what gets written where. That&amp;rsquo;s the difference between a database and a memory system.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The MCP server&lt;/strong&gt; is what makes any of it useful. A small Go binary that speaks Model Context Protocol over stdio to Claude Code, exposing eight tools: &lt;code&gt;remember&lt;/code&gt;, &lt;code&gt;recall&lt;/code&gt;, &lt;code&gt;list_memories&lt;/code&gt;, &lt;code&gt;forget&lt;/code&gt;, &lt;code&gt;memory_stats&lt;/code&gt;, &lt;code&gt;add_relation&lt;/code&gt;, &lt;code&gt;recall_related&lt;/code&gt;, &lt;code&gt;forget_relation&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The topology is deliberately boring:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;Mac                                    Home server
┌──────────────┐   ┌──────────┐        ┌─────────────────┐
│ Claude Code  │◄─►│ mem0-mcp │  LAN   │ Qdrant + Neo4j  │
│              │   │ (Go)     │───────►│                 │
└──────────────┘   └──────────┘        └─────────────────┘
       stdio                    HTTP + bolt
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Client binary on my laptop, containers on a box. No cloud in the middle, no account, no dashboard, no seat license.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-ansible-role-is-the-whole-argument&#34;&gt;The Ansible Role Is the Whole Argument&lt;/h2&gt;
&lt;p&gt;Anyone can &lt;code&gt;docker compose up&lt;/code&gt; a stack once. That&amp;rsquo;s a weekend, not infrastructure. What makes this real is that it&amp;rsquo;s a role in a repo, and rebuilding it on a fresh box is one command:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;ansible-playbook -i common_hosts home.yml --tags mem0
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That role does the unglamorous work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Installs a read-only deploy key scoped to exactly one repo, with an SSH &lt;code&gt;Host&lt;/code&gt; alias so it can&amp;rsquo;t collide with my personal GitHub key&lt;/li&gt;
&lt;li&gt;Clones and updates the source at a pinned branch&lt;/li&gt;
&lt;li&gt;Templates a &lt;code&gt;.env&lt;/code&gt; with secrets pulled from Ansible Vault, &lt;code&gt;no_log: true&lt;/code&gt; so nothing leaks into terminal output on a &lt;code&gt;--diff&lt;/code&gt; run&lt;/li&gt;
&lt;li&gt;Brings up the compose stack with &lt;code&gt;remove_orphans: true&lt;/code&gt;, so when I dropped a service upstream, the stale container went with it instead of lingering forever&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Be careful with your secrets and how you are creating your .env files!&lt;/p&gt;
&lt;h2 id=&#34;the-honest-part-about-the-api-key&#34;&gt;The Honest Part About the API Key&lt;/h2&gt;
&lt;p&gt;I self-host the state and rent the inference. Two reasons.&lt;/p&gt;
&lt;p&gt;The first reason is speed. I ran embeddings locally before this, on CPU, and it was miserable: roughly 87 seconds to embed 32 memories, against about 2 seconds through a hosted API. That is a 45x difference on an operation sitting directly in the path of every &lt;code&gt;remember&lt;/code&gt; and &lt;code&gt;recall&lt;/code&gt;. A good model on a CPU is still a slow model, and this was never a quality problem.&lt;/p&gt;
&lt;p&gt;The second is that embeddings have gotten cheap enough that not worth the time to setup your own embedding service. &lt;a href=&#34;https://mistral.ai/pricing/api&#34;&gt;Mistral&lt;/a&gt; charges &lt;strong&gt;$0.10 per million tokens&lt;/strong&gt; for &lt;code&gt;mistral-embed&lt;/code&gt;. Google&amp;rsquo;s &lt;a href=&#34;https://ai.google.dev/gemini-api/docs/pricing&#34;&gt;&lt;code&gt;gemini-embedding-001&lt;/code&gt;&lt;/a&gt; is &lt;strong&gt;$0.15 per million&lt;/strong&gt;, halved on their batch API. Both are good models. Both bill you.&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://developers.cloudflare.com/workers-ai/platform/pricing/&#34;&gt;Cloudflare&lt;/a&gt; is the worth knowing about if you&amp;rsquo;d rather not pay at all. Workers AI includes &lt;strong&gt;10,000 neurons per day free&lt;/strong&gt;, on the free plan as well as the paid one. Neurons are their normalized compute unit, and &lt;code&gt;bge-m3&lt;/code&gt; costs 1,075 of them per million input tokens — so that daily allowance is roughly &lt;strong&gt;nine million tokens a day, at no cost.&lt;/strong&gt; Past it you&amp;rsquo;re at $0.012 per million, which is an order of magnitude under the paid competition. For a personal memory layer, nine million tokens a day is not a trial. It&amp;rsquo;s just free.&lt;/p&gt;
&lt;p&gt;One detail if you&amp;rsquo;re swapping: &lt;code&gt;bge-m3&lt;/code&gt; emits 1024-dimension vectors, the same as &lt;code&gt;mistral-embed&lt;/code&gt;. Go back to that Qdrant gotcha — matching dimensions means your existing collection still works. Mismatched ones mean starting over.&lt;/p&gt;
&lt;p&gt;And the escape hatch is already built. The env vars in my role are &lt;code&gt;TEI_BASE_URL&lt;/code&gt;, &lt;code&gt;TEI_MODEL&lt;/code&gt;, &lt;code&gt;TEI_DIMENSIONS&lt;/code&gt;. Generic OpenAI-compatible embedder knobs, named after Text Embeddings Inference for historical reasons and pointed at Mistral today. Aim them at a self-hosted TEI container, at Ollama, at anything speaking that shape, and the rest of the stack doesn&amp;rsquo;t notice.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s what local-first buys you. Not purity. &lt;strong&gt;Optionality.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&#34;so-the-5-box&#34;&gt;So, the $5 Box&lt;/h2&gt;
&lt;p&gt;My server has 60GB of RAM, which is absurd overkill and exists because it does a dozen other things. The stack itself measured &lt;strong&gt;640MB with three weeks of uptime&lt;/strong&gt;, essentially zero CPU at idle.&lt;/p&gt;
&lt;p&gt;That fits comfortably on a small cloud VPS in the few-dollars-a-month range. Check current pricing yourself rather than trusting a number in a blog post, but the shape is: a 2 vCPU / 4GB instance from Hetzner or similar costs less per month than one seat of most AI memory SaaS products, and you get to run everything else on it too.&lt;/p&gt;
&lt;p&gt;Your real constraint is RAM, specifically Neo4j&amp;rsquo;s JVM floor. On a 1GB instance you&amp;rsquo;d be fighting it. At 2GB you&amp;rsquo;re fine. At 4GB you&amp;rsquo;ll forget it&amp;rsquo;s running.&lt;/p&gt;
&lt;h2 id=&#34;why-i-care&#34;&gt;Why I Care&lt;/h2&gt;
&lt;p&gt;The indie web ethos is about noticing that renting your identity from a platform means the platform decides what happens to it.&lt;/p&gt;
&lt;p&gt;We&amp;rsquo;re about to make the same mistake with agent memory, except worse, because the thing being accumulated this time is a working model of how you think and what you&amp;rsquo;re building. Every &amp;ldquo;our AI remembers you across sessions&amp;rdquo; product is a proposal that you deposit that into someone else&amp;rsquo;s database and hope the pricing page stays reasonable.&lt;/p&gt;
&lt;p&gt;Qdrant is Apache 2.0. Neo4j Community is GPL. Docker Compose is a YAML file. Ansible is idempotent YAML. Nothing in this stack is exotic. The barrier to owning your agent memory is &lt;strong&gt;an afternoon and 640 megabytes.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Not everyone needs this, and I&amp;rsquo;m not going to pretend a solo dev with three side projects is being exploited by a $20 subscription. But if you&amp;rsquo;re accumulating context you&amp;rsquo;d be genuinely sad to lose, the math changes. Own the state, rent the compute, and keep the role in version control so the whole thing is reproducible on a box you haven&amp;rsquo;t bought yet.&lt;/p&gt;
&lt;p&gt;Moving the embedder onto Cloudflare&amp;rsquo;s free tier is next on my list, what&amp;rsquo;s on yours?&lt;/p&gt;
&lt;hr&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
      <source:markdown>I SSH&#39;d into my home server this afternoon and ran `docker stats` on the memory layer that every one of my coding agent sessions talks to. Here&#39;s what came back:

```
mem0-qdrant    28.09MiB / 60.75GiB    2.13%
mem0-neo4j     612.7MiB / 60.75GiB    0.77%
```

**640 megabytes.** Vector store and graph store, both up for three weeks straight, serving every `remember` and `recall` call my agents make. The entire persistent memory for my AI tooling uses less RAM than one Chrome tab with Figma open.

So let&#39;s talk about why you&#39;re paying a monthly subscription for this.

## Local-First, Not Local-Only

I want to be precise here, because &#34;self-hosted AI&#34; has become a phrase people use to mean nine different things.

My setup is local-first, not local-only. The *state* lives on my hardware. The memories, the embeddings, the graph relationships, everything my agents have learned about my projects, all of it sits on a box I own, in a Docker volume I can `tar` and carry away. Nobody can deprecate it, price-hike it, or sunset it.

The inference does not. My embedder points at Mistral&#39;s managed API. I&#39;ll get to why, and how to swap it, but I&#39;m not going to pretend otherwise in a post about self-hosting.

That embedder is the only thing that leaves my network, and only when something actually gets embedded, so when writing a memory and searching for one. Listing, deleting, and every graph operation are local with zero API calls.

**State is what you can&#39;t get back. Compute is a commodity you rent by the token.** Losing access to an API means switching providers. Losing two years of accumulated project context means starting over.

## The Four Pieces

**Qdrant** is semantic search. When I ask what it remembers about my package manager preferences, Qdrant turns that into a similarity query and hands back the relevant memories. It&#39;s Rust, it&#39;s fast, and at **28MB resident** it&#39;s essentially free to run. One gotcha: vector dimensions are fixed when the collection is created. Swap embedding models and you need a new collection, not a migration. I learned that the annoying way.

**Neo4j** is the graph store. Vectors are great at &#34;find me things that sound like this&#34; and bad at &#34;what depends on what.&#34; The graph holds explicit subject-predicate-object facts, so `project X` `built_with` `Python 3.13` is a traversable edge instead of a fuzzy match. It&#39;s the heavy one at **613MB**, but it&#39;s a JVM, so that&#39;s mostly heap floor rather than working set. If you&#39;re squeezing onto the smallest possible VPS, interrogate this one first.

**mem0** is the orchestration on top: what gets extracted from a conversation, what gets deduped against existing memories, what gets written where. That&#39;s the difference between a database and a memory system.

**The MCP server** is what makes any of it useful. A small Go binary that speaks Model Context Protocol over stdio to Claude Code, exposing eight tools: `remember`, `recall`, `list_memories`, `forget`, `memory_stats`, `add_relation`, `recall_related`, `forget_relation`.

The topology is deliberately boring:

```
Mac                                    Home server
┌──────────────┐   ┌──────────┐        ┌─────────────────┐
│ Claude Code  │◄─►│ mem0-mcp │  LAN   │ Qdrant + Neo4j  │
│              │   │ (Go)     │───────►│                 │
└──────────────┘   └──────────┘        └─────────────────┘
       stdio                    HTTP + bolt
```

Client binary on my laptop, containers on a box. No cloud in the middle, no account, no dashboard, no seat license.

---

## The Ansible Role Is the Whole Argument

Anyone can `docker compose up` a stack once. That&#39;s a weekend, not infrastructure. What makes this real is that it&#39;s a role in a repo, and rebuilding it on a fresh box is one command:

```bash
ansible-playbook -i common_hosts home.yml --tags mem0
```

That role does the unglamorous work:

- Installs a read-only deploy key scoped to exactly one repo, with an SSH `Host` alias so it can&#39;t collide with my personal GitHub key
- Clones and updates the source at a pinned branch
- Templates a `.env` with secrets pulled from Ansible Vault, `no_log: true` so nothing leaks into terminal output on a `--diff` run
- Brings up the compose stack with `remove_orphans: true`, so when I dropped a service upstream, the stale container went with it instead of lingering forever

Be careful with your secrets and how you are creating your .env files!

## The Honest Part About the API Key

I self-host the state and rent the inference. Two reasons.

The first reason is speed. I ran embeddings locally before this, on CPU, and it was miserable: roughly 87 seconds to embed 32 memories, against about 2 seconds through a hosted API. That is a 45x difference on an operation sitting directly in the path of every `remember` and `recall`. A good model on a CPU is still a slow model, and this was never a quality problem.

The second is that embeddings have gotten cheap enough that not worth the time to setup your own embedding service. [Mistral](https://mistral.ai/pricing/api) charges **$0.10 per million tokens** for `mistral-embed`. Google&#39;s [`gemini-embedding-001`](https://ai.google.dev/gemini-api/docs/pricing) is **$0.15 per million**, halved on their batch API. Both are good models. Both bill you.

[Cloudflare](https://developers.cloudflare.com/workers-ai/platform/pricing/) is the worth knowing about if you&#39;d rather not pay at all. Workers AI includes **10,000 neurons per day free**, on the free plan as well as the paid one. Neurons are their normalized compute unit, and `bge-m3` costs 1,075 of them per million input tokens — so that daily allowance is roughly **nine million tokens a day, at no cost.** Past it you&#39;re at $0.012 per million, which is an order of magnitude under the paid competition. For a personal memory layer, nine million tokens a day is not a trial. It&#39;s just free.

One detail if you&#39;re swapping: `bge-m3` emits 1024-dimension vectors, the same as `mistral-embed`. Go back to that Qdrant gotcha — matching dimensions means your existing collection still works. Mismatched ones mean starting over.

And the escape hatch is already built. The env vars in my role are `TEI_BASE_URL`, `TEI_MODEL`, `TEI_DIMENSIONS`. Generic OpenAI-compatible embedder knobs, named after Text Embeddings Inference for historical reasons and pointed at Mistral today. Aim them at a self-hosted TEI container, at Ollama, at anything speaking that shape, and the rest of the stack doesn&#39;t notice.

That&#39;s what local-first buys you. Not purity. **Optionality.**

## So, the $5 Box

My server has 60GB of RAM, which is absurd overkill and exists because it does a dozen other things. The stack itself measured **640MB with three weeks of uptime**, essentially zero CPU at idle.

That fits comfortably on a small cloud VPS in the few-dollars-a-month range. Check current pricing yourself rather than trusting a number in a blog post, but the shape is: a 2 vCPU / 4GB instance from Hetzner or similar costs less per month than one seat of most AI memory SaaS products, and you get to run everything else on it too.

Your real constraint is RAM, specifically Neo4j&#39;s JVM floor. On a 1GB instance you&#39;d be fighting it. At 2GB you&#39;re fine. At 4GB you&#39;ll forget it&#39;s running.

## Why I Care

The indie web ethos is about noticing that renting your identity from a platform means the platform decides what happens to it.

We&#39;re about to make the same mistake with agent memory, except worse, because the thing being accumulated this time is a working model of how you think and what you&#39;re building. Every &#34;our AI remembers you across sessions&#34; product is a proposal that you deposit that into someone else&#39;s database and hope the pricing page stays reasonable.

Qdrant is Apache 2.0. Neo4j Community is GPL. Docker Compose is a YAML file. Ansible is idempotent YAML. Nothing in this stack is exotic. The barrier to owning your agent memory is **an afternoon and 640 megabytes.**

Not everyone needs this, and I&#39;m not going to pretend a solo dev with three side projects is being exploited by a $20 subscription. But if you&#39;re accumulating context you&#39;d be genuinely sad to lose, the math changes. Own the state, rent the compute, and keep the role in version control so the whole thing is reproducible on a box you haven&#39;t bought yet.

Moving the embedder onto Cloudflare&#39;s free tier is next on my list, what&#39;s on yours?

---

&gt; I&#39;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at [@logan@llbbl.blog](https://micro.blog/llbbl?remote_follow=1).
</source:markdown>
    </item>
    
  </channel>
</rss>
