<?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-09-24T13:47: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[Porting My 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;h3 id=&quot;the-1996-original&quot;&gt;The 1996 Original&lt;/h3&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;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;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 My 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;h3 id=&quot;what-changed&quot;&gt;What Changed&lt;/h3&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;h3 id=&quot;the-2023-baseline&quot;&gt;The 2023 Baseline&lt;/h3&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;nb&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;nb&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;nx&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;s&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;s&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;n&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;n&quot;&gt;ZoneInfo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;Pacific/Norfolk&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;n&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;n&quot;&gt;ZoneInfo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;Pacific/Norfolk&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;n&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;h3 id=&quot;the-idea&quot;&gt;The Idea&lt;/h3&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>


<entry>
  <title type="html"><![CDATA[AI Slop: A Slack API Rate Limiting Disaster]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/03/12/ai-slop-a-slack-api-rate-limiting-disaster.html" />
  <id>https://code.dblock.org/2026/03/12/ai-slop-a-slack-api-rate-limiting-disaster</id>
  <published>2026-03-12T00:00:00+00:00</published>
  <updated>2026-03-12T00: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;Yesterday I &lt;a href=&quot;https://code.dblock.org/2026/03/11/right-sizing-engineering-teams-for-ai.html&quot;&gt;described&lt;/a&gt; AI-generated code as “plausible-looking, locally coherent, globally wrong.” Here’s a concrete example from my own codebase.&lt;/p&gt;

&lt;p&gt;I needed a cleanup job to close old Slack group DM conversations in my &lt;a href=&quot;https://github.com/dblock/slack-sup2&quot;&gt;slack-sup2&lt;/a&gt; app. The AI-generated solution looked perfectly 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;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;close_old_sups!&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;sup_close&lt;/span&gt;
  
  &lt;span class=&quot;n&quot;&gt;old_sups&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sups&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;where&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;conditions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;old_sups&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;each&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;ss&quot;&gt;:close!&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;old_sups&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;count&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;close!&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;unless&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;conversation_id&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;closed_at&lt;/span&gt;
  
  &lt;span class=&quot;n&quot;&gt;logger&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;info&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Closing DM channel &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;#{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;conversation_id&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;...&quot;&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;slack_client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;conversations_close&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;channel: &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;conversation_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;update_attributes!&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;closed_at: &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;now&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;k&quot;&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This code looks pretty great and completely breaks the app. Slack’s API has a &lt;a href=&quot;https://api.slack.com/docs/rate-limits&quot;&gt;global rate limit of 1 request per second&lt;/a&gt; for the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;conversations.close&lt;/code&gt; endpoint. So, when this job runs against a workspace with hundreds of old conversations, it immediately hits the rate limit. Worse, because rate limits are global across all endpoints, it takes down the entire application. Every other API call — posting messages, fetching user info, everything — starts failing.&lt;/p&gt;

&lt;p&gt;I asked AI to address this. It made the fix catastrophically worse.&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;close!&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;# ... existing code ...&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;begin&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;slack_client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;conversations_close&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;channel: &lt;/span&gt;&lt;span class=&quot;n&quot;&gt;conversation_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;rescue&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Slack&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Web&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Api&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;Errors&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;TooManyRequests&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;sleep_time&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;retry_after&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;60&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;sleep&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sleep_time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;retry&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;end&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;# ... rest of method ...&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;In &lt;a href=&quot;https://github.com/socketry/async&quot;&gt;socketry/async&lt;/a&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sleep()&lt;/code&gt; blocks the entire fiber and prevents other concurrent operations from executing. You should use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Async::Task.sleep()&lt;/code&gt; instead, but that still doesn’t solve the fundamental architectural problem of making hundreds of sequential API calls.&lt;/p&gt;

&lt;p&gt;What made this particularly insidious is that the assistant wrote some pretty professional-looking code and seemingly handled obvious edge cases. Yet, it failed to consider the distributed system constraints or global invariants. The rate limiting problem wasn’t visible in the local scope of the method - it was a system-wide concern that required human judgment.&lt;/p&gt;

&lt;p&gt;So, how did I actually fix the problem? I used the initial A.I. implementation (&lt;a href=&quot;https://github.com/dblock/slack-sup2/pull/93/changes/4c11b3a01e795d23ae6742fc868229ee4d32a9af&quot;&gt;4c11b3a0&lt;/a&gt;), but moved the task into the 30 minute cron to avoid triggering global rate limits (&lt;a href=&quot;https://github.com/dblock/slack-sup2/pull/93/changes/ab9e9518c1992fe76d7c8563a318a699bd84eddc&quot;&gt;ab9e9518&lt;/a&gt;). I then put the feature behind a setting that is off by default and wrote a script to slow-drain the many thousands of unclosed DM channels for existing customers (&lt;a href=&quot;https://github.com/dblock/slack-sup2/pull/93/changes/9feabd2c8fdea79e8e02e6e38c19b9b6517e76ee&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;9feabd2c&lt;/code&gt;&lt;/a&gt;). Finally, I … ahem … Copilot refactored the code to auto-close a limited number of DMs at any given time to avoid the rate limit altogether (&lt;a href=&quot;https://github.com/dblock/slack-sup2/pull/94&quot;&gt;#94&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;For now, human oversight of A.I. assistants remains critical.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/03/12/ai-slop-a-slack-api-rate-limiting-disaster.html&quot;&gt;AI Slop: A Slack API Rate Limiting Disaster&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 March 12, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Right-Sizing Engineering Teams for AI]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/03/11/right-sizing-engineering-teams-for-ai.html" />
  <id>https://code.dblock.org/2026/03/11/right-sizing-engineering-teams-for-ai</id>
  <published>2026-03-11T00:00:00+00:00</published>
  <updated>2026-03-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;Before AI coding assistants, a typical engineering team of 8-10 people might have been lucky to have one or two “10x engineers”, or “workhorses”, the kind of engineer that both keeps project quality and feature velocity high.&lt;/p&gt;

&lt;p&gt;AI tools have solved the workhorse half of this equation, enabling massive raw output. Today, almost every engineer can produce a high volume of code with GitHub Copilot, Claude, or Cursor.&lt;/p&gt;

&lt;p&gt;But the quality half of the equation has not kept up. Teams are shipping more code, but a greater fraction of it is AI slop: plausible-looking, locally coherent, globally wrong. Code review remains a human activity. Until that changes, AI assistants cannot substitute for the senior engineer. If AI triples output but the number of senior reviewers stays the same, the ratio of experienced judgment to code produced has gotten roughly 3x worse.&lt;/p&gt;

&lt;p&gt;The instinctive response to higher individual productivity is to hire fewer people, which is correct directionally but wrong in practice if you cut experience rather than volume. The difference is not in lines of code produced; it is in the accumulated judgment applied at every decision point. A team of four or five senior engineers with AI assistants will, in my experience, outperform a team of ten mixed-experience engineers with the same tools because the ratio of judgment to output stays healthy.&lt;/p&gt;

&lt;p&gt;This has implications for how engineering leaders should think about headcount planning. A well-functioning engineering team today should be five to seven people, with at most one junior. A reliable signal that you’ve gotten this wrong: pull requests that sit unreviewed for days, not because people are busy, but because no one feels confident enough to approve them.&lt;/p&gt;

&lt;p&gt;Smaller, more experienced engineering teams are not a new idea - Fred Brooks noted that you cannot make a late project earlier by adding people. What AI has done is make the argument sharper and more urgent. When every engineer can produce the volume that once required three, the scarcest resource is no longer effort; it is experience. Optimize for that.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/03/11/right-sizing-engineering-teams-for-ai.html&quot;&gt;Right-Sizing Engineering Teams for 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 March 11, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[The Golden Ratio of Manager to IC]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/02/04/the-golden-ratio-of-manager-to-ic.html" />
  <id>https://code.dblock.org/2026/02/04/the-golden-ratio-of-manager-to-ic</id>
  <published>2026-02-04T00:00:00+00:00</published>
  <updated>2026-02-04T00: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 &lt;a href=&quot;https://www.wsj.com/tech/ai/meta-to-create-new-applied-ai-engineering-organization-in-reality-labs-division-d41c4a69&quot;&gt;today’s shocker&lt;/a&gt;, Meta is to “create a new applied AI engineering organization aiming for an ultra-flat structure of up to 50 employees to one manager”.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/posts/2026/2026-02-04-the-golden-ratio-of-manager-to-ic/meta-applied-ai-engineering.png&quot; alt=&quot;Meta to create new applied AI engineering organization with ultra-flat structure&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Like all software engineers I, too, tend to apply a data‑driven, mathematical approach to every problem in the world. Yet I would have chosen a more romantic number and applied the golden ratio: roughly 1.6:1, the proportion that shows up in seashells, galaxies, and every second slide about “natural elegance”, rather than 50:1, a measure that feels less like harmony and more like a spreadsheet’s idea of efficiency.&lt;/p&gt;

&lt;p&gt;The idea of flattening an organization is not new and can be a good one. I know plenty of managers who have not done any individual contributor work, code or otherwise, in years. This is particularly striking with former strong coders who are promoted to managerial roles. After 2–3 cycles of promotions they are so far detached from what’s happening at the individual‑contributor level that they become 100% overhead, spending their entire life in meetings and actively preventing real work from being done. It’s natural to want to eliminate layers of such people as they simply don’t have any impact. And so, the real news at Meta is that it’s fighting its own organization design in which, at least in some teams according to my friends who work or have worked there, people managers are discouraged from doing deep technical work, don’t own much beyond process, and mostly serve as reporting‑structure placeholders.&lt;/p&gt;

&lt;p&gt;Another reason to flatten an organization is the introduction of AI assistants that have created a major shift in the capabilities of individual contributors. Two years ago you could maybe find one single “10x engineer” in every team—someone who has dramatically higher velocity than their peers. A good manager would recognize these extraordinary abilities, make such an individual their right hand and technical partner, share the responsibility of advancing a project, create effective mentorship, and help bring the rest of the team along, distributing work in ways that optimize for the long term where juniors are grown slowly and incrementally. But with AI every engineer can—and in fact must—become a 10x engineer, fast. It therefore makes sense for a flagship AI team to hire already experienced, top‑1% engineers and not bother with juniors, thus requiring fewer managers.&lt;/p&gt;

&lt;p&gt;The role of the manager must continue to evolve, but it has fundamentally not changed in my opinion. It remains critically important for any level of manager to be a role model and to deliver results. In a healthy team a manager has time to do some individual‑contributor work, and that is my favorite kind of manager. Today, this could mean using AI assistants to write some code. Beyond that, a manager’s job is to take ownership, articulate a vision and mission, disambiguate and provide clarity to their team, work with customers, recognize and show people what they are capable of, enable human aspirations, and take responsibility for shortcomings. If a manager does “real work”, it matters less how many people report to them; we want a team sized to the problem we are trying to solve, to create the right kind of focus, rather than to enforce a ratio.&lt;/p&gt;

&lt;p&gt;An organization is just a tool to delineate responsibilities and provide clarity of purpose to groups of people. When we begin adjusting ratios, we forget that we are working with humans, and that like all metrics, the number is not the goal.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/02/04/the-golden-ratio-of-manager-to-ic.html&quot;&gt;The Golden Ratio of Manager to IC&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 February 04, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Serving Markdown for AI Agents in Jekyll]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2026/01/15/serving-markdown-for-ai-agents.html" />
  <id>https://code.dblock.org/2026/01/15/serving-markdown-for-ai-agents</id>
  <published>2026-01-15T00:00:00+00:00</published>
  <updated>2026-01-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;Dries Buytaert recently wrote about &lt;a href=&quot;https://dri.es/the-third-audience&quot;&gt;The Third Audience&lt;/a&gt;. For decades, websites have targeted two audiences: humans and search engines. AI agents are now the third audience, and most websites aren’t optimized for them yet.&lt;/p&gt;

&lt;p&gt;AI agents prefer clean, structured content over HTML. Markdown is ideal - it’s readable, semantic, and free of navigation chrome. So I made this blog serve its source markdown files alongside the HTML.&lt;/p&gt;

&lt;h3 id=&quot;how-it-works&quot;&gt;How It Works&lt;/h3&gt;

&lt;p&gt;For every post like &lt;a href=&quot;/2026/01/15/serving-markdown-for-ai-agents.html&quot;&gt;/2026/01/15/serving-markdown-for-ai-agents.html&lt;/a&gt;, you can now fetch the source at &lt;a href=&quot;/2026/01/15/serving-markdown-for-ai-agents.md&quot;&gt;/2026/01/15/serving-markdown-for-ai-agents.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;AI agents can discover this via a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tag in the HTML head:&lt;/p&gt;

&lt;div class=&quot;language-html 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;nt&quot;&gt;&amp;lt;link&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;href=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;serving-markdown-for-ai-agents.md&quot;&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;type=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;text/markdown&quot;&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;rel=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;alternate&quot;&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;title=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Markdown&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;implementation&quot;&gt;Implementation&lt;/h3&gt;

&lt;p&gt;Since this is a GitHub Pages blog, custom Jekyll plugins don’t work. Instead, I added a &lt;a href=&quot;https://github.com/dblock/code.dblock.org/blob/gh-pages/.github/workflows/deploy.yml&quot;&gt;GitHub Actions workflow&lt;/a&gt; that builds Jekyll and copies the markdown source files to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_site&lt;/code&gt; before deployment.&lt;/p&gt;

&lt;p&gt;The workflow extracts the date and slug from each post filename and copies it to the matching URL path with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.md&lt;/code&gt; extension.&lt;/p&gt;

&lt;h3 id=&quot;should-you-do-this&quot;&gt;Should You Do This?&lt;/h3&gt;

&lt;p&gt;Dries raises a valid concern: are we just making it easier for AI companies to use our content without sending traffic back? Perhaps. But AI agents are already crawling our sites. Giving them cleaner input might lead to better attribution and more accurate responses that reference our work.&lt;/p&gt;

&lt;p&gt;The web has always been about making information accessible. This is just the next evolution.&lt;/p&gt;

&lt;p&gt;Plus, we will soon all work for AI anyway, so might as well get on its good side.&lt;/p&gt;

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

&lt;p&gt;This entire feature was implemented by &lt;a href=&quot;https://claude.ai&quot;&gt;Claude&lt;/a&gt;, and this post was written by it as well.&lt;/p&gt;

&lt;ul&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/45769b0&quot;&gt;Use Ruby 3.4 in deploy workflow.&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/code.dblock.org/commit/e5cb737&quot;&gt;Also serve page markdown files.&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/code.dblock.org/commit/fcff077&quot;&gt;Added link rel alternate for markdown discovery.&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/code.dblock.org/commit/e5eede2&quot;&gt;Moved markdown link to post layout.&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/code.dblock.org/commit/f6ae02a&quot;&gt;Fixed markdown alternate URL to remove .html.&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/dblock/code.dblock.org/commit/5ef474c&quot;&gt;Serving Markdown for AI Agents.&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2026/01/15/serving-markdown-for-ai-agents.html&quot;&gt;Serving Markdown for AI Agents in Jekyll&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 January 15, 2026.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Turning Hypermedia APIs into MCPs]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2025/09/18/turning-hypermedia-apis-into-mcps.html" />
  <id>https://code.dblock.org/2025/09/18/turning-hypermedia-apis-into-mcps</id>
  <published>2025-09-18T09:00:00+00:00</published>
  <updated>2025-09-18T09: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’ve &lt;a href=&quot;https://code.dblock.org/2014/07/18/serving-hypermedia-with-a-grape-api-and-roar.html&quot;&gt;written&lt;/a&gt; and &lt;a href=&quot;https://www.slideshare.net/slideshow/designing-a-hypermedia-api-with-grape-roar/41151492&quot;&gt;talked&lt;/a&gt; extensively about Hypermedia APIs for about a decade. The &lt;a href=&quot;https://en.wikipedia.org/wiki/HATEOAS&quot;&gt;HATEOAS&lt;/a&gt; constraints in representing resources for a RESTful API has numerous advantages in API design, readability, discoverability, and performance over a hand-rolled REST API. Unfortunately, adoption of Hypermedia APIs is very low compared to, for example GraphQL, but I find the implementation a lot simpler and continue adding Hypermedia APIs to my pet projects.&lt;/p&gt;

&lt;p&gt;At its root a Hypermedia API looks like so.&lt;/p&gt;

&lt;div class=&quot;language-json 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;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;_links&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;self&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;href&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;https://sup2.playplay.io/api&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;status&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;href&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;https://sup2.playplay.io/api/status&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;team&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;href&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;https://sup2.playplay.io/api/teams/{id}&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;templated&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The above example is an extract from &lt;a href=&quot;https://sup2.playplay.io/api&quot;&gt;this API&lt;/a&gt;. For this post we will use &lt;a href=&quot;https://sup2.playplay.io/&quot;&gt;one of my Slack bots called S’Up&lt;/a&gt;, which generates fresh triads of team members in Slack to meet for coffee every week in an informal standup. The bot is &lt;a href=&quot;https://github.com/dblock/slack-sup2&quot;&gt;open-source&lt;/a&gt; and you can try it &lt;a href=&quot;https://sup2.playplay.io/&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Because the Hypermedia API structure is fixed with only “resources” and “links” (and similar to API frameworks such as GraphQL) you don’t need an application-specific client to interact with the API. In Ruby, the most popular generic Hypermedia client is &lt;a href=&quot;https://github.com/codegram/hyperclient&quot;&gt;Hyperclient&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;With the API above, a client can retrieve the bot status, and using an API token obtained from an existing installation some team information.&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;api&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;Hyperclient&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;s1&quot;&gt;&apos;https://sup2.playplay.io/api&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&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;client&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;|&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;X-Access-Token&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;no&quot;&gt;ENV&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;TOKEN&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;nil&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;span class=&quot;n&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;status&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;puts&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Bot is &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;#{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ping&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;presence&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;presence&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;.&quot;&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;team&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;team&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;ss&quot;&gt;id: &lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;1234&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;puts&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Team name is &apos;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;#{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;team&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&apos;.&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This will output &lt;em&gt;“Bot is online.”&lt;/em&gt; and &lt;em&gt;“Team name is ‘dblock’.”&lt;/em&gt; for my installation.&lt;/p&gt;

&lt;p&gt;In this case, the generic client knew nothing about “team” - it was discovered programmatically and was, in a way, self-documenting. Unsurprisingly, this idea of templated resources is reused almost exactly the same way in the model context protocol (MCP). Therefore, we can easily turn any Hypermedia API into an MCP server generically with a new gem called &lt;a href=&quot;https://github.com/dblock/hyperclient-mcp&quot;&gt;hyperclient-mcp&lt;/a&gt;.&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;gem &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;hyperclient-mcp
1 gem installed
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Start by using the command-line &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hyperclient-mcp&lt;/code&gt; to discover resources.&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;&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;hyperclient-mcp &lt;span class=&quot;nt&quot;&gt;--api&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;https://sup2.playplay.io/api resources

Hyperclient::Mcp::Resources::Root
Hyperclient::Mcp::Resources::Status
Hyperclient::Mcp::Resources::Subscriptions
Hyperclient::Mcp::Resources::CreditCards
Hyperclient::Mcp::Resources::Teams
Hyperclient::Mcp::Resources::Channels
Hyperclient::Mcp::Resources::Users
Hyperclient::Mcp::Resources::Rounds
Hyperclient::Mcp::Resources::Sups
Hyperclient::Mcp::Resources::Stats
Hyperclient::Mcp::Resources::User
Hyperclient::Mcp::Resources::Channel
Hyperclient::Mcp::Resources::Team
Hyperclient::Mcp::Resources::Round
Hyperclient::Mcp::Resources::Sup
Hyperclient::Mcp::Resources::Data2
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Then run the MCP server.&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;hyperclient-mcp &lt;span class=&quot;nt&quot;&gt;--api&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;https://sup2.playplay.io/api &lt;span class=&quot;nt&quot;&gt;--header&lt;/span&gt; X-Access-Token&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$TOKEN&lt;/span&gt; start

Listening on http://0.0.0.0:9292
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Add the MCP to Claude.&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;claude mcp add &lt;span class=&quot;nt&quot;&gt;--transport&lt;/span&gt; sse &lt;span class=&quot;nb&quot;&gt;local &lt;/span&gt;http://127.0.0.1:9292/mcp/sse
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Ensure it works.&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;&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;claude mcp list
Checking MCP server health...

&lt;span class=&quot;nb&quot;&gt;local&lt;/span&gt;: http://127.0.0.1:9292/mcp/sse &lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;SSE&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt; - ✓ Connected
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Try &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;claude&lt;/code&gt; with &lt;em&gt;“use the local mcp and find out the bot status”&lt;/em&gt; or &lt;em&gt;“use the local mcp to find the name of the team with ID 1234”&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://code.dblock.org/images/posts/2025/2025-09-18-turning-hypermedia-apis-into-mcps/mcp.gif&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;

&lt;p&gt;While this was interesting, Claude actually supports Hypermedia APIs natively. It might need help with passing the access token, which can be forced by telling to &lt;em&gt;“just use curl”&lt;/em&gt;. YMMV.&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;╭───────────────────────────────────────────────────╮
│ ✻ Welcome to Claude Code!                         │
│                                                   │
│   /help &lt;span class=&quot;k&quot;&gt;for &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;help&lt;/span&gt;, /status &lt;span class=&quot;k&quot;&gt;for &lt;/span&gt;your current setup  │
│                                                   │
│   cwd: /Users/dblock/source/hyperclient-mcp       │
╰───────────────────────────────────────────────────╯

&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; given a hypermedia api &lt;span class=&quot;k&quot;&gt;in &lt;/span&gt;https://sup2.playplay.io/api what are available resources

⏺ I&lt;span class=&quot;s1&quot;&gt;&apos;ll help you discover the available resources in the hypermedia API at https://sup2.playplay.io/api.

⏺ Fetch(https://sup2.playplay.io/api)
  ⎿  Received 1.4KB (200 OK)

⏺ The hypermedia API at https://sup2.playplay.io/api provides the following resources:

  Collection Resources:
  - Status - API status endpoint
  - Subscriptions - Subscription management
  - Credit Cards - Payment method management
  - Teams - Team collections with filtering
  - Channels - Channel collections with filtering
  - Users - User collections with filtering
  - Rounds - Round collections with filtering
  - Sups - Sup collections with filtering
  - Stats - Statistics with filtering

  Individual Resources:
  - User - Individual user by ID
  - Channel - Individual channel by ID
  - Team - Individual team by ID
  - Round - Individual round by ID
  - Sup - Individual sup by ID
  - Data - Individual data by ID

  Most collection endpoints support query parameters like cursor, size, sort, offset, total_count, and
  filtering by team_id, channel_id, round_id, and active status.

&amp;gt; find out the bot status

⏺ Fetch(https://sup2.playplay.io/api/status)
  ⎿  Received 378 bytes (200 OK)

⏺ The bot status shows:

  Authentication: ✅ Connected
  - Team: dblock
  - User: sup2

  Presence: 🔴 Away
  - Online: No
  - Connection count: 0
  - Manual away: No
  - Auto away: No

  The bot is authenticated but currently offline/away with no active connections.

&amp;gt; find the name of the team with ID 1234

⏺ Bash(curl -H &quot;X-Access-Token: ....&quot;
      https://sup2.playplay.io/api/teams/...)
  ⎿ {&quot;id&quot;:&quot;...&quot;,&quot;team_id&quot;:&quot;T04KB5WQH&quot;,&quot;name&quot;:&quot;dblock&quot;,&quot;active&quot;:true,                   
    &quot;subscribed&quot;:true,&quot;created_at&quot;:&quot;2023-03-15T22:46:33.517Z&quot;,&quot;updated_at&quot;:&quot;2025-09-10T14:17:18.698Z&quot;,&quot;_l
    inks&quot;:{&quot;channels&quot;:{&quot;href&quot;:&quot;https://sup2.playplay.io/api/channels?team_id=...&quot;},&quot;
    … +6 lines (ctrl+o to expand)

⏺ dblock
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Hyperclient-mcp is &lt;a href=&quot;https://github.com/dblock/hyperclient-mcp&quot;&gt;on GitHub&lt;/a&gt;.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2025/09/18/turning-hypermedia-apis-into-mcps.html&quot;&gt;Turning Hypermedia APIs into MCPs&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 18, 2025.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[Using AI to Reduce the Burden of Status Reporting]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2025/08/29/using-ai-to-reduce-the-burden-of-status-reporting.html" />
  <id>https://code.dblock.org/2025/08/29/using-ai-to-reduce-the-burden-of-status-reporting</id>
  <published>2025-08-29T09:00:00+00:00</published>
  <updated>2025-08-29T09: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;One of the negative side effects of having any top-down command structure is that every additional layer risks introducing a new level of bureaucracy, which then actively subtracts value. Consider the Politburo of the USSR with dozens of offices of skilled bureaucrats. Because most produced no actual value, the best apparatchiks spent their entire time managing up to remain in control. Individuals who tried to upend the system either became exhausted and gave up, or were deported to Siberia.&lt;/p&gt;

&lt;p&gt;The Soviet example also applies to software development. Consider a typical CEO → CTO → VP of Engineering → Director → Manager → Technical Lead → IC command structure. Each project team consists of a technical lead and a handful of individual contributors. The people committed to the project do not need status reports to know what’s going on, they are actively working on it. The stakeholders and the hierarchy, however, naturally want progress and status reports. This gets aggregated at Director level, then at VP level, the CTO, and finally the CEO.&lt;/p&gt;

&lt;p&gt;From the point of view of every manager in this hierarchy collecting status and asking questions feels very natural. After all, it’s much more efficient to get a summary of what’s going on from a line manager than to read every status update or code change yourself. You have the power to call a meeting, have things explained and summarized to you. You also have the authority to make snap judgments based on this carefully crafted summary and partial information, and the responsibility to tell the team to focus on something they likely already know. This system quickly promotes those who are good at reporting the things their manager wants to hear, and encourages political behavior as people learn to navigate the system rather than focus on outcomes.&lt;/p&gt;

&lt;p&gt;It’s worth acknowledging that these hierarchical structures exist for good reasons. Large organizations need coordination, resource allocation, and strategic alignment. The problem isn’t hierarchy itself, but rather when hierarchies become focused on control rather than enablement, when they add process without adding value, and when they create distance between decision-makers and the work being done.&lt;/p&gt;

&lt;p&gt;So, as senior managers, we have a responsibility to prevent excessive status reporting from happening, and the solution in 2025 is remarkably simple! Use A.I.&lt;/p&gt;

&lt;p&gt;Today’s A.I. can replace 95% of status reporting toil and remove the need for most status meetings. Your project management system should be reading all work channels (GitHub, Slack, etc.) and automatically summarizing progress weekly in a draft that gets published with minimal edit by each project’s team lead. Build this summarization all the way up to the CEO, make all updates visible to everyone at the company, and eliminate meetings with an audience of one. Shopify has such a system, called GSD (Get Shit Done), and it works remarkably well. It also feels similar to the open-source CHANGELOG, which I have learned to appreciate over the years.&lt;/p&gt;

&lt;p&gt;But now, whether you’re an IC or a CEO, you actually have to read those summaries! This is how you learn the real progress being made on projects, and gain the credibility to ask meaningful questions in public project or team channels. Personally, I make sure to spend time reading relevant project summaries and catching up to current state before engaging in any meaningful discussion with anyone.&lt;/p&gt;

&lt;p&gt;So what is a pointy-haired boss to do if not status meetings? As a manager, your primary responsibility is building and aligning teams. Spend the vast majority of your time hiring and retaining people, identifying leaders who produce good outcomes, and giving them more resources. Communicate and over-communicate strategy, mission and vision. Use the time saved from status reporting to be deeply involved in one or another project by doing deep work.&lt;/p&gt;

&lt;p&gt;The best managers understand that their job is to make themselves less necessary, not more important. They build systems and teams that can operate effectively without constant oversight, and they focus their energy on removing obstacles rather than creating them. Stop asking your team for status, create a system where you can find out for yourself.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2025/08/29/using-ai-to-reduce-the-burden-of-status-reporting.html&quot;&gt;Using AI to Reduce the Burden of Status Reporting&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 29, 2025.&lt;/p&gt;</content>
</entry>


<entry>
  <title type="html"><![CDATA[How to Disagree and Commit like Switzerland]]></title>
 <link rel="alternate" type="text/html" href="https://code.dblock.org/2025/08/03/how-to-disagree-and-commit-like-switzerland.html" />
  <id>https://code.dblock.org/2025/08/03/how-to-disagree-and-commit-like-switzerland</id>
  <published>2025-08-03T09:00:00+00:00</published>
  <updated>2025-08-03T09: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 most abused principle in dysfunctional organizations is “Disagree and Commit”. In &lt;a href=&quot;/2025/07/30/dont-tell-engineers-what-to-do.html&quot;&gt;Don’t Tell Engineers What to Do&lt;/a&gt; I said that &lt;em&gt;“telling people what to do, especially if they are in your direct reporting chain, must not be mistaken for “Disagree and Commit”&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So, what is a healthy “Disagree and Commit”?&lt;/p&gt;

&lt;p&gt;The best example I know is the Swiss government. Even when individual members of the Federal Council personally oppose a popular initiative, they publicly defend and implement it once it’s approved, acting as a unified body. This approach ensures stable governance and respect for democratic decisions, as officials set aside personal views to uphold the collective will and present a united front.&lt;/p&gt;

&lt;p&gt;I was born in the USSR where people had opinions, then the Party decided. Then, I became Swiss in my teens, and lived in Geneva for 9 years. Having had this experience, it became quite obvious to me that the Swiss system was more sustainable, and I have always been impressed with it.&lt;/p&gt;

&lt;p&gt;In this post I will teach you how to be Switzerland.&lt;/p&gt;

&lt;p&gt;The keys to a healthy disagree and commit are to 1) identify the individual competent to make a decision, 2) lay out the arguments for one decision vs. the other, 3) let the decision maker decide, 4) collectively commit to the outcome sought by the decision.&lt;/p&gt;

&lt;h3 id=&quot;identify-the-individual-competent-to-make-a-decision&quot;&gt;Identify the individual competent to make a decision&lt;/h3&gt;

&lt;p&gt;A decision requires clarity about who has the authority and expertise to make the final decision. It’s best to identify this person upfront, not after disagreements arise, usually through a strong sense of ownership in the organization.&lt;/p&gt;

&lt;p&gt;When identifying the decision maker remember that a local decision is faster and cheaper than a decision at a higher level, and you never want a team that is so helpless that to reach to the manager for every small decision.&lt;/p&gt;

&lt;p&gt;Begin by separating “one way door” and “two way door” decisions. The former cannot, or may be very costly to “walk back” (e.g. adding a public API), while the latter can easily be undone (e.g. choosing a JSON parsing library). Critical, one way door decisions, will need more scrutiny, but two way door decisions can be made lower in the hierarchy. In both cases, look for the decision maker that is the true owner of the work in question. Who will be affected by this decision daily? Who is taking risks? Assuming we will have made the best decision, who will be celebrated as being right when the project is done? That should be the owner of the decision.&lt;/p&gt;

&lt;p&gt;When making a significant one way door decision, look for a trusted tie breaker with authority in the hierarchy of the organization. At Amazon, Principal Engineers often step in as tie-breakers for any technical decision. For non-technical decisions use the organizational hierarchy to find the common denominator (e.g. a common manager). Be careful escalating the ask, though - the more senior the manager, the least context they have, and therefore are susceptible to the most articulate arguments (form vs. substance). In dysfunctional organization you will also find a lot of pass-through “leaders” who will refuse to make a decision and escalate to their manager, delaying the decision significantly. Watch a Director ask for more data, then question the existence of the whole project.&lt;/p&gt;

&lt;p&gt;In general, I am of the opinion that a technical decision should almost never be made by a manager. A people manager carries organizational weight, often has veto power (can tell people what to do), usually has the least amount of detail regarding any technical decision no matter how technical they are, and will not be suffering the consequences of the decision on a daily basis.&lt;/p&gt;

&lt;h3 id=&quot;lay-out-the-arguments-for-one-decision-vs-the-other&quot;&gt;Lay out the arguments for one decision vs. the other&lt;/h3&gt;

&lt;p&gt;Once the decision maker is identified, all parties must present their arguments clearly and respectfully. Good arguments include data, examples, risks, and benefits, and should be written down. The goal is to ensure the decision maker has all relevant information and perspectives before making their choice and to get on the same page (literally). This prevents decisions made in ignorance, and ensures that even unpopular choices are made with full awareness of the alternatives. Soliciting broad input at this stage creates more visibility and therefore is an opportunity to FYI the decision to your manager or a senior staff member. Don’t be scared, ask other people’s opinions now! Give everyone time to think about the problem and to sleep on it.&lt;/p&gt;

&lt;p&gt;It’s critical that everyone can agree that each option is complete and viable, including yourself. Instead of thinking how you dislike one option (the cons), think in terms of its advantages and disadvantages (both pros and cons) and learn to articulate the pros, too. Finally, if you are the decision maker, avoid writing “recommended” next to any of the options until the last moment not to bias the group.&lt;/p&gt;

&lt;h3 id=&quot;let-the-decision-maker-decide&quot;&gt;Let the decision maker decide&lt;/h3&gt;

&lt;p&gt;After all arguments are presented, the decision maker must be given the space and authority to make their choice without interference. This means no lobbying after the fact, no attempts to undermine the decision, and no passive-aggressive resistance.&lt;/p&gt;

&lt;p&gt;Don’t lie by saying “I’d prefer the team to decide”, when, in fact, you’d prefer to decide, and don’t flex your decision making power, it just shows how insecure you are at wielding it. If you are a manager asked to make a decision in a room with subordinates, try saying “These are well laid arguments. I will let the team decide.” as much as possible, then side with the majority. And if you are the most senior member of the technical staff, never say “I am the Principal Engineer, therefore I decide” - everyone already knows it, and that just makes you look like a d*ck.&lt;/p&gt;

&lt;p&gt;The decision maker should explain their reasoning, and document the decision. Once the choice is made, it becomes the team’s direction. This requires trust in the decision maker’s judgment and a commitment to respect their authority, even when the outcome differs from one’s personal preference.&lt;/p&gt;

&lt;h3 id=&quot;collectively-commit-to-the-outcome-sought-by-the-decision&quot;&gt;Collectively commit to the outcome sought by the decision&lt;/h3&gt;

&lt;p&gt;Everyone, regardless of their initial position, must fully commit to implementing the chosen direction. This means actively working toward the success of the decision, not just grudgingly complying. You must be able to explain and advocate for the decision to others, as if it were your own choice. This collective commitment transforms a potentially divisive decision into a unified team effort, ensuring the organization moves forward together rather than being pulled apart by lingering disagreements.&lt;/p&gt;

&lt;p&gt;If you disagreed with the option chosen, this is your time to earn trust and commit visibly. I’ve recently disagreed with a decision made by a Principal Engineer to remove an emergency status from a project that gave the team extraordinary authority to pull additional resources in. This was an easy “disagree and commit” for me. The reasons were clearly laid out and while I had my pros and cons for the option to keep the emergency status, those were no longer relevant after the decision was made. After the PE made the decision I wrote: &lt;em&gt;“This is an easy disagree and commit for me, because I do agree that the project had achieved its original goals, and understand that the emergency status may no longer needed because problems should be resolved business as usual”.&lt;/em&gt; Since then, I have defended the decision like it was mine.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://code.dblock.org/2025/08/03/how-to-disagree-and-commit-like-switzerland.html&quot;&gt;How to Disagree and Commit like Switzerland&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 03, 2025.&lt;/p&gt;</content>
</entry>

</feed>
