<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>DAVID TORCIVIA</title>
<link>https://davidtorcivia.com</link>
<description>Work, art, writing, and research from David Torcivia.</description>
<language>en</language>
<lastBuildDate>Wed, 19 Aug 2026 00:00:00 +0000</lastBuildDate>
<atom:link href="https://davidtorcivia.com/feed.xml" rel="self" type="application/rss+xml" />
<item>
<title>SteadQ - the filesystem is a message queue</title>
<link>https://davidtorcivia.com/posts/steadq</link>
<guid isPermaLink="true">https://davidtorcivia.com/posts/steadq</guid>
<pubDate>Wed, 19 Aug 2026 00:00:00 +0000</pubDate>
<description>SteadQ is a brokerless job queue built entirely from files: atomic renames for leases, fsync for durability. No daemon, no database.</description>
<category>steadq</category><category>programming</category><category>engineering</category><category>filesystem</category><category>queue</category>
<content:encoded><![CDATA[<p>I've spent the last few weeks obsessing about an idea that is either very old Unix wisdom, or a symptom of spending too much time around software: <strong>files should be true</strong>.</p>
<p>By &quot;true&quot; I mean <strong>authoritative</strong>. Not a serialization of some state that actually lives in a database, not an export from someone's pet application, or any other kind of convenient human-readable shadow. The files themselves should be the thing.</p>
<p>If some program disappears, if I delete a bunch of databases and come back to it in five years with nothing but my trusty shell, I should still be able to get a good sense of what's going on.</p>
<p>This started, innocently enough, with me tinkering around with this website. Every dev writes a static site gen at some point and it was my time. I rewrote the entire app in Go (a program with the uninspired name of <a href="https://github.com/davidtorcivia/dtcom">dtcom</a>), with the obvious idea that all of the posts should be in Markdown, the site configuration can be a simple YAML file, and then some HTML templating will stitch it all together.</p>
<p>But it's 2026 and just because something is simple and authoritative doesn't mean it can't have nice modern features. I use a SQLite database, because some things (such as admin backend, search, basic analytics, etc) are just plain easier in a relational database. The crucial difference is that these SQLite databases don't hold any <em>authoritative</em> state. The site continues to work if you delete the database files, and even recreates it if you remove all generated HTML - everything can be built from those authoritative markdown (and YAML) files.</p>
<p>When it comes to actually editing posts, every path edits Markdown files. Log into the administrative interface and start writing, use the REST API or MCP server (why not let agents interact directly), open the relevant file up in vim and start hacking away on it - every path leads to the same underlying set of files.</p>
<p>There's something deeply satisfying about having only one version of truth in a piece of software, and not trying to keep multiple data stores coherent.</p>
<p>At the same time I have been working on a far more elaborate project called <a href="https://github.com/halideworks/VOT">VOT</a> (<strong>Verified Object Transport</strong> - a way of transferring immutable objects, and having full assurance that what you receive is really, absolutely the same object) which takes this idea further. In particular, objects don't really change: rather you create a new object with some relation to an old one (such as &quot;this is version 2 of it&quot;), identify this new object cryptographically, and publish the result somewhere where you know that nobody can overwrite what's already there (while producing evidence as to how everything relates back).</p>
<p>After staring at these two projects, I started to wonder just how far this idea goes. In particular - could the file system itself be used as a work queue?</p>
<p>Note that I'm not talking about storing payloads for jobs in files and then keeping pointers to them (along with state) somewhere else like a database. The idea is simpler and far more stupid:</p>
<p><strong>Could we build an entirely broker-less queue out of just files?</strong></p>
<p>Files would represent jobs ready to be worked, and others could try to take ownership by renaming them atomically. If a job completes successfully, you'd create some kind of durable record (such as an acknowledgement), or if it fails then you maybe retry later, up to a certain number of attempts. If something dies while working on the job (such as power being cut), then you'd like to know about it so someone else can retry the job later.</p>
<p>This has been solved many, many times already, with very good software implementations. Whether it's Redis (which I love and use almost everywhere), RabbitMQ, SQS or Postgres, there are a lot of choices here.</p>
<p>This approach has precursors on the file side too; unsurprisingly, it closely resembles Dan Bernstein's maildir format. A message is a file - the application creates it in the maildir's <code>tmp/</code> directory and then atomically renames it into <code>new/</code>. That rename is the commit point, after which the message has been delivered exactly once; no locking or dedicated daemon required. It was safe by the standards of its time. CERN's <code>dirq</code> library and other simple tools like <code>nq</code> extended this pattern to general-purpose job directories. But they're limited; maildir only solves the problem of delivering a message once and nothing more. None of them address what should happen when things go wrong: if the process working on that file dies, how can you tell? How many times has the job been attempted already? Was it ever actually finished? SteadQ takes that single atomic rename and tries to drive a full-fledged job queue off of it, which is where all the generation counters and leases and durability barriers come from.</p>
<p>Because on any single machine we already have something that coordinates access to files by multiple processes, provides primitives for persisting state reliably and deals with the horrors of hardware crashing at any point: it's called a filesystem.</p>
<p>I've got to say that when I first thought about this (and sketched out what looked like it might work), there was a small part of me that thought this could never be made reliable. I mean, the idea is stupidly simple:</p>
<p>A file is a job; its path name encodes a generation count and an integrity tag for the current name/state, with the attempt count and maximum attempts recorded as well. If someone wants to work on this job then they try and rename it (atomically) into a different pathname that encodes some lease information, including lease deadlines and a large random lease token.</p>
<p>This all happens carefully constrained, no-symlink file and directory opens, and crucially - state transitions use Linux <code>renameat2(..., RENAME_NOREPLACE)</code>: if two people try to rename the same source at around the same time, only one will succeed.</p>
<p>As I say: simple, stupid idea. And it works! Though said sentence got expanded into several thousand lines of Rust code (as is always the case with both robust engineering and agent assisted development), formal models and fault injection testing because - as we will see: making assumptions about files is hard. More on this later.</p>
<p>Now that I've gone and spoiled the ending for you: let me simply explain how <a href="https://github.com/davidtorcivia/steadq">SteadQ</a> works.</p>
<h3 id="how-steadq-works">How SteadQ Works</h3>
<p>As far as layout goes - a queue is just some directory tree. There are top level directories for:</p>
<ul>
<li>Jobs ready to be worked on (<code>ready</code>);</li>
<li>Active leases, organized by boot ID and deadline bucket (<code>leased</code>);</li>
<li>Delayed jobs, which recovery promotes to ready once they become eligible (<code>delayed</code>);</li>
<li>Dead jobs (<code>dead</code>);</li>
<li>Acknowledgement receipts for completed work (<code>receipts</code>);</li>
<li>Named temporary staging used by the fallback publication path (<code>tmp</code>);</li>
<li>Corrupt files that we've detected and quarantined (<code>quarantine</code>); as well as</li>
<li>Some other control records (<code>control</code> and <code>FORMAT</code>).</li>
</ul>
<p>Jobs themselves are sharded into different subdirectories (based on their hash) so that we don't end up with one giant namespace where everyone needs to serialize against each other.</p>
<p>Crucially - there's no daemon sitting above this, nor is there a separate database that keeps track of what <em>should</em> be in those directories. The directory structure and filenames are the entire current state.</p>
<figure><picture class="theme-light"><source type="image/webp" srcset="/images/ce73afb6982abf797c0a923b9bad9d04.w480.webp 480w, /images/ce73afb6982abf797c0a923b9bad9d04.w768.webp 768w, /images/ce73afb6982abf797c0a923b9bad9d04.w1080.webp 1080w, /images/ce73afb6982abf797c0a923b9bad9d04.w1440.webp 1440w, /images/ce73afb6982abf797c0a923b9bad9d04.webp 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px"><img alt="" src="/images/ce73afb6982abf797c0a923b9bad9d04.w1080.png" srcset="/images/ce73afb6982abf797c0a923b9bad9d04.w480.png 480w, /images/ce73afb6982abf797c0a923b9bad9d04.w768.png 768w, /images/ce73afb6982abf797c0a923b9bad9d04.w1080.png 1080w, /images/ce73afb6982abf797c0a923b9bad9d04.w1440.png 1440w, /images/ce73afb6982abf797c0a923b9bad9d04.png 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="1600" height="734" decoding="async" fetchpriority="high" data-full="/images/ce73afb6982abf797c0a923b9bad9d04.png" data-full-webp="/images/ce73afb6982abf797c0a923b9bad9d04.webp" data-full-w="1600" data-full-h="734"></picture><picture class="theme-dark"><source type="image/webp" srcset="/images/eaf2f67ffbf49d5404b2ba499ae14010.w480.webp 480w, /images/eaf2f67ffbf49d5404b2ba499ae14010.w768.webp 768w, /images/eaf2f67ffbf49d5404b2ba499ae14010.w1080.webp 1080w, /images/eaf2f67ffbf49d5404b2ba499ae14010.w1440.webp 1440w, /images/eaf2f67ffbf49d5404b2ba499ae14010.webp 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px"><img alt="" src="/images/eaf2f67ffbf49d5404b2ba499ae14010.w1080.png" srcset="/images/eaf2f67ffbf49d5404b2ba499ae14010.w480.png 480w, /images/eaf2f67ffbf49d5404b2ba499ae14010.w768.png 768w, /images/eaf2f67ffbf49d5404b2ba499ae14010.w1080.png 1080w, /images/eaf2f67ffbf49d5404b2ba499ae14010.w1440.png 1440w, /images/eaf2f67ffbf49d5404b2ba499ae14010.png 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="1600" height="734" decoding="async" loading="lazy" data-full="/images/eaf2f67ffbf49d5404b2ba499ae14010.png" data-full-webp="/images/eaf2f67ffbf49d5404b2ba499ae14010.webp" data-full-w="1600" data-full-h="734"></picture></figure>
<p>If you're a producer, then to enqueue work you first create and write out the entire job (including all metadata and its SHA-256 payload and envelope digests) into a temporary file. Normally this is an unnamed <code>O_TMPFILE</code> in the destination directory; where that isn't available SteadQ falls back to a named temporary file under <code>tmp/</code>. Before publication SteadQ calls <code>fsync()</code> on the file: pushing everything down onto the storage device.</p>
<p>Now you can &quot;publish&quot; this new job into a sharded subdirectory under <code>ready/</code> with a no-overwrite publication step (normally <code>linkat()</code> for an <code>O_TMPFILE</code>, or <code>renameat2(..., RENAME_NOREPLACE)</code> for the named fallback) with some path name such as:</p>
<pre class="chroma-chroma"><code><span class="chroma-line"><span class="chroma-cl">&lt;job-id-32hex&gt;.g0000000000000000.a00000000.m00000005.k&lt;name-tag-16hex&gt;.sqj
</span></span></code></pre><p>Here I'm using <code>.g&lt;generation&gt;</code> to denote the generation, which starts at 0 (this job has never existed before), <code>.a&lt;attempt&gt;</code> denotes the number of successful claims so far, which also starts at 0 for brand new jobs. The <code>.m&lt;maximum-attempts&gt;</code> means we allow up to five committed claims on this job, and <code>.k&lt;name-tag&gt;</code> is a 64-bit integrity tag derived from the queue ID and canonical name context using SHA-256. These numeric fields are fixed-width lowercase hexadecimal; the tag is not a keyed MAC.</p>
<figure><picture class="theme-light"><source type="image/webp" srcset="/images/8feaab0ef603ec1ee847703219e2135e.w480.webp 480w, /images/8feaab0ef603ec1ee847703219e2135e.w768.webp 768w, /images/8feaab0ef603ec1ee847703219e2135e.w1080.webp 1080w, /images/8feaab0ef603ec1ee847703219e2135e.w1440.webp 1440w, /images/8feaab0ef603ec1ee847703219e2135e.webp 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px"><img alt="" src="/images/8feaab0ef603ec1ee847703219e2135e.w1080.png" srcset="/images/8feaab0ef603ec1ee847703219e2135e.w480.png 480w, /images/8feaab0ef603ec1ee847703219e2135e.w768.png 768w, /images/8feaab0ef603ec1ee847703219e2135e.w1080.png 1080w, /images/8feaab0ef603ec1ee847703219e2135e.w1440.png 1440w, /images/8feaab0ef603ec1ee847703219e2135e.png 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="1600" height="800" decoding="async" loading="lazy" data-full="/images/8feaab0ef603ec1ee847703219e2135e.png" data-full-webp="/images/8feaab0ef603ec1ee847703219e2135e.webp" data-full-w="1600" data-full-h="800"></picture><picture class="theme-dark"><source type="image/webp" srcset="/images/546ed152242bf4a1858164f7db038adf.w480.webp 480w, /images/546ed152242bf4a1858164f7db038adf.w768.webp 768w, /images/546ed152242bf4a1858164f7db038adf.w1080.webp 1080w, /images/546ed152242bf4a1858164f7db038adf.w1440.webp 1440w, /images/546ed152242bf4a1858164f7db038adf.webp 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px"><img alt="" src="/images/546ed152242bf4a1858164f7db038adf.w1080.png" srcset="/images/546ed152242bf4a1858164f7db038adf.w480.png 480w, /images/546ed152242bf4a1858164f7db038adf.w768.png 768w, /images/546ed152242bf4a1858164f7db038adf.w1080.png 1080w, /images/546ed152242bf4a1858164f7db038adf.w1440.png 1440w, /images/546ed152242bf4a1858164f7db038adf.png 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="1600" height="800" decoding="async" loading="lazy" data-full="/images/546ed152242bf4a1858164f7db038adf.png" data-full-webp="/images/546ed152242bf4a1858164f7db038adf.webp" data-full-w="1600" data-full-h="800"></picture></figure>
<p>Now any consumers that want to process jobs can scan one of those ready shard directories and pick a job. If you want to work on this then you try to create a &quot;lease&quot; state, by renaming the file out of <code>ready/&lt;shard&gt;/</code> and into <code>leased/&lt;boot-id&gt;/&lt;deadline-bucket&gt;/&lt;shard&gt;/</code>. The name gains some extra information (the boot ID, boot-time and wall-clock lease deadlines, an incremented attempt number and a large random value - the &quot;lease token&quot;), and the path now encodes when, and under which boot, this lease expires:</p>
<pre class="chroma-chroma"><code><span class="chroma-line"><span class="chroma-cl">leased/&lt;boot-id-32hex&gt;/&lt;bucket-16hex&gt;/&lt;shard&gt;/&lt;job-id-32hex&gt;.g0000000000000001.a00000001.m00000005.o&lt;boot-id-32hex&gt;.b&lt;boottime-deadline-16hex&gt;.w&lt;wall-deadline-16hex&gt;.t&lt;lease-token-32hex&gt;.k&lt;name-tag-16hex&gt;.sqj
</span></span></code></pre><p>The bucket is the lease's boot-time deadline rounded down to a width recorded in the queue's <code>FORMAT</code> file (10 seconds by default). This is what keeps recovery cheap: expired leases are whole buckets whose deadline has already passed, so a recovering worker lists those buckets and reclaims everything inside instead of parsing every live lease's filename. Leases from dead boots are even easier, since any <code>leased/&lt;boot-id&gt;/</code> that isn't ours belongs to a boot that no longer exists, and everything under it is reclaimable wholesale.</p>
<p>If you succeed then congratulations - out of all the processes who were scanning that shard directory for jobs: you now own this job!</p>
<p>If two processes tried to acquire the same lease then only one of those calls would succeed and so - without a lock or central arbitrator deciding ownership: we know who owns the job.</p>
<figure><picture class="theme-light"><source type="image/webp" srcset="/images/59fadb1cda9b028c535ff71b5b5b5bfd.w480.webp 480w, /images/59fadb1cda9b028c535ff71b5b5b5bfd.w768.webp 768w, /images/59fadb1cda9b028c535ff71b5b5b5bfd.w1080.webp 1080w, /images/59fadb1cda9b028c535ff71b5b5b5bfd.w1440.webp 1440w, /images/59fadb1cda9b028c535ff71b5b5b5bfd.webp 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px"><img alt="" src="/images/59fadb1cda9b028c535ff71b5b5b5bfd.w1080.png" srcset="/images/59fadb1cda9b028c535ff71b5b5b5bfd.w480.png 480w, /images/59fadb1cda9b028c535ff71b5b5b5bfd.w768.png 768w, /images/59fadb1cda9b028c535ff71b5b5b5bfd.w1080.png 1080w, /images/59fadb1cda9b028c535ff71b5b5b5bfd.w1440.png 1440w, /images/59fadb1cda9b028c535ff71b5b5b5bfd.png 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="1600" height="614" decoding="async" loading="lazy" data-full="/images/59fadb1cda9b028c535ff71b5b5b5bfd.png" data-full-webp="/images/59fadb1cda9b028c535ff71b5b5b5bfd.webp" data-full-w="1600" data-full-h="614"></picture><picture class="theme-dark"><source type="image/webp" srcset="/images/2fbfa58f5f59f9b2e71a56aef5a39045.w480.webp 480w, /images/2fbfa58f5f59f9b2e71a56aef5a39045.w768.webp 768w, /images/2fbfa58f5f59f9b2e71a56aef5a39045.w1080.webp 1080w, /images/2fbfa58f5f59f9b2e71a56aef5a39045.w1440.webp 1440w, /images/2fbfa58f5f59f9b2e71a56aef5a39045.webp 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px"><img alt="" src="/images/2fbfa58f5f59f9b2e71a56aef5a39045.w1080.png" srcset="/images/2fbfa58f5f59f9b2e71a56aef5a39045.w480.png 480w, /images/2fbfa58f5f59f9b2e71a56aef5a39045.w768.png 768w, /images/2fbfa58f5f59f9b2e71a56aef5a39045.w1080.png 1080w, /images/2fbfa58f5f59f9b2e71a56aef5a39045.w1440.png 1440w, /images/2fbfa58f5f59f9b2e71a56aef5a39045.png 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="1600" height="614" decoding="async" loading="lazy" data-full="/images/2fbfa58f5f59f9b2e71a56aef5a39045.png" data-full-webp="/images/2fbfa58f5f59f9b2e71a56aef5a39045.webp" data-full-w="1600" data-full-h="614"></picture></figure>
<p>As long as that process can renew this lease before it expires (renewal renames the file again to a fresh name with new deadlines, possibly landing in a different bucket) then everything is working properly, and when it finishes working on that job - we re-verify the payload and create a durable receipt by moving the leased job into <code>receipts/</code> and calling <code>fsync()</code> on the required directories:</p>
<pre class="chroma-chroma"><code><span class="chroma-line"><span class="chroma-cl">&lt;job-id-32hex&gt;.g0000000000000002.a00000001.m00000005.t&lt;lease-token-32hex&gt;.k&lt;name-tag-16hex&gt;.rct
</span></span></code></pre><p>where <code>.rct</code> denotes an acknowledgement receipt. The full receipt is the verified job object moved into terminal receipt state (and can later be compacted while preserving verified evidence). If someone else tries to acknowledge this job using an older lease, or some other state - then they will fail: because only the holder of that latest version can produce a valid acknowledgement record.</p>
<p>If it fails, and you want to retry later - we move it under the bucketed <code>delayed/</code> directory with something like this:</p>
<pre class="chroma-chroma"><code><span class="chroma-line"><span class="chroma-cl">&lt;job-id-32hex&gt;.g0000000000000002.a00000001.m00000005.d&lt;not-before-unix-ns-16hex&gt;.k&lt;name-tag-16hex&gt;.sqj
</span></span></code></pre><p>and if you've exhausted the maximum number of attempts for this job then it gets moved under the bucketed <code>dead/</code> directory with an <code>.x&lt;reason&gt;</code> code rather than a retry time.</p>
<p>If the consumer crashes, or there's an unclean shutdown then on recovery: we just scan those file system directories and look for expired or old-boot leases, or delayed jobs that have become eligible. There's nothing else we need to reconcile against.</p>
<p>All of which begs the question - how do you know when something actually happened? The reason I ask this, is because as far as files are concerned - it depends on when you look at them. This turned out to be rather important: and took up a lot of time, energy and lines of code.</p>
<h3 id="when-exactly-did-that-thing-happen">When exactly did that thing happen?</h3>
<p>Like most things in computing - files can be somewhat complicated. Yes, some filesystem operations are specified as atomic: but &quot;atomic&quot; doesn't always mean what you might think. It certainly does not necessarily imply that the operation survived a crash, or power being cut to your computer.</p>
<p>A system call can succeed - but that doesn't mean it was made durable. And just because a directory entry changed (such as via <code>rename()</code>) - that doesn't mean it's now permanently there.</p>
<p>Once I actually started writing a specification for SteadQ, something that ended up helping me think about this was splitting each operation into one of three results:</p>
<ul>
<li>The outcome is known, and it <em>didn't happen</em> (<code>Not committed</code>);</li>
<li>It's definitely committed to the storage device (<code>Committed</code>); or</li>
<li>We can neither confirm nor deny whether it happened - we're in an <code>Outcome unknown</code> state.</li>
</ul>
<figure><picture class="theme-light"><source type="image/webp" srcset="/images/c5e0f436b75979508da40102e2cf7b6b.w480.webp 480w, /images/c5e0f436b75979508da40102e2cf7b6b.w768.webp 768w, /images/c5e0f436b75979508da40102e2cf7b6b.w1080.webp 1080w, /images/c5e0f436b75979508da40102e2cf7b6b.w1440.webp 1440w, /images/c5e0f436b75979508da40102e2cf7b6b.webp 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px"><img alt="" src="/images/c5e0f436b75979508da40102e2cf7b6b.w1080.png" srcset="/images/c5e0f436b75979508da40102e2cf7b6b.w480.png 480w, /images/c5e0f436b75979508da40102e2cf7b6b.w768.png 768w, /images/c5e0f436b75979508da40102e2cf7b6b.w1080.png 1080w, /images/c5e0f436b75979508da40102e2cf7b6b.w1440.png 1440w, /images/c5e0f436b75979508da40102e2cf7b6b.png 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="1600" height="454" decoding="async" loading="lazy" data-full="/images/c5e0f436b75979508da40102e2cf7b6b.png" data-full-webp="/images/c5e0f436b75979508da40102e2cf7b6b.webp" data-full-w="1600" data-full-h="454"></picture><picture class="theme-dark"><source type="image/webp" srcset="/images/8eec08b65429fc40161eeb612ee88d53.w480.webp 480w, /images/8eec08b65429fc40161eeb612ee88d53.w768.webp 768w, /images/8eec08b65429fc40161eeb612ee88d53.w1080.webp 1080w, /images/8eec08b65429fc40161eeb612ee88d53.w1440.webp 1440w, /images/8eec08b65429fc40161eeb612ee88d53.webp 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px"><img alt="" src="/images/8eec08b65429fc40161eeb612ee88d53.w1080.png" srcset="/images/8eec08b65429fc40161eeb612ee88d53.w480.png 480w, /images/8eec08b65429fc40161eeb612ee88d53.w768.png 768w, /images/8eec08b65429fc40161eeb612ee88d53.w1080.png 1080w, /images/8eec08b65429fc40161eeb612ee88d53.w1440.png 1440w, /images/8eec08b65429fc40161eeb612ee88d53.png 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="1600" height="454" decoding="async" loading="lazy" data-full="/images/8eec08b65429fc40161eeb612ee88d53.png" data-full-webp="/images/8eec08b65429fc40161eeb612ee88d53.webp" data-full-w="1600" data-full-h="454"></picture></figure>
<p>This last possibility is important, because the operation might have succeeded and persisted: but you can't tell from looking at it. I cannot emphasize how important this is to think about (and plan for). Just because an operation might have happened does not make your computer more reliable. It means that if something crashes at the wrong point, then you could observe one of two things when it comes back up.</p>
<p>Let's imagine for the moment that we have some directory, and there are two files in it:</p>
<pre class="chroma-chroma"><code><span class="chroma-line"><span class="chroma-cl">&lt;job-id-a-32hex&gt;.g0000000000000005.a00000000.m00000008.k&lt;name-tag-a-16hex&gt;.sqj
</span></span></code></pre><p>and</p>
<pre class="chroma-chroma"><code><span class="chroma-line"><span class="chroma-cl">&lt;job-id-b-32hex&gt;.g0000000000000005.a00000000.m00000008.k&lt;name-tag-b-16hex&gt;.sqj
</span></span></code></pre><p>Further, let's say that you want to make a hard link called <code>job-4321-hardlink.sqj</code> that points to the same file. You call <code>link()</code>, and it succeeds: but power is cut immediately after this happens.</p>
<p>When the system boots up again you might observe:</p>
<pre class="chroma-chroma"><code><span class="chroma-line"><span class="chroma-cl">&lt;job-id-a-32hex&gt;.g0000000000000005.a00000000.m00000008.k&lt;name-tag-a-16hex&gt;.sqj
</span></span><span class="chroma-line"><span class="chroma-cl">job-4321-hardlink.sqj
</span></span><span class="chroma-line"><span class="chroma-cl">&lt;job-id-b-32hex&gt;.g0000000000000005.a00000000.m00000008.k&lt;name-tag-b-16hex&gt;.sqj
</span></span></code></pre><p>or equally possible - that the hard link doesn't actually exist at all! It might not be there. And given we only have access to what's on disk after a crash (and the fact that a successful namespace change need not have been made durable) means we cannot tell from that post-crash state whether or not the link had been created.</p>
<p>SteadQ uses these terms to talk about the outcomes of different operations. This is also important when we're thinking about durability barriers: because if the linearization point and all required durability barriers completed successfully then we can say with confidence - yes, this thing definitely happened. This is why I call <code>fsync()</code> and related calls &quot;durability barriers&quot;.</p>
<p>I got fairly carried away with this idea, because once I started testing SteadQ: it became very clear that assumptions about files and path names could not be taken for granted. Writing the first few fault injection tests (that would fail system calls at controlled boundaries using SteadQ's built-in fault injection hooks) was eye opening to say the least. So too were 700+ tests across unit, integration, conformance and formal model checking, written by and to constrain agents of course and force robust engineering.</p>
<p>With the help of some crash testing infrastructure that I built (inspired in part by <a href="https://pdos.csail.mit.edu/projects/fscq.html">FSCQ</a>, and built on Linux's <a href="https://www.kernel.org/doc/html/latest/admin-guide/device-mapper/log-writes.html"><code>dm-log-writes</code> / <code>replay-log</code></a> machinery) which can replay block-level writes up to each persistence barrier: I was able to test SteadQ against ext4, XFS, btrfs, f2fs and ZFS on two different Linux hosts. Between the two boxes we checked 761 crash states on one kernel and 793 on the other, and all five filesystem profiles passed. <em>This is only block-replay evidence for those recorded workloads, not physical power-cut testing.</em> I will tackle this shortly - now that static aarch64 builds exist I've run out of excuses not to dig out my Raspberry Pis.</p>
<p>All of this infrastructure emerged because I wanted to rename some files in a deterministic way. But that's okay as we now know just how far this idea can go and whether it could be reliable under those tested assumptions.</p>
<h3 id="why-bother-with-this">Why Bother With This?</h3>
<p>At the moment SteadQ is nowhere near as feature-rich or battle-tested as any number of fantastic pieces of queue software already out there. It's also not Kafka: it doesn't do network distribution, FIFO ordering guarantees (strictly), priorities of any kind - nor can you use it to do transactions or produce exactly once side effects.</p>
<p>However there are lots of cases where work happens on a single machine (or appliance), and you don't necessarily want to run another service just so your local processes can talk reliably.</p>
<p>Video transcoding (something that affects my work and was part of the genesis of this idea) is a great example of this where one process produces render jobs and some worker processes chew through them: generating proxies for editing, thumbnails for seeking or transcoding the final result into different bit rates and resolutions. If one of those workers crashes then their lease expires, and recovery can free the job up for someone else to pick it up again.</p>
<p>This same pattern fits running AI models or agents on your local machine extremely well: different processes can enqueue and process work from each other without needing to orchestrate the whole thing with a layer like a Redis server.</p>
<p>I could see this being used for build workers on a big machine that runs CI jobs, download and ingest pipelines (such as from an NDI source), processing of durable notifications for home automation, image generation or just running background indexing and backups on a Linux NAS's local storage. It's also perfect for small or embedded systems where added services might become a constraint (there are static musl builds for both x86-64 and aarch64).</p>
<p>Basically anywhere that you find yourself combining some mix of <code>cron</code>, shell scripts and ad-hoc programs to run work: there may be space for this. The CLI makes that direct: <code>steadq work /path/to/queue --concurrency 4 -- ./transcode.sh</code> leases jobs, feeds each payload to your command on stdin, keeps the lease renewed while it runs, then acks on exit 0 or requeues on failure. Add <code>--once</code> and it slots straight into a crontab. There's also <code>steadq stats</code> for Prometheus-friendly gauges straight off a directory scan, and <code>steadq fsck</code> for when you want post-crash reassurance. If you can't tell - I've gotten carried away with the idea of using a file system as a queue, but it's strangely satisfying when something works exactly how you think it should.</p>
<p>It is also reasonably quick: on the repository's Intel Core i5-13500 reference system with NVMe ext4 storage - SteadQ can process roughly 3,000 small jobs per second with strict individual commits through a single worker. With eight workers it can do more than 8,000 jobs per second. With batching this pushes past 10,000 jobs/s. Idle consumers don't pay for this with busy polling either: an inotify wake hint on the ready shards brings median dispatch latency down to a few hundred microseconds, while a plain directory scan remains the permanent fallback (the hint is never trusted for correctness). Admittedly, some file systems are slower than others (looking at my ZFS mirror).</p>
<figure><picture class="theme-light"><source type="image/webp" srcset="/images/b26ba8f8c2527f0fe7d93a2354325008.w480.webp 480w, /images/b26ba8f8c2527f0fe7d93a2354325008.w768.webp 768w, /images/b26ba8f8c2527f0fe7d93a2354325008.w1080.webp 1080w, /images/b26ba8f8c2527f0fe7d93a2354325008.w1440.webp 1440w, /images/b26ba8f8c2527f0fe7d93a2354325008.webp 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px"><img alt="" src="/images/b26ba8f8c2527f0fe7d93a2354325008.w1080.png" srcset="/images/b26ba8f8c2527f0fe7d93a2354325008.w480.png 480w, /images/b26ba8f8c2527f0fe7d93a2354325008.w768.png 768w, /images/b26ba8f8c2527f0fe7d93a2354325008.w1080.png 1080w, /images/b26ba8f8c2527f0fe7d93a2354325008.w1440.png 1440w, /images/b26ba8f8c2527f0fe7d93a2354325008.png 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="1600" height="534" decoding="async" loading="lazy" data-full="/images/b26ba8f8c2527f0fe7d93a2354325008.png" data-full-webp="/images/b26ba8f8c2527f0fe7d93a2354325008.webp" data-full-w="1600" data-full-h="534"></picture><picture class="theme-dark"><source type="image/webp" srcset="/images/6f1a28cf012384e6854a8fedae15388e.w480.webp 480w, /images/6f1a28cf012384e6854a8fedae15388e.w768.webp 768w, /images/6f1a28cf012384e6854a8fedae15388e.w1080.webp 1080w, /images/6f1a28cf012384e6854a8fedae15388e.w1440.webp 1440w, /images/6f1a28cf012384e6854a8fedae15388e.webp 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px"><img alt="" src="/images/6f1a28cf012384e6854a8fedae15388e.w1080.png" srcset="/images/6f1a28cf012384e6854a8fedae15388e.w480.png 480w, /images/6f1a28cf012384e6854a8fedae15388e.w768.png 768w, /images/6f1a28cf012384e6854a8fedae15388e.w1080.png 1080w, /images/6f1a28cf012384e6854a8fedae15388e.w1440.png 1440w, /images/6f1a28cf012384e6854a8fedae15388e.png 1600w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="1600" height="534" decoding="async" loading="lazy" data-full="/images/6f1a28cf012384e6854a8fedae15388e.png" data-full-webp="/images/6f1a28cf012384e6854a8fedae15388e.webp" data-full-w="1600" data-full-h="534"></picture></figure>
<p>A &quot;job&quot; in this case isn't just an enqueue/dequeue: instead we track it from the moment where a producer writes out and publishes that job, all the way through when some worker acquires it from <code>ready/</code>, verifies its payload and metadata against their hash values, all the way through when they acknowledge that job successfully. The only thing not included here is whatever work a worker might do.</p>
<p>I should also mention that these numbers come from using an at-least-once local queue. This means that if there's a crash or power cut then jobs may get processed again, and SteadQ <em>does not guarantee exactly-once external side effects even on the same machine</em>. If you're looking for something faster or more elaborate than this, then I'd suggest that Redis is probably a good place to start looking for it.</p>
<p>However unlike many other pieces of queue software out there - SteadQ does not require any kind of daemon, or external service: you just point it at a file path.</p>
<p>Pick your poison.</p>
<h3 id="what-about-networked-file-systems">What About Networked File Systems?</h3>
<p>One thing you will notice about SteadQ is that there's an explicit assumption of locality: all the processes are on one machine. So naturally, after finishing SteadQ and starting work on this article - I started wondering whether it was possible to do the same sort of thing again - but using object storage this time.</p>
<p>This is slightly more complicated because - unlike the file system: S3 general-purpose buckets (for example) don't support things like atomic renames (though some special new buckets do, so that's something to pay attention to). The good news is that object stores can support <em>atomic conditional creation</em>, which is what <a href="https://github.com/davidtorcivia/stowq">StowQ</a> uses instead.</p>
<p>Here each job in the queue continues to be an immutable object, whose key contains some information about it (such as its ID and generation number). In order for a consumer to &quot;take ownership&quot; of that job they need to create an immutable claim object, whose key is based on information from the original - such as how many times it has been attempted (or when this claim expires). Instead of having an atomic no-overwrite rename select the winner we instead use conditional creation, and if that is successful then, out of everyone who was scanning the bucket for jobs, you now own it.</p>
<p>Just like with SteadQ, there is no central service or leader, just a bunch of stateless processes who can figure out everything they need to know by scanning the object store (or bucket). However unlike SteadQ, this isn't limited to a single machine, and you can figure out the state by looking at those key names. There are tradeoffs of course (completed jobs per second is in the TENS, not thousands), but for some use cases it's viable and it's an interesting exercise regardless. More on this in the future.</p>
<p>This all started off as an incredibly stupid idea, but unfortunately it keeps working - which means I have to find out just what is possible. For what it's worth - I think there might be some mileage in this. But regardless of the outcome, it's been a really fun journey so far.</p>
]]></content:encoded>
</item>
<item>
<title>DTCOM - Yes, Another Static Site</title>
<link>https://davidtorcivia.com/posts/dtcom---yes-another-static-site</link>
<guid isPermaLink="true">https://davidtorcivia.com/posts/dtcom---yes-another-static-site</guid>
<pubDate>Mon, 10 Aug 2026 00:00:00 +0000</pubDate>
<description>Building a custom home for the web.</description>
<category>site</category><category>go</category><category>programming</category>
<content:encoded><![CDATA[<p>Yes, yet another static site generator, but this one is mine with all the idiosyncrasies attached. I'm a big fan of static sites as they're performant, secure, and simple. The less I have to think about frameworks or plumbing for front end the better. My own sites for years have been some variation of hand edited HTML or hugo generated static constructions, but hand editing is inefficient and running hugo to generate and upload is just somehow not satisfying. Beyond that, having a nice admin backend (analytics, post editing, site settings) admittedly makes life a little nicer. There is a tension here, so of course I had to roll my own engine to temper it.</p>
<p>The filesystem as source of truth is an ancient idea, older than databases, older than the web. <a href="https://github.com/davidtorcivia/dtcom">DTCOM</a>, unimaginatively named and running this site, treats every change as a filesystem operation and serves the result, atomically rebuilt. The source are markdown files with front matter - just a single file per post. The go binary backend then reads those files, renders them using goldmark and html/template and places them in a public directory which is then served to visitors. This engine watches the source director (fsnotify) and rebuilds automatically when it detects changes, updating the live served static site. Every page (homepage, search, sitemap, feed, 404, robots.txt, etc) are generated from this pipeline.</p>
<figure><img alt="" src="/images/1ba7d2f0afec4e6c67678abbacdeaab4.w1080.jpg" srcset="/images/1ba7d2f0afec4e6c67678abbacdeaab4.w480.jpg 480w, /images/1ba7d2f0afec4e6c67678abbacdeaab4.w768.jpg 768w, /images/1ba7d2f0afec4e6c67678abbacdeaab4.w1080.jpg 1080w, /images/1ba7d2f0afec4e6c67678abbacdeaab4.w1440.jpg 1440w, /images/1ba7d2f0afec4e6c67678abbacdeaab4.jpg 1652w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="1652" height="1169" decoding="async" fetchpriority="high" data-full="/images/1ba7d2f0afec4e6c67678abbacdeaab4.jpg" data-full-w="1652" data-full-h="1169"></figure>
<p>This go backend enables a few nice features: post creation/editing/preview, backups, editable nav and bio, dynamic RSS links, and an API/MCP authenticated bridge that means I can direct agents to update the site directly if needed. The site is both dynamic and static and gives me the best of both worlds.</p>
<p>There is no database as a source of truth, only the file system. The <em>is</em> an optional SQLite index that enables fast search and simple view counts, but nothing else. You can create content by dropping in a file, writing in the admin interface, or using the mcp/api backend to manipulate things from afar - all of these trigger a near instantaneous reload and the updated content is live. The site is flexible in <em>how</em> you create with it, but keeps serving simple. It does this with mutex and concurrent triggers that coalesce into one rebuild/write/delete (if necessary) which means no downtime and seamless updates.</p>
<p>There are lots of opinionated niceties in presentation. Images are uploaded, dynamically generated into optimized resolutions and formats, and served seamlessly with lightbox support (and pinch to zoom capability). It supports LaTeX math rendering,  styled code blocks, and advanced markdown including footnotes, quote blocks, RSS, raw markdown rendering for agents browsing, and many other tiny thoughtful features.</p>
<p>While it was developed with a variety of agents, the codebase is small enough you can read it and understand it (which means any llm you point at it can too). This is a site for a single author, not designed to scale, but to be a personal thing - a place to share, a cozy home that fits me just right.</p>
]]></content:encoded>
</item>
<item>
<title>macOS 26 shipped with TIME_WAIT sockets that never expire</title>
<link>https://davidtorcivia.com/posts/macos-26-shipped-with-time-wait-sockets-that-never-expire</link>
<guid isPermaLink="true">https://davidtorcivia.com/posts/macos-26-shipped-with-time-wait-sockets-that-never-expire</guid>
<pubDate>Mon, 27 Jul 2026 00:00:00 +0000</pubDate>
<description>tracking down an apple kernel bug</description>
<category>apple</category><category>kernel</category><category>bug</category>
<content:encoded><![CDATA[<p><strong>This bug was identified in macOS 26.0 and was patched somewhere between then and 26.5.2</strong>. This is simply a documentation of my experience with it. If you are still running an early build of macOS 26 and you find your machine randomly hanging for minutes at a time when connecting to localhost, then this documentation is for you (update your OS).</p>
<p>In mid July I was working on creating a training corpus using a local inference server on my Mac Studio. I had three worker threads using a 120B model to stream millions of tokens into jsonl shards. This is a slow process, but I noticed every 20 minutes or so the entire system would hang. No errors, no temp issues, no log lines, and then three or four minutes later it would pick back up and chug away happily like nothing ever happened.</p>
<p>This was more annoyance than anything destructive, but I got curious about what was happening and started poking around.</p>
<h2 id="the-server">The server</h2>
<p>The obvious suspect was the inference server (oMLX) running on localhost. oMLX is typically very solid, but I have a few commits in it and my copy tends to a slightly customized branch so it seemed obvious that any problem would likely originate there. Long sustained runs from big (for me at least) models have lots of places to stall: memory pressure, cache spills, wedged schedulers, thermal throttling, etc. But when I started poking into logs during the stalls I found none of this. The event loop was idle and the scheduler queue was empty. CPU usage was basically 0, no model load or unload events, and the smoking gun - all three workers stalled at the same time and then all cleared together.</p>
<p>Time to start probing. I waited for a stall and then started hitting localhost. Some went through and some stalled even though it was the same endpoint at the same time. Interesting. Take a look:</p>
<pre><code>23:02:19 probe[get-models]      code=200 took=0s
23:02:19 probe[get-admin-stats] code=200 took=0s
23:02:29 probe[post-chat]       code=000 took=10s   &lt;- curl gave up at its own timeout
23:02:29 probe[post-login]      code=200 took=0s
23:02:39 probe[get-chat-noauth] code=000 took=10s
</code></pre>
<p>If this was a server issue then you'd expect ALL the connections to have hung. Instead, some were getting replies instantly while others languished - something was discriminating on a per connection basis preventing some requests from ever even reaching the server. All these hung connections were sitting in <code>SYN_SENT</code> over loopback which meant <code>SYN</code> was being sent, but nothing was replying. Why was it inconsistent?</p>
<h2 id="the-stuck">The stuck</h2>
<p>Another clue: all the connections that closed left their four-tuple in <code>TIME_WAIT</code>, kept for <code>2xMSL</code> (30 seconds on macOS as net.inet.tcp.msl is 15000). On a healthy machine, <code>TIME_WAIT</code> should ebb and flow as connections churn, but this machine was not healthy. The <code>TIME_WAIT</code> count kept increasing and by late evening there were nearly 18,000 stuck <code>TIME_WAIT</code> connections, most of them pointed at my inference server port. The oldest were hours old, well past their expiry - the kernel was failing to reap these connections.</p>
<p>The repro, more or less:</p>
<pre class="chroma-chroma"><code><span class="chroma-line"><span class="chroma-cl"><span class="chroma-kn">import</span> <span class="chroma-nn">socket</span>
</span></span><span class="chroma-line"><span class="chroma-cl"><span class="chroma-k">for</span> <span class="chroma-n">_</span> <span class="chroma-ow">in</span> <span class="chroma-nb">range</span><span class="chroma-p">(</span><span class="chroma-mi">10</span><span class="chroma-p">):</span>
</span></span><span class="chroma-line"><span class="chroma-cl">    <span class="chroma-n">s</span> <span class="chroma-o">=</span> <span class="chroma-n">socket</span><span class="chroma-o">.</span><span class="chroma-n">create_connection</span><span class="chroma-p">((</span><span class="chroma-s2">&#34;127.0.0.1&#34;</span><span class="chroma-p">,</span> <span class="chroma-mi">9999</span><span class="chroma-p">))</span>  <span class="chroma-c1"># any listening port</span>
</span></span><span class="chroma-line"><span class="chroma-cl">    <span class="chroma-n">s</span><span class="chroma-o">.</span><span class="chroma-n">close</span><span class="chroma-p">()</span>
</span></span><span class="chroma-line"><span class="chroma-cl"><span class="chroma-c1"># netstat -an | grep TIME_WAIT</span>
</span></span><span class="chroma-line"><span class="chroma-cl"><span class="chroma-c1"># waited 30 seconds... waited an hour... still there</span>
</span></span></code></pre><p>Ten connections create ten <code>TIME_WAIT</code> entries that just get stuck and accumulate. Thirty seconds later (when they should be reaped), they were still there. An hour later, still there.</p>
<h2 id="the-failure-chain">The failure chain</h2>
<p>So we had an answer. The workers were slowly drowning the studio's address space, filling it with more and more <code>TIME_WAIT</code> entries that never expired. The processes were using <code>urllib</code> which opens a new TCP connection for every request (not the most efficient system, granted) and these workers were making thousands of requests an hour. Every single close blocked that tuple until a full machine restart and of course there are only so many ephemeral ports.</p>
<p>Eventually a worker gets handed a port it had already used while churning through connections over the last few hours. The kernel then sees the <code>SYN</code>, notes that that tuple was still in <code>TIME_WAIT</code>, and then drops the connection. On a working kernel, those <code>TIME_WAIT</code> holds would be reaped, but something was broken and the client just kept retransmitting <code>SYN</code>s at nothing until the connection timeout trigger forced it to finally give up.</p>
<p>These stalls tended to be 3.5-4.9 minutes long. If you add the default keepinit retry ladder, well no surprise that that's roughly the number you get. The bad ports were essentially random luck which is why we saw some connections get through while others would just hang.</p>
<h2 id="the-workaround">The workaround</h2>
<p>The solution, as stupid as it sounds, was just to stop opening connections. I rewrote the workers' HTTP layer to just use one connection per thread and to keep it alive (plus a second silent retry if needed since uvicorn hangs on idle keep-alive after five seconds or so). Now that connection churn was essentially eliminated, the rest of the corpus run ran without any stalls or issues through the night. This, however, did not clear the pile of dead tuples - only a complete reboot did that.</p>
<p>It's unsurprising that this wasn't a widely reported issue - who else is stupid (or lazy) enough to open and close thousands of loopback connections an hour? It's certainly not the behavior of a well written client. I just happened to be running exactly such a hastily hacked script so as to encounter this bug.</p>
<h2 id="the-patch">The patch</h2>
<p>On July 27th, I went to rerun the repro to write up a bug report, but my machine had updated to 26.5.2 the day before, and lo and behold the bug was fixed. I'm not sure when it happened (since I was behind in updates) and I couldn't find any notes or mentions of <code>TIME_WAIT</code> or <code>TCP</code> related to this, so I semi regret not filing Apple Feedback to immortalize the bug in some database, but I'm glad it was fixed. If you know which build fixed the issue (or if you are still able to reproduce it), I'd love to hear about it!</p>
]]></content:encoded>
</item>
<item>
<title>Why SDR content looks washed out in Windows 11 HDR</title>
<link>https://davidtorcivia.com/posts/on-the-betrayal-of-light</link>
<guid isPermaLink="true">https://davidtorcivia.com/posts/on-the-betrayal-of-light</guid>
<pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate>
<description>on the betrayal of light</description>
<category>hdr</category><category>calibration</category><category>windows</category><category>gamma</category><category>color correction</category>
<content:encoded><![CDATA[<blockquote>
<p>When in HDR mode, Windows displays SDR content with a piecewise sRGB transfer function. Most SDR content is produced on and for a power law response like gamma 2.2 or gamma 2.4. This discrepancy lifts shadows and ruins the work of creators. This article explains the what, how, and why of the mismatch and highlights the free and open source software I wrote to correct it.</p>
</blockquote>
<p>The shadows were wrong.</p>
<p>On my right sat my critical reference display, a Flanders Scientific DM250 hooked up to my workstation directly via SDI. It displayed gorgeous deep rich blacks, just as I and the DP and director (and countless other artists) had intended.</p>
<p>Directly in front of me was my newly purchased MSI MAG 271QP X28 OLED monitor set to HDR. To my left my HDR Gigabyte M27Q. Above all three, my client reference LG OLED (which I had been running in SDR but had just flipped to HDR to coordinate with the new display). All three were, for the first time, using Window 11's HDR pipeline. They looked terrible.</p>
<p>Instead of the deep atmospheric blacks my FSI and vectorscopes were showing, I was accosted by washed out greys and a general flat muddiness that was not at all what storeroom HDR displays promised. The difference was drastic, like looking at a photocopy of a photocopy or a sunset through a smudged and hazy window.</p>
<p>I'm a senior colorist with well over a decade of experience. I know HDR, I know SDR, I know pipelines and transfer functions and color spaces and all the minutiae. I've got a well worn copy of <em>Digital Video and HD</em> sitting next to my desk. I've colored and produced HDR content. I've spent my life training my eyes to <em>notice</em>. When I say the color was wrong, that the shadows were lifted, I mean it and I speak from deep and disappointed authority. My new HDR monitor, which I had purchased for OLED blacks and only enabled in HDR because I measured the SDR as less accurate with my colorimeter, looked bad. It lacked weight - games were washed out, my work was drab, everything felt off. Windows was doing something and it was disrupting decades of convention. Most people, I learned, stopped here, simply disabling HDR or learning to live a slightly greyer existence - but colorists are perfectionists.</p>
<p>Research revealed this was a common complaint and it wasn't a bug or configuration issue, but an intentional design decision from Microsoft in the way the OS HDR compositor works. As a result of these choices, the majority of SDR content (which is mastered on and for power-law gamma displays) looks incorrect in HDR. There is no option to adjust or disable this behavior and no documentation as to why this decision was made.</p>
<p>I couldn't bear to look at this anymore, so I put together a patch.</p>
<p><a href="https://getgloam.org">Gloam</a> (which takes its name from Windows’ inability to distinguish between night and noon), post processes the correction. The software intercepts Window's mangled gamma curve, applies a mathematical inverse, and then applies a proper gamma 2.2 or 2.4 curve per monitor restoring proper contrast. As an added bonus, we can apply a superior night mode in HDR (which breaks a lot of existing solutions), provide perceptual dimming to reduce the searing brightness of HDR without clamping, add per game (or program) picture profiles (superior contrast, etc), and if you have a colorimeter we can measure and correct your monitor's gamut and gamma response for more accurate images. All of this is done seamlessly, persistently, without any flicker, registry hacks, or heavy background software.</p>
<p>Gloam shouldn't exist, but it needs to.</p>
<p>What follows is a detailed explanation of what is wrong with Windows, how it came to be that way, why it's important, and how we fix it. There is some non-trivial math, but it's manageable and I'll explain as we go. This <em>is</em> a technical document, but it is also a chronical of my exasperation.</p>
<figure><img alt="On the left, an SDR image as Windows 11 HDR decodes it with the sRGB curve. On the right, the same image through Gloam's gamma 2.2. Detail from Georges de La Tour, The Penitent Magdalen, ca. 1640. Metropolitan Museum of Art, Open Access (CC0)." src="/images/825b51148d428ae64a9afd5f5579eccc.w1080.jpg" srcset="/images/825b51148d428ae64a9afd5f5579eccc.w480.jpg 480w, /images/825b51148d428ae64a9afd5f5579eccc.w768.jpg 768w, /images/825b51148d428ae64a9afd5f5579eccc.w1080.jpg 1080w, /images/825b51148d428ae64a9afd5f5579eccc.w1440.jpg 1440w, /images/825b51148d428ae64a9afd5f5579eccc.jpg 2000w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="2000" height="1429" decoding="async" fetchpriority="high" data-full="/images/825b51148d428ae64a9afd5f5579eccc.jpg" data-full-w="2000" data-full-h="1429"><figcaption>On the left, an SDR image as Windows 11 HDR decodes it with the sRGB curve. On the right, the same image through Gloam's gamma 2.2. Detail from Georges de La Tour, The Penitent Magdalen, ca. 1640. Metropolitan Museum of Art, Open Access (CC0).</figcaption></figure>
<hr>
<h2 id="i-gamma">I. Gamma</h2>
<p>This story begins with gamma and we must understand it and its origins before we can understand what Windows gets wrong.</p>
<p>Old displays used cathode ray tubes (CRTs) - vacuum tubes with an electron emitter or emitters that would shoot and excite a phosphorescent screen thus creating an image. These tubes had a non-linear relationship between input voltage and output luminance. At an input signal for 50% brightness, they'd emit ~22% of their maximum luminance following an approximate power law:</p>
<p><span class="math display">\[L = V^\gamma
\]</span></p>
<p>L is the output of a CRT phosphor (luminance) and the input signal V has an exponent <span class="math inline">\(\gamma\)</span> (gamma) which was typically ~2.5 for most phosphor types<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>. This gamma value is not arbitrary and derives from measured output and thus all media downstream needed to apply inverse functions at the source in order to preserve the perceptual accuracy (despite the downstream nonlinearities). This gamma correction worked well enough and has been around for decades. Gamma 2.2 (which represents the bulk of content today) originated as an ad hoc standard for computer monitors intended to compromise CRT nonlinearities and typical viewing conditions<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>.</p>
<p>Microsoft's error in this process, through ignorance or intention, was their assumption that content creators work with rigorous attention to specification. This is not to say that pockets of creators don't as anyone who has delivered something for theatrical release or thumbed through Netflix deliverable requirements will tell you, but &quot;content&quot; as an industry is vast and much of it is, let's say, less than rigorous. A typical game developer lighting a dungeon is not measuring contrast ratios and power laws, she is shifting values until the image looks great on her monitor. If that monitor is utilizing gamma 2.2 (as the vast majority of displays manufactured in the last three decades do) then those values implicitly encode gamma 2.2.</p>
<p>This is what Windows disregards.</p>
<hr>
<h2 id="ii-the-dead-spec">II. The Dead Spec</h2>
<p>A 1996 agreement between HP and Microsoft, sRGB was created to impose some sanity on the consumer digital imaging market. It defined a color space by specifying three primary colors and a white point, but it also defined how to map code values to luminance by means of a transfer function.</p>
<p>The sRGB transfer function is actually a little more elaborate than a simple 2.2 exponent: there’s also a linear section near zero, for numerical stability with eight-bit integers; beyond that it’s a 2.4-power function, with fudge factors to make the two sections join smoothly together.<sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup> It looks like this:</p>
<p><span class="math display">\[L = \begin{cases}
V / 12.92 & \text{if } V \leq 0.04045 \\

\left(\frac{V + 0.055}{1.055}\right)^{2.4} & \text{if } V > 0.04045
\end{cases}
\]</span></p>
<p>It’s an exchange rate that varies with the amount being converted. For large transactions (bright values), sRGB and gamma 2.2 use nearly the same rate, agreeing on how many dollars (units of light) to buy with a given number of pesos (signal values). But for small transactions (shadow values), they use different rates, with sRGB giving you more dollars per peso than gamma 2.2.</p>
<p>How much more? At the 10% signal level, which corresponds to deep shadow in most images, sRGB produces almost 60% more light than gamma 2.2 would; at the 5% level it produces almost three times as much.</p>
<figure><picture class="theme-dark"><source type="image/webp" srcset="/images/cee9dfb20860d229326b34b66f2d26f2.w480.webp 480w, /images/cee9dfb20860d229326b34b66f2d26f2.w768.webp 768w, /images/cee9dfb20860d229326b34b66f2d26f2.w1080.webp 1080w, /images/cee9dfb20860d229326b34b66f2d26f2.w1440.webp 1440w, /images/cee9dfb20860d229326b34b66f2d26f2.webp 2000w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px"><img alt="Both curves turn the same signal into light. Across the full range they are nearly the same curve, which is how a mismatch this size stayed invisible for a decade. Below 40% signal the gap opens and keeps widening: at 5% signal, sRGB emits 2.9 times the light gamma 2.2 does. The shaded area is the unwanted light Windows adds." src="/images/cee9dfb20860d229326b34b66f2d26f2.w1080.png" srcset="/images/cee9dfb20860d229326b34b66f2d26f2.w480.png 480w, /images/cee9dfb20860d229326b34b66f2d26f2.w768.png 768w, /images/cee9dfb20860d229326b34b66f2d26f2.w1080.png 1080w, /images/cee9dfb20860d229326b34b66f2d26f2.w1440.png 1440w, /images/cee9dfb20860d229326b34b66f2d26f2.png 2000w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="2000" height="962" decoding="async" loading="lazy" data-full="/images/cee9dfb20860d229326b34b66f2d26f2.png" data-full-webp="/images/cee9dfb20860d229326b34b66f2d26f2.webp" data-full-w="2000" data-full-h="962"></picture><picture class="theme-light"><source type="image/webp" srcset="/images/04c4bbae5eea204d066058cd1899a4a8.w480.webp 480w, /images/04c4bbae5eea204d066058cd1899a4a8.w768.webp 768w, /images/04c4bbae5eea204d066058cd1899a4a8.w1080.webp 1080w, /images/04c4bbae5eea204d066058cd1899a4a8.w1440.webp 1440w, /images/04c4bbae5eea204d066058cd1899a4a8.webp 2000w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px"><img alt="Both curves turn the same signal into light. Across the full range they are nearly the same curve, which is how a mismatch this size stayed invisible for a decade. Below 40% signal the gap opens and keeps widening: at 5% signal, sRGB emits 2.9 times the light gamma 2.2 does. The shaded area is the unwanted light Windows adds." src="/images/04c4bbae5eea204d066058cd1899a4a8.w1080.png" srcset="/images/04c4bbae5eea204d066058cd1899a4a8.w480.png 480w, /images/04c4bbae5eea204d066058cd1899a4a8.w768.png 768w, /images/04c4bbae5eea204d066058cd1899a4a8.w1080.png 1080w, /images/04c4bbae5eea204d066058cd1899a4a8.w1440.png 1440w, /images/04c4bbae5eea204d066058cd1899a4a8.png 2000w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="2000" height="962" decoding="async" loading="lazy" data-full="/images/04c4bbae5eea204d066058cd1899a4a8.png" data-full-webp="/images/04c4bbae5eea204d066058cd1899a4a8.webp" data-full-w="2000" data-full-h="962"></picture><figcaption>Both curves turn the same signal into light. Across the full range they are nearly the same curve, which is how a mismatch this size stayed invisible for a decade. Below 40% signal the gap opens and keeps widening: at 5% signal, sRGB emits 2.9 times the light gamma 2.2 does. The shaded area is the unwanted light Windows adds.</figcaption></figure>
<p>The difference diminishes through the midtones and disappears in the highlights, but is concentrated where we most need darkness: in the shadows that give an image its atmospheric depth; in the blacks that give weight and substance to a picture; in the darks that video games and films use to create mood and tension.</p>
<p>For thirty years now, Windows has operated with two different transfer functions: a de jure piecewise sRGB and a de facto gamma 2.2 that the hardware actually implements. The specification was acknowledged in documents but universally ignored in silicon: virtually no monitor implements piecewise sRGB. Instead, it implements gamma 2.2, which is what the hardware naturally provides and what the industry standardized on long before sRGB was published. Nobody cared because monitors ignored the specification anyway, so the two functions were different but irrelevant.</p>
<p>Then came HDR, and Windows finally began to respect its own spec.</p>
<hr>
<h2 id="iii-the-hdr-pipeline-and-its-discontents">III. The HDR Pipeline and Its Discontents</h2>
<p>With HDR enabled in Windows 11, the Desktop Window Manager (DWM) becomes a critical component of the display pipeline. It acts as an intermediary between apps and your HDR panel, taking in content from windows, translating it into a common format for compositing before sending the final image to be displayed on screen.</p>
<p>For native HDR content, this translation is straightforward. However, the majority of content remains SDR: websites, desktop applications, older games, photographs. This must be lifted into the HDR space, which requires a decision on how to interpret the SDR values.</p>
<p>They chose to view them through sRGB-tinted glasses.</p>
<p>The DWM decodes SDR content using the piecewise sRGB curve, lifts it into a linear light space scaled to your configured “SDR Content Brightness” level, and then re-encodes it using the ST.2084 Perceptual Quantizer (PQ) curve for HDR output.<sup id="fnref:4"><a href="#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup> Each step of this process follows from a mathematically rigorous specification, and the end result of all that spec-compliance is that shadows look like garbage.</p>
<p>This is because the content was not created for sRGB decoding. The game developer, the photographer, the web designer worked on gamma 2.2 monitors, so their shadow values assume gamma 2.2 decoding. When Windows decodes those same sRGB values it produces more light in the shadows than the creators intended. The dungeon that was supposed to be dark becomes hazy, atmospheric fog that was supposed to suggest depth is now an even wash. True blacks OLED technology enables become filled with phantom gray.</p>
<p>I first thought I had misconfigured something, having observed this on three HDR enabled displays all showing the same lifted shadows with my SDI connected color critical Flanders (incidentally also an OLED), displaying reality, alongside them. I went down a rabbit hole of display settings, driver control panels, monitor OSD menus, calibration software that produced results that drifted and shifted, never stabilizing. I discovered connecting all three Windows displays simultaneously causes the system to seize, screens flashing on and off in a cascade that can only be resolved by forcing shutdown. I was frustrated. Deeply frustrated really and so I decided to put some math together to handle this translation properly.</p>
<hr>
<h2 id="iv-the-correction">IV. The Correction</h2>
<p>I was not the first to diagnose it. <a href="https://github.com/dylanraga/win11hdr-srgb-to-gamma2.2-icm">Dylan Raga</a> spotted that sRGB vs gamma-2.2 mismatch in the Windows HDR compositor and published a corrected ICC profile to fix it; Gloam began as a fork of his work, but has grown into something more.<sup id="fnref:5"><a href="#fn:5" class="footnote-ref" role="doc-noteref">5</a></sup> The diagnosis is the hard part (all due respect to Dylan who saved me enormous amounts of time); what follows is built on top of that realization.</p>
<p>Fix what Windows broke, then do it right.</p>
<p>Gloam produces a look-up table, a LUT, that says &quot;when you see X, output Y.&quot; Windows has computed a PQ value for each shadow level that corresponds to sRGB. The LUT changes the output so that Windows will send a PQ value for each shadow level that corresponds to gamma 2.2 instead of sRGB. The display then converts the PQ value into light, and the shadows are fixed.</p>
<p>Five steps, I'll spare the math (see it in the <a href="https://getgloam.org/whitepaper">whitepaper</a>). Picture a chain of translators.</p>
<ol>
<li>
<p>Windows encoded its linear light values, the incorrect ones, using the ST.2084 PQ curve. Decode this first, to recover what Windows thought the light should have been.</p>
</li>
<li>
<p>Windows assumed the original content was sRGB-encoded. Reverse that by applying the sRGB encoding function; in essence, ask what input value would have produced this output under sRGB decoding.</p>
</li>
<li>
<p>With the original signal value reconstructed, decode it using gamma 2.2 (or gamma 2.4 for dark-room viewing), which is what the content actually expected.</p>
</li>
<li>
<p>Re-encode the corrected linear light value back into PQ for the trip to the HDR display.</p>
</li>
<li>
<p>Above the SDR range, blend smoothly toward identity, so true HDR content is left untouched and only the SDR region is corrected.</p>
</li>
</ol>
<p>The key characteristic of HDR, the greatly expanded luminance range it offers (modern displays can reach up to 1,000 or even 4,000 nits, versus only 80-480 nits in SDR content), makes this final step especially critical. Certain applications labeled &quot;SDR&quot; in fact make use of the extra headroom that HDR displays provide, for bright highlights and UI elements. If we applied our shadow correction to such bright values, we would improperly crush them. What we want instead is a smooth blending, from full correction at the SDR white level to no correction whatsoever as luminance rises toward the HDR peak.</p>
<p>This correction curve is computed at 1024 points per channel and then resampled onto the 256-entry hardware gamma ramp that every GPU exposes. Since a display's white level is part of its transfer function, each monitor gets its own curve. Early builds of Gloam shelled out to ArgyllCMS's dispwin utility to load the curve onto the hardware; now it writes the ramp directly through the same Win32 call, keeping dispwin as a fallback for display drivers that refuse it.<sup id="fnref:6"><a href="#fn:6" class="footnote-ref" role="doc-noteref">6</a></sup></p>
<p>The correction persists until something else overwrites the hardware gamma ramp, and things do: fullscreen games and driver events silently reset it. So Gloam reads back the hardware state, verifies the curve it set is still in place, and restores it when it has been stomped, besides reapplying automatically at startup, on display changes, and on resume from sleep.</p>
<hr>
<h2 id="v-beyond-shadows-night-mode-and-perceptual-dimming">V. Beyond Shadows: Night Mode and Perceptual Dimming</h2>
<p>The gamma correction addresses the core issue, yet a screen operates within an environment, and environments vary. The program enhances the primary correction in multiple ways, each responding to real-world shortcomings of other software.</p>
<p>Night mode means warming your display’s color temperature in the evening to cut down on blue light. Our circadian rhythms are attuned to the changing color temperature of natural daylight, warm at sunrise and sunset, neutral around midday, cooler in overcast conditions. Blue light exposure in the evening inhibits melatonin secretion and disrupts healthy sleep-wake cycles. Windows provides a &quot;Night Light&quot; setting for this, while third-party solutions such as f.lux (or redshift) have long been available.</p>
<p>These SDR-oriented tools were never designed to operate in HDR, so it should come as no surprise that they produce aberrations on HDR panels. When I tested f.lux, Lightbulb, and Windows Night Light with my HDR displays, the results varied from useless to psychotic. The colors shifted in directions they weren’t supposed to go, luminance relationships were warped in ways that made the images look damaged rather than warmed. These tools work by manipulating gamma tables or using compositor overlays, but the internal conversions of the HDR pipeline interact with these manipulations in unintended ways.</p>
<p>By generating the LUT with an integrated warming computation instead of a post-process overlay, Gloam can compute the shift as chromatic adaptation: moving white point in CAT16 cone space, with an explicit degree of adaptation,<sup id="fnref:7"><a href="#fn:7" class="footnote-ref" role="doc-noteref">7</a></sup> similar to the way the human eye adapts to warmer lighting conditions. Most tools warm an image by cutting the blue channel, but Gloam's adaptation keeps neutrals gray even at candle temperatures—while a cut drags them toward muddy green-brown. The adjustment is carried in high-precision floating point through to quantization, preserving shadow integrity despite the warming. Transitions are paced in mired space, a perceptually uniform scale for color temperature steps—fades from 6500 K to 2700 K move at a constant apparent rate, avoiding the crawling and lurching of fixed-step fades. Geographic coordinates enable solar position calculations for automatic transitions tracking the actual sun, not rigid schedules oblivious to seasonal variation. For deepest evening settings Gloam's Ultra Night profile can compute warmth spectrally, and accept a measured sample of your own panel's emission spectra. (This is what I use and it's hard to go back to anything else once you get used to it).</p>
<figure><picture class="theme-light"><source type="image/webp" srcset="/images/848afe67921492ec8df5e333a42fdb08.w480.webp 480w, /images/848afe67921492ec8df5e333a42fdb08.w768.webp 768w, /images/848afe67921492ec8df5e333a42fdb08.w1080.webp 1080w, /images/848afe67921492ec8df5e333a42fdb08.w1440.webp 1440w, /images/848afe67921492ec8df5e333a42fdb08.webp 2000w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px"><img alt="Every mode at 3400 K against the same frame. The top four are all aiming at
the same Planckian point and land in nearly the same place: redshift's
published table works out to 1.000 / 0.561 / 0.242 in linear light, between
Gloam's Standard and its CIE 1931 solution. The lower band is where Gloam
moves into novelty. Blue reduction keeps the green and most of the blue,
Perceptual eases the shift back toward neutral with an incomplete adaptation
of D = 0.8, and Ultra night gives up color entirely for the deepest cut.
Redshift is the canonical open
implementation of the same blackbody approach, and its table can be cited. The
Gloam multipliers are read from the shipping app rather than modelled.
Pieter Bruegel the Elder, The Harvesters, 1565. Metropolitan Museum of Art,
Rogers Fund, 1919. Open Access (CC0)." src="/images/848afe67921492ec8df5e333a42fdb08.w1080.png" srcset="/images/848afe67921492ec8df5e333a42fdb08.w480.png 480w, /images/848afe67921492ec8df5e333a42fdb08.w768.png 768w, /images/848afe67921492ec8df5e333a42fdb08.w1080.png 1080w, /images/848afe67921492ec8df5e333a42fdb08.w1440.png 1440w, /images/848afe67921492ec8df5e333a42fdb08.png 2000w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="2000" height="1111" decoding="async" loading="lazy" data-full="/images/848afe67921492ec8df5e333a42fdb08.png" data-full-webp="/images/848afe67921492ec8df5e333a42fdb08.webp" data-full-w="2000" data-full-h="1111"></picture><picture class="theme-dark"><source type="image/webp" srcset="/images/07d28f6e6c9b1e4e50c18adcba039af1.w480.webp 480w, /images/07d28f6e6c9b1e4e50c18adcba039af1.w768.webp 768w, /images/07d28f6e6c9b1e4e50c18adcba039af1.w1080.webp 1080w, /images/07d28f6e6c9b1e4e50c18adcba039af1.w1440.webp 1440w, /images/07d28f6e6c9b1e4e50c18adcba039af1.webp 2000w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px"><img alt="Every mode at 3400 K against the same frame. The top four are all aiming at
the same Planckian point and land in nearly the same place: redshift's
published table works out to 1.000 / 0.561 / 0.242 in linear light, between
Gloam's Standard and its CIE 1931 solution. The lower band is where Gloam
moves into novelty. Blue reduction keeps the green and most of the blue,
Perceptual eases the shift back toward neutral with an incomplete adaptation
of D = 0.8, and Ultra night gives up color entirely for the deepest cut.
Redshift is the canonical open
implementation of the same blackbody approach, and its table can be cited. The
Gloam multipliers are read from the shipping app rather than modelled.
Pieter Bruegel the Elder, The Harvesters, 1565. Metropolitan Museum of Art,
Rogers Fund, 1919. Open Access (CC0)." src="/images/07d28f6e6c9b1e4e50c18adcba039af1.w1080.png" srcset="/images/07d28f6e6c9b1e4e50c18adcba039af1.w480.png 480w, /images/07d28f6e6c9b1e4e50c18adcba039af1.w768.png 768w, /images/07d28f6e6c9b1e4e50c18adcba039af1.w1080.png 1080w, /images/07d28f6e6c9b1e4e50c18adcba039af1.w1440.png 1440w, /images/07d28f6e6c9b1e4e50c18adcba039af1.png 2000w" sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1080px) calc(100vw - 4rem), 1016px" width="2000" height="1111" decoding="async" loading="lazy" data-full="/images/07d28f6e6c9b1e4e50c18adcba039af1.png" data-full-webp="/images/07d28f6e6c9b1e4e50c18adcba039af1.webp" data-full-w="2000" data-full-h="1111"></picture><figcaption>Every mode at 3400 K against the same frame. The top four are all aiming at
the same Planckian point and land in nearly the same place: redshift's
published table works out to 1.000 / 0.561 / 0.242 in linear light, between
Gloam's Standard and its CIE 1931 solution. The lower band is where Gloam
moves into novelty. Blue reduction keeps the green and most of the blue,
Perceptual eases the shift back toward neutral with an incomplete adaptation
of D = 0.8, and Ultra night gives up color entirely for the deepest cut.
Redshift is the canonical open
implementation of the same blackbody approach, and its table can be cited. The
Gloam multipliers are read from the shipping app rather than modelled.
Pieter Bruegel the Elder, The Harvesters, 1565. Metropolitan Museum of Art,
Rogers Fund, 1919. Open Access (CC0).</figcaption></figure>
<p>Simply reducing the brightness of an OLED display by multiplying all pixel values with a scaling factor is not a viable solution because it crushes shadow detail before meaningfully dimming the highlights. Perceptual dimming seeks to avoid this problem. OLED displays can be uncomfortably bright in low-light conditions despite their ability to render perfect blacks, the technology’s searing highlights prove problematic.</p>
<p>The eye’s response to light is compressive, not linear: equal increments of luminance are perceived as progressively smaller changes in brightness. By implementing perceptual rather than physical dimming, this software preserves shadow separation while reducing overall luminance,<sup id="fnref:8"><a href="#fn:8" class="footnote-ref" role="doc-noteref">8</a></sup> much as an orchestra turned down proportionally keeps its quiet passages audible while the fortissimos become tolerable. The same principle is employed in professional photo editing software to adjust the exposure of images, and here it is applied to real-time display output.</p>
<hr>
<h2 id="vi-the-meter-has-the-last-word">VI. The Meter Has the Last Word</h2>
<p>The reference monitor is where I began, and it would be dishonest to end this essay without circling back. A correction derived from mathematics is a model, and models drift away from reality in all the usual ways. Panels age, factory calibrations lie, two units of the same model will disagree with each other. Gloam has a measurement loop built into it, so you can connect a colorimeter and drive ArgyllCMS through a full characterization of the panel, in SDR or HDR (it measures patches at known positions on the PQ curve rather than trusting what the display claims about itself). This correction gets baked into an MHC2 transform in a Windows color profile, which the Windows compositor reads. This means it will persist as a system level setting and work with live gamma curves, night mode etc rather than against them. Gloam applies the profile and then re-measures the panel through it, so that every number in the final report is a post-install measurement.</p>
<hr>
<h2 id="vii-the-gap-between-specification-and-practice">VII. The Gap Between Specification and Practice</h2>
<p>The Windows HDR debacle has a broader lesson, about standards and specifications, about the difference between what they say and what practitioners do.</p>
<p>It was published in 1996, this sRGB thing. For almost thirty years now it has specified a transfer function that virtually no display actually implements and many common desktop, game, photographic, and video workflows have historically been judged on displays closer to a power-law response. The specification exists but reality ignores it; everything works because the mismatch is consistent - gamma 2.2 monitors receiving gamma 2.2-mastered content through a pipeline that nominally calls itself &quot;sRGB&quot; but actually behaves otherwise. The lie was persistent and I suppose persistence is a kind of stability and a stable lie cannot be told from truth.</p>
<p>Then, in the name of correctness, Microsoft decided to enforce the spec. They built an HDR compositor that implemented sRGB exactly as described in the document. And suddenly every piece of SDR content looked wrong: every game, every photograph, every website that had been lovingly mastered over the course of three decades on displays with a gamma of 2.2. It looked wrong not because the content had changed, and it looked wrong not because displays had changed, but because Microsoft had corrected a bug that was, in classic Microsoft fashion, actually a feature: they'd fixed something that wasn't broken, applied a spec to a world that had evolved around ignoring it.</p>
<p>Gloam is itself a lie: it pretends Windows did not do what Windows did. It's a counter-lie that restores the old regime of mutual deception. The LUT says &quot;when you see this value, output that value instead,&quot; and means &quot;forget what Windows just told you.&quot; A hack in the purest sense, working around the system rather than through it, succeeding by subversion instead of cooperation.</p>
<p>Microsoft should expose a setting: sRGB for content that theoretically targets it, gamma 2.2 for the overwhelming majority that assumes it, and gamma 2.4 for professional dark-room environments. Technically this is easy to do since the parameter already exists in their pipeline, it merely needs a control surface. But institutional inertia is powerful and those who understand the problem rarely have the organizational capital to ship solutions.</p>
<p>Until such a fix becomes available, if ever, we must make ourselves into the users that consumer operating systems are designed to protect us from being. We must learn how to calculate transfer functions, generate LUTs, and encode PQ; we must compile tools, create profiles, and write values to hardware registers that Microsoft would rather we not touch.</p>
<p>Gloam is free and MIT licensed. Get it at <a href="https://getgloam.org">getgloam.org</a> or on GitHub at <a href="https://github.com/halideworks/gloam">github.com/halideworks/gloam</a>. The <a href="https://getgloam.org/whitepaper">whitepaper</a> documents the mathematics in detail, including what the correction can and cannot claim. My displays now show shadows that are genuinely black, color temperatures that smoothly shift with the sun, and brightness levels that I can tolerate in the evening. The light is true at last, because I wrote software to correct for the pipeline's lies.</p>
<hr>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>Charles Poynton's <em>Digital Video and HDTV: Algorithms and Interfaces</em> (Morgan Kaufmann, 2003) is the technical reference for display color science. It covers CRT gamma physics and the standardization processes that followed, in great detail.&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>Gamma 2.2, the unofficial standard, arose from a dialogue between CRT display physics and office environments with their typical levels of ambient illumination. The International Electrotechnical Commission's IEC 61966-2-1:1999 specification for sRGB recognizes that &quot;a display gamma of 2.2 is assumed&quot; under viewing conditions where the ambient light exceeds 64 lux, which describes most computer use. For dark-room viewing conditions in cinema where ambient light does not raise the effective black level, gamma 2.4 (formalized in BT.1886) has been adopted as the standard.&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3">
<p>The sRGB standard was a 1996 joint effort by Hewlett-Packard and Microsoft: Michael Stokes et al., &quot;A Standard Default Color Space for the Internet - sRGB.&quot; Its linear segment near zero was there to prevent numerical instability when quantizing to 8 bits, an issue for 1996-era systems that has faded with wider bit depths but remains frozen into the specification.&#160;<a href="#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:4">
<p>SMPTE ST 2084:2014, &quot;High Dynamic Range Electro-Optical Transfer Function of Mastering Reference Displays.&quot; Dolby designed the Perceptual Quantizer (PQ) curve to match the human perceptual response to luminance, which lets it encode the full 0–10,000 nit range of HDR efficiently; the PQ constants come from the Barten contrast-sensitivity model.&#160;<a href="#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:5">
<p>Gloam builds on Dylan Raga's identification of Windows HDR's sRGB/gamma 2.2 mismatch and his ICC-profile fix for it (github.com/dylanraga/win11hdr-srgb-to-gamma2.2-icm), the insight the whole application is built around, and adds live per-monitor correction, a CAT16 night mode, perceptual dimming, gamma 2.4 support, per-game profiles, persistent automatic reapplication, and measured colorimeter calibration.&#160;<a href="#fnref:5" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:6">
<p>ArgyllCMS, the Graeme Gill project that has been load-bearing in Gloam's implementation, supplies all of the measurement and profiling tools behind each colorimeter calibration, and its dispwin utility provides a fallback mechanism for loading gamma ramps in the event that the direct call is rejected by the driver. Gloam's native path writes the same 256-entry hardware ramp that dispwin would, but it does so without spawning a separate process on every adjustment.&#160;<a href="#fnref:6" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:7">
<p>The default degree-of-adaptation D = 0.8 in Gloam reflects an empirical fact: human adaptation to a colored illuminant is never complete. CAT16 is the chromatic adaptation transform underlying the CAM16 appearance model (Li, C. et al., &quot;Comprehensive color solutions: CAM16, CAT16, and CAM16-UCS,&quot; <em>Color Research &amp; Application</em> 42(6), 2017). A full adaptation would warm the image too much, washing out color intrinsic to the content; at D = 0.8 the image warms while keeping more of the content's own color.&#160;<a href="#fnref:7" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:8">
<p>Linear dimming crushes the shadows: scale everything down by, say, 30%, and what used to be near-black detail drops below the visible floor of your display long before the highlights start feeling dim. Gloam's perceptual mode fixes white at the requested brightness while easing the exponent of the curve as brightness falls, preserving near-black separation during reduction; linear dimming is available as an option.&#160;<a href="#fnref:8" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded>
</item>
<item>
<title>SchedLock</title>
<link>https://davidtorcivia.com/posts/schedlock</link>
<guid isPermaLink="true">https://davidtorcivia.com/posts/schedlock</guid>
<pubDate>Sat, 31 Jan 2026 00:00:00 +0000</pubDate>
<description>A proxy that holds an AI agent&#39;s calendar writes in a pending state until you approve them, so a compromised or confused agent never touches your schedule directly.</description>
<category>ai</category><category>agents</category><category>security</category>
<content:encoded><![CDATA[<figure><img src="https://images.disinfo.zone/uploads/Bkn7gnKuOtksjY0JDt586bMpk9heYvyEnOOaysi6.jpg" alt=""></figure>
<p>You might have noticed, while browsing the web, a proliferation of AI agents: moltbot, openclaw, claudebot, and so on—dozens of them, I've lost count. They are capable of quite sophisticated behavior (at least in appearance), and if you use one long enough you may find yourself restructuring your work around what it can do for you. Pretty soon you're wondering what other tasks it could take off your hands, and before you know it you've delegated a large portion of your digital life to an AI agent - terrifying in practice. That's why I'm both interested in these tools and wary of them, since they are only useful in time saving after you've handed over control.</p>
<p>The trouble with agents is that most were built fast and shipped faster, so it should be no surprise their threat models show it. Every new permission you grant widens the attack surface, and vibe-coded security guarantees are worth about what you’d expect them to be. An agent that can read your calendar, send emails on your behalf, and browse the web for you is also an agent that can be manipulated into doing all of those things on behalf of whoever slips the right instructions into its context window. Prompt injection remains an unsolved problem, yet we keep granting the access anyway.</p>
<p>I didn’t want to bet that my agent would never be compromised or hallucinate a meeting into my calendar at three in the morning. So I built SchedLock this week, which I’ve since been using to mediate between my agent setup and Google Calendar.</p>
<p>SchedLock is a proxy that sits between your agent and the calendar API. Reads go through with whatever access level you configure, so the agent can see your schedule and check availability; or you can lock it down further. Writes never go through: a new event, a moved meeting, or a cancellation are all captured as pending requests and sent to you for approval before anything happens on your calendar. A notification is sent via ntfy, Pushover, Telegram or a webhook for custom integrations and you can approve or deny the agent’s request with one tap.</p>
<p>The point of putting the human in at the proxy layer is how failure looks. If an agent gets talked into scheduling a bunch of garbage, well, that's an explicit request it made to you which you can reject. If it misunderstands your instructions and starts doing the wrong thing, it will surface that misunderstanding to you as a notification on your phone so you can correct it before the problem becomes too big or messy to handle.</p>
<p>Your calendar entries are written to an audit log. Yes, this sounds like bureaucracy, and yes, you will come to appreciate it the first time you wish to retrace your steps. An agent with access to your life will take small liberties over a period of weeks, and you won't remember approving each one. That's why the audit log is there: so you can go back and confirm that every calendar write traces to a request you said yes to.</p>
<p>Three kinds of API key, read-only keys that can only make queries; write-keys that can only submit requests for approval; admin keys, which are used to configure the system. API keys are hashed with HMAC-SHA256; Google OAuth tokens are encrypted with AES-256-GCM, and the decision-tokens in approval-callbacks are single-use. You can have multiple notification-providers active simultaneously; get the push-notification on your phone, and a webhook to your home-automation system, and a Telegram message too; if you are the paranoid type. You should be.</p>
<p>On the agent side there is no plug-in, and there’s no bespoke interface to stand up. Instead the agent learns about the proxy from a SKILL.md file that is hosted at a URL you provide, which it will consult when calendar work comes up, in much the same way that a person might learn to operate an unfamiliar tool by consulting its documentation. I like this approach because it makes calendar access into something the agent was taught, and makes the approval requirement part of that lesson.</p>
<p>After a few days it works. The agent makes suggestions, I get notified, and if any of them seem reasonable I confirm them; my calendar stays under my control. There's some latency involved with the approval flow but I can tolerate it; most suggestions are confirmed within seconds of being buzzed and the agent copes well with that.</p>
<p>There are still some rough edges I am filing down. The underlying architecture seems solid, though, and this problem is common enough that it seemed better to release the code rather than sit on it until it was polished. It is at <a href="https://github.com/davidtorcivia/schedlock">github.com/davidtorcivia/schedlock</a> and it deploys with Docker. The state is stored in a SQLite database, and there's a web UI for configuration - and for approving things when you have access to a keyboard, rather than only your phone. See the README for details on getting it up and running. If you are running agents that have access to your calendar, and this has been causing you sleepless nights - well, here is one possible solution.</p>
]]></content:encoded>
</item>
<item>
<title>Four Maxims for Technology</title>
<link>https://davidtorcivia.com/posts/four-maxims-for-technology</link>
<guid isPermaLink="true">https://davidtorcivia.com/posts/four-maxims-for-technology</guid>
<pubDate>Fri, 14 Nov 2025 00:00:00 +0000</pubDate>
<description>Four working principles for living with technology deliberately — choose it wisely, know yourself, balance old ways against new, and make your vision visible.</description>
<category>technology</category><category>philosophy</category>
<content:encoded><![CDATA[<h4 id="i-use-your-own-technology-wisely">I. Use your own technology, wisely</h4>
<p>We pick up our devices a thousand times a day, doorknobs we don't bother to notice. Phones and feeds are little glowing rectangles that follow us to bed, but they are not our friends. They belong to proprietors who have plans for you and me. It is our responsibility to hold them to the light, interrogate them as we would a stranger who demanded hours of our day: Is this serving my deepest desires? Might it serve me better than it does? Could I employ it, honestly use it to the good of somebody else?</p>
<h4 id="ii-know-thyself">II. Know thyself</h4>
<p>Technology is a lever, and only as good as the ground under the person pulling it. It works best in the hands of someone who at least roughly knows, who it is that's pulling.</p>
<p>The problem with most people is they don't know themselves very well. So when they say things like &quot;I want to resist technology&quot; or &quot;I want my life back,&quot; it's empty words, because they don't really know what they want, or don't want, from the machines.</p>
<p>If you really want to resist technology, first figure out who it is that's doing the wanting. Go looking for yourself in all the old places: your spirit, your quest to become someone; your watching of your own mind in its silent hours; and not least, your honest talk with those who know you.</p>
<p>Get to know yourself first, then decide what it is that you want from the machines. They can wait.</p>
<h4 id="iii-find-the-right-balance">III. Find the right balance</h4>
<p>The old ways still have their uses. The new tools can be very helpful, but there is a time and a place for all things, even in this age of digital everything. It would be foolish to believe that the new tools have made the old ones obsolete, or that the darkroom is now a museum piece. The handwritten letter may be an affectation in our time, but it still has its place - some tasks want the sensor, and some want a letter that will take a week to arrive, to be kept in a drawer for thirty years. We must learn both the old crafts and the new, so that either will come readily to hand when called for,  but more importantly we must learn to tell which one is really needed in a given situation.</p>
<h4 id="iv-share-your-vision">IV. Share your vision</h4>
<p>However you move through all this, do not go about it invisibly. A path is worth little if kept private: talk about it, write it down, make pictures or films  an institution around it - or even just a table where people come and sit, and talk. Those close to you are also moving through this same miasma of machines without a map: they deserve from you an account. Give them a destination, give them a clear image - where are they heading? What will they become? How can they get there intact?</p>
]]></content:encoded>
</item>
</channel>
</rss>
