<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
<title type="text">code.dblock.org | tech blog</title>
<generator uri="https://github.com/mojombo/jekyll">Jekyll</generator>
<link rel="self" type="application/atom+xml" href="https://code.dblock.org/feed.xml" />
<link rel="alternate" type="text/html" href="https://code.dblock.org" />
<updated>2026-10-11T17:44:30+00:00</updated>
<id>https://code.dblock.org/</id>
<author>
  <name>Daniel Doubrovkine</name>
  <uri>https://code.dblock.org/</uri>
  <email>dblock@dblock.org</email>
</author>


<entry>
  <title type="html"><![CDATA[ELPDS: Inspecting FAT Partitions in 1995]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/10/11/elpds-a-1995-dos-disk-inspection-tool.html" />
  <id>https://code.dblock.org/2026/10/11/elpds-a-1995-dos-disk-inspection-tool</id>
  <published>2026-10-11T00:00:00+00:00</published>
  <updated>2026-10-11T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;I recovered another program I wrote in 1995: &lt;a href=&quot;https://github.com/dblock/elpds&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ELPDS&lt;/code&gt;&lt;/a&gt;, or Enhanced Logical &amp;amp; Physical Drive Status. Run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ELPDS C:&lt;/code&gt; and it reports physical disk geometry, partition entries, FAT boot-sector details, and free space. It doesn’t change anything on the disk. My help says it was for checking whether a partition existed and was formatted. I think I was just trying to learn about the underlying partition mechanics.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-10-11-elpds-a-1995-dos-disk-inspection-tool/elpds-dosbox.png&quot; alt=&quot;ELPDS inspecting a FAT16 demo disk in DOSBox-X&quot; /&gt;&lt;/p&gt;

&lt;p&gt;I didn’t write all of it. The help credits “Mr. Schaeffer” for a “huge part of FIPS code.” That was Arno Schaefer, whose name I misspelled. &lt;a href=&quot;https://en.wikipedia.org/wiki/FIPS_(computer_program)&quot;&gt;FIPS&lt;/a&gt; could split a FAT partition without deleting its files, often to make room for Linux. Its &lt;a href=&quot;https://web.archive.org/web/20060426002555/http://www.igd.fhg.de:80/~aschaefe/fips/&quot;&gt;original project page&lt;/a&gt; and &lt;a href=&quot;https://web.archive.org/web/20090413115233/http://bmrc.berkeley.edu/people/chaffee/fips/fips.html&quot;&gt;Berkeley project page&lt;/a&gt; are archived. Comparing my code with the &lt;a href=&quot;https://web.mit.edu/ghudson/sipb/fips/logdr_st.cc&quot;&gt;archived FIPS source&lt;/a&gt; found nearly identical boot-sector parsing, disk geometry routines, and even comments. I had turned its disk-reading machinery into a standalone status tool.&lt;/p&gt;

&lt;p&gt;GitHub Copilot CLI and I made a separate DOS port using Open Watcom, leaving the original source and executable unchanged. It runs in DOSBox-X against a disposable FAT16 disk image: 16 heads, 63 sectors per track, 65 cylinders, and about 32 MiB. We fixed decoding and error handling, but this is still a tool for old DOS disks, not modern ones.&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://github.com/dblock/elpds&quot;&gt;source, port, and build instructions are on GitHub&lt;/a&gt;. FIPS was GPL version 2 or later, so ELPDS is too. Thirty-one years later, I finally got the author’s name and the license right.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/10/11/elpds-a-1995-dos-disk-inspection-tool.html&quot;&gt;ELPDS: Inspecting FAT Partitions in 1995&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on October 11, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[A 1998 PDF-to-Text Converter]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/10/09/a-1998-pdf-to-text-converter.html" />
  <id>https://code.dblock.org/2026/10/09/a-1998-pdf-to-text-converter</id>
  <published>2026-10-09T00:00:00+00:00</published>
  <updated>2026-10-09T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;In 1998 I needed to convert PDF files to text for a search engine called Alkaline, without requiring separately installed libraries. So I wrote a C++ parser. Alkaline is another story.&lt;/p&gt;

&lt;p&gt;I recovered the source from a backup dated December 26, 1998. It reads PDF 1.0 through 1.2, follows object references and page trees, decompresses content with bundled zlib, and prints metadata, page text and bookmark titles. There are my own string, vector and hash-table libraries in there too. The encryption code is a stub. This was a work in progress.&lt;/p&gt;

&lt;p&gt;I wasn’t reverse engineering an undocumented format. Adobe had &lt;a href=&quot;https://www.adobe.com/accessibility/pdf.html&quot;&gt;published the specification in 1993&lt;/a&gt;, and my comments refer to the PDF Reference, right down to a page number for stream filters. This predates tagged PDF, introduced in 2001. Getting text meant interpreting page drawing commands, not following a tagged reading order.&lt;/p&gt;

&lt;p&gt;I was very impressed by how clever the format was. You start near the end, find the cross-reference table, and use its byte offsets to jump straight to the objects you need. A page points to its content and resources, so the reader follows those references rather than parsing the whole file from beginning to end. You don’t have to read the entire document to render one page.&lt;/p&gt;

&lt;p&gt;GitHub Copilot CLI and I made it build again as a C++17 port, keeping the parsing approach but replacing the old containers with the standard library and using system zlib. The original is preserved unchanged. The port fixes binary stream lengths, line endings and several text operators, but it still targets those old PDF versions. Don’t point it at a modern PDF and expect miracles.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-10-09-a-1998-pdf-to-text-converter/pdfreader.gif&quot; alt=&quot;The restored PDF reader converting a sample document to text&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://github.com/dblock/cplusplus-pdf-reader&quot;&gt;source and port are on GitHub&lt;/a&gt;, under the MIT license.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/10/09/a-1998-pdf-to-text-converter.html&quot;&gt;A 1998 PDF-to-Text Converter&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on October 09, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Universal Copy System Link for 97 Windows PCs]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/10/03/ucslink-fusion-configuring-university-workstations-in-1997.html" />
  <id>https://code.dblock.org/2026/10/03/ucslink-fusion-configuring-university-workstations-in-1997</id>
  <published>2026-10-03T00:00:00+00:00</published>
  <updated>2026-10-03T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;In 1997 I wrote &lt;a href=&quot;https://github.com/dblock/ucsLink.Fusion&quot;&gt;UcsLink.Fusion&lt;/a&gt; for the &lt;a href=&quot;https://www.unige.ch/&quot;&gt;University of Geneva&lt;/a&gt;. We had hundreds of PCs across the city, Sun and Windows NT servers, and Windows 95 and Linux workstations. Getting a user’s software and settings onto whichever PC they sat down at was a job for login scripts. I wrote a Delphi 2.0 application to do it instead.&lt;/p&gt;

&lt;p&gt;UCS stood for Universal Copy System. Fusion read a script that mapped network drives, copied shared software, created shortcuts, connected printers, set environment variables and synchronized the clock. It could include other scripts, test conditions and clean up files left by a previous run. The surviving configuration maps a personal drive, a software drive and a group drive, then chooses printers based on the workstation’s location.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-10-03-ucslink-fusion-configuring-university-workstations-in-1997/fusion_treeview.gif&quot; alt=&quot;The original UcsLink.Fusion user options browser&quot; /&gt;&lt;/p&gt;

&lt;p&gt;A user could disable optional software rather than wait for it to copy at every login, or install it later by double-clicking its icon. Administrators could make tasks mandatory and protect individual options. A tree showed the tasks and their settings; a second list showed what would run. User choices went into a personal profile, separate from the shared script.&lt;/p&gt;

&lt;p&gt;When Fusion started at login, it displayed the little progress window below, showing the tasks as it configured the workstation.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-10-03-ucslink-fusion-configuring-university-workstations-in-1997/fusion_listview.gif&quot; alt=&quot;The original UcsLink.Fusion task list&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Fusion could scan a shared software directory and automatically add each application it found to the list of available software. Each application could come with its own script describing how to install and configure it. Tasks normally ran one after another, but a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;detached&lt;/code&gt; option let copies or banners run concurrently. Later, server-generated “virtual” scripts grouped applications into one file to avoid dozens of slow network accesses.&lt;/p&gt;

&lt;p&gt;Fusion was released as &lt;a href=&quot;https://github.com/dblock/ucsLink.Fusion/blob/master/LICENSE&quot;&gt;public-domain software&lt;/a&gt;. It was also used at &lt;a href=&quot;https://web.archive.org/web/19980529151154/http://www.usu.br/1itic.htm&quot;&gt;ITIC&lt;/a&gt;, the Instituto de Tecnologia da Informação e da Comunicação (Institute of Information and Communication Technology) at &lt;a href=&quot;https://usu.edu.br/&quot;&gt;Universidade Santa Úrsula&lt;/a&gt; in Rio de Janeiro. Its &lt;a href=&quot;https://web.archive.org/web/19980529152924/http://www.usu.br/2itichist.htm&quot;&gt;archived history page&lt;/a&gt; says the institute was established on March 30, 1995, initially offering Computer Engineering. It later added an information technology degree and Library Science, with an emphasis on information management. Its &lt;a href=&quot;https://web.archive.org/web/19980529152836/http://www.usu.br/2iticmiss.htm&quot;&gt;mission&lt;/a&gt; included supporting the university’s own information and communication technology needs. The illustration below says Fusion would configure the Windows 95 programs available to ITIC students.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-10-03-ucslink-fusion-configuring-university-workstations-in-1997/itic.gif&quot; alt=&quot;The original illustration of UcsLink.Fusion at ITIC&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Geneva was also helping Santa Úrsula with its campus network. An &lt;a href=&quot;https://web.archive.org/web/19991011062240/http://www.unige.ch/aides/bresil.htm&quot;&gt;archived University of Geneva page&lt;/a&gt; says that in April 1997, Chancellor Madre Maria de Fâtima Maron Ramos requested an independent assessment of the network installation. A first visit took place April 18–27, 1997, with Ethernet and Fast Ethernet planned for the campus and a fiber-optic backbone connecting buildings on opposite sides of a street. Later visits were made by Jean-François L’haire, from the University of Geneva’s IT division, who came with students to propose hardware and software for Santa Úrsula. Fusion was one of these, along with &lt;a href=&quot;https://tldp.org/HOWTO/Remote-Boot-5.html&quot;&gt;BpBatch&lt;/a&gt;, a remote boot program from the same university. L’haire also &lt;a href=&quot;https://www.unige.ch/presse/communique/98-99/conference_sur_internet.html&quot;&gt;taught at Santa Úrsula&lt;/a&gt;, and in 1999 he gave a course there live from Geneva over the web.&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://github.com/dblock/ucsLink.Fusion/tree/master/original&quot;&gt;last source code backup&lt;/a&gt; is from September 29, 1997. There is a whole administrator reference, a &lt;a href=&quot;https://web.archive.org/web/19991008033249/http://cuiwww.unige.ch/info/pc/fusion/manual.html&quot;&gt;student guide&lt;/a&gt; and a C++Builder Windows Control Panel extension that launched the configuration browser. An application’s script could define the name, icon and options shown to users in the configuration browser.&lt;/p&gt;

&lt;p&gt;I haven’t reconnected it to the university’s old servers. A local demonstration creates a workspace, copies a welcome document and makes a Notepad shortcut, all in its own Wine prefix. The configuration browser works again; the network operations and separate Control Panel extension remain unverified.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-10-03-ucslink-fusion-configuring-university-workstations-in-1997/fusion.gif&quot; alt=&quot;The restored UcsLink.Fusion configuration browser and local task demonstration&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The original source and documentation, the port, and the build scripts are &lt;a href=&quot;https://github.com/dblock/ucsLink.Fusion&quot;&gt;on GitHub&lt;/a&gt;.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/10/03/ucslink-fusion-configuring-university-workstations-in-1997.html&quot;&gt;Universal Copy System Link for 97 Windows PCs&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on October 03, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[zBalls: a 1998 Java Applet]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/09/29/zballs-a-1998-java-applet.html" />
  <id>https://code.dblock.org/2026/09/29/zballs-a-1998-java-applet</id>
  <published>2026-09-29T00:00:00+00:00</published>
  <updated>2026-09-29T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;In January 1998, a classmate and I wrote a game. It was called “Bolongas zBalls”, and it was a Java applet.&lt;/p&gt;

&lt;video controls=&quot;&quot; preload=&quot;metadata&quot; width=&quot;350&quot; style=&quot;display: block; margin-bottom: 1.5em;&quot; poster=&quot;/images/posts/2026/2026-09-29-zballs-a-1998-java-applet/zballs.png&quot;&gt;
  &lt;source src=&quot;/images/posts/2026/2026-09-29-zballs-a-1998-java-applet/zballs.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;p&gt;You click to start, then move the mouse, without clicking. Touch the dark ball to blow it up, and the next ball turns dark. Clear them all and the next level doubles the number of balls. Touch a plain ball and it spawns another one, so if you’re sloppy, the balls multiply until you lose. Each level has a time limit.&lt;/p&gt;

&lt;p&gt;The intro music is the theme song from Capitaine Flam, the French version of the Japanese cartoon Captain Future, which aired on TF1 in 1981. I honestly don’t remember why we used it, nor why the game is called “Bolongas”.&lt;/p&gt;

&lt;p&gt;Applets were removed in &lt;a href=&quot;https://openjdk.org/jeps/504&quot;&gt;JDK 26&lt;/a&gt;, which is a shame, because a gray box that froze your browser for ten seconds and then asked you to trust a stranger’s code was clearly the future of the web.&lt;/p&gt;

&lt;p&gt;You can find the source &lt;a href=&quot;https://github.com/dblock/zballs&quot;&gt;on GitHub&lt;/a&gt; and play the game &lt;a href=&quot;https://dblock.github.io/zballs/&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/09/29/zballs-a-1998-java-applet.html&quot;&gt;zBalls: a 1998 Java Applet&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on September 29, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Good Morning, Dave: the 1998 INET Conference Kiosk Launcher]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/09/29/good-morning-dave-the-1998-inet-conference-kiosk-launcher.html" />
  <id>https://code.dblock.org/2026/09/29/good-morning-dave-the-1998-inet-conference-kiosk-launcher</id>
  <published>2026-09-29T00:00:00+00:00</published>
  <updated>2026-09-29T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;In July 1998, the Internet Society held INET ‘98, “The Internet Summit”, at Palexpo in Geneva. The University of Geneva provided about 250 computers for the conference, and I was a student there. I wrote much of the conference network’s website, &lt;a href=&quot;https://dblock.github.io/inet98/&quot;&gt;www.inet98.ch&lt;/a&gt;, and the &lt;a href=&quot;https://github.com/dblock/inet98/tree/master/app&quot;&gt;launcher&lt;/a&gt; that ran on the public Windows 95 PCs instead of Explorer.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://dblock.github.io/inet98/&quot;&gt;&lt;img src=&quot;/images/posts/2026/2026-09-29-good-morning-dave-the-1998-inet-conference-kiosk-launcher/inet98-website.png&quot; alt=&quot;The INET &apos;98 network website&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-29-good-morning-dave-the-1998-inet-conference-kiosk-launcher/inet98-launcher.gif&quot; alt=&quot;The Inet 98 Launcher running under Wine on macOS&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The Inet 98 Launcher was a full-height bar on the left of the screen with a button for each installed application: Internet Explorer, Netscape, PC Pine, Telnet, WS-FTP and Office 95 and 97. I wrote it in Delphi 3. It showed a screen saver when a machine sat idle, reset itself, and could reboot the PC to return it to a clean state.&lt;/p&gt;

&lt;p&gt;It also had a remote-control server on TCP port 895. Staff could connect from anywhere on the network, type a password and send a message to a user, hide the bar, start the screen saver, or reboot the machine. A wrong password got “alarm has been activated, you are logged!”. The right one got “Good morning dave!”.&lt;/p&gt;

&lt;p&gt;This is the launcher running again today, under Wine on macOS. The buttons change icon on hover. The hidden &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-stats&lt;/code&gt; command asks for a password and shows the activity counters. A remote session then logs in. It gets the alarm on a wrong password and the greeting on the right one. Then it sends a message, hides and shows the bar, reads machine stats and starts the screen saver.&lt;/p&gt;

&lt;p&gt;The conference was also the first time I had access to a T1 line to the US. Once the machines were up and running, there wasn’t much left for me to do, so I spent hours on IRC.&lt;/p&gt;

&lt;p&gt;I found the source of both recently. They’re now at &lt;a href=&quot;https://github.com/dblock/inet98&quot;&gt;dblock/inet98&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;building-it-again&quot;&gt;Building It Again&lt;/h2&gt;

&lt;p&gt;I don’t have Delphi 3, so with Copilot I ported the launcher to Free Pascal and Lazarus. It builds with the Windows version of the compiler and runs under Wine on macOS.&lt;/p&gt;

&lt;p&gt;Most of the work was in the details. The binary Delphi forms had to be converted to text. Several embedded bitmaps had headers that Delphi tolerated and Lazarus didn’t. My button component drew into its own device context and came out blank. A background thread started before the main form existed. The Delphi socket components are gone, so a small replacement is built on Free Pascal’s sockets. macOS doesn’t let a normal user listen on port 895, so it listens on 10895. The password dialog’s OK button did nothing, because Lazarus doesn’t apply a button’s default result when it loads a form.&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://github.com/dblock/inet98/blob/master/app/README.md#what-it-took-to-port&quot;&gt;full list&lt;/a&gt;, the original source, the port, the build scripts and a prebuilt &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;inet98.exe&lt;/code&gt; are all &lt;a href=&quot;https://github.com/dblock/inet98&quot;&gt;on GitHub&lt;/a&gt;.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/09/29/good-morning-dave-the-1998-inet-conference-kiosk-launcher.html&quot;&gt;Good Morning, Dave: the 1998 INET Conference Kiosk Launcher&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on September 29, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[File & Folder 98: a 1998 Word Document Manager]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/09/29/file-and-folder-98-a-1998-word-document-manager.html" />
  <id>https://code.dblock.org/2026/09/29/file-and-folder-98-a-1998-word-document-manager</id>
  <published>2026-09-29T00:00:00+00:00</published>
  <updated>2026-09-29T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;In the 1990s I wrote a document manager for a small business in Geneva with about 20 workstations. Most of its staff were new to computers, and folders were a challenging idea to grasp. They saved Word documents wherever Word happened to put them, and the network supervisor spent his days looking for lost files by panicked coworkers. File &amp;amp; Folder solved that problem. My 1999 product page said it was for “a small business which is sick and tired of seeing it’s workers putting documents a bit everywhere in the system.”&lt;/p&gt;

&lt;p&gt;The first version, File &amp;amp; Folder 1.0, was a Visual Basic app for Windows 3.11. It sold as shareware for US$75.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-29-file-and-folder-98-a-1998-word-document-manager/ff10-main.png&quot; alt=&quot;File &amp;amp; Folder 1.0&quot; /&gt;&lt;/p&gt;

&lt;p&gt;A 32-bit version for Windows 95 and NT, File &amp;amp; Folder 1.2, followed in 1996. It worked in one root folder with numbered subfolders, supposedly one per client or case number. You typed a number, and it listed the Word documents in that folder, or offered to create one. It drove Word over OLE automation to open and create documents. The source, the Word stand-in and the scripts to run it are &lt;a href=&quot;https://github.com/dblock/ff95&quot;&gt;on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In 1998 I attempted to generalize the idea and rewrote File &amp;amp; Folder from scratch in Delphi 3 as File &amp;amp; Folder 98. It replaced drives and directories with virtual volumes, simplifying the file system navigation into a layout that an administrator could choose. Under a volume, a folder held either documents or other folders, never both. Users created, opened, copied, moved and renamed Word documents within this one window, without needing to use the Word file dialogs. The tool also offered document previews.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-29-file-and-folder-98-a-1998-word-document-manager/ff98-splash-stolen.png&quot; alt=&quot;File &amp;amp; Folder 98 splash screen, Stolen Technologies&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-29-file-and-folder-98-a-1998-word-document-manager/ff98-splash-vestris.png&quot; alt=&quot;File &amp;amp; Folder 98 splash screen, Vestris&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-29-file-and-folder-98-a-1998-word-document-manager/ff98.gif&quot; alt=&quot;File &amp;amp; Folder 98 running under Wine on macOS&quot; /&gt;&lt;/p&gt;

&lt;p&gt;File &amp;amp; Folder 98’s original source, the port, the build scripts are &lt;a href=&quot;https://github.com/dblock/ff98&quot;&gt;on GitHub&lt;/a&gt;.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/09/29/file-and-folder-98-a-1998-word-document-manager.html&quot;&gt;File & Folder 98: a 1998 Word Document Manager&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on September 29, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[The Second Life of Autoconf for MS-DOS]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/09/27/the-second-life-of-autoconf-for-ms-dos.html" />
  <id>https://code.dblock.org/2026/09/27/the-second-life-of-autoconf-for-ms-dos</id>
  <published>2026-09-27T00:00:00+00:00</published>
  <updated>2026-09-27T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;Before GNU Autoconf generated &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;configure&lt;/code&gt; scripts, there was a DOS program called Autoconf. It let you keep one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CONFIG.SYS&lt;/code&gt; and one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AUTOEXEC.BAT&lt;/code&gt;, then choose among boot configurations by pressing a key.&lt;/p&gt;

&lt;p&gt;You did not have to wait for a menu. The PC BIOS kept early keystrokes in its keyboard queue, so you could press a configuration letter before &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AUTOCONF.SYS&lt;/code&gt; had even loaded. When the driver finally ran, it found the key and continued immediately.&lt;/p&gt;

&lt;p&gt;I did not write the original. I copied its x86 assembly source by hand from a French computer magazine. It was the first assembly program I had ever seen and the first piece of code I compiled. I spent the next three or four years extending it into my first commercial product.&lt;/p&gt;

&lt;p&gt;For a long time I could not remember who had written that first version. I had thrown away the magazine, forgotten the issue, and lost the trail before old print archives became easy to search. Only now, with AI helping sift through a large collection of scanned SVM issues downloaded from &lt;a href=&quot;https://www.abandonware-magazines.org/&quot;&gt;Abandonware Magazines&lt;/a&gt; and the &lt;a href=&quot;https://archive.org/&quot;&gt;Internet Archive&lt;/a&gt;, was I able to find the seven pages I had copied.&lt;/p&gt;

&lt;h2 id=&quot;the-magazine&quot;&gt;The Magazine&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;/images/posts/2026/2026-09-27-the-second-life-of-autoconf-for-ms-dos/svm-88-autoconf-pages-227-233.pdf&quot;&gt;&lt;img src=&quot;/images/posts/2026/2026-09-27-the-second-life-of-autoconf-for-ms-dos/svm-88-cover-small.png&quot; alt=&quot;Cover of Science &amp;amp; Vie Micro issue 88, November 1991&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The original Autoconf appeared in the &lt;a href=&quot;https://archive.org/details/science-et-vie-micro-088&quot;&gt;November 1991 issue of &lt;em&gt;Science &amp;amp; Vie Micro&lt;/em&gt;, number 88&lt;/a&gt;, on pages 227–233. The article, &lt;a href=&quot;https://github.com/dblock/autoconf/blob/master/original/SVM-88-Autoconf-pages-227-233.pdf&quot;&gt;“La configuration idéale”&lt;/a&gt;, was written by &lt;a href=&quot;http://gruntthepeon.free.fr/&quot;&gt;Julien Pommier&lt;/a&gt; and won the magazine’s monthly programming contest, including a 2,000-franc prize.&lt;/p&gt;

&lt;p&gt;The seven pages contained the complete assembly listings for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AUTOCONF.ASM&lt;/code&gt; and the 11-byte &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;READCONF.COM&lt;/code&gt;, installation instructions, sample startup files, and the commands required to build everything with MASM, LINK, and EXE2BIN.&lt;/p&gt;

&lt;p&gt;I must have spent a week copying the listing without understanding what most of it did, correcting it character by character, and figuring out how to assemble it. I needed multiple boot configurations because I was tired of editing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CONFIG.SYS&lt;/code&gt; whenever I wanted to play a game.&lt;/p&gt;

&lt;p&gt;Thirty-five years later, Copilot and I transcribed the listings again and built them under DOS with JWasm. They produced a 1,180-byte &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AUTOCONF.SYS&lt;/code&gt; and the original 11-byte &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;READCONF.COM&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then the original program booted again.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-27-the-second-life-of-autoconf-for-ms-dos/autoconf-original-doom-boot.gif&quot; alt=&quot;The original 1991 Autoconf selecting DOOM during an MS-DOS 5 boot&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;finding-julien&quot;&gt;Finding Julien&lt;/h3&gt;

&lt;p&gt;Once I knew his name, I sent Julien the article with a short question: “Is this you?”&lt;/p&gt;

&lt;p&gt;He replied the next morning: “Yes it’s me 35 years ago!” He had searched for the article several times but had never found it. He also had no idea that the program had developed a life after its publication. He had no Internet access or BBS at the time, never heard about my fork, later forgot the program’s name, and could not find it among all the results for the other Autoconf used by Linux software.&lt;/p&gt;

&lt;p&gt;Being published in SVM had been a major moment of pride for him. Finding the article and the reconstructed history, he wrote, brought back “a whole chapter of my life.”&lt;/p&gt;

&lt;p&gt;I told him that his program was the first code I had ever compiled and that it must have influenced my entire career. I had wanted to thank its author for decades, but I had thrown away the magazine and could no longer identify him. Old floppies and hard drives disappeared too, taking some of my own source snapshots with them.&lt;/p&gt;

&lt;p&gt;Julien was reading the new &lt;a href=&quot;https://github.com/dblock/autoconf/blob/master/HISTORY.md&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HISTORY.md&lt;/code&gt;&lt;/a&gt; while we exchanged messages. Two people who had never met were finally comparing what the same small assembly program had meant to each of them.&lt;/p&gt;

&lt;h3 id=&quot;my-autoconf&quot;&gt;My Autoconf&lt;/h3&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-27-the-second-life-of-autoconf-for-ms-dos/autoconf-banner.png&quot; alt=&quot;Autoconf banner advertising multiple configurations, assembly code, DOS, Windows 95, and freeware&quot; /&gt;&lt;/p&gt;

&lt;p&gt;I lived in Geneva from 1990 through 1999. My versions of Autoconf circulated mostly as ZIP archives on BBSs, beginning with Boris &amp;amp; Co. (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[Ne/V\eSiS]&lt;/code&gt;), which operated from the basement of Infomaniak when it was still a computer store. The archives traveled alongside pirated software.&lt;/p&gt;

&lt;p&gt;The compact magazine program grew into more than 7,000 lines of assembly. It gained named and nested configurations, a sorted menu, configuration previews, one-boot line editing, many keyboard layouts, searchable help, Windows 95 support, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BOOTIT&lt;/code&gt;, which could reboot directly into a chosen configuration.&lt;/p&gt;

&lt;p&gt;There were also occasional small contributions from others. The full sequence of 145 recorded changes, command-line options, companion utilities, and source archaeology can be found in the &lt;a href=&quot;https://github.com/dblock/autoconf/blob/master/HISTORY.md&quot;&gt;reconstructed project history&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The preserved later binary also boots under MS-DOS 5.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-27-the-second-life-of-autoconf-for-ms-dos/autoconf-doom-boot.gif&quot; alt=&quot;The later Autoconf selecting DOOM during an MS-DOS 5 boot&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The source and both preserved English and French binaries contain the same build string:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;assembl.: 11/01/96 (14:26)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That dates the surviving Autoconf 3.00 beta 2 build to January 11, 1996 at 14:26.&lt;/p&gt;

&lt;h3 id=&quot;megaboot&quot;&gt;MegaBoot&lt;/h3&gt;

&lt;p&gt;The same search uncovered another program. A friend of mine, David, wrote MegaBoot in 1996 while we spent time in the basement of Infomaniak in Carouge, Switzerland.&lt;/p&gt;

&lt;p&gt;MegaBoot was a spiritual successor to Autoconf: another DOS character-device driver that rewrote the in-memory &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CONFIG.SYS&lt;/code&gt;, but with a full-screen menu. I restored the source, made it build with JWasm, fixed two recovered bugs, and booted it under MS-DOS 5.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-27-the-second-life-of-autoconf-for-ms-dos/megaboot-doom-boot.gif&quot; alt=&quot;MegaBoot selecting DOOM during an MS-DOS 5 boot&quot; /&gt;&lt;/p&gt;

&lt;p&gt;David granted MegaBoot an MIT license. It now has its own repository at &lt;a href=&quot;https://github.com/dblock/megaboot&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dblock/megaboot&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;what-happened-to-it&quot;&gt;What Happened to It&lt;/h3&gt;

&lt;p&gt;MS-DOS 6.0 added native multiple configurations in 1993. Autoconf did not disappear immediately; development continued through the January 1996 beta, and it still offered many things DOS did not.&lt;/p&gt;

&lt;p&gt;Here is the same Minimal DOS, DOOM, and Windows 95 setup implemented with the native MS-DOS 6.22 startup menu.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-27-the-second-life-of-autoconf-for-ms-dos/msdos622-multiconfig.gif&quot; alt=&quot;MS-DOS 6.22 selecting DOOM with its native multiple-configuration menu&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Autoconf faded later. By the time software distribution moved from BBSs to the Internet, I had not moved the project with it, some of its source had been lost, and DOS already had a built-in solution. A separate boot-configuration product was no longer necessary.&lt;/p&gt;

&lt;p&gt;The original and later Autoconf sources now live together in &lt;a href=&quot;https://github.com/dblock/autoconf&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dblock/autoconf&lt;/code&gt;&lt;/a&gt;. MegaBoot lives in &lt;a href=&quot;https://github.com/dblock/megaboot&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dblock/megaboot&lt;/code&gt;&lt;/a&gt;. Most importantly, I finally found Julien and got to say thank you.&lt;/p&gt;

&lt;h3 id=&quot;links&quot;&gt;Links&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/autoconf&quot;&gt;Autoconf source and reconstructed history&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/megaboot&quot;&gt;MegaBoot source&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/images/posts/2026/2026-09-27-the-second-life-of-autoconf-for-ms-dos/svm-88-autoconf-pages-227-233.pdf&quot;&gt;Original November 1991 SVM article and source listing&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://archive.org/details/science-et-vie-micro-088&quot;&gt;Complete SVM issue 88 on Internet Archive&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://code.dblock.org/2011/09/06/autoconf-maybe-the-first-example-of-microsoft-pushing-features-into-the-os-to-kill-a-competitor.html&quot;&gt;My 2011 post about Autoconf&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/09/27/the-second-life-of-autoconf-for-ms-dos.html&quot;&gt;The Second Life of Autoconf for MS-DOS&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on September 27, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Porting a 1996 Pascal/Delphi Calculator to Rust (CLI + MCP)]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/09/24/porting-my-1996-pascal-calculator-to-rust.html" />
  <id>https://code.dblock.org/2026/09/24/porting-my-1996-pascal-calculator-to-rust</id>
  <published>2026-09-24T00:00:00+00:00</published>
  <updated>2026-09-24T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;In his &lt;a href=&quot;https://www.youtube.com/watch?v=vDjW_dRyKXY&quot;&gt;Rails World 2026 keynote&lt;/a&gt; DHH talks about one-shot apps built almost instantly with an agent as part of &lt;a href=&quot;https://omarchy.org/&quot;&gt;Omarchy&lt;/a&gt;. His canonical example is &lt;a href=&quot;https://github.com/omacom/omacalc&quot;&gt;Omacalc&lt;/a&gt;, a dead simple calculator. It’s a good demo and not a hard problem. Let’s take it further and rewrite a more advanced calculator in Rust that we can also run as an MCP server, so an AI coding agent can outsource arithmetic to it instead of hallucinating a square root.&lt;/p&gt;

&lt;p&gt;In 1994 I wrote an expression evaluator for a lab assignment. It grew into &lt;a href=&quot;https://web.archive.org/web/20031213112526/http://excalc.vestris.com/&quot;&gt;Expression Calculator&lt;/a&gt;, a shareware Windows app I sold through a company I co-founded, Vestris Inc. In 1997 it got pressed onto a CD-ROM and sold 3,000 copies in Germany under the name Global Calculator. I made money and users wrote fan mail. This week, Copilot and I ported its Pascal/Delphi core to Rust as &lt;a href=&quot;https://github.com/dblock/excalc-rs&quot;&gt;excalc-rs&lt;/a&gt; (&lt;a href=&quot;https://crates.io/crates/excalc&quot;&gt;crates.io&lt;/a&gt;), a CLI and MCP server, in under 24 hours.&lt;/p&gt;

&lt;p&gt;Both the port and the original are mathematical beasts. They can:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Chain 12 comparison and logical/bitwise operators, and 12 arithmetic operators with correct precedence&lt;/li&gt;
  &lt;li&gt;Fit 8 general/rounding functions and 7 statistics functions (mean, variance, standard deviation)&lt;/li&gt;
  &lt;li&gt;Evaluate 36 standard math functions: trig, inverse trig, and hyperbolic variants, both in radians and degrees&lt;/li&gt;
  &lt;li&gt;Answer 17 number theory questions: GCD/LCM, Fibonacci, primality, Mersenne and perfect numbers, Euler’s totient&lt;/li&gt;
  &lt;li&gt;Run 18 financial functions: NPV, IRR, loan payments, present/future value, depreciation schedules&lt;/li&gt;
  &lt;li&gt;Numerically integrate anything via 7 quadrature methods (trapezoid, Simpson, Gaussian, and more)&lt;/li&gt;
  &lt;li&gt;Compute 21 advanced/special functions: the Gamma and Beta functions, elliptic integrals, Fresnel integrals, the error function, dilogarithm, Dawson’s integral, sine and cosine integrals&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s verify that Euler’s Gamma function is the continuous extension of the factorial (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Γ(n) = (n-1)!&lt;/code&gt;), and comes back essentially zero (floating-point noise from numeric integration):&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;calc &apos;gamma(10) - 9!&apos;
# 0.000000054249539971351624
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Γ(10)&lt;/code&gt; equals &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;9!&lt;/code&gt; to 7 decimal places, computed via numeric integration rather than table lookup.&lt;/p&gt;

&lt;p&gt;Check out the &lt;a href=&quot;https://github.com/dblock/excalc-rs/blob/master/docs/README.md&quot;&gt;docs/&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;the-1996-original&quot;&gt;The 1996 Original&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-24-porting-my-1996-pascal-calculator-to-rust/calc.jpg&quot; alt=&quot;Expression Calculator 2.43 for Windows&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-24-porting-my-1996-pascal-calculator-to-rust/expression-calculator-banner.png&quot; alt=&quot;Expression Calculator banner advertising 2D plots, financial functions, trigonometric functions, and more&quot; /&gt;&lt;/p&gt;

&lt;p&gt;It wasn’t just a four-function calculator. You could work in hexadecimal, and long-running calculations ran as separate tasks. Here it is evaluating a hex expression and searching for a prime, with two tasks showing in the UI:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-24-porting-my-1996-pascal-calculator-to-rust/calculator-hex-mode.png&quot; alt=&quot;Expression Calculator evaluating a hexadecimal expression&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-24-porting-my-1996-pascal-calculator-to-rust/calculator-prime-task.png&quot; alt=&quot;Expression Calculator showing a prime search running with two tasks&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The original was &lt;em&gt;years&lt;/em&gt; of work. The Pascal source code is &lt;a href=&quot;https://github.com/dblock/excalc&quot;&gt;on GitHub&lt;/a&gt;, open-sourced in 2009 once Vestris wound down in its original British Virgin Islands offshore incarnation. The core &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;common/MCalc.pas&lt;/code&gt; is a 4,036 lines long evaluator. The full Windows app, UI included, is 10,339 lines of Pascal across the repo, backed by a 189-page &lt;a href=&quot;https://web.archive.org/web/20040129021441/http://excalc.vestris.com/docs/pdf/excalc.pdf&quot;&gt;user’s guide&lt;/a&gt; I apparently wrote it in XEmacs and typeset with SgmlTools. I have no memory of most of this code or the math behind it. It’s 30 years old. I was 20.&lt;/p&gt;

&lt;p&gt;In 1997, &lt;a href=&quot;https://github.com/dblock/excalc-rs/blob/master/HISTORY.md&quot;&gt;MediaGlobe GmbH sold 3,000 CD-ROM copies&lt;/a&gt; in Germany as Global Calculator, in English, German, and French. Here’s the front and back of the box:&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;/images/posts/2026/2026-09-24-porting-my-1996-pascal-calculator-to-rust/global-calculator-front.jpeg&quot;&gt;&lt;img src=&quot;/images/posts/2026/2026-09-24-porting-my-1996-pascal-calculator-to-rust/global-calculator-front-thumb.jpeg&quot; alt=&quot;Global Calculator CD-ROM front cover&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;/images/posts/2026/2026-09-24-porting-my-1996-pascal-calculator-to-rust/global-calculator-back.jpeg&quot;&gt;&lt;img src=&quot;/images/posts/2026/2026-09-24-porting-my-1996-pascal-calculator-to-rust/global-calculator-back-thumb.jpeg&quot; alt=&quot;Global Calculator CD-ROM back cover&quot; /&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I still have two unopened copies.&lt;/p&gt;

&lt;p&gt;A user was asking for a Linux port as early as 1999. &lt;a href=&quot;https://web.archive.org/web/20010422210554/http://agnes.vestris.com/db-cgi/intensive/guest?ExcalcGuest+ExcalcGuestHTML&quot;&gt;Christian Rose wrote&lt;/a&gt;, “I’ve never used any better calculator software. But as I’m now moving to Linux, I wonder if the Expression Calculator will ever be ported to Linux?” It took me 27 years, but here we are.&lt;/p&gt;

&lt;h3 id=&quot;the-2026-port&quot;&gt;The 2026 Port&lt;/h3&gt;

&lt;p&gt;The Rust port’s git history is one Copilot CLI session, 145 turns, spread over 24 hours:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;2026-09-23 20:46  Initial scaffold: core arithmetic engine, standard math, statistics
2026-09-23 20:47  Add CI: build/test on Linux/macOS/Windows, fmt + clippy checks
2026-09-23 20:53  Release v0.1.0
2026-09-23 21:14  Add test coverage reporting to CI via cargo-llvm-cov and Coveralls
2026-09-23 21:38  Add MCP server exposing evaluate as a stdio tool
2026-09-23 22:29  Implement general/rounding functions (abs, frac, intg, round, trunc, ceil, floor, random)
2026-09-23 22:53  Add comparison and logical/bitwise operators
2026-09-23 23:16  Implement advanced / special functions
2026-09-23 23:32  Implement financial functions
2026-09-23 23:43  Implement general-purpose numeric integration
2026-09-24 06:07  Add short descriptions to every README example
2026-09-24 06:27  Implement variable assignment (:=)
2026-09-24 07:24  Add degree/radian conversion and degree-native trig functions
2026-09-24 07:59  Add Homebrew formula and CI validation
2026-09-24 08:09  Add Windows MSI installer via cargo-wix
2026-09-24 08:22  Release v0.2.0
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The first &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v0.1.0&lt;/code&gt; cut that did basic math shipped 7 minutes after the initial scaffold. I slept for 6 hours and &lt;a href=&quot;https://www.strava.com/activities/20308922776&quot;&gt;went out on a run&lt;/a&gt;. The complete port totaled 59 commits, 5,060 lines of Rust, 217 tests, and a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v0.2.0&lt;/code&gt; published to &lt;a href=&quot;https://crates.io/crates/excalc&quot;&gt;crates.io&lt;/a&gt;, tapped on &lt;a href=&quot;https://github.com/dblock/excalc-rs#macoslinux&quot;&gt;Homebrew&lt;/a&gt;, and shipped as a Windows MSI, by the morning.&lt;/p&gt;

&lt;p&gt;The token bill for the port was 732 API calls across the session, ~83 million input tokens processed (~80 million of those were cache reads), and about 262,000 output tokens at a rough napkin math cost estimate of $50. Pretty great ROI given that I sold the original Expression Calculator for $25 a copy!&lt;/p&gt;

&lt;h3 id=&quot;some-interesting-math&quot;&gt;Some Interesting Math&lt;/h3&gt;

&lt;p&gt;Much of the advanced math in the calculator was taught by professors Ernst Hairer and Gerhard Wanner from their book, &lt;a href=&quot;https://www.unige.ch/~wanner/analysis.html&quot;&gt;&lt;em&gt;Analysis by Its History&lt;/em&gt;&lt;/a&gt;. I took the course as an undergrad.&lt;/p&gt;

&lt;p&gt;For example, the book spends real time on elliptic integrals, which get their name from the problem of computing the arc length of an ellipse, a problem with no elementary closed form. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;excalc-rs&lt;/code&gt; implements &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ellipticE&lt;/code&gt;, the incomplete elliptic integral of the second kind, exactly as the original Pascal did as a numeric integration, not a series approximation:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;calc &quot;4 * 5 * ellipticE(0.8)&quot;
# 25.52699886339813
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That’s the circumference of an ellipse with semi-major axis 5 and semi-minor axis 3 (eccentricity &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sqrt(1 - 3^2/5^2) = 0.8&lt;/code&gt;), a number with no closed form, computed by adaptive quadrature under the hood, the same way &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ci&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chi&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;erf&lt;/code&gt;, and a dozen other special functions are as thin wrappers around a shared &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;int(expression, variable, lowerBound, upperBound, tolerance)&lt;/code&gt; engine. See &lt;a href=&quot;https://github.com/dblock/excalc-rs/blob/master/docs/functions/advanced.md&quot;&gt;docs/functions/advanced.md&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;try-it-out&quot;&gt;Try It Out&lt;/h3&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;cargo &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;excalc
calc &lt;span class=&quot;s2&quot;&gt;&quot;sqrt(16) + sin(pi/2)&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# 5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;brew tap dblock/excalc-rs https://github.com/dblock/excalc-rs&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;brew install excalc&lt;/code&gt; on macOS/Linux, or grab the MSI from the &lt;a href=&quot;https://github.com/dblock/excalc-rs/releases/latest&quot;&gt;latest release&lt;/a&gt; on Windows.&lt;/p&gt;

&lt;h3 id=&quot;links&quot;&gt;Links&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/excalc-rs&quot;&gt;excalc-rs&lt;/a&gt;, the Rust port&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/excalc&quot;&gt;dblock/excalc&lt;/a&gt;, the original Pascal/Delphi source&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://crates.io/crates/excalc&quot;&gt;excalc-rs on crates.io&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/excalc-rs/blob/master/HISTORY.md&quot;&gt;HISTORY.md&lt;/a&gt;, the full story of Expression Calculator and Global Calculator&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/excalc-rs/blob/master/port/DESIGN.md&quot;&gt;port/DESIGN.md&lt;/a&gt; and &lt;a href=&quot;https://github.com/dblock/excalc-rs/blob/master/port/PROMPTS.md&quot;&gt;port/PROMPTS.md&lt;/a&gt;, the design decisions and full prompt history behind the port&lt;/li&gt;
&lt;/ul&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/09/24/porting-my-1996-pascal-calculator-to-rust.html&quot;&gt;Porting a 1996 Pascal/Delphi Calculator to Rust (CLI + MCP)&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on September 24, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Using AI Effectively in Technical Writing]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/09/23/using-ai-effectively-in-technical-writing.html" />
  <id>https://code.dblock.org/2026/09/23/using-ai-effectively-in-technical-writing</id>
  <published>2026-09-23T00:00:00+00:00</published>
  <updated>2026-09-23T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;I write a lot, and I use AI a lot. But AI does not write my blog posts. Mostly.&lt;/p&gt;

&lt;p&gt;AI is useful for making some of my writing less tedious. Recently, I asked GitHub Copilot CLI to read a few dozen posts from this blog and derive a &lt;a href=&quot;https://github.com/dblock/code.dblock.org/blob/gh-pages/VOICE.md&quot;&gt;VOICE.md&lt;/a&gt; to assist my writing. The result is now checked into this repo and linked from &lt;a href=&quot;https://github.com/dblock/code.dblock.org/blob/gh-pages/AGENTS.md&quot;&gt;AGENTS.md&lt;/a&gt;, so any AI agent working here gets the same reminder: this blog is authored by me, not by AI and the agent’s job is to help me rather than pretend to be me.&lt;/p&gt;

&lt;p&gt;The useful and interesting part of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VOICE.md&lt;/code&gt; was how it captured my weirdly specific patterns.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Start with a real story, failure, command, bug, or piece of data.&lt;/li&gt;
  &lt;li&gt;Say the opinion early.&lt;/li&gt;
  &lt;li&gt;Provide examples, pull requests, commands, screenshots, numbers, links.&lt;/li&gt;
  &lt;li&gt;Admit mistakes.&lt;/li&gt;
  &lt;li&gt;Let short posts stay short.&lt;/li&gt;
  &lt;li&gt;End quickly, use Russian jokes or a link.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Technical writing is just like coding, but do not attempt to fully automate it. Be picky about words, punctuation, examples, and whether a sentence actually sounds like something you would say. Use AI to produce a flow or even a draft, to collect patterns, suggest structure, and do the boring, time-consuming work around technical reproductions or samples. When &lt;a href=&quot;/2026/08/28/adventures-in-daylight-saving-norfolk-island-and-time-zone-math-in-ruby.html&quot;&gt;a post needs the same example in Ruby, Python, JavaScript, Go, Rust, PHP, C#, Java, Elixir, Swift, Objective-C, and Dart&lt;/a&gt;, have an agent generate the first pass then verify, fix, and explain the interesting parts.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-23-using-ai-effectively-in-technical-writing/voice.gif&quot; alt=&quot;VOICE.md generated by GitHub Copilot CLI&quot; /&gt;&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/09/23/using-ai-effectively-in-technical-writing.html&quot;&gt;Using AI Effectively in Technical Writing&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on September 23, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Serving Markdown for AI Agents, Now as a Jekyll Plugin]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/09/19/serving-markdown-with-a-jekyll-plugin.html" />
  <id>https://code.dblock.org/2026/09/19/serving-markdown-with-a-jekyll-plugin</id>
  <published>2026-09-19T00:00:00+00:00</published>
  <updated>2026-09-19T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;Back in January I wrote &lt;a href=&quot;/2026/01/15/serving-markdown-for-ai-agents.html&quot;&gt;Serving Markdown for AI Agents&lt;/a&gt;: for every page on this blog, there’s also a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.md&lt;/code&gt; version at the same URL, discoverable via a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;link rel=&quot;alternate&quot; type=&quot;text/markdown&quot;&amp;gt;&lt;/code&gt; tag, so AI agents can fetch clean Markdown instead of parsing HTML. It also &lt;a href=&quot;/2026/04/11/how-i-made-360-dollars-by-serving-markdown-from-my-jekyll-blog.html&quot;&gt;made me $360&lt;/a&gt;, via referral conversions an AI agent apparently generated after reading the clean Markdown version of a post.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://code.dblock.org/images/posts/2026/2026-09-19-serving-markdown-with-a-jekyll-plugin/jekyll-markdown.gif&quot; /&gt;&lt;/p&gt;

&lt;p&gt;That worked, but it lived in this repo as a one-off script, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_scripts/render_markdown.rb&lt;/code&gt;, &lt;a href=&quot;https://github.com/dblock/code.dblock.org/commit/6fab9f3&quot;&gt;wired into the GitHub Actions deploy workflow&lt;/a&gt; (needed since GitHub Pages’ native build only allows a small &lt;a href=&quot;https://pages.github.com/versions/&quot;&gt;allowlist of plugins&lt;/a&gt;, which &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jekyll-md&lt;/code&gt; isn’t on), with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tags added by hand via Liquid and a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;markdown_url&lt;/code&gt; front matter field on every page.&lt;/p&gt;

&lt;p&gt;I’ve since extracted all of it into &lt;a href=&quot;https://github.com/dblock/jekyll-md&quot;&gt;jekyll-md&lt;/a&gt;, a proper Jekyll plugin. It does the same thing - converts each page’s fully rendered HTML output to Markdown and injects the discovery &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tag - but as a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Jekyll::Generator&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Jekyll::Hooks&lt;/code&gt;, with no external script or per-page front matter required.&lt;/p&gt;

&lt;h2 id=&quot;what-changed&quot;&gt;What Changed&lt;/h2&gt;

&lt;p&gt;Add the gem and enable the plugin:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# Gemfile&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gem&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;jekyll-md&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# _config.yml&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;plugins&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;jekyll-md&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;If you don’t configure anything else, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jekyll-md&lt;/code&gt; looks for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;main&amp;gt;&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[role=&quot;main&quot;]&lt;/code&gt; before falling back to the whole &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;body&amp;gt;&lt;/code&gt;, since that’s the closest thing HTML has to a content/chrome convention. You can also configure a CSS selector to convert from, e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;md: selector: &quot;#markdown-content&quot;&lt;/code&gt;, and other options - see the &lt;a href=&quot;https://github.com/dblock/jekyll-md#readme&quot;&gt;README&lt;/a&gt; for the full list.&lt;/p&gt;

&lt;h3 id=&quot;what-about-llmstxt&quot;&gt;What About llms.txt?&lt;/h3&gt;

&lt;p&gt;There’s a related convention, &lt;a href=&quot;https://llmstxt.org&quot;&gt;llms.txt&lt;/a&gt;, a single root-level file that’s supposed to give an AI agent a curated map of a site. I considered adding &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;llms.txt&lt;/code&gt; generation to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jekyll-md&lt;/code&gt;, then didn’t. The spec is explicit that the file should “stay small enough to fit in context” and contrasts itself with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sitemap.xml&lt;/code&gt;, which it calls out for being too large and unfiltered to be useful. A plugin walking every page and dumping it into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;llms.txt&lt;/code&gt; - which is what some similar plugins do by default - just recreates the sitemap problem in Markdown instead of solving it. This blog alone has almost 600 posts; nobody wants all of them in one file.&lt;/p&gt;

&lt;p&gt;Instead, I wrote &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;llms.txt&lt;/code&gt; by hand as a plain Jekyll page with Liquid front matter, listing a handful of pinned posts, the 10 most recent ones, and a few key pages, reusing this blog’s existing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pinned: true&lt;/code&gt; front matter flag rather than inventing a new mechanism. You can see the result at &lt;a href=&quot;https://code.dblock.org/llms.txt&quot;&gt;code.dblock.org/llms.txt&lt;/a&gt; and the &lt;a href=&quot;https://github.com/dblock/code.dblock.org/blob/gh-pages/llms.txt&quot;&gt;template that generates it&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;meta&quot;&gt;Meta&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/jekyll-md&quot;&gt;jekyll-md&lt;/a&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/01/15/serving-markdown-for-ai-agents.html&quot;&gt;Serving Markdown for AI Agents&lt;/a&gt;, the original post.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/code.dblock.org/commit/6fab9f3&quot;&gt;Added GitHub Actions workflow to deploy and serve markdown.&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/code.dblock.org/commit/92bdb7c&quot;&gt;Use jekyll-md gem to serve Markdown for AI agents.&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/code.dblock.org/commit/1a40e12&quot;&gt;Add curated llms.txt.&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/09/19/serving-markdown-with-a-jekyll-plugin.html&quot;&gt;Serving Markdown for AI Agents, Now as a Jekyll Plugin&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on September 19, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[In Meetings, Raise Hands Before Reading]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/09/17/in-meetings-raise-hands-before-reading.html" />
  <id>https://code.dblock.org/2026/09/17/in-meetings-raise-hands-before-reading</id>
  <published>2026-09-17T00:00:00+00:00</published>
  <updated>2026-09-17T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;I spent five and a half years at AWS before moving (back) to Microsoft, where I work today. At Amazon, many meetings began with silent reading. Instead of presenting a document, the author gave everyone time to read it before the discussion started.&lt;/p&gt;

&lt;p&gt;There was a small facilitation trick that made this work surprisingly well: when reading began, everyone raised a hand. When someone finished, they lowered it. The meeting started with all hands up and counted down to zero.&lt;/p&gt;

&lt;p&gt;At Microsoft, I found the inverse practice: everyone began with their hands down and raised one when they finished. At the time of writing this, you’ll find me hard at work changing the culture of raised hands at Microsoft, one meeting at a time. Raise your hand as you start reading, lower it when you are done.&lt;/p&gt;

&lt;p&gt;Both approaches appear to communicate the same information. They do not. Counting down is better than counting up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A raised hand shows who still needs time, which is the information the facilitator needs.&lt;/li&gt;
  &lt;li&gt;Counting down ends unambiguously at zero without counting participants.&lt;/li&gt;
  &lt;li&gt;A forgotten click makes the group wait instead of cutting off a reader.&lt;/li&gt;
  &lt;li&gt;The facilitator can see progress and check in when only one reader remains.&lt;/li&gt;
  &lt;li&gt;Reading ends with every hand down, so raising one can immediately mean “I want to speak.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But first, why read during the meeting at all? Jeff Bezos &lt;a href=&quot;https://www.cnbc.com/2018/04/23/what-jeff-bezos-learned-from-requiring-6-page-memos-at-amazon.html&quot;&gt;explained the practice&lt;/a&gt; at Amazon. I really like this as it forces one to prepare for the meeting, gets everyone on the same page, and offers some of the smartest but quietest people an opportunity to comment on their own pace.&lt;/p&gt;

&lt;p&gt;Back to the hands.&lt;/p&gt;

&lt;p&gt;At Amazon, a raised hand meant “I still need time.” The signal described the state that mattered to the facilitator: these people were still reading. When the last hand went down, the group was ready. At Microsoft, a raised hand meant “I am done,” which was less actionable. The facilitator did not need to know who was done. They needed to know who still needed time.&lt;/p&gt;

&lt;p&gt;With hands up at the start, the meeting counts down. At zero, begin the conversation.&lt;/p&gt;

&lt;p&gt;When hands go up as people finish, the facilitator must count them and compare that number with the expected number of readers. Was it ten people or eleven? Did someone join late? Did someone leave? Is a person without a raised hand still reading or not participating? Counting up requires knowing the denominator. Counting down only requires reaching zero.&lt;/p&gt;

&lt;p&gt;People forget to click buttons. At Amazon, someone who forgot to lower their hand caused the group to wait a little longer. The facilitator could ask whether they were done.&lt;/p&gt;

&lt;p&gt;At Microsoft, someone who forgot to raise their hand was indistinguishable from someone who was still reading. Worse, if the facilitator miscounted the expected readers, they might begin the discussion while someone was still trying to finish the document.&lt;/p&gt;

&lt;p&gt;The Amazon model’s failure mode was to give people more time. The Microsoft model could fail by cutting them off.&lt;/p&gt;

&lt;p&gt;A decreasing number of hands makes the pace of the room obvious without interrupting the silence. If several hands are still up, the group needs more time. When one hand remains, the facilitator usually checks in with the last reader: “Do you need another minute?” The reader can ask for more time or say that they are ready, without polling the entire room.&lt;/p&gt;

&lt;p&gt;This lets the facilitator adapt the reading period to the actual document and actual readers instead of guessing in advance or repeatedly asking, “How much more time do people need?”&lt;/p&gt;

&lt;p&gt;At Amazon, people lowered their hands when done and moved on. The active signal belonged to the people who still needed something from the meeting: silence and time.&lt;/p&gt;

&lt;p&gt;At Microsoft, finished readers accumulated raised hands and kept signaling a state that no longer required action. The display became increasingly busy while the useful information - who was still reading - remained represented by an absence.&lt;/p&gt;

&lt;p&gt;The Amazon practice also created a clean transition from reading to discussion. By the time reading ended, every hand was down. Anyone could then raise a hand to signal that they wanted to speak.&lt;/p&gt;

&lt;p&gt;The inverse practice left every finished reader’s hand raised at exactly the moment the conversation began. Everyone first had to lower their hand before the same gesture could mean “I want to speak.” If someone forgot, the facilitator could not tell whether it was a stale “I finished reading” signal or a new request to join the discussion. From the Systems Engineering perspective, good protocols reset their state before changing the meaning of a signal. Counting down naturally resets every hand to down, ready for the conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start with every hand raised. Lower yours when you are done. Begin the conversation at zero.&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/09/17/in-meetings-raise-hands-before-reading.html&quot;&gt;In Meetings, Raise Hands Before Reading&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on September 17, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Porting a Discord Bot to Microsoft Teams, Entirely With AI]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/09/16/porting-strata-to-microsoft-teams-entirely-with-ai.html" />
  <id>https://code.dblock.org/2026/09/16/porting-strata-to-microsoft-teams-entirely-with-ai</id>
  <published>2026-09-16T00:00:00+00:00</published>
  <updated>2026-09-16T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;&lt;a href=&quot;https://slava.playplay.io&quot;&gt;Slava&lt;/a&gt; (&lt;a href=&quot;https://github.com/dblock/slack-strava&quot;&gt;slack-strava&lt;/a&gt;) connects a Strava account to a chat and posts a card for every activity, complete with map, pace, and elevation, plus commands like connect, disconnect, stats, and leaderboard. In 2023 I ported Slava to Discord by hand, evening by evening over about three weeks, and it took most of that stretch to get a working bot called &lt;a href=&quot;https://strada.playplay.io&quot;&gt;Strada&lt;/a&gt; (&lt;a href=&quot;https://github.com/dblock/discord-strava&quot;&gt;discord-strava&lt;/a&gt;). This week I ported the same app to Microsoft Teams as &lt;a href=&quot;https://strata.playplay.io&quot;&gt;Strata&lt;/a&gt; (&lt;a href=&quot;https://github.com/dblock/teams-strava&quot;&gt;teams-strava&lt;/a&gt;) in 3 days instead of the 3 weeks the Discord port took, using time carved out during the &lt;a href=&quot;https://www.microsoft.com/en-us/garage/hackathon/&quot;&gt;Microsoft Global Hackathon&lt;/a&gt;. By “I”, I mean &lt;a href=&quot;https://docs.github.com/en/copilot/how-tos/copilot-cli&quot;&gt;GitHub Copilot CLI&lt;/a&gt; and I.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-09-16-porting-strata-to-microsoft-teams-entirely-with-ai/activity-card.png&quot; alt=&quot;Strata posting a Strava activity to a Teams channel&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;the-2023-baseline&quot;&gt;The 2023 Baseline&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/dblock/discord-strava&quot;&gt;discord-strava&lt;/a&gt;’s git history is a fair record of how long a manual port takes. The first commit, “Initial fork from slack-strava,” landed July 30, 2023. “Most interactions working” was three days later. Getting install/help/error-handling polish took through August 20:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;2023-07-30 23:56  Initial fork from slack-strava.
2023-08-02 23:46  Most interactions working.
2023-08-03 06:18  Remove PNG retrieval, Discord displays redirects correctly.
2023-08-03 06:26  Removed clubs.
2023-08-03 08:28  Fixed model specs.
2023-08-03 18:20  Fix API specs.
2023-08-04 19:44  Fix command specs.
...
2023-08-08 10:34  Countinously poll with 1 minute intervals.
2023-08-20 22:59  Improved install button and text.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;20 commits spread across 9 evenings over roughly three weeks (there’s a real job and a life in between those timestamps). All hand-typed, with GitHub Copilot’s inline autocomplete helping the way it helped everyone in 2023, but no agent driving the actual porting work.&lt;/p&gt;

&lt;h3 id=&quot;the-2026-port&quot;&gt;The 2026 Port&lt;/h3&gt;

&lt;p&gt;teams-strava’s git history is a single continuous &lt;a href=&quot;https://docs.github.com/en/copilot/how-tos/copilot-cli&quot;&gt;Copilot CLI&lt;/a&gt; session, 107 conversational turns over three calendar days, and the timestamps compress dramatically:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;2026-09-14 11:05  Initial port of discord-strava to Microsoft Teams
2026-09-14 16:39  Fix local dev setup and personal-scope install handling
2026-09-14 16:43  Fix 400 error replying to personal-scope install notice
2026-09-15 08:29  Document CLI-only local dev workflow in DEV.md
2026-09-15 08:41  Fix Graph permission GUIDs and add missing CLI setup steps in DEV.md
2026-09-15 09:03  Fix Strava webhook subscription errors and threaded channel posts
2026-09-15 09:11  Send the Strava connect link via a private 1:1 message
2026-09-15 09:15  Fix broken help rendering in Teams
2026-09-15 09:18  Revert connect to posting in-channel; drop personal-scope DM for now
2026-09-16 08:33  Document production Azure Bot/Entra setup in DEV.md, fix --endpoint flag
2026-09-16 08:46  Add script/verify_production.rb production sanity check
2026-09-16 08:49  Point manifest at production bot, bump version to 1.0.0
2026-09-16 09:10  Make Strata free during beta and fix homepage app download
2026-09-16 09:16  Fix production NameError: rubyzip is a test-only transitive dependency
2026-09-16 09:26  Fix &apos;Activity resulted into multiple skype activities&apos; error on update
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;A working bot (connect, disconnect, post activities, help) existed by the end of the first day: five and a half hours elapsed between the first and last commit, but roughly three of those were actually spent hands-on-keyboard, in bursts, with breaks for a normal workday in between.&lt;/p&gt;

&lt;p&gt;The next two sessions were where I was genuinely out of my depth: registering an Entra app, wiring up Bot Framework client-credentials auth, building and validating a Teams app manifest, getting the bot into a tenant’s app catalog, and eventually standing up a separate production Azure subscription. I’d never touched any of it before this week. Copilot CLI ran the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;az&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;m365&lt;/code&gt; CLI commands, read the error messages back to me, and looked things up (multi-tenant bot registration being deprecated, a missing service principal, a renamed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--endpoint&lt;/code&gt; flag) faster than I could have found them myself reading Microsoft Learn cold. That’s arguably the bigger win over the 2023 Discord port: not “wrote the Ruby faster” but “got me through an unfamiliar cloud platform’s auth and app-distribution model without me having to become an expert in it first.” Either way, GitHub Copilot CLI got the whole port done in 3 days instead of the 3 weeks the Discord one took, roughly 7x faster.&lt;/p&gt;

&lt;p&gt;Copilot CLI’s own &lt;a href=&quot;https://docs.github.com/en/copilot/how-tos/copilot-cli&quot;&gt;usage tracking&lt;/a&gt; gives a sense of scale for just that first session: 659 API calls, about 272,000 output tokens generated, and roughly 72.8 million input tokens processed, of which about 71 million were cache reads rather than fresh tokens (the CLI re-sends accumulated context on every turn, and prompt caching is what keeps that affordable). That’s the token cost of a chatty pair-programming session where the “pair” reads the whole codebase back to itself before every reply. The session started on GPT-5.4 for the first 108 calls, then switched to Claude Sonnet 5 for the remaining 551, model choice is a CLI setting, not something baked into the port.&lt;/p&gt;

&lt;p&gt;Copilot CLI usage isn’t metered per token, it’s included in a Copilot subscription, so there’s no invoice to point at. But pricing it out at today’s public list prices for comparable models (roughly $3/$15 per million input/output tokens, with cache reads discounted to a fraction of that, cache writes at a slight premium) puts that first session at somewhere around $25-30 of raw model spend, almost all of it cache reads.&lt;/p&gt;

&lt;h3 id=&quot;this-isnt-a-fair-fight&quot;&gt;This Isn’t a Fair Fight&lt;/h3&gt;

&lt;p&gt;There’s zero reason to still do this kind of work by hand in 2026. Discord’s bot API is comparatively simple: a webhook, embeds, slash commands. Microsoft Teams brought its own pile of accidental complexity that the AI had to work through, not around: Bot Framework client-credentials auth against Entra ID, Adaptive Cards instead of embeds, a manifest/app-catalog model for distribution, and Teams Store submission requirements that don’t exist for Discord at all. The scope of this port is arguably larger than the 2023 one, and it still took a fraction of the time.&lt;/p&gt;

&lt;p&gt;AI of course still makes mistakes:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# TeamsStrava::CardRenderer used to render one Adaptive Card per embed&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# (activity + one per photo). Teams&apos; update API rejects an activity&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# update with more than one attachment:&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;#&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;#   400 BadSyntax: &quot;Activity resulted into multiple skype activities&quot;&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;#&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# Fixed by combining every embed into a single card/attachment.&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;body&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;embeds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;flat_map&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;embed&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;card_body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;embed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;activity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;add_card&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Teams&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Cards&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;AdaptiveCard&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;unless&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;empty?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# rubyzip was only ever required as a transitive, test-only dependency&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# of selenium-webdriver, so `zip -j` worked in every spec run and then&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# raised NameError: uninitialized constant ...::Zip the first time a&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# real user hit the download endpoint in production.&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gem&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;rubyzip&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;require: &lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;zip&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Both bugs shipped, passed a full green test suite, and only surfaced against the real Teams API and the real production environment. That’s not an argument against AI-driven development, it’s an argument for test coverage: when you’re merging on green because you trust the AI wrote the code, and largely didn’t review every line yourself, your test suite is the only thing standing between a passing build and a broken production. A 574-example spec suite at 87.95% line coverage and a clean rubocop run caught plenty, but no test suite covers what it doesn’t know to test, and there’s still no substitute for a production smoke test before you tell people the thing works.&lt;/p&gt;

&lt;h3 id=&quot;try-it-out&quot;&gt;Try It Out&lt;/h3&gt;

&lt;p&gt;Strata is in beta and not yet listed in the Teams Store, but it’s real and running in production. Head to &lt;a href=&quot;https://strata.playplay.io&quot;&gt;strata.playplay.io&lt;/a&gt; to download the app manifest and sideload it into your own tenant. This is temporary while I go through Teams Store submission; once that’s done, installing will be a one-click affair.&lt;/p&gt;

&lt;h3 id=&quot;links&quot;&gt;Links&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/teams-strava&quot;&gt;teams-strava&lt;/a&gt; — the new Microsoft Teams bot, install from &lt;a href=&quot;https://strata.playplay.io&quot;&gt;strata.playplay.io&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/discord-strava&quot;&gt;discord-strava&lt;/a&gt; — the 2023 manual port this is compared against, install from &lt;a href=&quot;https://strada.playplay.io&quot;&gt;strada.playplay.io&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/slack-strava&quot;&gt;slack-strava&lt;/a&gt; — the original, install from &lt;a href=&quot;https://slava.playplay.io&quot;&gt;slava.playplay.io&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/09/16/porting-strata-to-microsoft-teams-entirely-with-ai.html&quot;&gt;Porting a Discord Bot to Microsoft Teams, Entirely With AI&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on September 16, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Standardizing Exception Message Style in Ruby]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/09/05/standardizing-exception-message-style-in-ruby.html" />
  <id>https://code.dblock.org/2026/09/05/standardizing-exception-message-style-in-ruby</id>
  <published>2026-09-05T00:00:00+00:00</published>
  <updated>2026-09-05T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;While reviewing &lt;a href=&quot;https://github.com/ruby-grape/grape/pulls?q=is%3Apr+author%3Aericproulx&quot;&gt;dozens of PRs from ericproulx&lt;/a&gt; optimizing &lt;a href=&quot;https://github.com/ruby-grape/grape&quot;&gt;Grape&lt;/a&gt;’s internals recently, I noticed that the bare &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;raise ArgumentError, &quot;...&quot;&lt;/code&gt; calls scattered across the codebase were inconsistent: some messages were capitalized, some ended in a period, most were not. Longtime readers know &lt;a href=&quot;/2025/04/04/apologizing-for-my-obsessiveness-over-punctuation.html&quot;&gt;where this is going&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Ruby’s own core and standard library exceptions don’t do this - &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TypeError: no implicit conversion from nil to integer&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ArgumentError: wrong number of arguments&lt;/code&gt;, and so on all read lowercase and unpunctuated, because the message is meant to be read after the exception class name and a colon, not as a standalone sentence.&lt;/p&gt;

&lt;p&gt;Grape’s own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Grape::Exceptions::*&lt;/code&gt; classes already follow this convention. The bare &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;raise ArgumentError, &quot;...&quot;&lt;/code&gt; calls in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dsl/entity.rb&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dsl/inside_route.rb&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dsl/validations.rb&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;validations/types/dry_type_coercer.rb&lt;/code&gt; didn’t, so I fixed those seven sites and documented the convention in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CONTRIBUTING.md&lt;/code&gt;, in &lt;a href=&quot;https://github.com/ruby-grape/grape/pull/2909&quot;&gt;#2909&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;To avoid regressions, I wrote &lt;a href=&quot;https://github.com/dblock/rubocop-exception_messages&quot;&gt;rubocop-exception_messages&lt;/a&gt;, a RuboCop plugin gem with two cops:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExceptionMessages/Casing&lt;/code&gt; flags (and autocorrects) messages that don’t start with a lowercase letter.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExceptionMessages/Punctuation&lt;/code&gt; flags (and autocorrects) messages with a trailing period, with an exception for a literal ellipsis (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;still processing..&quot;&lt;/code&gt;), which is stylistic rather than a sentence ending.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both cops recognize &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;raise Class, &quot;message&quot;&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;raise Class.new(&quot;message&quot;)&lt;/code&gt; forms, and handle interpolated (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dstr&lt;/code&gt;) messages by only checking the literal string segments (the first segment for casing, the last for punctuation), since interpolated values in the middle are out of the cop’s control.&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# bad&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;ArgumentError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;Missing required option.&apos;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# good&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;ArgumentError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;missing required option&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The gem ships as a modern RuboCop plugin (via &lt;a href=&quot;https://github.com/standardrb/lint_roller&quot;&gt;lint_roller&lt;/a&gt;), so it’s a one-line addition to a consuming project’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.rubocop.yml&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;plugins&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;rubocop-exception_messages&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rubocop-exception_messages&lt;/code&gt; &lt;a href=&quot;https://rubygems.org/gems/rubocop-exception_messages&quot;&gt;0.2.0&lt;/a&gt; is out now, and I’ve added it back to Grape’s own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Gemfile&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.rubocop.yml&lt;/code&gt; in &lt;a href=&quot;https://github.com/ruby-grape/grape/pull/2909&quot;&gt;#2909&lt;/a&gt;, so the convention it started from is now enforced by CI going forward. Not everyone will agree with my choice of lowercase and unpunctuated, of course, so &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EnforcedStyle&lt;/code&gt; support to configure the opposite convention is available, along with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExceptionMessages/RedundantExceptionName&lt;/code&gt;, which flags messages that redundantly repeat the exception class name, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExceptionMessages/QuoteStyle&lt;/code&gt;, enabled by default, which checks that interpolated values in exception messages are consistently marked, e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;unknown type: `#{type}`&quot;&lt;/code&gt; — the same PR fixed the 16 messages across Grape’s codebase that weren’t.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/09/05/standardizing-exception-message-style-in-ruby.html&quot;&gt;Standardizing Exception Message Style in Ruby&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on September 05, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Adventures in Daylight Saving, Norfolk Island, and Time Zone Math (in Ruby)]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/08/28/adventures-in-daylight-saving-norfolk-island-and-time-zone-math-in-ruby.html" />
  <id>https://code.dblock.org/2026/08/28/adventures-in-daylight-saving-norfolk-island-and-time-zone-math-in-ruby</id>
  <published>2026-08-28T00:00:00+00:00</published>
  <updated>2026-08-28T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;&lt;a href=&quot;https://github.com/radar/distance_of_time_in_words&quot;&gt;distance_of_time_in_words&lt;/a&gt; is a small Ruby gem that turns two &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt; objects into a human-readable string like “3 days and 4 hours”. Several separate bug reports against it turned out to be variations on the same theme: computing a duration between two timestamps is not the trivial subtraction it looks like, the moment time zones are involved. The first two fixes shipped in &lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/blob/master/CHANGELOG.md#560-20260828&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dotiw&lt;/code&gt; 5.6.0&lt;/a&gt;; four more followed shortly after in &lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/blob/master/CHANGELOG.md#561-20260909&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dotiw&lt;/code&gt; 5.6.1&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;bug-1-dst-lies-when-you-least-expect-it&quot;&gt;Bug 1: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dst?&lt;/code&gt; Lies When You Least Expect It&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/issues/63&quot;&gt;#63&lt;/a&gt; reported that a duration of one minute was rendered as “less than 1 second” for users in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Europe/Dublin&lt;/code&gt;. The gem’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TimeHash&lt;/code&gt; had a DST correction that looked reasonable:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;hour&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;dst?&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;dst?&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;hour&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;dst?&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;dst?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The idea: if a DST transition happened between the two times, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt; subtraction already accounts for the wall-clock jump, so cancel it back out before splitting the duration into calendar units. That works everywhere except Ireland. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Europe/Dublin&lt;/code&gt; uses an &lt;em&gt;inverted&lt;/em&gt; DST scheme: its winter time is legally defined as “standard time minus one hour” rather than the more common “standard time is winter, summer is +1”. Depending on whether a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt; was constructed via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time.at(seconds)&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;datetime.to_time&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dst?&lt;/code&gt; could report different values for the exact same instant, even though &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;utc_offset&lt;/code&gt; agreed. The correction fired when it shouldn’t have, and a real one-minute gap got silently zeroed out.&lt;/p&gt;

&lt;p&gt;Reproducing it doesn’t even require mocking &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dst?&lt;/code&gt; — just running the example with the right &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TZ&lt;/code&gt; set is enough:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;no&quot;&gt;ENV&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;TZ&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;Europe/Dublin&apos;&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;start&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;DateTime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;DateTime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;now&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;minute&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# =&amp;gt; &quot;less than 1 second&quot;&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# expected: &quot;1 minute&quot;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;distance_of_time_in_words&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The fix (&lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/pull/152&quot;&gt;PR #152&lt;/a&gt;) was to stop asking “is this DST?” and just compare the actual offsets:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;offset_decreased?&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;utc_offset&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;utc_offset&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;offset_increased?&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;utc_offset&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;utc_offset&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;utc_offset&lt;/code&gt; doesn’t care how the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt; was constructed or what a particular country calls its winter clock — it’s just the number of seconds off UTC. Reliable, and it doesn’t require knowing anything about &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Europe/Dublin&lt;/code&gt;’s specific legal quirk.&lt;/p&gt;

&lt;h2 id=&quot;bug-2-dst-isnt-the-only-thing-that-changes-an-offset&quot;&gt;Bug 2: DST Isn’t the Only Thing That Changes an Offset&lt;/h2&gt;

&lt;p&gt;Fixing #63 with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;utc_offset&lt;/code&gt; comparisons was more correct, but still baked in an assumption: that any offset change is a ±1 hour DST transition. &lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/issues/153&quot;&gt;#153&lt;/a&gt; broke that assumption. Running the test suite under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TZ=Pacific/Norfolk&lt;/code&gt; produced a spurious “23 hours and 30 minutes” leaking into results that should have just been a clean calendar distance.&lt;/p&gt;

&lt;p&gt;It turns out &lt;a href=&quot;https://www.infrastructure.gov.au/territories-regions-cities/territories/norfolk_island/administrator/media/2015/ni-a-mr-201526&quot;&gt;Norfolk Island permanently changed its UTC offset from +11:30 to +11:00 on 4 October 2015&lt;/a&gt;, announced by the island’s Administrator a month earlier — a one-time &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tzdata&lt;/code&gt; rule change, not a recurring seasonal transition. The old code’s ±1 hour hardcoding had no way to represent a 30-minute, permanent shift.&lt;/p&gt;

&lt;p&gt;Again, a real (non-mocked) reproduction is enough — no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dst?&lt;/code&gt; mismatch involved this time, just a genuine historical offset change baked into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tzdata&lt;/code&gt; itself:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;no&quot;&gt;ENV&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;TZ&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;Pacific/Norfolk&apos;&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;start&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;2015-1-15&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;to_time&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;2016-3-15&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;to_time&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# =&amp;gt; &quot;1 year, 2 months, 23 hours, and 30 minutes&quot;&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# expected: &quot;1 year and 2 months&quot;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;distance_of_time_in_words&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The real fix (&lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/pull/154&quot;&gt;PR #154&lt;/a&gt;) was to stop special-casing “1 hour” and generalize to whatever the actual offset delta is:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;offset_delta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;utc_offset&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;utc_offset&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Here’s why that’s needed, worked out with real numbers. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;start.utc_offset&lt;/code&gt; is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;+11:30&lt;/code&gt; (41400 seconds) and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;finish.utc_offset&lt;/code&gt; is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;+11:00&lt;/code&gt; (39600 seconds). Ruby’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt; subtraction (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;finish - start&lt;/code&gt;) already factors that in: it returns &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;36,721,800&lt;/code&gt; seconds, which is 425 days and 1800 seconds (30 minutes) — not a clean 425 days. That’s correct: 30 real minutes did elapse due to the offset change, so the raw distance is right.&lt;/p&gt;

&lt;p&gt;The bug was in the &lt;em&gt;next&lt;/em&gt; step, where that distance gets split into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;years&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;months&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;weeks&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;days&lt;/code&gt;. That calendar breakdown doesn’t work from seconds at all — it reads &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;largest.year&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;largest.month&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;largest.day&lt;/code&gt; and subtracts &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;smallest&lt;/code&gt;’s, which are plain calendar fields with no concept of UTC offset. Jan 15 to Mar 15 is a clean “1 year, 2 months, 0 days” by the calendar, no remainder. Meanwhile the old buggy code applied its ±1 hour DST correction &lt;em&gt;before&lt;/em&gt; computing the distance (since it still thought in terms of “1 hour”, not the real -1800 second delta), which left the wrong remainder behind: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;36,721,800 - 3600 = 36,718,200&lt;/code&gt; seconds, i.e. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;424&lt;/code&gt; whole days plus an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;84,600&lt;/code&gt;-second (23.5 hour) remainder — reported as “23 hours and 30 minutes”. Neither the ±1 hour hardcoding nor the 30-minute reality had anywhere to go once years/months/days had already consumed the calendar-shaped part of the duration, so it leaked out as bogus hours and minutes. Using the real &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;offset_delta&lt;/code&gt; (-1800, not ±3600) and applying it consistently everywhere the code touches &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@distance&lt;/code&gt; removes that artifact entirely, leaving just “1 year and 2 months”.&lt;/p&gt;

&lt;p&gt;This is a strictly more general version of Bug 1’s fix — the “DST transition” case just falls out as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;offset_delta&lt;/code&gt; happening to equal ±3600 seconds. Once we stopped assuming &lt;em&gt;what kind&lt;/em&gt; of offset change was possible, both the recurring and the one-off cases worked with the same code path. The lesson: don’t encode a specific real-world cause (DST, 1 hour) into your math when what you actually care about is a more general effect (offset changed, by however much).&lt;/p&gt;

&lt;h2 id=&quot;do-other-languages-have-this-problem&quot;&gt;Do Other Languages Have This Problem?&lt;/h2&gt;

&lt;p&gt;Curious whether this is a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dotiw&lt;/code&gt;-specific mistake or a trap every “humanize a time difference” library falls into, I reproduced both scenarios — the Norfolk Island offset change and the Dublin DST-adjacent case — against similar libraries in JavaScript, Python, Go, Rust, PHP, C#, Java, Elixir, Swift, Objective-C, and Dart: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;date-fns&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dayjs&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;moment.js&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;humanize&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;arrow&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;go-humanize&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chrono-humanize&lt;/code&gt;, native &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DateTime::diff&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Carbon&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Humanizer&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PrettyTime&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Timex&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;humanizer&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RelativeDateTimeFormatter&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DateComponentsFormatter&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timeago&lt;/code&gt;. All the &lt;a href=&quot;https://github.com/dblock/tz_test&quot;&gt;test code is on GitHub&lt;/a&gt; if you want to run it yourself.&lt;/p&gt;

&lt;p&gt;Every one of them was clean on the Dublin case, and every one but one was clean on Norfolk too. Here’s the Norfolk Island case in JavaScript (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;date-fns&lt;/code&gt;) and Python (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;humanize&lt;/code&gt;):&lt;/p&gt;

&lt;div class=&quot;language-javascript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;TZ&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;Pacific/Norfolk&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;start&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2015&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;finish&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2016&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// =&amp;gt; &quot;about 1 year&quot;&lt;/span&gt;
&lt;span class=&quot;nf&quot;&gt;formatDistance&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;finish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;includeSeconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;environ&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;TZ&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Pacific/Norfolk&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;start&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;datetime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2015&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tzinfo&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;ZoneInfo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Pacific/Norfolk&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;datetime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2016&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tzinfo&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;ZoneInfo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Pacific/Norfolk&lt;/span&gt;&lt;span class=&quot;sh&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# =&amp;gt; &quot;1 year, 2 months&quot;
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;humanize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;naturaldelta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;And the Dublin case in Rust (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chrono-humanize&lt;/code&gt;) and PHP (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Carbon&lt;/code&gt;, the closest analog to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dotiw&lt;/code&gt; since it also supports a compound breakdown):&lt;/p&gt;

&lt;div class=&quot;language-rust highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;dstart&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Dublin&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;.with_ymd_and_hms&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2024&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;27&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;59&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;30&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;.earliest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;.unwrap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;dfinish&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;dstart&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;Duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;minutes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// =&amp;gt; &quot;in a minute&quot;&lt;/span&gt;
&lt;span class=&quot;nn&quot;&gt;HumanTime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dfinish&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;.signed_duration_since&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dstart&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-php highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nv&quot;&gt;$dstart&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Carbon&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2024&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;27&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;59&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;30&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;Europe/Dublin&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;$dfinish&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$dstart&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;copy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;addMinute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// =&amp;gt; &quot;1 minute before&quot;&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;$dstart&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;diffForHumans&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$dfinish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// =&amp;gt; &quot;1 year 2 months&quot; (Norfolk case, compound breakdown, still clean)&lt;/span&gt;
&lt;span class=&quot;nc&quot;&gt;Carbon&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2015&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;Pacific/Norfolk&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;diff&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;Carbon&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2016&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;Pacific/Norfolk&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;forHumans&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;C#’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Humanizer&lt;/code&gt; doesn’t attempt a calendar year/month breakdown at all, only weeks/days/hours/minutes, so the Norfolk case has no calendar-shaped bucket to leak into:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;norfolk&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TimeZoneInfo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;FindSystemTimeZoneById&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Pacific/Norfolk&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;start&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;DateTimeOffset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;2015&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;norfolk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetUtcOffset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;DateTime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;2015&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)));&lt;/span&gt;
&lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;DateTimeOffset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;2016&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;norfolk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetUtcOffset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;DateTime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;2016&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)));&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// =&amp;gt; &quot;60 weeks, 5 days, 30 minutes&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Humanize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;precision&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Java’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PrettyTime&lt;/code&gt; only ever formats a single instant relative to another (“1 year from now”), so there’s no compound breakdown at all to leak into:&lt;/p&gt;

&lt;div class=&quot;language-java highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nc&quot;&gt;ZonedDateTime&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;start&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ZonedDateTime&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;of&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2015&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ZoneId&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;of&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Pacific/Norfolk&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;));&lt;/span&gt;
&lt;span class=&quot;nc&quot;&gt;ZonedDateTime&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ZonedDateTime&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;of&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2016&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ZoneId&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;of&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Pacific/Norfolk&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;));&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// =&amp;gt; &quot;1 year from now&quot;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;PrettyTime&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;Date&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;toInstant&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;())).&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;Date&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;toInstant&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Elixir’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Timex&lt;/code&gt;, however, &lt;em&gt;does&lt;/em&gt; reproduce the bug — same shape as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dotiw&lt;/code&gt;’s original Norfolk failure, just a smaller leftover because it computes the real offset delta instead of hardcoding an hour:&lt;/p&gt;

&lt;div class=&quot;language-elixir highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:ok&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;DateTime&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sx&quot;&gt;~D[2015-01-15]&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;sx&quot;&gt;~T[00:00:00]&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Pacific/Norfolk&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Tzdata&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;TimeZoneDatabase&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:ok&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;DateTime&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sx&quot;&gt;~D[2016-03-15]&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;sx&quot;&gt;~T[00:00:00]&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Pacific/Norfolk&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Tzdata&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;TimeZoneDatabase&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# =&amp;gt; &quot;1 year, 2 months, 30 minutes&quot;&lt;/span&gt;
&lt;span class=&quot;no&quot;&gt;Timex&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Format&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Duration&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Formatters&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Humanized&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;no&quot;&gt;Timex&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Duration&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;from_seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;DateTime&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;diff&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That trailing “30 minutes” is exactly the Norfolk offset delta leaking out, the same artifact &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dotiw&lt;/code&gt; used to produce as “23 hours and 30 minutes” before PR #154. It’s a good confirmation that the bug isn’t a Ruby-specific mistake so much as a natural consequence of building a compound years/months/…/minutes breakdown from a raw second count without accounting for the offset change along the way — most libraries just happen to avoid the compound breakdown (or, in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Carbon&lt;/code&gt;’s case, avoid the bug despite it) rather than being immune to the underlying trap.&lt;/p&gt;

&lt;p&gt;I opened &lt;a href=&quot;https://github.com/bitwalker/timex/pull/793&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bitwalker/timex&lt;/code&gt; PR #793&lt;/a&gt; with a fix, following the same strategy as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dotiw&lt;/code&gt;’s: instead of formatting an opaque &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Duration&lt;/code&gt; (which has already lost all calendar context by the time it reaches the formatter), the fix adds a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;format/2&lt;/code&gt; that takes both datetimes directly, computes years/months via real calendar arithmetic, and only converts the true leftover to a duration:&lt;/p&gt;

&lt;div class=&quot;language-elixir highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;no&quot;&gt;Timex&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Format&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Duration&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Formatters&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Humanized&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# =&amp;gt; &quot;1 year, 2 months&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;While testing this fix, I also found that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;format/2&lt;/code&gt; crashes if &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;finish&lt;/code&gt; comes before &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;start&lt;/code&gt; — negative years/months get passed straight into Gettext’s plural translation, which requires a non-negative count. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;format/1&lt;/code&gt; has always been sign-independent (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Duration.from_erl({0, -65, 0})&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Duration.from_erl({0, 65, 0})&lt;/code&gt; both format the same way), so &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;format/2&lt;/code&gt; should be too. Filed as a follow-up, &lt;a href=&quot;https://github.com/bitwalker/timex/pull/794&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bitwalker/timex&lt;/code&gt; PR #794&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Timex&lt;/code&gt; itself is largely unmaintained at this point — the last push to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; was mid-2025, and it has more than 70 open issues — so while I was at it, I checked whether a maintained alternative avoids this whole class of bug. &lt;a href=&quot;https://github.com/ivan-podgurskiy/humanizer&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;humanizer&lt;/code&gt;&lt;/a&gt; is a small, actively developed, English-only library with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;relative_time/2,3&lt;/code&gt; function. It’s clean on both the Norfolk and Dublin cases, and it also handles reversed argument order correctly without crashing — it diffs absolute instants and branches on sign rather than doing calendar-aware year/month shifting, which sidesteps the bug class structurally at the cost of using fixed-width buckets (7/30/365 days) for weeks/months/years instead of exact calendar arithmetic. The reproduction is in the same &lt;a href=&quot;https://github.com/dblock/tz_test&quot;&gt;test repo&lt;/a&gt;, under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;elixir/humanizer_test/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I later extended the reproduction to Swift, Objective-C, and Dart. Swift and Objective-C share the same underlying Foundation implementation: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RelativeDateTimeFormatter&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSRelativeDateTimeFormatter&lt;/code&gt; (single-largest-unit “time ago” style) and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DateComponentsFormatter&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSDateComponentsFormatter&lt;/code&gt; (a compound breakdown, directly analogous to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dotiw&lt;/code&gt;’s output). All clean — Norfolk, Dublin, reversed order, and zero distance. Dart’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timeago&lt;/code&gt; package is clean too.&lt;/p&gt;

&lt;p&gt;One thing initially looked like a fourth bug during that pass, worth mentioning because I got it wrong at first. Given a reversed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(fromDate, toDate)&lt;/code&gt; pair, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSDateComponentsFormatter&lt;/code&gt; renders:&lt;/p&gt;

&lt;div class=&quot;language-objc highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;NSDateComponentsFormatter&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;f&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;NSDateComponentsFormatter&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;alloc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;];&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;unitsStyle&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;NSDateComponentsFormatterUnitsStyleFull&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;allowedUnits&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;NSCalendarUnitYear&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;NSCalendarUnitMonth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;calendar&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// Pacific/Norfolk&lt;/span&gt;

&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;f&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;stringFromDate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;start&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;toDate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;// =&amp;gt; &quot;1 year, 2 months&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;f&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;stringFromDate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;toDate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;// =&amp;gt; &quot;-1 year, 2 months&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That looks inconsistent — surely it should read &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;-1 year, -2 months&quot;&lt;/code&gt; if the underlying delta is negative in both fields? But this is actually standard mixed-radix negative notation, the same convention used for negative durations (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-1:30:00&lt;/code&gt; means minus one-and-a-half hours, not “minus one hour plus thirty minutes”) or negative degrees/minutes/seconds coordinates: only the leading unit carries the sign, and the rest are magnitudes of that same negative quantity. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSCalendar&lt;/code&gt; confirms this is intentional: the raw components really are &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;year=-1, month=-2&lt;/code&gt; underneath, and the formatter correctly collapses that into a single leading sign for display, exactly as it should. Not a bug — see the &lt;a href=&quot;https://github.com/dblock/tz_test&quot;&gt;test repo&lt;/a&gt; under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;objc/&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;swift/&lt;/code&gt; for the full reproduction and reasoning.&lt;/p&gt;

&lt;p&gt;The main reason none of the others reproduce the bug is structural: most round to a single largest unit (“about 1 year”, “a minute ago”) instead of building a compound breakdown across years, months, weeks, days, hours, &lt;em&gt;and&lt;/em&gt; minutes the way &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dotiw&lt;/code&gt; does. With nowhere calendar-shaped for a stray 30 minutes or 23 hours to end up, there’s no remainder left to misattribute. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Carbon&lt;/code&gt; is the exception that proves the rule: it does support a compound breakdown similar to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dotiw&lt;/code&gt;’s output, and still gets it right, because the offset math happens correctly underneath, at the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DateInterval&lt;/code&gt; level, before any splitting into units occurs.&lt;/p&gt;

&lt;p&gt;One other thing stood out while testing the Dublin case in Rust and C#. Both refuse to let you construct a local time that falls in an ambiguous window (the “fall back” hour that occurs twice) without handling it explicitly. Rust’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chrono-tz&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-rust highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;match&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Dublin&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;.with_ymd_and_hms&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2024&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;27&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;59&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;30&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nn&quot;&gt;chrono&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nn&quot;&gt;LocalResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Single&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nd&quot;&gt;println!&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Single: {}&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;dt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;nn&quot;&gt;chrono&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nn&quot;&gt;LocalResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;Ambiguous&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nd&quot;&gt;println!&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Ambiguous: {} OR {}&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;nn&quot;&gt;chrono&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nn&quot;&gt;LocalResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nd&quot;&gt;println!&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;None (doesn&apos;t exist, e.g. spring-forward gap)&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;cs&quot;&gt;# =&amp;gt; Ambiguous: 2024-10-27 01:59:30 IST OR 2024-10-27 01:59:30 GMT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;And .NET’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TimeZoneInfo&lt;/code&gt;, which surfaces the same fact via an explicit query instead of an enum:&lt;/p&gt;

&lt;div class=&quot;language-csharp highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;dublin&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TimeZoneInfo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;FindSystemTimeZoneById&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Europe/Dublin&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;kt&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;local&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;DateTime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;2024&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;27&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;59&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;30&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;DateTimeKind&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Unspecified&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;dublin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;IsAmbiguousTime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// =&amp;gt; true&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;dublin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;GetAmbiguousTimeOffsets&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// =&amp;gt; [00:00:00, 01:00:00]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Ruby (and most of the other languages tested) will silently pick one interpretation of an ambiguous wall-clock time and move on. Forcing the caller to disambiguate explicitly is exactly the kind of design that would have made a bug like #63 harder to write in the first place.&lt;/p&gt;

&lt;h2 id=&quot;bug-3-two-unrelated-offsets-arent-a-transition&quot;&gt;Bug 3: Two Unrelated Offsets Aren’t a Transition&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;offset_delta&lt;/code&gt; from Bug 2 fixed the “real transition” case, but it introduced a subtler mistake: it assumed &lt;em&gt;any&lt;/em&gt; two &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt; values with different &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;utc_offset&lt;/code&gt;s must represent the same clock crossing a real transition, and folded the difference in unconditionally. &lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/issues/160&quot;&gt;#160&lt;/a&gt; showed that’s not true. Comparing a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt; pinned to UTC against a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt; pinned to a fixed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-08:00&lt;/code&gt; offset — two clocks that have nothing to do with each other, no shared tzdata history, no transition between them — still triggered the same folding logic:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;start&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;utc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2026&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;12&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2026&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;12&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;30&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;-08:00&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# =&amp;gt; &quot;less than 1 second&quot; (8 hours silently subtracted)&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# expected: &quot;8 hours&quot;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;distance_of_time_in_words&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;finish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The fix (&lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/pull/161&quot;&gt;PR #161&lt;/a&gt;) added a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;same_clock?&lt;/code&gt; guard before applying &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;offset_delta&lt;/code&gt; at all — only fold the offset when both &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt;s plausibly represent observations of the &lt;em&gt;same&lt;/em&gt; underlying clock (same system zone abbreviation, both non-UTC, etc.), not merely because they happen to disagree on offset:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;offset_delta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;unless&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;same_clock?&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

  &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;utc_offset&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;utc_offset&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;bug-4-to_time-doesnt-always-mean-the-same-thing&quot;&gt;Bug 4: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;to_time&lt;/code&gt; Doesn’t Always Mean the Same Thing&lt;/h2&gt;

&lt;p&gt;Shipping &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;same_clock?&lt;/code&gt; immediately raised the next question: what actually counts as “the same clock”? &lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/issues/162&quot;&gt;#162&lt;/a&gt; is a case where two &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ActiveSupport::TimeWithZone&lt;/code&gt; values, in two different named zones (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Asia/Tokyo&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;America/Los_Angeles&lt;/code&gt;), get converted to plain &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt; via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#to_time&lt;/code&gt; before reaching &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dotiw&lt;/code&gt;. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time#zone&lt;/code&gt; accessor is normally a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;String&lt;/code&gt; (a zone abbreviation like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;JST&quot;&lt;/code&gt;) or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nil&lt;/code&gt; for a fixed offset — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;same_clock?&lt;/code&gt; treated any two non-nil &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#zone&lt;/code&gt;s as good enough. That was true until Rails 8.0, where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;to_time_preserves_timezone = :zone&lt;/code&gt; became the default (permanent as of 8.2): &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TimeWithZone#to_time&lt;/code&gt; now returns a plain &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt; whose &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#zone&lt;/code&gt; is the actual &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ActiveSupport::TimeZone&lt;/code&gt; &lt;em&gt;object&lt;/em&gt;, not a string. Two different zone objects both being “not nil” made &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;same_clock?&lt;/code&gt; say yes to two completely unrelated zones, and the real 1-hour difference between Tokyo and Los Angeles got folded away into “less than 1 second” again — but only on Rails &amp;gt;= 8.0. Reproducing it needed the Rails 8 code path specifically:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;tokyo_time&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;ActiveSupport&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;TimeZone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;Asia/Tokyo&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2026&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;16&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;to_time&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;la_time&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;ActiveSupport&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;TimeZone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;America/Los_Angeles&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2026&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;12&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;to_time&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# On Rails &amp;gt;= 8.0: tokyo_time.zone and la_time.zone are both TimeZone objects (not strings),&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# so the old same_clock? treated them as &quot;the same clock&quot; and folded the 1-hour gap away.&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# =&amp;gt; &quot;less than 1 second&quot; on Rails &amp;gt;= 8.0, &quot;1 hour&quot; on Rails &amp;lt;= 7.2&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;distance_of_time_in_words&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tokyo_time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;la_time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The fix (&lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/pull/163&quot;&gt;PR #163&lt;/a&gt;) taught &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;same_clock?&lt;/code&gt; to actually compare the zone objects for equality instead of just checking they’re both present:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;same_clock?&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;respond_to?&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:time_zone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;respond_to?&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:time_zone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;respond_to?&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:time_zone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;respond_to?&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:time_zone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;time_zone&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;time_zone&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;elsif&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;zone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;is_a?&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;zone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;is_a?&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;zone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;nil?&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;zone&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;zone&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;zone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;nil?&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;smallest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;utc?&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;zone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;nil?&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;largest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;utc?&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This one is a good reminder that a library’s own dependencies can quietly change the shape of the objects you’re handed. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dotiw&lt;/code&gt; never called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;to_time&lt;/code&gt; itself — a Rails minor version bump changed what &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time#zone&lt;/code&gt; returns for values constructed elsewhere entirely, and the bug only showed up for people on the new default.&lt;/p&gt;

&lt;h2 id=&quot;bringing-in-more-test-cases&quot;&gt;Bringing In More Test Cases&lt;/h2&gt;

&lt;p&gt;Since both of these bugs turned out to have prior art elsewhere, I went back through the test suites of the libraries surveyed for Bug 2/3 — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Luxon&lt;/code&gt;’s diff tests, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Timex&lt;/code&gt;’s humanized-duration tests, and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tz_test&lt;/code&gt; reproductions — and pulled in the ones that translate directly into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dotiw&lt;/code&gt; regression examples: a UTC-vs-CEST offset comparison from Luxon (&lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/pull/166&quot;&gt;PR #166&lt;/a&gt;) and the historical Norfolk Island offset change (&lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/pull/167&quot;&gt;PR #167&lt;/a&gt;), both passing cleanly against the fixed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;same_clock?&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;offset_delta&lt;/code&gt;. (These started life bundled together in a single PR #164, later split apart once it became clear the third example below needed materially different treatment.)&lt;/p&gt;

&lt;p&gt;The Timex-derived case — a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Europe/Dublin&lt;/code&gt; DST fall-back, one real minute elapsing across the clocks-back transition — turned up a fifth wrinkle while adapting it: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;same_clock?&lt;/code&gt; correctly recognizes both timestamps as the &lt;em&gt;same&lt;/em&gt; clock (they really are the same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TimeZone&lt;/code&gt; object), so &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;offset_delta&lt;/code&gt; still folds in the full 1-hour offset change. But here the &lt;em&gt;actual&lt;/em&gt; elapsed time is only 60 seconds — smaller than the offset delta being folded in — so the correction overshoots and produces a negative corrected distance, again collapsing to “less than 1 second” instead of “1 minute”. Unlike Bugs 3 and 4, this isn’t about misidentifying &lt;em&gt;whether&lt;/em&gt; two &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt;s are the same clock; it’s that the folding math assumes the real elapsed time is always large relative to the offset shift, which breaks down right at the boundary of a transition. Filed as &lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/issues/165&quot;&gt;#165&lt;/a&gt; and fixed in &lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/pull/169&quot;&gt;PR #169&lt;/a&gt;: the offset correction now only ever applies to the sub-day leftover, once the distance has already been split into calendar fields, rather than to the top-level distance used to decide which calendar branch (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build_years&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build_days&lt;/code&gt;, etc.) to take in the first place — so a genuinely tiny elapsed time can no longer be pushed past zero by a much larger offset shift.&lt;/p&gt;

&lt;h2 id=&quot;bug-5-an-unforced-conversion-not-a-rails-version-limitation&quot;&gt;Bug 5: An Unforced Conversion, Not a Rails Version Limitation&lt;/h2&gt;

&lt;p&gt;Verifying the #165 fix across every supported Rails version turned up one more thing. The regression spec for #165’s sibling case — the Norfolk historical offset change, this time expressed as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ActiveSupport::TimeWithZone&lt;/code&gt; values rather than plain &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt; — only passed on Rails &amp;gt;= 8.0. On Rails &amp;lt; 8.0, it failed with a spurious “1 year, 2 months, and 30 minutes” instead of “1 year and 2 months”, suspiciously similar to the very bug #154 had already fixed. I was pairing with an AI coding assistant on this fix, and its first instinct was to shrug this off as an inherent Rails &amp;lt; 8.0 limitation (the same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;to_time_preserves_timezone&lt;/code&gt; distinction from Bug 4) and skip the spec on older Rails.&lt;/p&gt;

&lt;p&gt;That instinct was wrong, and it took me pushing back — “the following code should always give the correct answer, no?” — before it looked closer instead of accepting its own shortcut. The real cause: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;distance_of_time_in_words&lt;/code&gt; unconditionally called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#to_time&lt;/code&gt; on any &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TimeWithZone&lt;/code&gt; argument before doing anything else with it:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;from_time&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;from_time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;to_time&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;from_time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;is_a?&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;from_time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;respond_to?&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:to_time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;On Rails &amp;lt; 8.0, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TimeWithZone#to_time&lt;/code&gt; (without &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;to_time_preserves_timezone&lt;/code&gt; set) returns a plain &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt; in the &lt;em&gt;process’s local system zone&lt;/em&gt;, discarding the actual zone entirely — which is exactly the information &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;same_clock?&lt;/code&gt; needs to recognize two readings as the same clock across a transition. But nothing about &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TimeHash&lt;/code&gt; actually needs a plain &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt; in the first place: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TimeWithZone&lt;/code&gt; already supports every operation it performs — subtraction, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#advance&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#year&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#month&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#day&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#hour&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#utc_offset&lt;/code&gt; — without ever being converted. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#to_time&lt;/code&gt; call wasn’t a Rails-version workaround at all, just an unforced, avoidable conversion that happened to only cause visible damage before Rails 8.0’s default changed what it produced.&lt;/p&gt;

&lt;p&gt;Filed as &lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/issues/170&quot;&gt;#170&lt;/a&gt; and fixed in &lt;a href=&quot;https://github.com/radar/distance_of_time_in_words/pull/167&quot;&gt;PR #167&lt;/a&gt; with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;coerce_to_time&lt;/code&gt; helper that leaves &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TimeWithZone&lt;/code&gt; arguments untouched, only calling &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#to_time&lt;/code&gt; on genuinely non-&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt;-like values (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Date&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DateTime&lt;/code&gt;):&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;coerce_to_time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;is_a?&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;respond_to?&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:time_zone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

  &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;respond_to?&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:to_time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;to_time&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;With that in place, the Norfolk &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TimeWithZone&lt;/code&gt; example (and the #165 Dublin fall-back example) both pass on every supported Rails version, 7.0 through 8.1, with no skip required — a stronger fix than the version-gated one I’d initially assumed was necessary.&lt;/p&gt;

&lt;h2 id=&quot;the-common-thread&quot;&gt;The Common Thread&lt;/h2&gt;

&lt;p&gt;All three failure modes share a shape: a plausible-looking shortcut (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dst?&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;utc_offset&lt;/code&gt;, “correct by exactly 1 hour”, “convert to a plain &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Time&lt;/code&gt; up front”) that works for the overwhelmingly common case and quietly breaks for a specific, real-world edge case that a bug reporter with an unusual time zone eventually ran into. None were caught by the existing test suite, because the test suite ran in one time zone, on inputs that never crossed the affected boundaries — and, in Bug 5’s case, on only one version of Rails.&lt;/p&gt;

&lt;p&gt;The actual fix was the same in spirit each time: replace the specific assumption with the general, verifiable fact it was standing in for — actual offsets instead of a DST flag, an arbitrary delta instead of a fixed hour, and no conversion at all where none was ever needed. If you maintain a library that touches wall-clock time, it’s worth asking, for every “obvious” shortcut in the code, what real-world weirdness it’s quietly assuming doesn’t exist — and, per Bug 5, whether “this only works on newer Rails” is really a platform limitation or just an unforced move your own code is making. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Europe/Dublin&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Pacific/Norfolk&lt;/code&gt; are more common exceptions to your assumptions than you’d think.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/08/28/adventures-in-daylight-saving-norfolk-island-and-time-zone-math-in-ruby.html&quot;&gt;Adventures in Daylight Saving, Norfolk Island, and Time Zone Math (in Ruby)&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on August 28, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Benchmarks Are Free Now]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/08/15/benchmarks-are-free-now.html" />
  <id>https://code.dblock.org/2026/08/15/benchmarks-are-free-now</id>
  <published>2026-08-15T00:00:00+00:00</published>
  <updated>2026-08-15T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;My &lt;a href=&quot;/2026/08/14/ruby-instance-variables-are-not-inherited-and-why-that-breaks-your-dsl&quot;&gt;previous post&lt;/a&gt; walked through four bugs in &lt;a href=&quot;https://github.com/dblock/ruby-enum&quot;&gt;ruby-enum&lt;/a&gt;, a gem I maintain, all stemming from the fact that class-level instance variables aren’t inherited by subclasses. The third fix, &lt;a href=&quot;https://github.com/dblock/ruby-enum/pull/59&quot;&gt;#59&lt;/a&gt;, made &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;keys&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;key?&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;value?&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;key&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;value&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;to_h&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;parse&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;each&lt;/code&gt; walk up &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;superclass&lt;/code&gt; and merge in a parent’s enums, so a subclass would see everything its ancestors defined. It was correct, fully tested, and shipped. It also made every one of those methods roughly 5x slower on any subclass.&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_enum_hash&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;superclass&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Ruby&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Enum&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;superclass&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;send&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:_enum_hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;merge&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_own_enum_hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;_own_enum_hash&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This recomputes the merged hash, walking the entire ancestor chain, on every single call. There’s no caching. A one-level subclass calling &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.value&lt;/code&gt; pays for building a brand new hash, on top of the superclass doing the same, every time. The test suite didn’t notice because tests check correctness, not speed, and correctness was fine.&lt;/p&gt;

&lt;p&gt;I only found this because I asked Copilot CLI to add a benchmark script comparing lookups at different inheritance depths, mostly out of curiosity about how the “Benchmarks” section of the README would read next to the new feature. It took one prompt and about a minute to get a working script:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Colors&lt;/span&gt;
  &lt;span class=&quot;kp&quot;&gt;include&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Ruby&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Enum&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;define&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:RED&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;red&apos;&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;define&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:GREEN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;green&apos;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;SubColors&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Colors&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;define&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:BLUE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;blue&apos;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;SubSubColors&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;SubColors&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;define&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:YELLOW&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;yellow&apos;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;benchmark&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;base class (no inheritance)&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Colors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:RED&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;benchmark&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;subclass (1 level)&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;SubColors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:RED&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;benchmark&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;sub-subclass (2 levels)&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;SubSubColors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:RED&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The first run told the story immediately: a 1-level subclass’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.value&lt;/code&gt; calls took roughly 5x as long as the base class, and a 2-level subclass was worse still. Nobody had written this benchmark before merging #59 because writing a throwaway benchmark script used to feel like more ceremony than it was worth for a one-off performance check. Now it’s a single sentence to an agent, and the script stays in the repo afterward as a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rake benchmark:inheritance&lt;/code&gt; task anyone can rerun.&lt;/p&gt;

&lt;p&gt;The fix was small: memoize the merged hash and invalidate the cache only when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;define&lt;/code&gt; adds a new entry.&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_enum_hash&lt;/span&gt;
  &lt;span class=&quot;vi&quot;&gt;@_enum_hash&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;superclass&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Ruby&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Enum&lt;/span&gt;
                    &lt;span class=&quot;n&quot;&gt;superclass&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;send&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:_enum_hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;merge&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_own_enum_hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                  &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;
                    &lt;span class=&quot;n&quot;&gt;_own_enum_hash&lt;/span&gt;
                  &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Re-running the same benchmark after the fix (&lt;a href=&quot;https://github.com/dblock/ruby-enum/pull/60&quot;&gt;#60&lt;/a&gt;):&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;--- .value lookups, by depth of inheritance ---
base class (no inheritance): 0.0627
subclass (1 level): 0.0608
sub-subclass (2 levels): 0.0606
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Subclass lookups are now indistinguishable from the base class, regardless of depth. I also had Copilot CLI write a second benchmark (&lt;a href=&quot;https://github.com/dblock/ruby-enum/pull/61&quot;&gt;#61&lt;/a&gt;) comparing basic &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ruby::Enum&lt;/code&gt; operations against plain Ruby equivalents, since I was about to write a README claim about the gem’s overhead and didn’t want to write “negligible” without a number to back it up. Constant access turned out to be free (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Colors::RED&lt;/code&gt; is exactly as fast as a plain Ruby constant), but hash-backed lookups like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.value&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.key?&lt;/code&gt; carry a real, if small, 3-5x cost versus calling &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Hash#[]&lt;/code&gt; directly - the price of the extra method dispatch and object wrapping &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ruby::Enum&lt;/code&gt; does. Worth knowing, not worth avoiding the gem over. That number, along with the exhaustive &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ruby::Enum::Case&lt;/code&gt; matcher’s much larger 50-100x overhead versus a native &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;case&lt;/code&gt; statement, is now spelled out in a dedicated &lt;a href=&quot;https://github.com/dblock/ruby-enum#performance&quot;&gt;Performance section&lt;/a&gt; in the README, so nobody has to ask or guess.&lt;/p&gt;

&lt;p&gt;None of this changes the underlying lesson that a performance regression is easy to introduce and easy to miss when your tests only check correctness. What’s changed is the cost of catching it. Writing a benchmark script used to be the kind of thing you’d skip on a Friday afternoon for a “just a getter method” change. Now, benchmarks are free.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/08/15/benchmarks-are-free-now.html&quot;&gt;Benchmarks Are Free Now&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on August 15, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Ruby Instance Variables Are Not Inherited (and Why That Breaks Your DSL)]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/08/14/instance-variables-are-not-inherited-and-why-that-breaks-your-dsl.html" />
  <id>https://code.dblock.org/2026/08/14/instance-variables-are-not-inherited-and-why-that-breaks-your-dsl</id>
  <published>2026-08-14T00:00:00+00:00</published>
  <updated>2026-08-14T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;The &lt;a href=&quot;https://github.com/dblock/ruby-enum&quot;&gt;ruby-enum&lt;/a&gt; gem is a small library I maintain that adds enum-like behavior to a class via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;include Ruby::Enum&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;define :KEY, value&lt;/code&gt;. Four pull requests landed against it recently, each fixing a different symptom, and all four turned out to be the same underlying bug: class-level instance variables set in a module’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;included&lt;/code&gt; hook are not inherited by subclasses the way you might expect. All of these fixes shipped in &lt;a href=&quot;https://github.com/dblock/ruby-enum/blob/master/CHANGELOG.md&quot;&gt;ruby-enum 1.2.0&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ruby::Enum&lt;/code&gt; stores its keys and values in instance variables on the class itself, set up when the module is included.&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;included&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;extend&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;ClassMethods&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;instance_variable_set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:@_enum_hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{})&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;instance_variable_set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:@_enums_by_value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{})&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This works fine for a single class. It gets interesting the moment subclasses or class reloading show up.&lt;/p&gt;

&lt;p&gt;A user opened &lt;a href=&quot;https://github.com/dblock/ruby-enum/pull/56&quot;&gt;#56&lt;/a&gt;, reporting &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DuplicateKeyError&lt;/code&gt; from a Rails console after a class that had already &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;define&lt;/code&gt;d its enums got reloaded. It reproduces without Rails.&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;class_body&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;proc&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do&lt;/span&gt;
  &lt;span class=&quot;kp&quot;&gt;include&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Ruby&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Enum&lt;/span&gt;

  &lt;span class=&quot;n&quot;&gt;define&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:RED&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;red&apos;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;

&lt;span class=&quot;no&quot;&gt;Colors&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Class&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;new&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;class_body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;no&quot;&gt;Colors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;class_eval&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;class_body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;# raises DuplicateKeyError&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Rails’ development-mode autoloader re-evaluates a class body, and the second &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;define :RED&lt;/code&gt; sees &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@_enum_hash&lt;/code&gt; still holding &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RED&lt;/code&gt; from the first load, so the duplicate-key check fires. The &lt;a href=&quot;https://github.com/dblock/ruby-enum/pull/56/files&quot;&gt;fix&lt;/a&gt; clears the hash whenever &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ruby::Enum&lt;/code&gt; is included, rather than assuming it’s always a fresh class. The contributor that reported the problem and submitted the fix also caught a sneakier variant: a subclass that redundantly re-includes &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ruby::Enum&lt;/code&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;include&lt;/code&gt; is supposed to be idempotent, right?) was wiping out enums inherited from its parent, because &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;included&lt;/code&gt; unconditionally reset the instance variables:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;OtherSecondSubclass&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;FirstSubclass&lt;/span&gt;
  &lt;span class=&quot;kp&quot;&gt;include&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Ruby&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Enum&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;# redundant, but shouldn&apos;t lose FirstSubclass&apos;s enums&lt;/span&gt;

  &lt;span class=&quot;n&quot;&gt;define&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:MAGENTA&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;magenta&apos;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Thanks, &lt;a href=&quot;https://github.com/flvrone&quot;&gt;Nazar&lt;/a&gt;, for tracking both of these down with tests.&lt;/p&gt;

&lt;p&gt;Months later, &lt;a href=&quot;https://github.com/dblock/ruby-enum/issues/49&quot;&gt;#49&lt;/a&gt; surfaced a related crash: a subclass that never calls &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;define&lt;/code&gt; itself, just inheriting from a parent that does, blew up on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.values&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Colors&lt;/span&gt;
  &lt;span class=&quot;kp&quot;&gt;include&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Ruby&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Enum&lt;/span&gt;

  &lt;span class=&quot;n&quot;&gt;define&lt;/span&gt; &lt;span class=&quot;ss&quot;&gt;:RED&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;red&apos;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;SubclassWithNoOwnDefines&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Colors&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;

&lt;span class=&quot;no&quot;&gt;SubclassWithNoOwnDefines&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;values&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;# NoMethodError: undefined method `values&apos; for nil&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This is because &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@_enum_hash&lt;/code&gt; is only ever set in two places: the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;included&lt;/code&gt; hook, and redundantly again in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;define&lt;/code&gt;. A subclass that inherits &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ruby::Enum&lt;/code&gt; transitively and never calls &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;define&lt;/code&gt; runs neither, so its own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@_enum_hash&lt;/code&gt; is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nil&lt;/code&gt;. This is the crux of it: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SubclassWithNoOwnDefines.instance_variable_get(:@_enum_hash)&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Colors.instance_variable_get(:@_enum_hash)&lt;/code&gt; are two completely separate variables, even though one class is a subclass of the other. Instance variables, unlike methods, don’t look up the ancestor chain.&lt;/p&gt;

&lt;p&gt;Eight methods (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;keys&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;key?&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;value?&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;key&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;value&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;to_h&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;parse&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;each&lt;/code&gt;) had this bug, just with less obvious failure modes. The fix in &lt;a href=&quot;https://github.com/dblock/ruby-enum/pull/58&quot;&gt;#58&lt;/a&gt; was to stop reading the raw &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@ivar&lt;/code&gt; directly and go through a private reader that lazily defaults to an empty hash.&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_enum_hash&lt;/span&gt;
  &lt;span class=&quot;vi&quot;&gt;@_enum_hash&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{}&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Once a subclass with no enums of its own correctly returned an empty list instead of crashing, the obvious next question was whether it should instead see its parent’s enums, the same way it sees inherited methods. Thus, &lt;a href=&quot;https://github.com/dblock/ruby-enum/pull/59&quot;&gt;#59&lt;/a&gt; extended all eight methods to walk up &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;superclass&lt;/code&gt; and merge in inherited enums, with a subclass’s own definitions taking precedence when they overlap.&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_enum_hash&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;superclass&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Ruby&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Enum&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;superclass&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;send&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;:_enum_hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;merge&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_own_enum_hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;_own_enum_hash&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Same lesson, opposite direction: since &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@_enum_hash&lt;/code&gt; isn’t automatically inherited, subclass-aware behavior has to be built by hand, walking &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;superclass&lt;/code&gt; and merging state explicitly.&lt;/p&gt;

&lt;p&gt;Recomputing that merge on every single call is correct but wasteful - &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;O(depth)&lt;/code&gt; work per lookup with no caching. I benchmarked it (more on that in a follow-up post) and found a one-level subclass paid a 5x tax on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.value&lt;/code&gt; lookups versus the base class. &lt;a href=&quot;https://github.com/dblock/ruby-enum/pull/60&quot;&gt;#60&lt;/a&gt; memoized the merged hash, invalidating it only when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;define&lt;/code&gt; adds a new entry.&lt;/p&gt;

&lt;p&gt;Class-instance-variables in Ruby - &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@ivar&lt;/code&gt; set on a class object, as opposed to instance-level &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@ivar&lt;/code&gt;s on regular objects - are per-class. They aren’t inherited, and reloading a class doesn’t reset them for you. If a gem’s DSL needs subclass-aware or reload-safe behavior, that has to be built in explicitly: clear state on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;include&lt;/code&gt;, default lazily instead of assuming initialization already ran, and walk &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;superclass&lt;/code&gt; yourself if you want inheritance-like semantics.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/08/14/instance-variables-are-not-inherited-and-why-that-breaks-your-dsl.html&quot;&gt;Ruby Instance Variables Are Not Inherited (and Why That Breaks Your DSL)&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on August 14, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Generating AI Descriptions of Automated Pull Requests]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/08/11/generating-ai-descriptions-of-automated-pull-requests.html" />
  <id>https://code.dblock.org/2026/08/11/generating-ai-descriptions-of-automated-pull-requests</id>
  <published>2026-08-11T00:00:00+00:00</published>
  <updated>2026-08-11T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;The &lt;a href=&quot;https://github.com/slack-ruby/slack-ruby-client&quot;&gt;slack-ruby-client&lt;/a&gt; library, an open source Ruby gem I maintain, runs a &lt;a href=&quot;https://github.com/slack-ruby/slack-ruby-client/blob/master/.github/workflows/update_api.yml&quot;&gt;scheduled GitHub Actions workflow&lt;/a&gt; that regenerates code from &lt;a href=&quot;https://github.com/slack-ruby/slack-api-ref&quot;&gt;Slack’s API definitions&lt;/a&gt; and opens a pull request with the diff. The commit message and CHANGELOG entry used to be a generic “Update API (2026-08-11)”, which told a reviewer nothing about what actually changed. Here’s how we taught the workflow to describe its own diffs, using GitHub Copilot CLI, which &lt;a href=&quot;https://docs.github.com/en/copilot/how-tos/copilot-on-github/set-up-copilot/enable-copilot/set-up-for-teachers-and-os-maintainers&quot;&gt;open source maintainers can get for free&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-08-11-generating-ai-descriptions-of-automated-pull-requests/changelog.png&quot; alt=&quot;Generated CHANGELOG entries grouped by PR&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;the-idea&quot;&gt;The Idea&lt;/h2&gt;

&lt;p&gt;The workflow already computes a diff before opening the pull request. Instead of a boilerplate commit message, we pipe that diff through an LLM and ask it to summarize what changed, then use the response as the commit message and PR body.&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Check for changes&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;changes&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;|&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;if git diff --quiet; then&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;echo &quot;changed=false&quot; &amp;gt;&amp;gt; &quot;$GITHUB_OUTPUT&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;else&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;echo &quot;changed=true&quot; &amp;gt;&amp;gt; &quot;$GITHUB_OUTPUT&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;fi&lt;/span&gt;
&lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Prepare diff for AI summary&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;steps.changes.outputs.changed == &apos;true&apos;&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;|&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;git diff --stat | sed &apos;s/^/      /&apos; &amp;gt; /tmp/diff_stat.txt&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;git diff | head -c 20000 | sed &apos;s/^/      /&apos; &amp;gt; /tmp/diff.txt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sed &apos;s/^/      /&apos;&lt;/code&gt; indent isn’t decorative. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;actions/ai-inference&lt;/code&gt; substitutes template variables as raw text into a prompt YAML file &lt;em&gt;before&lt;/em&gt; parsing it, so a multi-line diff starting at column 0 breaks the indentation of the enclosing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;content: |-&lt;/code&gt; block scalar. Pre-indenting the file to match keeps the YAML valid no matter what the diff looks like.&lt;/p&gt;

&lt;h3 id=&quot;the-prompt-file&quot;&gt;The Prompt File&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;actions/ai-inference&lt;/code&gt; supports &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.prompt.yml&lt;/code&gt; files, a small convention for keeping the system/user prompt out of the workflow YAML.&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;na&quot;&gt;messages&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;role&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;system&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;|-&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;You write CHANGELOG entries describing an automated API update to slack-ruby-client, a&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;Ruby gem whose Web API endpoint methods, argument validations, specs, and bin commands&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;are code-generated from vendored Slack API method definitions (via a git submodule and&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;rake task).&lt;/span&gt;

      &lt;span class=&quot;s&quot;&gt;Given a diffstat and a diff of the regenerated files, respond with ONLY a single JSON&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;object (no markdown code fences, no other text) in exactly this shape:&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;{&quot;entries&quot;: [string, ...]}&lt;/span&gt;

      &lt;span class=&quot;s&quot;&gt;Rules for entries:&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;- Each entry is a single short line, imperative mood, no leading bullet/dash and no&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;trailing period (the caller adds both).&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;- Group related changes together into one entry per notable change or affected method&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;group, rather than one entry per file.&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;- Focus on developer-visible API surface changes: new/removed/renamed methods, new/&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;removed arguments, validation changes.&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;role&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;user&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;|-&lt;/span&gt;
      
      
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Asking for a single line per group of changes, rather than per file, matters, a single Slack API update can touch dozens of generated files for one conceptual change (adding an argument to five related methods, say), and nobody wants five identical CHANGELOG lines about it.&lt;/p&gt;

&lt;h3 id=&quot;wiring-it-into-the-workflow&quot;&gt;Wiring It into the Workflow&lt;/h3&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Generate changelog entries with AI&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;steps.changes.outputs.changed == &apos;true&apos;&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;ai&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;uses&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;actions/ai-inference@v1&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;prompt-file&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;./.github/prompts/changelog-entries.prompt.yml&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;file_input&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;|&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;diff_stat: /tmp/diff_stat.txt&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;diff: /tmp/diff.txt&lt;/span&gt;
&lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Build changelog entries&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;entries&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;|&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;entries=&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;if [ &quot;$&quot; = &quot;true&quot; ]; then&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;response_file=&quot;$&quot;&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;echo &quot;AI changelog response:&quot;&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;cat &quot;$response_file&quot; || true&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;json_line=&quot;$(grep -E &apos;^\{.*\}$&apos; &quot;$response_file&quot; 2&amp;gt;/dev/null | tail -n 1)&quot;&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;if [ -z &quot;$json_line&quot; ]; then&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;echo &quot;::error::AI changelog response did not contain a JSON object, see response above.&quot;&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;exit 1&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;fi&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;entries=&quot;$(printf &apos;%s&apos; &quot;$json_line&quot; | jq -r &apos;.entries[]? // empty&apos;)&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;fi&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;if [ -z &quot;$entries&quot; ]; then&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;entries=&quot;Update API ($)&quot;&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;A few defensive touches worth calling out. The step always logs the raw AI response, so a bad run leaves a paper trail in the logs. Rather than trying to parse the whole response file as JSON, it greps for the &lt;em&gt;last&lt;/em&gt; line that looks like a JSON object (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;^\{.*\}$&lt;/code&gt;) and parses just that: Copilot CLI sometimes narrates its reasoning (“Let me check the diff…”, “Now I have enough info…”) before printing its final answer, and naively parsing the entire file as JSON would either crash on that narration or, worse, silently discard a perfectly good response. Finally, if no such JSON line is found at all, the workflow fails loudly with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;::error::&lt;/code&gt; instead of quietly falling back to a generic changelog entry, an earlier version of this step swallowed genuine parsing failures and it took a couple of confusing scheduled runs to notice.&lt;/p&gt;

&lt;p&gt;Once we have the entries, we use them both as the commit message/PR body and to insert one CHANGELOG line per group, all referencing the same (predictable, since GitHub allocates them sequentially) PR number:&lt;/p&gt;

&lt;div class=&quot;language-ruby highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;lines&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;entries&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;map&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;entry&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;
  &lt;span class=&quot;s2&quot;&gt;&quot;* [#&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;#{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pr_number&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;](&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;#{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pr_url&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;): &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;#{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;entry&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; - [@slack-ruby-ci-bot](https://github.com/apps/slack-ruby-ci-bot).&quot;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;you-cant-use-github-models&quot;&gt;You Can’t Use GitHub Models&lt;/h3&gt;

&lt;p&gt;If you’re setting this up today, skip GitHub Models as the backend, it was &lt;a href=&quot;https://github.blog/changelog/2026-07-30-github-models-is-now-retired/&quot;&gt;retired on July 30, 2026&lt;/a&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;actions/ai-inference&lt;/code&gt; no longer talks to it at any version. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v1&lt;/code&gt; tag, in particular, doesn’t float to the rewrite, it’s stuck resolving to the old GitHub-Models-only code, so don’t expect a version bump to fix things automatically. The current major version, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v3&lt;/code&gt;, speaks exclusively to &lt;a href=&quot;https://docs.github.com/en/copilot/how-tos/copilot-cli&quot;&gt;GitHub Copilot CLI&lt;/a&gt; instead, which changes a few things about the setup:&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Set up Node&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;uses&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;actions/setup-node@v6&lt;/span&gt;
&lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Install Copilot CLI&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;npm install -g @github/copilot&lt;/span&gt;
&lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Generate changelog entries with AI&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;steps.changes.outputs.changed == &apos;true&apos;&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;ai&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;uses&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;actions/ai-inference@v3&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;with&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;prompt-file&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;./.github/prompts/changelog-entries.prompt.yml&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;file_input&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;|&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;diff_stat: /tmp/diff_stat.txt&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;diff: /tmp/diff.txt&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;COPILOT_GITHUB_TOKEN&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;$&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Copilot CLI has to be installed on the runner (the action doesn’t bundle it any more), authentication is via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;COPILOT_GITHUB_TOKEN&lt;/code&gt; rather than the old &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;token&lt;/code&gt; input, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;responseFormat&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jsonSchema&lt;/code&gt; inputs are gone entirely, hence asking for bare JSON in the prompt and parsing it defensively, as above. We also set &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;model: &apos;&apos;&lt;/code&gt; explicitly; the action’s built-in default (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gpt-4.1&lt;/code&gt;) isn’t necessarily available on every Copilot plan, and an empty string tells the CLI to fall back to whatever model it has access to.&lt;/p&gt;

&lt;p&gt;For the token, if your org has a paid Copilot Business or Enterprise plan, you can enable “Allow use of Copilot CLI billed to the organization” and grant the workflow the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;copilot-requests: write&lt;/code&gt; permission, then the built-in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GITHUB_TOKEN&lt;/code&gt; just works, no secret needed. Free organizations (like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;slack-ruby&lt;/code&gt;) don’t have that option, since &lt;a href=&quot;https://docs.github.com/en/copilot/how-tos/copilot-on-github/set-up-copilot/enable-copilot/set-up-for-teachers-and-os-maintainers&quot;&gt;free Copilot access for open source maintainers&lt;/a&gt; is granted to individual maintainer accounts, not organizations, so the fallback is a personal fine-grained PAT with the “Copilot Requests” account permission, stored as a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;COPILOT_PAT&lt;/code&gt; secret.&lt;/p&gt;

&lt;h3 id=&quot;testing-before-merging&quot;&gt;Testing Before Merging&lt;/h3&gt;

&lt;p&gt;Both workflows have a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;github.repository == &apos;slack-ruby/...&apos;&lt;/code&gt; guard so they don’t run on forks by accident, which also means we can’t just dispatch them there to test. We got around this by pushing a disposable branch to our own fork, temporarily overriding the guard (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if: true&lt;/code&gt;), adding a throwaway step to force a non-empty diff (the real scraping step usually finds nothing new on a given day), and dispatching manually.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;gh workflow &lt;span class=&quot;nb&quot;&gt;enable&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Update API&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--repo&lt;/span&gt; dblock/slack-ruby-client
gh workflow run &lt;span class=&quot;s2&quot;&gt;&quot;Update API&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--repo&lt;/span&gt; dblock/slack-ruby-client &lt;span class=&quot;nt&quot;&gt;--ref&lt;/span&gt; test-copilot-cli
gh run watch &amp;lt;run-id&amp;gt; &lt;span class=&quot;nt&quot;&gt;--repo&lt;/span&gt; dblock/slack-ruby-client &lt;span class=&quot;nt&quot;&gt;--exit-status&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This is how I caught both the missing-token and the unavailable-model issues above, neither of which was obvious from reading the action’s README.&lt;/p&gt;

&lt;h3 id=&quot;links&quot;&gt;Links&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/slack-ruby/slack-ruby-client/pull/591&quot;&gt;#591: Generate AI CHANGELOG entries for automated API update PRs&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/slack-ruby/slack-ruby-client/pull/592&quot;&gt;#592: Fix YAML indentation bug in AI prompt template substitution&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/slack-ruby/slack-ruby-client/pull/593&quot;&gt;#593: Migrate AI CHANGELOG entry generation to Copilot CLI&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/slack-ruby/slack-ruby-client/pull/594&quot;&gt;#594: Include AI-generated changelog entries in the commit message&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/slack-ruby/slack-ruby-client/pull/596&quot;&gt;#596: Fix update_api workflow failing on non-JSON AI changelog response&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/slack-ruby/slack-ruby-client/pull/598&quot;&gt;#598: Extract JSON from Copilot CLI response despite narration, fail visibly when missing&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/slack-ruby/slack-ruby-client/actions/runs/31555479354/job/93986905749&quot;&gt;First successful run on master&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/08/11/generating-ai-descriptions-of-automated-pull-requests.html&quot;&gt;Generating AI Descriptions of Automated Pull Requests&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on August 11, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Coding with AI Agents is Now a Baseline Expectation for Managers]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/05/05/coding-with-ai-agents-is-now-a-baseline-expectation-for-managers.html" />
  <id>https://code.dblock.org/2026/05/05/coding-with-ai-agents-is-now-a-baseline-expectation-for-managers</id>
  <published>2026-05-05T00:00:00+00:00</published>
  <updated>2026-05-05T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;AI-first Engineering is now the operating default in many organizations, including mine. Developers have kept up. Vibe coding became agentic engineering, and the race towards truly autonomous engineering isn’t slowing.&lt;/p&gt;

&lt;p&gt;But what are we, managers, to do?&lt;/p&gt;

&lt;p&gt;A manager job continues to include recruiting and retaining people, setting clear goals and expectations, fostering communication, owning team workflows, and coaching daily behaviors. To this list we must now add driving AI transformation.&lt;/p&gt;

&lt;p&gt;Being a passive advocate is not enough. Unlike other subjects, no amount of AI training will help you keep up with the pace of evolution of this technology. Therefore, one cannot drive AI transformation without being an active AI practitioner. But what does being an active AI practitioner look like for a manager?&lt;/p&gt;

&lt;p&gt;To find out, I landed myself into a small Engineering team (7 people) within my 100+ people organization five weeks ago. This small group develops an essential internal Microsoft system used by every single Engineer at the company. I wanted to learn firsthand about their challenges by onboarding into the project, and maybe contribute 2-3 PRs myself. I decided to dedicate my Copious Free Time™ to this work, to only use Copilot (you can use Claude, or another CLI, too), and to attend the team’s daily standup.&lt;/p&gt;

&lt;p&gt;Before I talk about what I did or learned, I must apologize to the team for giving them a heart attack upon my arrival. It’s rare that a VP of Engineering, many levels “above”, inserts themselves into any team unless there’s some huge, urgent, problem. This wasn’t an issue with the specific team, but transforming the way we work remains absolutely urgent, and it’s my responsibility to do everything I can to make it happen.&lt;/p&gt;

&lt;p&gt;Initially, I expected to make 2-3 pull requests. Instead, I was able to make 89 (!), broke at least two production features, but also shipped some meaningful improvements. These fell into several categories.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Improved developer experience. I fixed several issues in local dev, wrote a new CONTRIBUTING guide, added a script to start and shutdown various services together, and added an AGENTS.md to make the repo AI-agent-ready.&lt;/li&gt;
  &lt;li&gt;Did batch dependency upgrades to address CVEs. Major upgrades required rework of the actual code. Two of these silently broke features in very subtle ways, showing gaps in integration testing, but also taking a lot of team’s time to debug unexpectedly.&lt;/li&gt;
  &lt;li&gt;Increased test coverage and overall repo quality. I wrote a number of missing unit and integration tests and fixed a few flaky ones. A clear win.&lt;/li&gt;
  &lt;li&gt;Improved CI/CD pipeline. I was able to bring the CI time down from over an hour to ~30 minutes, mostly by not building projects without code changes, and disabling the many checks and guards that didn’t need to run on every pull request.&lt;/li&gt;
  &lt;li&gt;Executed some overdue modernization. I finished a Webpack to Vite migration that another engineer stalled on because of conflicting priorities, significantly improving local build time, upgraded TypeScript 4 to 5, Node.js 16 to 20, LESS to SCSS, MSTest 2.x to 3.x. All these upgrades were on the team’s backlog, but nobody ever had time to do them.&lt;/li&gt;
  &lt;li&gt;Fixed many minor bugs. Other than bugs reported by users, I paid attention to warnings during build or in the JavaScript console, and asked Copilot to address them. I improved error display, corrected dark mode CSS, addressed sass deprecation warnings and ESLint errors.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here’s what I learned.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;This AI sh*t is wild. Driving Copilot is like holding the reins of a Russian winged troika. The only limitation was my own time, memory, and the amount of changes I was able to keep active in my head in parallel at one given time. I’d come up with a thing to do, tell Copilot to do it, it would make the change, open a pull request, get it reviewed by another AI, address comments, and iterate to green. I would intervene once this loop was finished, review the change, and mark it as ready to be reviewed by another Engineer.&lt;/li&gt;
  &lt;li&gt;We are absolutely not ready for the onslaught of agent-generated changes. Human attention quickly became the bottleneck. If a manager with a full-time job can open 20+ pull requests per week that pass CI and accomplish a positive change, a group of 5 full-time engineers plus an army of autonomous agents will be generating hundreds. The team immediately wondered whether they would go from being developers to full-time code reviewers, while real breaking changes slipped through otherwise trivial updates.&lt;/li&gt;
  &lt;li&gt;Copilot is the new rubber duck, developer social, and software architect. It’s now much faster to ask Copilot to explain a problem and then to fix an issue than to discuss any of it with teammates. It’s faster to design and implement 3 versions of a feature than to discuss which approach is cleaner ahead of time. You can now truly lead with code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;But the most interesting thing that happened during this experiment is that the team’s PR velocity doubled without counting mine. Using Copilot became contagious. I am confident this is permanent — I never want to write code by hand again. There’s no turning back.&lt;/p&gt;

&lt;p&gt;If you manage a team of any size and have never coded with AI at work, become the janitor of one of your team’s repos. Start by improving onboarding docs and CI/CD time. Doing this work was both impactful and a lot of fun. Feel the energy!&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/05/05/coding-with-ai-agents-is-now-a-baseline-expectation-for-managers.html&quot;&gt;Coding with AI Agents is Now a Baseline Expectation for Managers&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on May 05, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[How I Made $360 by Serving Markdown to A.I. agents from My Jekyll Blog]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/04/11/how-i-made-360-dollars-by-serving-markdown-from-my-jekyll-blog.html" />
  <id>https://code.dblock.org/2026/04/11/how-i-made-360-dollars-by-serving-markdown-from-my-jekyll-blog</id>
  <published>2026-04-11T00:00:00+00:00</published>
  <updated>2026-04-11T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;In April 2025 I wrote &lt;a href=&quot;/2025/04/05/apologizing-for-my-obsessiveness-over-punctuation.html&quot;&gt;Apologizing for My Obsessiveness Over Punctuation&lt;/a&gt;, a post about my various organizational compulsions. Near the end, almost as an aside, I mentioned that I obsessively categorize expenses in &lt;a href=&quot;https://www.monarchmoney.com/referral?code=8k3crzojhz&quot;&gt;Monarch&lt;/a&gt;, a personal finance app, linking it with my referral code. That post earned me exactly one referral in the nine months that followed.&lt;/p&gt;

&lt;p&gt;Then, on January 15th, 2026, I wrote &lt;a href=&quot;/2026/01/15/serving-markdown-for-ai-agents.html&quot;&gt;Serving Markdown for AI Agents&lt;/a&gt;. The idea is simple: for every post on this blog, there’s now a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.md&lt;/code&gt; version at the same URL. AI agents can discover and fetch clean markdown instead of parsing HTML. I didn’t think much of it.&lt;/p&gt;

&lt;p&gt;Exactly thirty days later - the length of Monarch’s free trial — I started getting a stream of referral conversion notifications without any change in SEO traffic.&lt;/p&gt;

&lt;p&gt;Eighteen signups.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://code.dblock.org/images/posts/2026/2026-04-11-how-i-made-360-dollars-by-serving-markdown-from-my-jekyll-blog/subscriptions.png&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Twelve converted to paid. At $30/referral, that’s &lt;strong&gt;$360&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://code.dblock.org/images/posts/2026/2026-04-11-how-i-made-360-dollars-by-serving-markdown-from-my-jekyll-blog/conversions.png&quot; /&gt;&lt;/p&gt;

&lt;p&gt;We live in the future.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/04/11/how-i-made-360-dollars-by-serving-markdown-from-my-jekyll-blog.html&quot;&gt;How I Made $360 by Serving Markdown to A.I. agents from My Jekyll Blog&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on April 11, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Opening Proprietary Code to Contributors Checklist]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/04/08/opening-proprietary-code-to-contributors-checklist.html" />
  <id>https://code.dblock.org/2026/04/08/opening-proprietary-code-to-contributors-checklist</id>
  <published>2026-04-08T00:00:00+00:00</published>
  <updated>2026-04-08T00:00:00+00:00</updated>
  <author>
    <name>Daniel Doubrovkine</name>
    <uri>https://code.dblock.org</uri>
    <email>dblock@dblock.org</email>
  </author>
  <content type="html">&lt;p&gt;Before AI coding assistants, a typical engineering team built expertise with the years: new team members joined, contributed small bug fixes, then were given more ambitious tasks over time as they became more comfortable in a codebase, to ultimately become experts. This process took years.&lt;/p&gt;

&lt;p&gt;In contrast, today, almost every engineer, and many non-engineers, have access to tools that can gather context from any codebase, and produce a large volume of high-quality code in a day. This lowers the bar for contributing meaningful changes to the software we build and enables many more individuals without domain or codebase expertise with good ideas to see them to light.&lt;/p&gt;

&lt;p&gt;At work, I asked my team to actively seek contributions from our customers with an active engagement for our proprietary code. Inspired by the open-source model, we are reviewing our systems from the angle of a newcomer easily contributing to them.&lt;/p&gt;

&lt;p&gt;Here’s a checklist to apply to any repo.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Create a single landing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CONTRIBUTING.md&lt;/code&gt;&lt;/strong&gt; that welcomes contributors and encourages them to contribute while insisting on higher standards, such as needing to write automated tests with every contribution.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Make your source code repo discoverable.&lt;/strong&gt; Your source code README, and your product or tool should have a visible link to your single &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CONTRIBUTING.md&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Include a simple to follow “Getting Started” checklist&lt;/strong&gt; in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CONTRIBUTING.md&lt;/code&gt; that enables any developer at the company to make a trivial change in the application and submit a pull request.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Ensure that your project has an automated test suite&lt;/strong&gt; that runs in CI, is trivial to run locally, has few to no dependencies, and has enough coverage to give confidence to any new contributor that they did not break key functionality.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Evolve every project to a state where a minimum viable version of it can run locally&lt;/strong&gt; without the need to ask for permissions, certificates, or keys, install or get access to unnecessary dependencies, removing those barriers from the new contributor path. Describe how to run the project locally in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CONTRIBUTING.md&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Ensure safe CD&lt;/strong&gt; by making the project’s deployment model self‑describing, clearly signaling where changes land first, what approvals and gates exist, and how regressions are detected and rolled back, so both new and existing contributors can ship changes with confidence.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Prefer GitHub fork/PR model&lt;/strong&gt; where contributors do not need permissions to your source code and protect source such that contributors cannot accidentally force push an update to main.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;One should be able to open a PR without ever needing to contact your team&lt;/strong&gt; that owns the project.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Enable and encourage contributors to open bugs and feature requests directly&lt;/strong&gt; and triage new issues weekly. Engage customers publicly within these systems rather than offline or e-mail.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Establish a publicly accessible channel&lt;/strong&gt; for your own developers and for contributors to meet and ask technical questions and link it from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CONTRIBUTING.md&lt;/code&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Create a daily short standup for your team that is open to others&lt;/strong&gt; actively contributing to your repo to drop into and make it easily discoverable. Use the standup to socialize external contributions across the team.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Create a habit of all team members reviewing and approving PRs&lt;/strong&gt; and delegate repetitive requests, such as needing tests, to A.I. reviewers. Ensure that both team PRs and PRs made by external contributors are engaged within one day, and that all team members are reviewing and approving each other’s PRs regularly, not just one person.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Create an automated CHANGELOG or a Release Notes process&lt;/strong&gt; that highlights both team and external contributors, and that is discoverable from your product or tool. Take the time to directly highlight one’s contribution to their manager when impactful.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Make the repo AI-friendly with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AGENTS.md&lt;/code&gt;&lt;/strong&gt; that contains instructions for AI that help humans collaborate with AI to produce a feature.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Continue prioritizing most impactful features to be shipped by your own team&lt;/strong&gt; while actively engaging your customers by inviting them to contribute the features that you cannot deliver quickly. Highlight external contributions in business reviews.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And if nobody volunteers to contribute to our projects, most of these steps will also make it easier and more efficient for my own team members to work on the codebase, to enable partner teams to do work, and reduce the time needed to onboard new engineers joining my organization.&lt;/p&gt;

&lt;p&gt;Level up!&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/04/08/opening-proprietary-code-to-contributors-checklist.html&quot;&gt;Opening Proprietary Code to Contributors Checklist&lt;/a&gt; was originally published by Daniel Doubrovkine at &lt;a href=&quot;https://code.dblock.org&quot;&gt;code.dblock.org | tech blog&lt;/a&gt; on April 08, 2026.&lt;/p&gt;</content>
</entry>

</feed>
