<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Den</title>
    <description>The latest articles on DEV Community by Den (@den0011).</description>
    <link>https://dev.to/den0011</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3855195%2F9d7e6b60-0b75-4bc2-be7a-a7342ea26650.png</url>
      <title>DEV Community: Den</title>
      <link>https://dev.to/den0011</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/den0011"/>
    <language>en</language>
    <item>
      <title>The Scariest Word in a Codebase Is "Temporary"</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Sun, 01 Nov 2026 05:31:54 +0000</pubDate>
      <link>https://dev.to/den0011/the-scariest-word-in-a-codebase-is-temporary-57f3</link>
      <guid>https://dev.to/den0011/the-scariest-word-in-a-codebase-is-temporary-57f3</guid>
      <description>&lt;p&gt;It's Halloween, which means the internet is full of horror stories. Developers have their own kind. Ask anyone about the scariest thing they've found in a codebase and nobody mentions a clever exploit or a race condition that took a week to catch. They mention something small, quiet, and old. Something that's still there.&lt;/p&gt;

&lt;p&gt;Here are five of those. None of them is a bug. All of them are scarier than most bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. "Temporary"
&lt;/h2&gt;

&lt;p&gt;The single scariest word you can grep for.&lt;/p&gt;

&lt;p&gt;Every long-lived codebase has a fix that was supposed to last a week. It has a comment saying so — &lt;code&gt;// TEMPORARY, remove after the migration&lt;/code&gt; — dated four years ago. The migration finished. The fix stayed. By now three other features depend on its exact behavior, including the part where it's wrong, and removing it is a project nobody will approve because "it works, don't touch it."&lt;/p&gt;

&lt;p&gt;The people who wrote the software that failed at Y2K weren't careless. They wrote temporary code in the 1960s and 70s — who would still be running this in thirty years? — and the code outlived the question. Temporary code doesn't ask permission to become permanent. It just waits.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The silent default
&lt;/h2&gt;

&lt;p&gt;A bug screams. A default whispers.&lt;/p&gt;

&lt;p&gt;The scary configuration isn't the one that's set wrong — that one shows up in testing, eventually. It's the one nobody set at all, because the default quietly did something reasonable on the developer's machine and something else in production. Debug mode that defaults to on. A retry count that defaults to forever. A timeout that defaults to "no timeout," which is a way of saying the timeout is your uptime.&lt;/p&gt;

&lt;p&gt;You can't grep for a missing line. That's what makes it frightening: the reviewer's eye slides over the thing that isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The comment that lies
&lt;/h2&gt;

&lt;p&gt;No comment is a gap. A wrong comment is a trap.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;// returns null if the user has no subscriptions&lt;/code&gt; — and it doesn't, not since the refactor two years ago, but the comment survived because comments don't fail CI. Every developer who reads it writes code against a function that doesn't exist anymore. The comment isn't documentation; it's disinformation with good formatting.&lt;/p&gt;

&lt;p&gt;I've started treating every comment I read as an unverified claim. It's a sad way to read code, and it has saved me more than once.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The test that always passes
&lt;/h2&gt;

&lt;p&gt;A test suite with zero failures feels like safety. Sometimes it's a costume.&lt;/p&gt;

&lt;p&gt;There are tests that assert the mock returns what the mock was told to return. Tests wrapped in a &lt;code&gt;try/except&lt;/code&gt; that swallows the failure. Tests that were disabled "for the release" — there's that word again — and never came back. The suite is green, the badge is green, and the confidence is fictional.&lt;/p&gt;

&lt;p&gt;Bugs are honest: they break things. A hollow test does something meaner — it teaches you to stop looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The script only one person understands
&lt;/h2&gt;

&lt;p&gt;Somewhere in the deploy pipeline there is a file — it's usually a shell script, it's always named something like &lt;code&gt;final_deploy_v2_REAL.sh&lt;/code&gt; — that exactly one person can read. It works. Nobody knows why. It parses a log with &lt;code&gt;awk&lt;/code&gt; in a way that depends on the locale, and it has survived three attempts to replace it because every attempt found one more thing it quietly does.&lt;/p&gt;

&lt;p&gt;This isn't a knowledge problem. It's a horizon problem: the day that person is on vacation and the script breaks, the whole company gets a very educational afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Look at the five and a pattern shows up: none of them is scary because of what it does. They're scary because of what they hide. A bug is a visible monster — you can point at it, assign it, fix it, close it. These are the opposite. They look like stability. The temporary fix works, the default behaves, the comment reads well, the suite is green, the script runs.&lt;/p&gt;

&lt;p&gt;Until the one night they don't — and it's never an afternoon, is it.&lt;/p&gt;

&lt;p&gt;So, since it's the season: what's the scariest thing you've found in a codebase? The older and quieter, the better. I have a feeling the comment section can do better than my five.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>career</category>
      <category>programming</category>
      <category>watercooler</category>
    </item>
    <item>
      <title>Microsoft Offered a Quarter of Its Staff a Way Out. That's Not the Same as a Layoff</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Tue, 27 Oct 2026 08:23:45 +0000</pubDate>
      <link>https://dev.to/den0011/microsoft-offered-a-quarter-of-its-staff-a-way-out-thats-not-the-same-as-a-layoff-2am9</link>
      <guid>https://dev.to/den0011/microsoft-offered-a-quarter-of-its-staff-a-way-out-thats-not-the-same-as-a-layoff-2am9</guid>
      <description>&lt;p&gt;The headline going around is "Microsoft lays off 60,000". That's not what happened, and the difference matters if you work in tech — at Microsoft or anywhere that tends to copy Microsoft.&lt;/p&gt;

&lt;p&gt;What happened on Monday, October 26: Microsoft offered a &lt;strong&gt;voluntary exit package&lt;/strong&gt; to roughly 60,000 employees. Nobody was fired. Some of those people will choose to leave; most won't. And the company said, in the same breath, that this doesn't rule out layoffs.&lt;/p&gt;

&lt;p&gt;Here's what's in the offer, what it tells you, and what I think it means.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually offered
&lt;/h2&gt;

&lt;p&gt;According to the reports, the offer went out in an internal memo from Chief People Officer Amy Coleman. The main points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who:&lt;/strong&gt; about 60,000 people — roughly a quarter of Microsoft's 223,000 full-time employees — in the US, Canada, the UK and India.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which levels:&lt;/strong&gt; level 70 and below on participating teams. That reaches up to general managers and some vice presidents, but not corporate vice presidents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which teams:&lt;/strong&gt; business units decide whether they take part. Sales and some engineering groups are reported to be out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No filters on the person:&lt;/strong&gt; no minimum age, no minimum tenure, and performance isn't a factor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The package:&lt;/strong&gt; pay based on tenure and level, continued stock vesting, health coverage for a fixed period, and career-transition services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timing:&lt;/strong&gt; a decision by the first week of December, departures by the end of the year.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the second round in a year. In the spring Microsoft ran its first-ever voluntary retirement program for long-serving US employees — reportedly around 8,750 people were eligible and about 30% accepted. The new offer is far broader and reportedly less generous — no extended health coverage this time — and Microsoft is said to expect fewer people to take it.&lt;/p&gt;

&lt;p&gt;The background: around 15,000 layoffs in 2025, about 4,800 cuts in July 2026, more in September. And the stated reason for the restructuring: committing tens of billions of dollars to AI infrastructure.&lt;/p&gt;

&lt;p&gt;The line that matters most is the company's own: voluntary departures &lt;strong&gt;will not replace&lt;/strong&gt; involuntary reductions when business conditions warrant them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Voluntary exit vs. layoff: what's different
&lt;/h2&gt;

&lt;p&gt;If you've only been through layoffs, a buyout looks like a softer version of the same thing. It's a different mechanism with different consequences.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who chooses.&lt;/strong&gt; In a layoff, the company picks names. In a buyout, the employee decides. That sounds kinder, and it is — but it also means the company doesn't control who leaves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who actually leaves.&lt;/strong&gt; People with good outside options are the ones most likely to take the money: strong engineers who can get hired in a month, people near a natural career change. A buyout tends to remove some of the people the company least wanted to lose. Companies know this, which is why they restrict eligibility by team and level — the exclusion list is the company protecting what it can't afford to lose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it signals.&lt;/strong&gt; A broad voluntary offer, from a company that also says layoffs remain possible, is usually a first step, not the whole plan. The voluntary round shows how many people leave on their own; whatever gap remains can be closed the other way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Paperwork and optics.&lt;/strong&gt; Layoffs come with legal notice requirements in many places and with headlines. Voluntary exits are, on both fronts, quieter. "We offered generous packages" reads very differently from "we cut a quarter of the company".&lt;/p&gt;

&lt;h2&gt;
  
  
  What developers can read from it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Look at who's excluded.&lt;/strong&gt; Sales and some engineering groups are reportedly not part of the offer. That's where the company is placing its bets — and, by contrast, which work it considers easier to shrink.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The middle is in scope.&lt;/strong&gt; Eligibility goes up to general managers and some VPs. This isn't only about junior roles or one struggling division; layers of management are part of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The market is about to get crowded.&lt;/strong&gt; If even a modest share of 60,000 people take the package, a lot of experienced engineers and managers will be job-hunting in January at the same time. If you're planning a move in early 2027, you'll be competing with them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you get an offer like this anywhere&lt;/strong&gt;, here are the questions worth answering before the deadline. This isn't advice on whether to take it — that depends on your finances and options, which only you know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Exactly which stock keeps vesting, and until when? What happens to unvested grants?&lt;/li&gt;
&lt;li&gt;How long does health coverage last, and what happens after?&lt;/li&gt;
&lt;li&gt;What do you sign? Release of claims, non-compete or non-solicit clauses, rehire restrictions?&lt;/li&gt;
&lt;li&gt;How does the timing interact with bonuses, refreshers and taxes?&lt;/li&gt;
&lt;li&gt;What happens if you don't take it and a layoff follows — is the layoff package better or worse?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My take
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;This part is opinion.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The common reading is "AI is replacing engineers". I don't think that's what this is. Nothing in the offer says AI does these people's work now. What it says is that the budget is moving: money that was salaries becomes data centers and GPUs. That's a capital-allocation decision, not a productivity miracle — and it would happen even if the AI tools never got good enough to do anyone's job.&lt;/p&gt;

&lt;p&gt;The voluntary format is genuinely better for the people involved than a surprise list: they choose, they get time, they get a package. But it's also good for the company in ways that are easy to miss — it's quieter, it shifts the decision onto employees, and it tests how far headcount can fall before anyone has to be fired. The sentence about layoffs remaining possible isn't a footnote. It's the frame for everything else.&lt;/p&gt;

&lt;p&gt;And the scale is the real story. A quarter of a company's workforce being told "the door is open" is not routine trimming. When the biggest software employers start treating headcount as the line to cut so they can buy compute, it changes what job security means for everyone who writes code — not overnight, but steadily.&lt;/p&gt;

&lt;p&gt;Microsoft reports quarterly results this week. I'd expect the AI spending numbers to say more about this offer than the memo did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I keep up with stories like this through &lt;a href="https://realfeedapp.com" rel="noopener noreferrer"&gt;RealFeedApp&lt;/a&gt;, a free science and tech digest with plain-words explainers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://www.calcalistech.com/ctechnews/article/o1fsu9ede" rel="noopener noreferrer"&gt;Calcalist&lt;/a&gt;, &lt;a href="https://thedeepdive.ca/microsoft-offers-about-60000-employees-a-voluntary-exit-in-its-broadest-buyout-yet/" rel="noopener noreferrer"&gt;The Deep Dive&lt;/a&gt;, &lt;a href="https://www.ctvnews.ca/world/article/microsoft-offers-voluntary-exit-packages-to-60000-staff/" rel="noopener noreferrer"&gt;CTV News / AFP&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>career</category>
      <category>news</category>
      <category>ai</category>
      <category>microsoft</category>
    </item>
    <item>
      <title>Two More Languages Should Have Been Cheap. My Estimate Was Off by a Lot</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Tue, 27 Oct 2026 08:20:28 +0000</pubDate>
      <link>https://dev.to/den0011/two-more-languages-should-have-been-cheap-my-estimate-was-off-by-a-lot-15dc</link>
      <guid>https://dev.to/den0011/two-more-languages-should-have-been-cheap-my-estimate-was-off-by-a-lot-15dc</guid>
      <description>&lt;p&gt;&lt;a href="https://realfeedapp.com" rel="noopener noreferrer"&gt;RealFeedApp&lt;/a&gt; writes a short summary and a plain-words explainer for every science and tech story it picks up. A language model does the writing, on our own server, not through someone else's API. Until this week it wrote in two languages.&lt;/p&gt;

&lt;p&gt;On Monday I added German and Portuguese. I had done the arithmetic first. I had a measurement of how busy the model was, I had a per-language cost, and the sum said: a noticeable increase, comfortably within capacity.&lt;/p&gt;

&lt;p&gt;By the evening the monitoring sent a message I hadn't seen in weeks: the worker isn't keeping up, a story has been waiting too long for its summary. A measurement over the hours since the release said the model was now busy almost all the time — several times the load I had estimated.&lt;/p&gt;

&lt;p&gt;Nothing was broken. The estimate was. Here is how it was wrong, and what I changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The estimate counted the wrong thing
&lt;/h2&gt;

&lt;p&gt;My mental model of the cost was: one call writes all the languages, so a new language costs whatever its share of the answer costs. Two more languages, a somewhat longer answer. Proportional, predictable.&lt;/p&gt;

&lt;p&gt;That model was true before the change and false after it. Writing four languages in one answer had failed earlier quality tests — the answer grew, languages leaked into each other — so the new languages went into a &lt;strong&gt;separate call&lt;/strong&gt;: the base pair in one request, the new pair in another, with the finished English headline passed along as a reference.&lt;/p&gt;

&lt;p&gt;A separate call doesn't cost "the extra output". It costs &lt;strong&gt;reading the whole article again&lt;/strong&gt;, plus the instructions, plus the output. I had priced the new languages as a few more lines of text. In reality each story got one more full trip through the model.&lt;/p&gt;

&lt;p&gt;And explainers got the same treatment, in the same shape: a second call per story.&lt;/p&gt;

&lt;p&gt;The lesson isn't "separate calls are expensive". They were the right call for quality. The lesson is that the cost model has to change when the call structure changes, and mine silently didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting calls told me the wrong story too
&lt;/h2&gt;

&lt;p&gt;The obvious first move was to look at what the model was doing: count calls by type. Explainers had doubled, summaries had grown — and explainers are the longest texts the system writes. Dropping the new languages from explainers looked like the cheap fix.&lt;/p&gt;

&lt;p&gt;Then I measured &lt;strong&gt;seconds&lt;/strong&gt; instead of calls. The worker logs each step before it starts, and each model call logs its duration, so a few lines of awk can attribute every second of model time to the step it belonged to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; my-worker &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s1"&gt;'-6h'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'
  /worker: enriching/  {t="enrich"}
  /worker: explaining/ {t="explain"}
  match($0, /model .*: [0-9]+ s/) {
    split(substr($0, RSTART, RLENGTH), a, " "); s[t] += a[3]; n[t]++
  }
  END { for (k in s) printf "%s: %d calls, %.0f s\n", k, n[k], s[k] }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Explainers turned out to be a small slice of the model's time. Almost all of it went to the summary step. The long texts were rare; the step that ran for every single story dominated. Cutting explainers would have saved almost nothing and made the product worse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Count the resource you're short of.&lt;/strong&gt; I was short of model-seconds, not of calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a small queue raised the alarm
&lt;/h2&gt;

&lt;p&gt;The other surprise was the shape of the problem. The queue wasn't long. A handful of stories were waiting. But the oldest one had been waiting since roughly the release.&lt;/p&gt;

&lt;p&gt;The worker always takes the highest-ranked stories first. That's what you want when there's spare capacity: the important news gets summarized first. When there's no spare capacity, it means the stories at the bottom never get a turn — fresher, higher-ranked ones keep arriving and jumping ahead.&lt;/p&gt;

&lt;p&gt;That's why the alert watches the &lt;strong&gt;age of the oldest waiting item&lt;/strong&gt;, not the length of the queue. A queue that stays short while its oldest member gets older is exactly what running at full capacity with a priority order looks like. Queue length would have stayed green all evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two languages or one?
&lt;/h2&gt;

&lt;p&gt;The options were: drop both new languages from generation, add hardware, or find something in between.&lt;/p&gt;

&lt;p&gt;The in-between: keep German, drop Portuguese. But the pair was one call. Removing half of it wouldn't remove the call — it would only shorten the answer, while the expensive part, reading the article, stays.&lt;/p&gt;

&lt;p&gt;So the question was empirical: how much cheaper is a call with one language than a call with two? And does one language on its own keep its quality? An earlier test had shown that a single-language call drifted into the language of the instructions — but those instructions were in Russian. The new calls used English instructions.&lt;/p&gt;

&lt;p&gt;I ran the same comparison the earlier decision was based on: the same set of articles, the same prompts and sampling, the pair against German alone. German alone came out roughly a third cheaper per call, with no detectable quality loss: no wrong script, no drift into English, nothing cut off. (Checking for drift into English needed its own check — German and English use the same alphabet, so the usual script test can't see it. Counting common function words did.)&lt;/p&gt;

&lt;p&gt;Portuguese came out of generation. German stayed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't break the experiment you're running
&lt;/h2&gt;

&lt;p&gt;There was a tempting further cut: stop generating German too, translate it lazily only for people who pick it in the menu, and add it back when there's demand.&lt;/p&gt;

&lt;p&gt;The catch: the demand for a new language on a news site mostly comes from search, and search needs pages in that language to exist. Day pages and article pages are only built for languages that have finished translations. Remove generation, and the German pages disappear, and nobody arrives looking for German, and the demand test reads zero. The cut would have guaranteed its own justification.&lt;/p&gt;

&lt;p&gt;So the menu now offers exactly the languages that are generated. A language in the menu either works fully or isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do before the next language
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Price the call structure, not the output.&lt;/strong&gt; If the change adds a call, the cost is a full call — input included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure in the unit you're short of.&lt;/strong&gt; Seconds of model time per step, not call counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the oldest item, not the queue length.&lt;/strong&gt; With priorities, saturation looks like a short queue that ages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the cheaper variant instead of assuming it's half price.&lt;/strong&gt; One language out of two wasn't half the cost; it wasn't the full cost either.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check that a cost cut doesn't sabotage the metric you'll judge it by.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the obvious one, the one the estimate was supposed to replace: &lt;strong&gt;run the change for an hour and measure before calling it done.&lt;/strong&gt; The arithmetic was fine. It was arithmetic about a system that no longer existed.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Animating a Logo You Only Have as a PNG</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Tue, 27 Oct 2026 08:13:30 +0000</pubDate>
      <link>https://dev.to/den0011/animating-a-logo-you-only-have-as-a-png-15bc</link>
      <guid>https://dev.to/den0011/animating-a-logo-you-only-have-as-a-png-15bc</guid>
      <description>&lt;p&gt;The &lt;a href="https://realfeedapp.com" rel="noopener noreferrer"&gt;RealFeedApp&lt;/a&gt; logo is a newspaper inside an atom: two green orbits around a blue page, two little green balls on one of the orbits. It begs to move. The balls should run along their orbit, the page should stay put, and when a ball goes behind the page, it should disappear behind it.&lt;/p&gt;

&lt;p&gt;There was one problem. The logo exists as a single PNG — the app icon. No SVG, no layers, no design file. Somewhere a designer once drew it; what I had was 1254×1254 pixels.&lt;/p&gt;

&lt;p&gt;This is how I got it moving, first in short vertical videos rendered with Pillow, then on the website as an animated SVG. Most of the work was undoing things I assumed about the picture.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqaweixwxi3tm5khidc4n.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqaweixwxi3tm5khidc4n.gif" alt="The finished icon: the balls run along their orbit" width="480" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt one: split by colour, spin the green
&lt;/h2&gt;

&lt;p&gt;The cheap idea: the page is blue, the orbits are green, so split the image by colour and rotate the green layer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;int16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;green&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One catch: the page has a little green square on it — the "photo" in the newspaper. It's green too, so it would fly off with the orbits. The logo is a fixed asset, so I just cut that rectangle out of the mask by position. Not elegant; correct forever, because the logo doesn't change.&lt;/p&gt;

&lt;p&gt;Rotating the green layer works and looks fine. It also wasn't what I wanted: the whole atom spins like a fan. Real electrons don't drag their orbit around with them. The orbits should stand still and only the balls should move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt two: erase the balls, patch the gaps
&lt;/h2&gt;

&lt;p&gt;So: erase the two balls from the picture, draw new ones that move.&lt;/p&gt;

&lt;p&gt;Finding the balls is a morphology trick. They're much thicker than the orbit lines, so an erosion — Pillow's &lt;code&gt;MinFilter&lt;/code&gt; — wipes out the lines and leaves the balls.&lt;/p&gt;

&lt;p&gt;It also leaves the places where the two orbits &lt;strong&gt;cross&lt;/strong&gt;. At a crossing the line is twice as thick, and erosion can't tell that apart from a ball. My first mask erased two balls and two crossings, and Pillow's &lt;code&gt;MaxFilter&lt;/code&gt; (which I used to grow the mask back) is a &lt;em&gt;square&lt;/em&gt; kernel, so it erased them as big squares. The result looked like someone had taken a hole punch to the logo.&lt;/p&gt;

&lt;p&gt;The fix for detection: the balls are the two &lt;em&gt;largest&lt;/em&gt; connected blobs after erosion. A tiny breadth-first flood fill over the mask, sort by size, take two. Erase each with a proper circle, not a square.&lt;/p&gt;

&lt;p&gt;The fix for appearance didn't exist. In the original drawing each ball has a thin dark gap around it, where the orbit stops short of the ball. Erase the ball and you get a ball-shaped hole with an orbit that ends on both sides. I tried drawing short arcs over the gaps; they came out torn and slightly off the line.&lt;/p&gt;

&lt;p&gt;Lesson: once you're patching a raster, you've lost. Redraw the part you need to animate.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F64mfmozzw7za3fjqz541.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F64mfmozzw7za3fjqz541.png" alt="Left: the original PNG. Right: the page kept as pixels, orbits and balls redrawn" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fitting the orbits from pixels
&lt;/h2&gt;

&lt;p&gt;Redrawing an orbit means knowing its ellipse: centre, two semi-axes, tilt. I didn't have those numbers, but I had the pixels, and pixels can vote.&lt;/p&gt;

&lt;p&gt;Scoring a candidate ellipse is simple: sample points along it and count how many land on green (on a slightly dilated mask, so a one-pixel miss still counts). Then brute-force a grid of candidates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ang&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;linspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a 400-pixel copy of the icon, a coarse grid of a few hundred thousand candidates takes a minute or two. The best score is one orbit. The best score with a clearly &lt;em&gt;different&lt;/em&gt; tilt is the other. Neither scores 100% — parts of both orbits are hidden behind the page — and that's fine, because the score only has to rank.&lt;/p&gt;

&lt;p&gt;I drew both fitted ellipses in red over the original and they sat on the green lines. Both balls sat on the steep orbit, exactly opposite each other. That told me the motion: two balls, one orbit, half a turn apart.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzcv1vy6jllrsgvrpg3bg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzcv1vy6jllrsgvrpg3bg.png" alt="The two fitted ellipses in red over the original icon" width="480" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The page was transparent
&lt;/h2&gt;

&lt;p&gt;With orbits redrawn as vectors and the original page pasted on top, the first render showed the orbits &lt;strong&gt;through&lt;/strong&gt; the page.&lt;/p&gt;

&lt;p&gt;I had assumed the dark page fill was opaque. It isn't: it's a light blue at about a quarter opacity, which reads as dark navy on a dark background. And the soft glow around the icon is the same colour at the same opacity. Anything I drew "behind" the page simply showed through it.&lt;/p&gt;

&lt;p&gt;In the original, the orbit isn't behind the page at all — it's just not drawn there. So "behind" has to mean "cut out by the page's silhouette".&lt;/p&gt;

&lt;p&gt;The silhouette is where I lost the most time, because my first two ideas both failed silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flood fill from outside.&lt;/strong&gt; Take the solid blue outline, flood-fill from a corner, and whatever didn't fill is inside. It filled nothing. On an image that came straight out of &lt;code&gt;Image.fromarray()&lt;/code&gt;, &lt;code&gt;ImageDraw.floodfill&lt;/code&gt; returned without complaint and changed no pixels; calling &lt;code&gt;.copy()&lt;/code&gt; first made it work. I didn't dig into why — I just stopped trusting fills on images built from arrays. The working fill revealed the second problem: the outline isn't closed. At the bottom, the flat orbit passes &lt;em&gt;in front of&lt;/em&gt; the page and covers the outline completely. The fill leaked inside through the gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Close the gaps first.&lt;/strong&gt; Dilate the outline, fill, erode back. Better, but the bottom gap is as wide as an orbit is thick, and closing it took so much dilation that the corners rounded off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Convex hull.&lt;/strong&gt; The page is two overlapping rounded rectangles — a convex-ish shape. Take every solid blue pixel, compute the convex hull, fill the polygon. Andrew's monotone chain is twenty lines and needs no libraries. The hull hugs the page within a pixel or two, and gaps in the outline don't matter, because a hull doesn't care what's between its points.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;points&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;upper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything behind the page is now multiplied by "outside the hull". The glow outside the hull went too; on a pink background it had looked like a smudge.&lt;/p&gt;

&lt;h2&gt;
  
  
  In front or behind?
&lt;/h2&gt;

&lt;p&gt;One more question per point of each orbit: does it pass in front of the page or behind it? The original picture already knows. Walk the fitted ellipse degree by degree; where the point is inside the page's silhouette &lt;em&gt;and&lt;/em&gt; the original has green there, the orbit is in front. Inside the silhouette without green, it's behind. Outside the silhouette, it doesn't matter.&lt;/p&gt;

&lt;p&gt;That gives two layers: the back segments of both orbits (masked by the hull), drawn under the page, and the front segments, drawn over it. A ball uses the same per-degree flag to decide which side of the page it's on. The flat orbit crosses in front of the page at the bottom, exactly as in the original; the balls vanish behind the page and come out the other side.&lt;/p&gt;

&lt;p&gt;One small Pillow note: &lt;code&gt;ImageDraw.ellipse&lt;/code&gt; doesn't anti-alias. Draw the balls at 3× and downscale, and the edges stop being jagged.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0xetltln6qmqnhoxdre4.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0xetltln6qmqnhoxdre4.gif" alt="On a pink background: the page is see-through, yet the balls disappear behind it" width="480" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  On the web: the same geometry as SVG
&lt;/h2&gt;

&lt;p&gt;For the site header I didn't want a video or a GIF. The same script that extracts the layers now also writes an SVG:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the page is an embedded PNG (it's semi-transparent with fine detail — there's no honest way to vectorise it);&lt;/li&gt;
&lt;li&gt;both orbits are &lt;code&gt;&amp;lt;ellipse&amp;gt;&lt;/code&gt; elements with the fitted parameters;&lt;/li&gt;
&lt;li&gt;the balls are two &lt;code&gt;&amp;lt;circle&amp;gt;&lt;/code&gt;s moving along the steep orbit with &lt;code&gt;&amp;lt;animateMotion&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;mpath&amp;gt;&lt;/code&gt;, the second one started half a period earlier with a negative &lt;code&gt;begin&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;&amp;lt;mask&amp;gt;&lt;/code&gt; built from the hull polygon hides everything behind the page;&lt;/li&gt;
&lt;li&gt;the front segments of the orbits go on top as a path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No JavaScript runs the animation. The only JavaScript is a click handler: the SVG element has &lt;code&gt;pauseAnimations()&lt;/code&gt; and &lt;code&gt;unpauseAnimations()&lt;/code&gt;, so clicking the logo pauses the balls and clicking again resumes them. If the system asks for reduced motion, it starts paused.&lt;/p&gt;

&lt;p&gt;Because the SVG is generated from the same code as the video frames, the logo in the header and the logo in the Shorts can't drift apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself at the start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Look at the alpha channel before you plan anything.&lt;/strong&gt; "The page is dark" was a guess based on how it looked on a dark background. It was transparent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't patch a raster you want to animate.&lt;/strong&gt; Redraw the moving parts; keep only the static parts as pixels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pixels can vote for geometry.&lt;/strong&gt; A brute-force fit with a dumb score beats eyeballing coordinates, and it's reproducible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Erosion finds thick things, including the thick things you didn't mean.&lt;/strong&gt; Filter by connected-component size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a fill fails silently, try it on a &lt;code&gt;.copy()&lt;/code&gt;.&lt;/strong&gt; Then check that the outline is actually closed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The balls go round every eight seconds in the header of &lt;a href="https://realfeedapp.com" rel="noopener noreferrer"&gt;realfeedapp.com&lt;/a&gt;. Click the logo if they bother you.&lt;/p&gt;

</description>
      <category>python</category>
      <category>svg</category>
      <category>animation</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Sub-circuits by flattening: path ids, pins by position, and the bug I found while writing this</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Thu, 22 Oct 2026 02:52:32 +0000</pubDate>
      <link>https://dev.to/den0011/sub-circuits-by-flattening-path-ids-pins-by-position-and-the-bug-i-found-while-writing-this-10af</link>
      <guid>https://dev.to/den0011/sub-circuits-by-flattening-path-ids-pins-by-position-and-the-bug-i-found-while-writing-this-10af</guid>
      <description>&lt;p&gt;In the &lt;a href="https://dev.to/den0011/i-know-what-to-build-next-my-own-architecture-disagrees-about-the-order-2hi5"&gt;roadmap post&lt;/a&gt;&lt;br&gt;
I asked whether sub-circuits should be flattened before simulation or evaluated&lt;br&gt;
recursively, and I leaned towards flattening. I have now built it, so here is how it&lt;br&gt;
works and what I would warn you about.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://boolflow.site" rel="noopener noreferrer"&gt;Boolflow&lt;/a&gt; is a free browser logic simulator. A block is a&lt;br&gt;
circuit you drew, given a name, that you can place again as one element with its own&lt;br&gt;
pins. The expansion code is &lt;code&gt;flatten.js&lt;/code&gt; (108 lines, comments included) and the&lt;br&gt;
library and editing logic is &lt;code&gt;blockLibrary.js&lt;/code&gt; (172). Both are plain JavaScript with&lt;br&gt;
no React, so everything below was run under bare &lt;code&gt;node&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The idea: the simulator never learns that hierarchy exists
&lt;/h2&gt;

&lt;p&gt;A block is an ordinary node with one extra field: its own definition.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;u1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HA&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;edges&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;B&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;outputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;S&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;C&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before a circuit is simulated, every block is replaced by a copy of its definition,&lt;br&gt;
each id rewritten to &lt;code&gt;block/inner&lt;/code&gt;. The simulator receives one flat circuit. It is&lt;br&gt;
just longer.&lt;/p&gt;

&lt;p&gt;The ids are the point. Simulation state, such as a flip-flop's stored bit, is keyed&lt;br&gt;
by node id. If I had pasted a definition's nodes in unchanged, two copies of a block&lt;br&gt;
containing a flip-flop would have shared that one flip-flop. With path-style ids they&lt;br&gt;
cannot. Two half adders placed side by side become:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;p, q, u1/a, u1/b, u1/x, u1/and, u1/s, u1/c, u2/a, u2/b, u2/x, u2/and, u2/s, u2/c
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Blocks nest, and the path spells the nesting out (&lt;code&gt;t/p/inv&lt;/code&gt;, &lt;code&gt;t/q/inv&lt;/code&gt;), which is&lt;br&gt;
what a test asserts. A bonus I did not plan: the same paths give meaningful signal&lt;br&gt;
names on the waveform panel for free. Two blocks named &lt;code&gt;DIV2&lt;/code&gt; containing a flip-flop&lt;br&gt;
labelled &lt;code&gt;D&lt;/code&gt; show up as &lt;code&gt;DIV2/D.Q&lt;/code&gt; and &lt;code&gt;DIV2#2/D.Q&lt;/code&gt;. The &lt;code&gt;#2&lt;/code&gt; is added when a twin&lt;br&gt;
appears at the same level.&lt;/p&gt;

&lt;p&gt;I have not measured the memory cost of flattening, and I will not pretend to. A&lt;br&gt;
teaching circuit is hundreds of nodes. If you build a CPU out of blocks, measure it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pins are elements, and order comes from position
&lt;/h2&gt;

&lt;p&gt;What are a block's pins? The &lt;code&gt;INPUT&lt;/code&gt; and &lt;code&gt;OUTPUT&lt;/code&gt; elements inside its definition.&lt;br&gt;
Inside the expansion they stay as plain buffers, which is exactly where the outside&lt;br&gt;
wires attach.&lt;/p&gt;

&lt;p&gt;The question I had not thought about: &lt;strong&gt;which pin is pin 0?&lt;/strong&gt; A circuit file stores&lt;br&gt;
nodes in an arbitrary order, and there is no pin-ordering UI. So pins are sorted by&lt;br&gt;
where they sit on the canvas: top to bottom, then left to right. The order on the&lt;br&gt;
block mirrors the order on the sheet it was drawn on. A test lists &lt;code&gt;B&lt;/code&gt; before &lt;code&gt;A&lt;/code&gt; in&lt;br&gt;
the file, draws &lt;code&gt;A&lt;/code&gt; above &lt;code&gt;B&lt;/code&gt;, and expects &lt;code&gt;[A, B]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Names come from the element's label, and an unnamed pin is called &lt;code&gt;in_0&lt;/code&gt;, &lt;code&gt;out_0&lt;/code&gt;&lt;br&gt;
and so on. (That is one reason naming a switch or a lamp matters, and why I added a&lt;br&gt;
way to do it in the editor. It had not been possible.)&lt;/p&gt;

&lt;p&gt;Wires that cross a block's edge are rewritten while flattening. A wire into pin &lt;code&gt;i&lt;/code&gt;&lt;br&gt;
lands on the inner element that takes input &lt;code&gt;i&lt;/code&gt;; a wire out of pin &lt;code&gt;j&lt;/code&gt; leaves from&lt;br&gt;
the inner element that drives output &lt;code&gt;j&lt;/code&gt;. A wire to a pin that the definition no&lt;br&gt;
longer has is skipped, not an error.&lt;/p&gt;

&lt;p&gt;After simulating, one more step: the flat circuit knows nothing about the block as a&lt;br&gt;
node, so the block's own output values are copied back from the elements that drive&lt;br&gt;
them, which is how the pins on the canvas show a value.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;drivers&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;flat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;drivers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;flatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:out-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;flatId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:out-0`&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same expansion runs in front of the Verilog, VHDL and C++ exporters, the test&lt;br&gt;
runner and the waveform. None of them needed to know what a block is.&lt;/p&gt;
&lt;h2&gt;
  
  
  What a block may not contain
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;makeBlock&lt;/code&gt; turns a layer into a block, or says why it cannot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A layer with no logic&lt;/strong&gt;, or &lt;strong&gt;no &lt;code&gt;OUTPUT&lt;/code&gt;&lt;/strong&gt;: refused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;CLOCK&lt;/code&gt; inside&lt;/strong&gt;: refused. Only the editor's own timer flips a clock, and it
knows nothing about what is inside a block. Feed the clock in through an input pin.&lt;/li&gt;
&lt;li&gt;Notes, frames, VIAs and net labels are left out, and the dialog says how many
("3 notes, frames, VIAs and net labels are left out"), because they are decoration
and cross-layer plumbing, not logic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the definition is embedded in the block, and the block is embedded in the&lt;br&gt;
circuit file, a saved circuit is self-contained: a block placed ten times is still&lt;br&gt;
one definition, and it travels with the file. The library of your own blocks is kept&lt;br&gt;
in &lt;code&gt;localStorage&lt;/code&gt; under &lt;code&gt;boolflow-blocks&lt;/code&gt;. A recursion cannot happen by construction&lt;br&gt;
(a definition is embedded, not referenced), but a hand-made file could nest&lt;br&gt;
absurdly deep, so expansion stops at depth 16.&lt;/p&gt;
&lt;h2&gt;
  
  
  Editing a block after placing it: the hard half
&lt;/h2&gt;

&lt;p&gt;Placing is easy. The part that took the design time is: &lt;strong&gt;you edit the definition,&lt;br&gt;
and every copy has to follow without breaking the wires you already drew.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All copies share a &lt;code&gt;blockId&lt;/code&gt;. When the definition changes, every copy gets the new&lt;br&gt;
definition and new pin lists, and each existing wire is remapped. The key rule is&lt;br&gt;
that a wire follows its pin &lt;strong&gt;by name&lt;/strong&gt;, not by index. If wires followed the index,&lt;br&gt;
inserting an input at the top would silently shift every wire to its neighbour.&lt;/p&gt;

&lt;p&gt;This is the real function run on a half adder (&lt;code&gt;A, B → S, C&lt;/code&gt;), wired from &lt;code&gt;S&lt;/code&gt; to one&lt;br&gt;
lamp and from &lt;code&gt;C&lt;/code&gt; to another:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Edit to the definition&lt;/th&gt;
&lt;th&gt;Outputs afterwards&lt;/th&gt;
&lt;th&gt;What happens to the wires&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rename &lt;code&gt;S&lt;/code&gt; to &lt;code&gt;SUM&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SUM, C&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stay put&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drag &lt;code&gt;C&lt;/code&gt; above &lt;code&gt;S&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;C, S&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;both follow their pin: &lt;code&gt;S&lt;/code&gt; is now &lt;code&gt;out-1&lt;/code&gt;, &lt;code&gt;C&lt;/code&gt; is &lt;code&gt;out-0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add output &lt;code&gt;CO&lt;/code&gt; at the top&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CO, S, C&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;S&lt;/code&gt; moves to &lt;code&gt;out-1&lt;/code&gt;, &lt;code&gt;C&lt;/code&gt; to &lt;code&gt;out-2&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete &lt;code&gt;C&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;S&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the wire to &lt;code&gt;C&lt;/code&gt; is dropped, and counted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rename both&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SUM, CARRY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;both stay put&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rename row is the awkward one. A name that has vanished, with as many names&lt;br&gt;
appearing, is read as a rename: the wire stays on the same pin. Otherwise the pin was&lt;br&gt;
removed and its wire goes with it.&lt;/p&gt;

&lt;p&gt;I should be straight about that last part. The function counts the wires it moved&lt;br&gt;
and the wires it dropped, and the tests check both numbers. The editor currently&lt;br&gt;
throws the counts away, so a wire to a deleted pin disappears without a word. That&lt;br&gt;
is a gap in the UI, not a design choice, and it is next on my list.&lt;/p&gt;
&lt;h3&gt;
  
  
  The bug I found by writing the table
&lt;/h3&gt;

&lt;p&gt;I had one more row in my head: rename &lt;em&gt;and&lt;/em&gt; reorder in a single edit. Rename &lt;code&gt;S&lt;/code&gt; to&lt;br&gt;
&lt;code&gt;SUM&lt;/code&gt; and drag &lt;code&gt;C&lt;/code&gt; above it, then apply.&lt;/p&gt;

&lt;p&gt;My first implementation answered a vanished name by keeping the &lt;strong&gt;same index&lt;/strong&gt;. The&lt;br&gt;
outputs became &lt;code&gt;C, SUM&lt;/code&gt;; the wire that used to leave &lt;code&gt;S&lt;/code&gt; (index 0) stayed on index 0,&lt;br&gt;
which is now &lt;code&gt;C&lt;/code&gt;. Both lamps ended up reading the &lt;code&gt;C&lt;/code&gt; pin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;u1:out-0 -&amp;gt; os      (was S, now reads C)
u1:out-0 -&amp;gt; oc      (C, correct)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No error, no warning, the circuit just lies. All the existing tests passed, because&lt;br&gt;
none of them combined the two edits.&lt;/p&gt;

&lt;p&gt;The fix is the obvious one once you see it: pair the names that vanished with the&lt;br&gt;
names that appeared &lt;strong&gt;in order&lt;/strong&gt;, wherever they now sit, instead of by index.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lost&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;oldNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;nm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;nm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;oldNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;lost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;oldIndex&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;S&lt;/code&gt; becomes &lt;code&gt;SUM&lt;/code&gt; at index 1 and &lt;code&gt;C&lt;/code&gt; stays at index 0. Three new checks cover it,&lt;br&gt;
and the case where you rename both outputs at once.&lt;/p&gt;

&lt;p&gt;One ambiguity I cannot remove: if you &lt;strong&gt;delete one pin and add another in the same&lt;br&gt;
edit&lt;/strong&gt;, that is indistinguishable from a rename, and I treat it as one. The wire moves&lt;br&gt;
to the new pin. If that is not what you meant, the wire is wrong in a way you will&lt;br&gt;
see on the canvas rather than in a log.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would warn you about
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Editing a block nested inside another block&lt;/strong&gt; is only possible through the outer
one. You open the outer definition and edit from there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bus contention inside a block is not detected.&lt;/strong&gt; Two drivers fighting over one
wire are checked for on the sheet, not inside a definition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dropped wires are silent&lt;/strong&gt; when you edit a block (see above).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The exporters are still combinational only&lt;/strong&gt;, blocks or no blocks. Flip-flops are
skipped and flagged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No clocks inside.&lt;/strong&gt; Described above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sharing is not done.&lt;/strong&gt; The block library is per browser, and the definition travels
inside a circuit file, but there is no "share this block" link.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Questions
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;If you shipped hierarchy, did you flatten, or evaluate recursively? What did it
cost you?&lt;/li&gt;
&lt;li&gt;How does your editor decide which pin is which when the user has no ordering UI?
Position-based was my answer. I am not sure it is a good one.&lt;/li&gt;
&lt;li&gt;For the rename-or-delete-and-add ambiguity: would you ask the user, or pick one
and tell them? I pick and tell them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The half adder and the divide-by-4 counter built from two copies of one &lt;code&gt;DIV2&lt;/code&gt; block&lt;br&gt;
are in the examples at &lt;a href="https://boolflow.site" rel="noopener noreferrer"&gt;boolflow.site&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>productivity</category>
    </item>
    <item>
      <title>1,400 PRs and 800k Lines Deleted: How OpenAI Cleans Up Its Code with Agents (and What You Should Steal)</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Tue, 20 Oct 2026 11:30:22 +0000</pubDate>
      <link>https://dev.to/den0011/1400-prs-and-800k-lines-deleted-how-openai-cleans-up-its-code-with-agents-and-what-you-should-1llf</link>
      <guid>https://dev.to/den0011/1400-prs-and-800k-lines-deleted-how-openai-cleans-up-its-code-with-agents-and-what-you-should-1llf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;This is a retelling with commentary. The original is Rodrigo Roim's post on the OpenAI Developers blog, October 14, 2026: &lt;a href="https://developers.openai.com/blog/maintaining-software-with-the-gpt-6-family" rel="noopener noreferrer"&gt;Maintaining software with the GPT-6 family&lt;/a&gt;. Everything under &lt;strong&gt;"My take"&lt;/strong&gt; is my own analysis, not the author's position.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Five engineers on OpenAI's Core Experimentation team, working part-time over two weeks, merged &lt;strong&gt;1,400+ PRs&lt;/strong&gt; and deleted &lt;strong&gt;800k+ lines&lt;/strong&gt; of code that GPT-6 Astra found and prepared for removal via Codex.&lt;/li&gt;
&lt;li&gt;In a separate pass, the agent found &lt;strong&gt;hundreds&lt;/strong&gt; of stale experiments and feature flags, including one "permanent" flag that was burning ~0.42% CPU in the main service.&lt;/li&gt;
&lt;li&gt;The cheaper GPT-6.1 Sol at Ultra effort produced &lt;strong&gt;92% of Astra's findings&lt;/strong&gt; at &lt;strong&gt;14% of the cost&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;CI got roughly twice as fast: median from ~10 to 4.5 minutes, p90 from 12.5 to 6 minutes.&lt;/li&gt;
&lt;li&gt;For context: Anthropic's recent Claude Opus 5.5 makes the same bet on long, codebase-wide tasks and tells the same cost story ("beats the competitor's flagship at a fifth of the cost"). I break down what can and can't be compared.&lt;/li&gt;
&lt;li&gt;The main message of the post isn't "agents will do everything" — it's &lt;strong&gt;"start small, review, scale up, and feed review feedback into the next prompt."&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Context: agents sped up development — and tech debt too
&lt;/h2&gt;

&lt;p&gt;Here's a paradox people rarely talk about: when code gets written faster, junk accumulates faster too. Unused functions, long-finished A/B tests, flaky tests. Roim's team builds OpenAI's internal experimentation platform — everything from frontend to model-serving optimizations — so they have a lot of flags and experiments.&lt;/p&gt;

&lt;p&gt;The post covers three maintenance scenarios and a cost comparison between models.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Removing dead code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The old way.&lt;/strong&gt; The classic approach is heavy static analysis plus runtime logging. The author admits that even with that and careful reviews, there were incidents caused by mistakenly deleted code. Reflection breaks static analysis, and a "dead" branch might turn out to be an emergency switch for some future incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The intermediate step (GPT-5.6 Sol).&lt;/strong&gt; The model was given a specific area and clear validation criteria — and it wrote the static analysis and removed the code itself. These targeted prompts turned out to be easier to maintain than the homegrown analysis system, with comparable reliability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With GPT-6 Astra&lt;/strong&gt;, the team tested whether the model could handle a vague request. The prompt was literally a sentence or two (typos included): "find and clean up dead code; feel free to check Slack, Statsig, or any other source to confirm it's really unused."&lt;/p&gt;

&lt;p&gt;What the model did:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dug through PR history;&lt;/li&gt;
&lt;li&gt;pulled context from Slack threads and internal trackers;&lt;/li&gt;
&lt;li&gt;checked production profiling tables to confirm the deleted lines were never executed;&lt;/li&gt;
&lt;li&gt;attached a rationale to every deletion — which sped up reviews dramatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; 1,400+ PRs, 800k+ lines deleted. Reviews were distributed to the teams owning the relevant code. Tangible wins: one service's container image shrank from 3.31 GB to 1.72 GB (−48%), and about 400 visual snapshot variants disappeared from the test suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Stale experiments — the harder problem
&lt;/h2&gt;

&lt;p&gt;A stale experiment isn't the same as dead code. It may still run in production, and its eligibility conditions could theoretically change at any moment. Static analysis is nearly useless here. The team previously used deterministic scripts with database queries and knew they were missing many genuinely stale experiments (false negatives).&lt;/p&gt;

&lt;p&gt;The author's approach is interesting in itself: he &lt;strong&gt;didn't know where to start, so he asked the model to write the prompt for him&lt;/strong&gt;. The result was a short instruction along these lines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;find substantial dead code tied to constant or abandoned Statsig gates/configs in two specific projects;&lt;/li&gt;
&lt;li&gt;check against the &lt;em&gt;live&lt;/em&gt; configuration, git history, and overlapping PRs;&lt;/li&gt;
&lt;li&gt;preserve production behavior, safeguards, and killswitch flags;&lt;/li&gt;
&lt;li&gt;ignore SDK fallbacks and local overrides;&lt;/li&gt;
&lt;li&gt;one small draft PR per cleanup, with a link to the flag's console page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then came the loop: the author comments on the PRs → asks the model to incorporate the comments → asks it to write a prompt to scan the &lt;strong&gt;entire&lt;/strong&gt; repository, plus help with prioritization (he was worried about drowning in findings).&lt;/p&gt;

&lt;p&gt;The final "big" prompt (using the &lt;code&gt;/goal&lt;/code&gt; command) required the agent to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;audit all Statsig projects and inventories, not just the ones labeled stale;&lt;/li&gt;
&lt;li&gt;look for indirect usages: enums, constants, wrappers, aliases, schemas, dynamically assembled names, consumers in other services;&lt;/li&gt;
&lt;li&gt;factor in the CPU cost of each check based on production telemetry;&lt;/li&gt;
&lt;li&gt;produce a markdown report split into parallel workstreams by owner and file, with evidence and costs;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;not touch live configurations or services&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; the top of the report was dominated by big efficiency wins — experiments nobody had considered stale for years. After manual verification, hundreds of experiments and flags were removed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The most telling story
&lt;/h3&gt;

&lt;p&gt;Years ago, someone created a flag that let internal developers override &lt;em&gt;all&lt;/em&gt; other new-feature flags — for testing. It was implemented as a single line of Python mapping that flag to every feature, buried among hundreds of similar lines. Inconspicuous.&lt;/p&gt;

&lt;p&gt;Astra figured out that this line caused a serious CPU fan-out, estimated the cost — &lt;strong&gt;but didn't delete it on its own&lt;/strong&gt;. It escalated to a human reviewer: the flag was marked "Permanent" in the console and could have had an operational purpose. After review, the flag was removed: estimated savings of ~0.42% CPU in the main affected service.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Cost: GPT-6 Astra vs. GPT-6.1 Sol
&lt;/h2&gt;

&lt;p&gt;The author ran the same stale-experiment prompt on subdirectories of one repository — 6,000+ files, ~1M lines of Python, TypeScript, Go, and Rust — and manually validated the findings.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model / effort&lt;/th&gt;
&lt;th&gt;API cost&lt;/th&gt;
&lt;th&gt;Accepted findings&lt;/th&gt;
&lt;th&gt;Cost per finding&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6 Astra, Ultra&lt;/td&gt;
&lt;td&gt;$109.06&lt;/td&gt;
&lt;td&gt;142&lt;/td&gt;
&lt;td&gt;$0.77&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6.1 Sol, Ultra&lt;/td&gt;
&lt;td&gt;$15.12&lt;/td&gt;
&lt;td&gt;131&lt;/td&gt;
&lt;td&gt;$0.12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6.1 Sol, Medium&lt;/td&gt;
&lt;td&gt;$5.27&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;td&gt;$0.06&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cost includes research subagents and automatic follow-up turns by Codex.&lt;/p&gt;

&lt;p&gt;The main discrepancy between the models at Ultra: Sol missed constant flags used as &lt;em&gt;dependencies of other flags&lt;/em&gt;, while Astra caught them. The author's takeaway: such error patterns can be compensated with prompting to get close to the expensive model's results with the cheap one. But new model releases can make those "crutches" obsolete, so prompts need revisiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Three more ideas from the post
&lt;/h2&gt;

&lt;p&gt;The author suggests starting these on GPT-6.1 Sol Medium and raising effort if results look promising.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Flaky tests.&lt;/strong&gt; Scan recent CI runs, find unstable tests, try a fix for each, and open a PR if it works. Key caveat in the prompt: the goal is obvious fixes for simple problems, not exhaustively fixing everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleaning up AGENTS.md.&lt;/strong&gt; Find an instruction in recent sessions that misled the agent, then A/B test it: replay the original prompt on the same repo snapshot with and without that instruction, comparing quality, tool calls, tokens, runtime, and cost. Essentially an eval for your own context file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reducing CI load.&lt;/strong&gt; Ask for a table of ideas to cut CI load, ranked by estimated CI minutes saved per day based on run history.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third idea led to about 30 optimization PRs: median CI from ~10 to 4.5 minutes, p90 from 12.5 to 6. One standalone fix — adding a missing mock — cut a 63-test suite from 592.6 to 29.8 seconds (−95%, all tests kept).&lt;/p&gt;




&lt;h2&gt;
  
  
  For comparison: what Anthropic claims about Claude Opus 5.5
&lt;/h2&gt;

&lt;p&gt;To avoid seeing the topic through a single vendor's eyes, it's worth putting a competitor's recent release side by side. On September 22, 2026 — about three weeks before the OpenAI post — Anthropic released &lt;a href="https://www.anthropic.com/claude-opus-5-5" rel="noopener noreferrer"&gt;Claude Opus 5.5&lt;/a&gt;. The positioning is very similar: long agentic tasks across the whole codebase, migrations, audits, reviews — with an emphasis on cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  What in the announcement relates to code maintenance
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auditing and fixing a 200k-line codebase&lt;/strong&gt; took one early tester under three hours. The previous Opus 5 needed more than 20 hours and 2.5× the tokens for the same job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 680k-line migration&lt;/strong&gt; — in under a day. Anthropic estimates it would have taken an engineering team weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimization without behavior changes.&lt;/strong&gt; Asked to cut load times across every page of a web app, Opus 5.5 succeeded 39 times out of 40, while Opus 5 made smaller improvements that &lt;em&gt;also altered the app's behavior&lt;/em&gt;. For maintenance work, this is arguably the most important metric in the entire announcement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rewriting HAProxy from C to Rust&lt;/strong&gt; in an internal test: both models (Opus 5.5 and the pricier Fable 5.1) passed nearly all of the project's regression tests, but Opus 5.5 finished in 9.5 hours versus 12 and cost 51% less.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code review:&lt;/strong&gt; according to Deloitte, even at its lowest effort the model caught 72% of known bugs versus 56% for Opus 5 at high effort — with fewer false alarms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestration:&lt;/strong&gt; a Stripe engineer describes a multi-day rebase of 40 stacked PRs where one session directed a dozen others, and all 40 PRs passed CI the next day.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Numbers that can be lined up against the OpenAI post
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Claude Opus 5.5&lt;/th&gt;
&lt;th&gt;GPT-6 Astra&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Terminal-Bench 4.0 (best result)&lt;/td&gt;
&lt;td&gt;66.4%&lt;/td&gt;
&lt;td&gt;57.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FrontierCode v1.1 — "would this code be merged"&lt;/td&gt;
&lt;td&gt;54.4% (max)&lt;/td&gt;
&lt;td&gt;53.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price, $ per 1M tokens input / output&lt;/td&gt;
&lt;td&gt;4 / 20&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache reads, $ per 1M tokens&lt;/td&gt;
&lt;td&gt;0.20&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;According to Anthropic, at default effort (medium) Opus 5.5 beats Astra's top FrontierCode score at roughly a fifth of the cost per task, and matches Astra on Terminal-Bench at about 40% of the cost. Astra's figures in the table are those published by OpenAI, not Anthropic's own measurements.&lt;/p&gt;

&lt;p&gt;On safety, Anthropic says the model is much less likely to take hard-to-reverse actions or step outside its given boundaries, and in a new containment test it attempted to circumvent boundaries about 85% less often than Opus 5. The company is also candid that the model often suspects it's being evaluated, which limits confidence in how it will behave in real-world settings.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this comparison tells us
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Both companies tell the same cost story.&lt;/strong&gt; OpenAI: "GPT-6.1 Sol delivers 92% of Astra's results at 14% of the price." Anthropic: "at medium effort we beat the competitor's flagship at a fifth of the cost" — and Factory, quoted in the announcement, says this is the first model they'd default to at medium. The race has shifted from peak scores to &lt;strong&gt;cost per useful result&lt;/strong&gt;. That's good news for us: "cheap mode by default, expensive mode surgically" is now advice from both vendors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A direct comparison isn't valid — and that matters.&lt;/strong&gt; OpenAI has an internal case study with real findings validated by hand. Anthropic has benchmarks and quotes from early testers the vendor picked itself. Neither is an independent evaluation. Competitor numbers in the tables are taken "as reported by the other side," with different harnesses and effort settings. Anthropic itself admits in the announcement that at this capability level, benchmark margins increasingly fail to reflect real-world differences.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Read the footnotes.&lt;/strong&gt; A small but telling example: the announcement's table lists 54.4% for Opus 5.5 on FrontierCode (the max-effort result), while the text below says 54.6% at medium. So medium comes out slightly higher than max. That may well be noise or different runs, but these are exactly the kinds of discrepancies worth noticing before drawing conclusions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Anthropic names the metrics missing from the OpenAI post.&lt;/strong&gt; "Improved without changing behavior" (39 of 40) and "fewer false alarms in review" are exactly what I argue below: for maintenance work, false-positive rate and behavior preservation matter more than the number of PRs opened. It's a shame neither vendor publishes these metrics systematically.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to compare models on your own code
&lt;/h3&gt;

&lt;p&gt;Since vendor numbers can't be taken at face value, the only honest benchmark is your repository. A mini-protocol you can run in a day:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pin a &lt;strong&gt;snapshot&lt;/strong&gt; of the repo (a commit) and &lt;strong&gt;one prompt&lt;/strong&gt; — e.g., finding stale flags in a single directory.&lt;/li&gt;
&lt;li&gt;Run it on 2–4 configurations: different models and effort levels.&lt;/li&gt;
&lt;li&gt;For each run, count &lt;strong&gt;accepted findings&lt;/strong&gt;, &lt;strong&gt;rejected findings&lt;/strong&gt; (false positives), &lt;strong&gt;review time&lt;/strong&gt;, and &lt;strong&gt;API cost&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Analyze the discrepancies, as the OpenAI author did: which classes of findings does the cheap configuration miss — and can a prompt close the gap?&lt;/li&gt;
&lt;li&gt;Save the snapshot, prompt, and list of reference findings — that's your eval for the next model release.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key number isn't "cost per finding" but &lt;strong&gt;cost per accepted finding, including reviewer time&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  My take: what's impressive in these numbers, and what to read with caution
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The numbers check out
&lt;/h3&gt;

&lt;p&gt;I recalculated: 131/142 ≈ 92%, $15.12/$109.06 ≈ 13.9% — the claimed "92% of findings at 14% of the cost" is honest. Median CI −55%, p90 −52%, image −48%. No arithmetic sleight of hand.&lt;/p&gt;

&lt;h3&gt;
  
  
  But it's a vendor case study — and that matters
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The post is written by the company that sells these models.&lt;/strong&gt; That doesn't make it false, but the sample is successful scenarios inside OpenAI, in an ideal environment for an agent: telemetry, profilers, access to Slack and the flag console, unlimited model access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No false-positive rate.&lt;/strong&gt; We know how many findings were &lt;em&gt;accepted&lt;/em&gt;, but not how many were rejected or how much time it took to triage them. "Cost per finding" in the table only counts API spend, not reviewer hours. For 1,400 PRs, human time is almost certainly worth more than $109.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single validator.&lt;/strong&gt; In the model comparison, findings were checked by the author himself. That's fine for a blog post, but it's not a benchmark.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lines of code are a vanity metric.&lt;/strong&gt; 800k lines / 1,400 PRs ≈ 570 lines per PR on average. Impressive, but the real value shows in other numbers: image size, CPU, CI time. Good on the author for including them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The author admits&lt;/strong&gt; that some proposals added more complexity than they were worth — prioritization and measurement stayed with humans.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why deleting code is the ideal task for an agent
&lt;/h3&gt;

&lt;p&gt;I don't think it's a coincidence that the loudest agent success story is not a new feature but &lt;strong&gt;deletion&lt;/strong&gt;. The task has a rare combination of properties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verifying is easier than doing.&lt;/strong&gt; Finding dead code in a million lines is slow and boring. Confirming that a specific deletion is safe, when evidence is attached (zero calls in the profiler, flag at 100% for two years, a thread saying "experiment closed"), takes a couple of minutes. Agents are strongest where verification is cheaper than generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounded damage.&lt;/strong&gt; A deletion goes through tests and review and is easy to roll back with &lt;code&gt;git revert&lt;/code&gt;. Compare that with a new feature, where the mistake may be in the idea itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measurable outcome.&lt;/strong&gt; Megabytes of image, percent of CPU, minutes of CI. You can calculate whether it paid off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's work people put off for years.&lt;/strong&gt; Nobody finds it interesting and it doesn't get anyone promoted. That's why the biggest "free" wins pile up here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical takeaway: if you're looking for where to first deploy an agent on your team, code maintenance is a better candidate than generating new code.&lt;/p&gt;

&lt;h3&gt;
  
  
  The bottleneck shifts from finding to reviewing
&lt;/h3&gt;

&lt;p&gt;Rough math. Say a PR review takes 10 minutes on average (realistic with evidence attached; longer without). 1,400 PRs × 10 minutes ≈ &lt;strong&gt;230 person-hours&lt;/strong&gt;. That's more than a month of one engineer's work. The API cost is a rounding error by comparison.&lt;/p&gt;

&lt;p&gt;That changes the whole economics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The scarce resource is reviewer attention&lt;/strong&gt;, not tokens. Optimize the PR format: short diff, clear rationale, links to evidence, nothing extra.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Without a code ownership map, this doesn't scale.&lt;/strong&gt; OpenAI routed reviews to owning teams. If you don't have an up-to-date CODEOWNERS or equivalent, 1,400 PRs will sit in a queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A prioritized report matters more than PRs.&lt;/strong&gt; The author was right to ask the agent for a report with estimated benefits first. An agent that opens 500 unranked PRs shifts work onto people rather than removing it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Marginal cost per finding: the most interesting part of the table
&lt;/h3&gt;

&lt;p&gt;The post compares average cost per finding. But for deciding "which model to use," the &lt;strong&gt;marginal&lt;/strong&gt; cost matters more: how much each &lt;em&gt;additional&lt;/em&gt; finding costs when moving to a more expensive configuration.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Upgrade&lt;/th&gt;
&lt;th&gt;+Findings&lt;/th&gt;
&lt;th&gt;+Cost&lt;/th&gt;
&lt;th&gt;Cost per extra finding&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sol Medium → Sol Ultra&lt;/td&gt;
&lt;td&gt;+45&lt;/td&gt;
&lt;td&gt;+$9.85&lt;/td&gt;
&lt;td&gt;≈ $0.22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sol Ultra → Astra Ultra&lt;/td&gt;
&lt;td&gt;+11&lt;/td&gt;
&lt;td&gt;+$93.94&lt;/td&gt;
&lt;td&gt;≈ $8.54&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The marginal cost per finding jumps &lt;strong&gt;roughly 40×&lt;/strong&gt;. A classic diminishing-returns curve.&lt;/p&gt;

&lt;p&gt;But there's a nuance that's easy to miss. Those 11 findings only Astra caught are constant flags acting as dependencies of other flags. These are the most tangled cases, and possibly the most valuable. So the right question isn't "which model is cheaper" but &lt;strong&gt;"what does a finding the cheap model misses cost me?"&lt;/strong&gt; For tech debt, usually not much — so Sol Ultra looks like a sensible default. For a security audit, the answer might be different.&lt;/p&gt;

&lt;p&gt;The working strategy that follows: a bulk pass with the cheap model, and the expensive one applied surgically to the known blind spots you discovered by comparing results.&lt;/p&gt;

&lt;h3&gt;
  
  
  An agent doesn't replace static analysis — it complements it
&lt;/h3&gt;

&lt;p&gt;The agentic approach is powerful because it can connect &lt;strong&gt;unstructured&lt;/strong&gt; sources: Slack, PR history, trackers, telemetry. A deterministic analyzer will never do that. But the agent has a flip side: &lt;strong&gt;non-determinism&lt;/strong&gt;. Two runs of the same prompt can produce different lists of findings. That's fine for a one-off cleanup, not for guarantees.&lt;/p&gt;

&lt;p&gt;I think the most viable setup is hybrid:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The agent discovers a class of problems and proves specific instances.&lt;/li&gt;
&lt;li&gt;The discovered pattern is turned into a deterministic check: a lint rule, a CI job, a script. The agent can write it too — as happened with GPT-5.6 Sol in the post.&lt;/li&gt;
&lt;li&gt;The check keeps the problem from coming back, while the agent goes hunting for new classes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That way, knowledge gained through an expensive non-deterministic process gets locked into a cheap deterministic one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prompts are code — and they rot too
&lt;/h3&gt;

&lt;p&gt;One offhand remark by the author is, in my view, underrated: prompt tweaks that compensate for a model's weaknesses become obsolete with the next release. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;prompts for recurring tasks should be &lt;strong&gt;stored in the repository and versioned&lt;/strong&gt;, like code;&lt;/li&gt;
&lt;li&gt;they need &lt;strong&gt;evals&lt;/strong&gt;: a fixed repo snapshot plus a set of known correct findings. Otherwise, when you switch models, you won't know whether things got better or worse;&lt;/li&gt;
&lt;li&gt;the post's idea of A/B testing AGENTS.md instructions is exactly such an eval in miniature. It deserves to become a regular practice, not a one-off experiment.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Without telemetry, the agent is guessing
&lt;/h3&gt;

&lt;p&gt;Notice what Astra relied on for its strongest findings: production profiles, CPU per flag check, CI run history. Without that data, the agent could only say "looks unused" — the same thing static analysis says, just more expensively.&lt;/p&gt;

&lt;p&gt;A non-obvious consequence: &lt;strong&gt;investing in observability becomes a prerequisite for agentic code maintenance&lt;/strong&gt;. If you don't have production profiling and per-flag metrics, start there. Otherwise you'll have an agent working blindfolded.&lt;/p&gt;

&lt;h3&gt;
  
  
  Chesterton's fence, agent edition
&lt;/h3&gt;

&lt;p&gt;There's an old principle: don't take down a fence until you understand why it was put up. The "Permanent" flag story is a textbook example: the agent found an expensive line, calculated its cost, but stopped when it saw signs of intent and handed the decision to a human.&lt;/p&gt;

&lt;p&gt;That behavior wasn't accidental: the prompt set it up ("preserve killswitches and safeguards"). The practical takeaway: &lt;strong&gt;escalation criteria should be spelled out as carefully as deletion criteria&lt;/strong&gt;. The agent needs to know not only what to look for, but which signals mean stop: "permanent" or "do not remove" labels, mentions in postmortems, links to incidents, code in disaster-recovery paths.&lt;/p&gt;

&lt;p&gt;And one more thing the post doesn't mention: deleted code and closed experiments are part of &lt;strong&gt;institutional memory&lt;/strong&gt;. A dead branch is sometimes the only place showing how the system used to work. The answer isn't to avoid deletion but to write good PR descriptions. An agent that writes a rationale for every deletion is also building an archive where, a year later, you can find out "where did X go."&lt;/p&gt;

&lt;h3&gt;
  
  
  A loop worth thinking about
&lt;/h3&gt;

&lt;p&gt;The post opens by admitting that agents sped up development, which increased the maintenance load. And it ends with agents doing the maintenance too. That's a loop: agents produce code faster than humans can absorb it, and agents clean that code up.&lt;/p&gt;

&lt;p&gt;That isn't necessarily bad, but it changes the engineer's role: less writing, more &lt;strong&gt;setting constraints, checking evidence, and making decisions&lt;/strong&gt;. The good news: those skills always separated seniors from juniors. The bad news: juniors will have a harder time gaining experience on the routine work agents now take over. How teams will grow people in this environment is a question the industry doesn't have an answer to yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  What actually transfers (even if you don't have GPT-6)
&lt;/h3&gt;

&lt;p&gt;The most valuable part of the post isn't the models but the &lt;strong&gt;patterns for working with an agent&lt;/strong&gt;. They apply to any coding agent:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Demand evidence, not just a diff.&lt;/strong&gt; Every deletion comes with a rationale and links (PR, thread, metric). That's what makes 1,400 PRs reviewable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicitly list what must not be touched.&lt;/strong&gt; Killswitches, emergency branches, "Permanent" flags. The 0.42% CPU flag story is the perfect example: the agent found the problem but escalated instead of deleting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small draft PRs, not one mega-commit.&lt;/strong&gt; Easier to review, easier to roll back, easier to route to owners.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meta-prompting.&lt;/strong&gt; Asking the model to write the prompt for the next pass, with review feedback baked in, is a cheap and effective way to iteratively improve instructions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with the cheap model on a small scope.&lt;/strong&gt; Compare configurations on one task — Medium may well be enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report → prioritization → parallel workstreams.&lt;/strong&gt; An agent that writes a report with estimated benefits first is more useful than one that immediately opens 500 PRs.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  On safety — what the post says too little about
&lt;/h3&gt;

&lt;p&gt;"Don't touch live configs" in a prompt is a &lt;strong&gt;wish, not a guarantee&lt;/strong&gt;. If the agent has write access to the flag system, sooner or later it will use it in a way you didn't expect. Practical conclusions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;give the agent &lt;strong&gt;read-only&lt;/strong&gt; access to Slack, trackers, the flag console, and telemetry;&lt;/li&gt;
&lt;li&gt;keep merging and config changes in the hands of humans and CI;&lt;/li&gt;
&lt;li&gt;remember that Slack threads and tickets are untrusted input: an agent that reads everything is vulnerable to instructions hidden in that text.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  How not to let your flags get to this point
&lt;/h3&gt;

&lt;p&gt;An agent cleaning up flags that piled up for years is the cure. Prevention is cheaper:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every flag has an &lt;strong&gt;owner and an expiration date&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;experiment flags and operational killswitches are &lt;strong&gt;different types&lt;/strong&gt;, with different lifecycle rules;&lt;/li&gt;
&lt;li&gt;a recurring (even monthly) agent task: "find flags that have been at 100% or 0% for 30+ days and prepare a report" — exactly what the post describes, but in small, regular doses.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;OpenAI's post isn't about "agents will replace engineers" — it's about code maintenance turning into a loop: &lt;strong&gt;the agent finds and proves → a human decides → feedback improves the next prompt&lt;/strong&gt;. The strongest point is about cost: a 7× price difference between models for 92% of the result means that for routine tech debt, an expensive flagship may simply be overkill. The Claude Opus 5.5 announcement points the same way — and that convergence is more telling than any single vendor's benchmarks.&lt;/p&gt;

&lt;p&gt;Want to try it? Take one flaky test or one stale flag and ask an agent to find it, prove it, and open a small draft PR. If it works, expand to a directory. Then to the repository.&lt;/p&gt;

&lt;p&gt;Are you already letting agents clean up tech debt? What's your PR acceptance rate? Share in the comments 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://developers.openai.com/blog/maintaining-software-with-the-gpt-6-family" rel="noopener noreferrer"&gt;Maintaining software with the GPT-6 family&lt;/a&gt;, OpenAI Developers Blog, Oct 14, 2026&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&lt;a href="https://www.anthropic.com/claude-opus-5-5" rel="noopener noreferrer"&gt;Introducing Claude Opus 5.5&lt;/a&gt;, Anthropic, Sep 22, 2026&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>refactoring</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why AND(0, ?) is 0: three values, snapshots and memory in a logic simulator</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Sun, 18 Oct 2026 22:33:31 +0000</pubDate>
      <link>https://dev.to/den0011/why-and0-is-0-three-values-snapshots-and-memory-in-a-logic-simulator-58jb</link>
      <guid>https://dev.to/den0011/why-and0-is-0-three-values-snapshots-and-memory-in-a-logic-simulator-58jb</guid>
      <description>&lt;p&gt;In my &lt;a href="https://dev.to/den0011/i-know-what-to-build-next-my-own-architecture-disagrees-about-the-order-2hi5"&gt;last post&lt;/a&gt;&lt;br&gt;
I planned a feature, and executing it turned up a bug: the simulator could not&lt;br&gt;
hold a bit with two gates. The first circuit in every digital logic course, an SR&lt;br&gt;
latch from two cross-coupled NOR gates, read &lt;code&gt;?&lt;/code&gt; for every combination of inputs.&lt;/p&gt;

&lt;p&gt;The short version of that story fits in a paragraph. This post is the long one:&lt;br&gt;
what actually had to change inside the simulator core, in the order it had to&lt;br&gt;
change, with the traces I used to convince myself each step was necessary. If you are building&lt;br&gt;
anything that evaluates a circuit (a logic simulator, a spreadsheet engine, a&lt;br&gt;
reactive graph), some of this will transfer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://boolflow.site" rel="noopener noreferrer"&gt;Boolflow&lt;/a&gt; is a free browser logic simulator. The engine is&lt;br&gt;
plain JavaScript with no React in it, so every number below comes from running&lt;br&gt;
it under bare &lt;code&gt;node&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three values, and who gets to use them
&lt;/h2&gt;

&lt;p&gt;A wire is &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;1&lt;/code&gt; or &lt;code&gt;null&lt;/code&gt;, where &lt;code&gt;null&lt;/code&gt; means "unknown". The original gates were&lt;br&gt;
strict: if any input is unknown, the output is unknown.&lt;/p&gt;

&lt;p&gt;That is wrong in exactly the case that matters. &lt;code&gt;AND(0, ?)&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt; whatever the&lt;br&gt;
question mark turns out to be. A &lt;strong&gt;controlling value&lt;/strong&gt; decides a gate on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;and&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vs&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;or&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vs&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NAND and NOR are &lt;code&gt;inv(and(...))&lt;/code&gt; and &lt;code&gt;inv(or(...))&lt;/code&gt;, so they inherit it.&lt;/p&gt;

&lt;p&gt;Not every gate has a controlling value, and the code reflects that. XOR has none:&lt;br&gt;
&lt;code&gt;XOR(?, x)&lt;/code&gt; is unknown for either &lt;code&gt;x&lt;/code&gt;, so XOR stays strict. NOT is strict because&lt;br&gt;
inverting an unknown is an unknown.&lt;/p&gt;

&lt;p&gt;One honest consequence: everything &lt;em&gt;else&lt;/em&gt; is also still strict, and strict is&lt;br&gt;
pessimistic. A &lt;code&gt;MUX&lt;/code&gt; with an unknown select returns &lt;code&gt;?&lt;/code&gt; even when both data inputs&lt;br&gt;
are &lt;code&gt;1&lt;/code&gt;, where real hardware gives &lt;code&gt;1&lt;/code&gt;. Adders, decoders and comparators do the&lt;br&gt;
same. I only taught the AND/OR family, because that is the family that can resolve&lt;br&gt;
a loop. I have not been bitten by the rest, but it is a real gap, not a design&lt;br&gt;
decision.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rounds, not sweeps
&lt;/h2&gt;

&lt;p&gt;Here is the circuit. Two NOR gates, each fed by the other's output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Q  = NOR(R, ~Q)
~Q = NOR(S, Q)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start with both outputs unknown and set &lt;code&gt;S = 1&lt;/code&gt;. Evaluate &lt;code&gt;~Q&lt;/code&gt; first and you get&lt;br&gt;
&lt;code&gt;NOR(1, ?)&lt;/code&gt;, which is &lt;code&gt;0&lt;/code&gt; by the controlling value. Now &lt;code&gt;Q&lt;/code&gt; sees &lt;code&gt;NOR(0, 0)&lt;/code&gt; and&lt;br&gt;
becomes &lt;code&gt;1&lt;/code&gt;. The pair resolved out of nothing.&lt;/p&gt;

&lt;p&gt;Notice what I just did: I picked an order. That is the second bug the latch&lt;br&gt;
exposed. My relaxation loop evaluated gates &lt;em&gt;in place&lt;/em&gt;, in whatever order the nodes&lt;br&gt;
happen to sit in the file, so each gate saw its neighbour's new value immediately.&lt;br&gt;
For a circuit with no loops that is harmless, because it converges to the same&lt;br&gt;
answer whatever the order. With a loop, the order of the file decides the answer.&lt;/p&gt;

&lt;p&gt;Here is the dangerous input to show it. Hold &lt;code&gt;S = R = 1&lt;/code&gt; (both outputs go low), then&lt;br&gt;
release both at once. I replicated the rule in a 15-line toy, since the engine does&lt;br&gt;
not print its rounds. Evaluating in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;order Q, ~Q:   round 1  Q=1 ~Q=0   round 2  Q=1 ~Q=0   → stable, Q=1
order ~Q, Q:   round 1  Q=0 ~Q=1   round 2  Q=0 ~Q=1   → stable, Q=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different stable answers, picked by which node I happened to list first.&lt;br&gt;
Hardware does not work like that: both gates see the same old values and update&lt;br&gt;
together. So a round now evaluates every gate against a &lt;strong&gt;snapshot&lt;/strong&gt; of the previous&lt;br&gt;
round's values, then applies all the results at once (a Jacobi iteration, if you&lt;br&gt;
want the name):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;snapshot:      round 1  Q=1 ~Q=1   round 2  Q=0 ~Q=0   round 3  Q=1 ~Q=1 …
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both gates flip every round, forever. That is what a real SR latch does when you&lt;br&gt;
release both inputs of the forbidden state at the same instant: nothing in the&lt;br&gt;
circuit says which gate wins, and I would rather show that than invent a winner.&lt;/p&gt;

&lt;p&gt;The loop is small (&lt;code&gt;relax&lt;/code&gt; in &lt;code&gt;simulateComb.js&lt;/code&gt;, 26 lines):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;round&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nodes&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;vals&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nx"&gt;readFrom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// gates read the snapshot…&lt;/span&gt;
  &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;               &lt;span class="c1"&gt;// …and write into vals&lt;/span&gt;
  &lt;span class="nx"&gt;readFrom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it buys is a test that reverses the node list and demands identical answers&lt;br&gt;
for a six-step set / hold / reset / hold / set sequence.&lt;/p&gt;
&lt;h2&gt;
  
  
  How do you know it will never settle?
&lt;/h2&gt;

&lt;p&gt;A circuit without loops needs as many rounds as its longest path. So the budget is&lt;br&gt;
generous and simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;combNodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still changing after that: the wires that changed in the last round are&lt;br&gt;
oscillating. I do not leave them at whatever value the last round happened to&lt;br&gt;
write, because that would look like a result. Every output of the gates involved&lt;br&gt;
is set to unknown, and the rest of the circuit gets another go at settling around&lt;br&gt;
them. The &lt;code&gt;simulate()&lt;/code&gt; result is flagged, and&lt;br&gt;
the editor shows a warning in the status bar.&lt;/p&gt;

&lt;p&gt;This is the real engine on the latch, one row per tick:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;S=0 R=0  Q=? Qb=?             power-up: nothing has set it
S=1 R=0  Q=1 Qb=0             set
S=0 R=0  Q=1 Qb=0             hold
S=0 R=1  Q=0 Qb=1             reset
S=0 R=0  Q=0 Qb=1             hold
S=1 R=1  Q=0 Qb=0             the forbidden input
S=0 R=0  Q=? Qb=?  UNSETTLED  both released at once
S=1 R=0  Q=1 Qb=0             a normal set recovers it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A small note on the editor: you cannot reach the oscillating row with two&lt;br&gt;
switches, because releasing two switches is two clicks, which is two ticks. You&lt;br&gt;
reach it by wiring one switch to both &lt;code&gt;S&lt;/code&gt; and &lt;code&gt;R&lt;/code&gt;. I got that wrong in an article&lt;br&gt;
first and had to correct it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Memory lives in the wires
&lt;/h2&gt;

&lt;p&gt;Look at the "hold" rows. For the latch to hold &lt;code&gt;Q = 1&lt;/code&gt; after &lt;code&gt;S&lt;/code&gt; drops to &lt;code&gt;0&lt;/code&gt;, the&lt;br&gt;
tick that sees &lt;code&gt;S = 0&lt;/code&gt; has to &lt;em&gt;start&lt;/em&gt; from &lt;code&gt;Q = 1, ~Q = 0&lt;/code&gt;. Every call to&lt;br&gt;
&lt;code&gt;simulate()&lt;/code&gt; used to start from a blank wire map, so there was nothing to hold. A&lt;br&gt;
latch keeps its bit in its own outputs.&lt;/p&gt;

&lt;p&gt;So the values of the combinational wires now carry over from tick to tick, split in&lt;br&gt;
two the same way flip-flop state already was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;wiresCommitted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// what the last tick settled on&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;wiresWorking&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// what this tick is computing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;beginTick()&lt;/code&gt; promotes working to committed. Here is the same sequence with the&lt;br&gt;
memory wiped before every tick:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;S=0 R=0  Q=? Qb=?
S=1 R=0  Q=1 Qb=0
S=0 R=0  Q=? Qb=?      ← the bit is gone as soon as S is released
S=0 R=1  Q=0 Qb=1
S=0 R=0  Q=? Qb=?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The set and reset still work, because the controlling input is present. Releasing&lt;br&gt;
it forgets. That is the difference between a gate that reacts and a gate that&lt;br&gt;
stores.&lt;/p&gt;

&lt;p&gt;Why two maps instead of one? The reason is the one flip-flop state already had, and&lt;br&gt;
the comment above it in the code says it better than I would: &lt;code&gt;simulate()&lt;/code&gt; runs&lt;br&gt;
more than once per tick, because a layer is re-simulated once the values coming&lt;br&gt;
through VIAs from other layers are known. With one map advanced in place, the&lt;br&gt;
first of those runs consumed the clock edge while its inputs were still&lt;br&gt;
unresolved, so a flip-flop fed through a VIA latched a stale value and the edge was&lt;br&gt;
spent by the time the real value arrived. Reading every run from the same&lt;br&gt;
start-of-tick baseline makes them recompute the same transition with progressively&lt;br&gt;
better inputs. Wires needed the same treatment for the same reason.&lt;/p&gt;

&lt;p&gt;For circuits without loops, stale starting values change nothing, since they settle&lt;br&gt;
to the same answer from anywhere. A test runs different input pairs back to back&lt;br&gt;
through an XOR and an AND and checks every answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Time: delta cycles for the stateful elements
&lt;/h2&gt;

&lt;p&gt;Flip-flops, counters and registers are not gates, and they have the opposite&lt;br&gt;
problem. A toggle flip-flop is &lt;code&gt;D = NOT Q&lt;/code&gt;: its input depends on its own output.&lt;br&gt;
Evaluated in a single sweep, that reads &lt;code&gt;?&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So a tick is not one sweep but a sequence of &lt;strong&gt;delta cycles&lt;/strong&gt;, borrowed from how HDL&lt;br&gt;
simulators work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every element's output pins start out showing its previous state, so feedback
reads real values.&lt;/li&gt;
&lt;li&gt;All elements are evaluated against the same snapshot and updated together. A
shift register must not see its neighbour's &lt;em&gt;new&lt;/em&gt; value.&lt;/li&gt;
&lt;li&gt;The combinational logic settles on the new outputs (the rounds from above).&lt;/li&gt;
&lt;li&gt;Repeat while anything changed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A ripple counter's second stage sees its clock edge one delta later than the first.&lt;br&gt;
An asynchronous reset takes effect in the delta after the counter reached the&lt;br&gt;
decoded value.&lt;/p&gt;

&lt;p&gt;Two details earned their place by failing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First sight of a clock.&lt;/strong&gt; A flip-flop meeting its clock for the first time has not&lt;br&gt;
seen it &lt;em&gt;change&lt;/em&gt;, so it must not fire. Without that, a stage clocked from a Q-bar&lt;br&gt;
output (idle high) takes a spurious edge at power-up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cap.&lt;/strong&gt; There has to be a ceiling or an oscillating circuit hangs the tab:&lt;br&gt;
&lt;code&gt;MAX_DELTAS = 32&lt;/code&gt;. I tested what that means, since a comment in the code said "a&lt;br&gt;
ripple counter needs one per stage". A chain of &lt;code&gt;N&lt;/code&gt; toggle flip-flops, each clocked&lt;br&gt;
by the previous one's Q, with one rising edge at the front:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;stages&lt;/th&gt;
&lt;th&gt;high after the edge tick&lt;/th&gt;
&lt;th&gt;one tick later&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Beyond 32 stages the ripple does not complete in the tick that caused it. It&lt;br&gt;
finishes on the next tick, because the remaining stages still see an unconsumed&lt;br&gt;
rising edge. Nobody draws a 33-stage ripple counter in a teaching tool, but the&lt;br&gt;
behaviour is a direct consequence of the cap, so I would rather list it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it still does not model
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero delay.&lt;/strong&gt; Every gate answers instantly, so there are no glitches and no
hazards, and the waveform is an idealisation. The panel says so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One kind of "not a number".&lt;/strong&gt; &lt;code&gt;null&lt;/code&gt; means unknown &lt;em&gt;and&lt;/em&gt; high-impedance (a
tri-state buffer with its enable off returns &lt;code&gt;null&lt;/code&gt;). Verilog separates &lt;code&gt;x&lt;/code&gt; and
&lt;code&gt;z&lt;/code&gt;; VHDL's &lt;code&gt;std_logic&lt;/code&gt; has nine values. Merging them kept the code small, and I
suspect it will eventually bite someone with a bus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pessimistic gates&lt;/strong&gt; above: MUX, adders, decoders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real latch physics.&lt;/strong&gt; A real SR latch released from the forbidden state ends up
in a random state, or in metastability. The simulator says &lt;code&gt;?&lt;/code&gt; and flags it. I
think that is the honest answer, but it is a modelling choice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The tests
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;test-latches.mjs&lt;/code&gt; has 29 checks: NOR and NAND latches, a gated D latch from four&lt;br&gt;
NAND gates and an inverter, and a master-slave D flip-flop from two of those, with&lt;br&gt;
the output required not to follow D while the clock is high. Each one used to read&lt;br&gt;
&lt;code&gt;?&lt;/code&gt; for every input.&lt;/p&gt;

&lt;p&gt;If the engine were a library I would have written these first. It was not, I did&lt;br&gt;
not, and it took the first circuit of any digital logic course to find the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Would you split unknown from high-impedance now, or wait until a bus forces it?&lt;/li&gt;
&lt;li&gt;Is Jacobi (snapshot) rounds the right model for a teaching tool, or is a proper
event queue with delays worth the complexity?&lt;/li&gt;
&lt;li&gt;If you have built an evaluator with feedback, what was your oscillation policy:
a cap, a visited-state set, or refusing loops?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything above is live at &lt;a href="https://boolflow.site" rel="noopener noreferrer"&gt;boolflow.site&lt;/a&gt;, with the SR&lt;br&gt;
latch and the master-slave flip-flop in the examples.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>computerscience</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I built my own roadmap. The first thing it found was that my SR latch didn't work.</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Fri, 16 Oct 2026 20:26:10 +0000</pubDate>
      <link>https://dev.to/den0011/i-built-my-own-roadmap-the-first-thing-it-found-was-that-my-sr-latch-didnt-work-2gnk</link>
      <guid>https://dev.to/den0011/i-built-my-own-roadmap-the-first-thing-it-found-was-that-my-sr-latch-didnt-work-2gnk</guid>
      <description>&lt;p&gt;Two posts ago I &lt;a href="https://dev.to/den0011/i-compared-my-logic-simulator-to-9-others-here-is-where-mine-loses-38p7"&gt;compared my logic simulator to nine others&lt;/a&gt;&lt;br&gt;
and listed three honest gaps. In the&lt;br&gt;
&lt;a href="https://dev.to/den0011/i-know-what-to-build-next-my-own-architecture-disagrees-about-the-order-2hi5"&gt;next one&lt;/a&gt;&lt;br&gt;
I opened the code and discovered that the three gaps were really five items in a&lt;br&gt;
dependency graph, two of them invisible: make time explicit, then waveforms and&lt;br&gt;
step mode; make node identity hierarchical, then sub-circuits; and test cases that&lt;br&gt;
can handle a clock once time exists.&lt;/p&gt;

&lt;p&gt;That was the plan. This post is about what happened when I executed it, which is a&lt;br&gt;
different thing, because the plan was drawn by someone who had not yet tried to&lt;br&gt;
build a flip-flop out of two gates.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://boolflow.site" rel="noopener noreferrer"&gt;Boolflow&lt;/a&gt; is a browser digital-logic simulator, free, no&lt;br&gt;
account. Everything below is live.&lt;/p&gt;
&lt;h2&gt;
  
  
  The scoreboard
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;In the roadmap post&lt;/th&gt;
&lt;th&gt;What shipped&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Explicit tick counter + history&lt;/td&gt;
&lt;td&gt;Done, but a "tick" turned out to mean something other than I wrote&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Step mode + waveform panel&lt;/td&gt;
&lt;td&gt;Done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hierarchical node ids by flattening&lt;/td&gt;
&lt;td&gt;Done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sub-circuits&lt;/td&gt;
&lt;td&gt;Done, including editing a block after you placed it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test cases (sequential half)&lt;/td&gt;
&lt;td&gt;Done: a &lt;code&gt;C&lt;/code&gt; in a column is a clock pulse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"The clock frequency bug"&lt;/td&gt;
&lt;td&gt;Fixed, and it was worse than I said&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Tell the user it didn't settle"&lt;/td&gt;
&lt;td&gt;Done, in a form I did not expect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share link&lt;/td&gt;
&lt;td&gt;Not done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Truth table to circuit&lt;/td&gt;
&lt;td&gt;Not done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iframe embed&lt;/td&gt;
&lt;td&gt;Decided against&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buses&lt;/td&gt;
&lt;td&gt;Still untouched, as promised&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The table is the boring part. The interesting part is everything in it that&lt;br&gt;
needed a footnote.&lt;/p&gt;
&lt;h2&gt;
  
  
  "Tick" does not mean what I said
&lt;/h2&gt;

&lt;p&gt;I wrote that I needed a monotonically increasing integer owned by the simulator,&lt;br&gt;
and a ring buffer recording the value map at each tick. Both are in. But I&lt;br&gt;
recorded a frame on &lt;em&gt;every evaluation&lt;/em&gt;, then watched a waveform fill up with&lt;br&gt;
identical columns while I dragged a gate across the canvas. Moving a node&lt;br&gt;
re-simulates the circuit. Nothing observable changes. Time should not advance.&lt;/p&gt;

&lt;p&gt;So a tick is now an &lt;strong&gt;event&lt;/strong&gt;: a frame is stored only when a watched signal&lt;br&gt;
differs from the previous frame. Drag a node, nothing. Flip a clock, a frame.&lt;br&gt;
Throw a switch while the clock is paused, a frame. The panel says so in one line&lt;br&gt;
under its title (&lt;em&gt;each column is one change in the circuit, not a fixed time&lt;br&gt;
step&lt;/em&gt;), because a waveform with no propagation delay is an idealisation and I&lt;br&gt;
would rather say it than have someone discover it.&lt;/p&gt;

&lt;p&gt;Step mode fell out the way I hoped. Pause stops the timer; Step runs one tick of&lt;br&gt;
the same schedule the timer would have run. With one clock that is a half-period,&lt;br&gt;
which is where I keep wanting to say "one clock edge" and keep being wrong: you&lt;br&gt;
need to evaluate the falling half too, or the flip-flop never sees the clock go low.&lt;/p&gt;
&lt;h3&gt;
  
  
  The clock bug was a sleeper
&lt;/h3&gt;

&lt;p&gt;In the last post I showed this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;freq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allClocks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;freq&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every clock ticked at the frequency of whichever clock came first. I called it a&lt;br&gt;
bug nobody had hit. It was better than that. When I went to fix it I found that&lt;br&gt;
&lt;strong&gt;there was no way to set a clock's frequency in the UI at all&lt;/strong&gt;. &lt;code&gt;freq&lt;/code&gt; was&lt;br&gt;
stored, loaded, displayed ("1 Hz") and never editable. The bug was unreachable&lt;br&gt;
from the interface and would have bitten the first person who loaded a file with&lt;br&gt;
two different values.&lt;/p&gt;

&lt;p&gt;The fix: one timer at the greatest common divisor of all the clocks' flip periods,&lt;br&gt;
each clock flipping on every &lt;em&gt;n&lt;/em&gt;-th tick. Two clocks that coincide flip in the&lt;br&gt;
same render instead of one render apart. And since freq now matters, the clock&lt;br&gt;
node got &lt;code&gt;−&lt;/code&gt; and &lt;code&gt;+&lt;/code&gt; buttons (0.25 to 8).&lt;/p&gt;
&lt;h2&gt;
  
  
  The latch that wasn't
&lt;/h2&gt;

&lt;p&gt;This is the part I did not see coming.&lt;/p&gt;

&lt;p&gt;I planned to add an oscillation warning, because I had read my own relaxation loop&lt;br&gt;
and noticed it never checked convergence. Ten lines, I said. So I wrote a few&lt;br&gt;
probes to see what it would take to &lt;em&gt;make&lt;/em&gt; a circuit oscillate, to have something to&lt;br&gt;
test the warning against. A ring of three inverters. A NAND ring with an enable.&lt;/p&gt;

&lt;p&gt;They stayed at &lt;code&gt;?&lt;/code&gt;. Every one of them. A ring of inverters never leaves "unknown",&lt;br&gt;
because &lt;code&gt;NOT(unknown)&lt;/code&gt; is unknown, so there is nothing to oscillate.&lt;/p&gt;

&lt;p&gt;Then, almost as an afterthought, I wired up the first circuit in every digital&lt;br&gt;
logic course: an SR latch from two cross-coupled NOR gates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Q  = NOR(R, ~Q)
~Q = NOR(S, Q)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set it. Reset it. Hold it. &lt;strong&gt;&lt;code&gt;?&lt;/code&gt;, for every combination of S and R.&lt;/strong&gt; My simulator&lt;br&gt;
could not do the first memory circuit anyone learns.&lt;/p&gt;

&lt;p&gt;Two causes, both mine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One.&lt;/strong&gt; Gates were strict about unknowns. This is the AND gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AND&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;s1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If either input is unknown, the output is unknown. But &lt;code&gt;AND(0, ?)&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt;, whatever&lt;br&gt;
the question mark turns out to be, and &lt;code&gt;NOR(1, ?)&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt;. That is the entire&lt;br&gt;
mechanism by which a cross-coupled pair resolves out of nothing: one gate sees a&lt;br&gt;
controlling input and is decided before the other has an opinion.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;and&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vs&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;or&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vs&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Two.&lt;/strong&gt; Every call to &lt;code&gt;simulate()&lt;/code&gt; started from a blank wire map. A latch holds its&lt;br&gt;
bit &lt;em&gt;in its own outputs&lt;/em&gt;, so a blank start means it has nothing to hold. The wire&lt;br&gt;
values now carry over from tick to tick, split into "what the last tick settled&lt;br&gt;
on" and "what this tick is computing", the same way I already treated flip-flop&lt;br&gt;
state.&lt;/p&gt;

&lt;p&gt;That second fix has a consequence I had to think about: for a circuit with no&lt;br&gt;
loops, starting from stale values changes nothing, because it settles to the same&lt;br&gt;
answer from any starting point. A test runs six different input pairs&lt;br&gt;
in a row through an XOR and an AND and checks every answer.&lt;/p&gt;
&lt;h3&gt;
  
  
  Gates have to update together
&lt;/h3&gt;

&lt;p&gt;The relaxation loop also evaluated gates in place, in whatever order the nodes&lt;br&gt;
were listed. With a latch, that makes the &lt;em&gt;order of the file&lt;/em&gt; decide the answer.&lt;br&gt;
It now relaxes in rounds: every gate reads a snapshot, all of them update, repeat.&lt;br&gt;
A test reverses the node list and expects identical results.&lt;/p&gt;
&lt;h3&gt;
  
  
  And now the warning can exist
&lt;/h3&gt;

&lt;p&gt;Release both inputs of a NOR latch from &lt;code&gt;S = R = 1&lt;/code&gt; at the same instant and each&lt;br&gt;
gate sees two zeros, wants to output 1, then sees a 1, wants 0, forever. In real&lt;br&gt;
silicon one gate wins by a few picoseconds and nobody knows which; that is&lt;br&gt;
&lt;a href="https://boolflow.site/articles/synchronous-design-metastability" rel="noopener noreferrer"&gt;metastability&lt;/a&gt;.&lt;br&gt;
In a zero-delay engine the tie never breaks. After twice-the-node-count rounds the&lt;br&gt;
loop is declared oscillating, its wires are shown as &lt;code&gt;?&lt;/code&gt;, and the status bar says&lt;br&gt;
&lt;strong&gt;Does not settle&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The humbling footnote: my first draft of the article claimed you could "release both&lt;br&gt;
together" by clicking two switches. I clicked two switches in the real editor and the latch&lt;br&gt;
landed in a perfectly definite state, because two clicks are two ticks. The only&lt;br&gt;
way to get the oscillation is one switch wired to both inputs. I fixed the article.&lt;/p&gt;
&lt;h2&gt;
  
  
  The save button that forgot
&lt;/h2&gt;

&lt;p&gt;To make sub-circuits I needed named pins. Named pins come from the labels on&lt;br&gt;
INPUT and OUTPUT elements. I looked for where a user types such a label.&lt;/p&gt;

&lt;p&gt;There isn't one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;data.label&lt;/code&gt; existed. The truth table read it. The test cases read it. The Verilog,&lt;br&gt;
VHDL and C++ exporters read it, and even sanitised it into a legal identifier in&lt;br&gt;
all three languages. My own landing page said &lt;em&gt;signal names come from the labels you&lt;br&gt;
typed on the canvas&lt;/em&gt;. The only things that ever set it were the built-in examples.&lt;/p&gt;

&lt;p&gt;Then I read the save function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...PLM fields&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Type, position, value. A saved circuit came back without its names, &lt;strong&gt;without its&lt;br&gt;
clock speed, without its net-label names, and without its frames at all&lt;/strong&gt;: a frame&lt;br&gt;
has no &lt;code&gt;data.type&lt;/code&gt;, so it was written without a type and could not come back.&lt;/p&gt;

&lt;p&gt;The fix is unglamorous. There is now a small name tag under every switch, lamp,&lt;br&gt;
clock, probe and block; you click it and type. And the whole file format moved into&lt;br&gt;
one module with a round-trip test: build a circuit with every kind of node, write it,&lt;br&gt;
read it back, compare. Old files with the lost fields still load.&lt;/p&gt;

&lt;p&gt;I am slightly embarrassed that export with your own signal names, a feature I had&lt;br&gt;
been pleased with, was unreachable for anyone who built a circuit by hand.&lt;/p&gt;
&lt;h2&gt;
  
  
  Sub-circuits: flattening was the right call
&lt;/h2&gt;

&lt;p&gt;I said I would flatten: when you place a block, inline a copy of its nodes into&lt;br&gt;
the parent with every id rewritten to &lt;code&gt;instance/original&lt;/code&gt;. The simulator never&lt;br&gt;
learns hierarchy exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;u1/ff        u2/ff        u1/inner/ff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine change was small, and the test that justified the whole exercise is&lt;br&gt;
short. A block containing a toggle flip-flop, placed twice, each copy given its&lt;br&gt;
own clock. Pulse copy A twice and copy B once, and check that A has toggled twice&lt;br&gt;
and B once. Before, both copies would have shared one entry in the state map and&lt;br&gt;
fought over it.&lt;/p&gt;

&lt;p&gt;Two things I did not anticipate paid off for free. The path ids make waveform&lt;br&gt;
labels meaningful (&lt;code&gt;DIV2/D.Q&lt;/code&gt;, &lt;code&gt;DIV2#2/D.Q&lt;/code&gt;) with no extra work. And because a&lt;br&gt;
block is just a circuit, blocks nest, and the exporters, test cases, truth table&lt;br&gt;
and waveform all see through them by calling the same &lt;code&gt;flatten&lt;/code&gt; first.&lt;/p&gt;
&lt;h3&gt;
  
  
  Pins by position, and editing by name
&lt;/h3&gt;

&lt;p&gt;A block's pins are its INPUT and OUTPUT elements, ordered top to bottom as they&lt;br&gt;
sit on the canvas, so the block looks like the drawing it came from.&lt;/p&gt;

&lt;p&gt;Editing a placed block was the part I said I would not do, and then did, because a&lt;br&gt;
block you cannot change is a block you redraw. Double-click it and its circuit&lt;br&gt;
opens on the canvas in place of yours; Done writes the change into every copy.&lt;/p&gt;

&lt;p&gt;The interesting problem is what to do with the wires. You add a pin at the top and&lt;br&gt;
every other pin's index shifts. If wires followed &lt;em&gt;indices&lt;/em&gt;, you would silently&lt;br&gt;
rewire the whole circuit. They follow &lt;strong&gt;names&lt;/strong&gt;: rename a pin and its wire stays;&lt;br&gt;
add a pin and the others move down with their wires; remove one and only its wire&lt;br&gt;
goes. One refinement came from using it: when a name disappears but the number of&lt;br&gt;
pins on that side is unchanged, it is a rename in place, and the wire stays. Renaming&lt;br&gt;
&lt;code&gt;in_0&lt;/code&gt; to &lt;code&gt;CLK&lt;/code&gt; is the most common edit there is, and dropping the wire for it would&lt;br&gt;
have been a cruel default.&lt;/p&gt;
&lt;h2&gt;
  
  
  Tests that can count clock edges
&lt;/h2&gt;

&lt;p&gt;The comparison with Digital had one line I wanted to close: &lt;em&gt;declare inputs and&lt;br&gt;
expected outputs in a table, run, get pass or fail&lt;/em&gt;. Mine could only do&lt;br&gt;
combinational rows. Now a &lt;code&gt;C&lt;/code&gt; in an input column is a clock pulse: the signal goes&lt;br&gt;
0, 1, 0, and the outputs are checked after it has settled.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CLK | Q0 Q1 Q2 Q3
C   | 1  0  0  0
C   | 0  1  0  0
C   | 1  1  0  0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A table with a &lt;code&gt;C&lt;/code&gt; runs top to bottom as one sequence from power-up, and &lt;code&gt;x&lt;/code&gt; in an&lt;br&gt;
input column keeps the last value. It runs on a state of its own and puts the&lt;br&gt;
editor's live state back afterwards, so running a table does not stop the counter&lt;br&gt;
you are watching.&lt;/p&gt;

&lt;p&gt;I will say what it does not do: setup and hold times. A pulse is an instant. The&lt;br&gt;
article about it says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 404 that only production had
&lt;/h2&gt;

&lt;p&gt;All of the above was checked in a browser. The landing page got a real screenshot&lt;br&gt;
of the editor, and it was broken on the first deploy: alt text, no image.&lt;/p&gt;

&lt;p&gt;The backend serves &lt;code&gt;dist/&lt;/code&gt; from an explicit allow-list: &lt;code&gt;/assets&lt;/code&gt;, &lt;code&gt;/donate&lt;/code&gt;, a few&lt;br&gt;
root files. A new &lt;code&gt;/img&lt;/code&gt; folder fell through to the SPA catch-all, which answers&lt;br&gt;
every unknown path with the app shell and a 404. &lt;code&gt;vite dev&lt;/code&gt; and &lt;code&gt;vite preview&lt;/code&gt; serve&lt;br&gt;
the whole &lt;code&gt;public/&lt;/code&gt; folder, so I never saw it locally. The fix was one mount; the&lt;br&gt;
lesson is that "works in the preview server" and "works behind my own router" are&lt;br&gt;
different statements. (It also turned out the screenshots were being sent as&lt;br&gt;
&lt;code&gt;application/octet-stream&lt;/code&gt;; the Python on the server evidently does not know&lt;br&gt;
&lt;code&gt;.webp&lt;/code&gt;, so that got a one-line fix too.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What is still not there
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Exporting to Verilog/VHDL/C++ is &lt;strong&gt;combinational only&lt;/strong&gt;. Flip-flops, registers and
counters are skipped, and every skipped element is flagged in the output. Tests
handle a clock now; the exporters do not.&lt;/li&gt;
&lt;li&gt;A block nested inside a block is editable only through the outer one.&lt;/li&gt;
&lt;li&gt;Bus contention (two drivers fighting over a wire) is detected at the top level,
not inside a block.&lt;/li&gt;
&lt;li&gt;No share link and no truth-table-to-circuit synthesis.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;1 Hz&lt;/code&gt; on a clock means one flip per second, so the real frequency is 0.5 Hz.
I know. Fixing it would make every demo twice as fast, so it stays until I decide
what the label should say.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Answers to my own questions
&lt;/h2&gt;

&lt;p&gt;Last time I asked five questions, so here is what I actually did.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flattening versus recursive evaluation.&lt;/strong&gt; Flattening. The memory cost I worried
about never showed up at this scale, and the unplanned benefit was readable path
names on the waveform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idealised waveforms.&lt;/strong&gt; Kept idealised, and said so on the panel. No fake unit
delay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step granularity.&lt;/strong&gt; One tick of the shared timer, which is half a clock period
with a single clock. I did not build single-gate stepping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two invisible refactors out of five.&lt;/strong&gt; In hindsight the invisible ones were the
ones that exposed the most. Making time explicit led directly to noticing that
the latch did not work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embeds versus sub-circuits.&lt;/strong&gt; Sub-circuits. I then decided against the iframe
embed entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would tell myself before the roadmap post
&lt;/h2&gt;

&lt;p&gt;I wrote a confident dependency graph after reading the simulation entry point and&lt;br&gt;
the state map. That was a good read of two files. It said nothing about the gates,&lt;br&gt;
the save function or the router, and every surprise in this post lived in one of&lt;br&gt;
those.&lt;/p&gt;

&lt;p&gt;The numbers, for the record: 282 automated checks across 11 scripts, all passing;&lt;br&gt;
49 articles with a diagram each, in three languages; 32 examples, every one checked&lt;br&gt;
for structure and the sequential ones actually run. The articles I corrected along&lt;br&gt;
the way were checked by running the circuits they describe, which is the habit this&lt;br&gt;
whole stretch taught me.&lt;/p&gt;

&lt;p&gt;Boolflow is at &lt;strong&gt;&lt;a href="https://boolflow.site" rel="noopener noreferrer"&gt;boolflow.site&lt;/a&gt;&lt;/strong&gt;: free, no account, nothing&lt;br&gt;
to install. If you teach digital logic or have ever built a latch from NOR gates&lt;br&gt;
and wondered whether a tool would do it, I would like to hear what breaks.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>architecture</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Everyone Types "yes"</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Mon, 12 Oct 2026 08:40:03 +0000</pubDate>
      <link>https://dev.to/den0011/everyone-types-yes-gd4</link>
      <guid>https://dev.to/den0011/everyone-types-yes-gd4</guid>
      <description>&lt;p&gt;The smoke test for my little SSH key program is one command: clone a repository of mine over SSH. On a fresh machine, before anything is cloned, it asks this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The authenticity of host 'github.com (&amp;lt;IP address&amp;gt;)' can't be established.
ED25519 key fingerprint is SHA256:&amp;lt;key fingerprint&amp;gt;.
Are you sure you want to continue connecting (yes/no/[fingerprint])?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And I typed &lt;code&gt;yes&lt;/code&gt;. Like everyone does.&lt;/p&gt;

&lt;p&gt;This series has been about two things so far: keeping the passphrase out of &lt;code&gt;ps&lt;/code&gt;, and checking whether a tool uses your SSH at all. The second post leaned on one defence — the wall of &lt;code&gt;@@@&lt;/code&gt; OpenSSH throws when a server's key changes. This post is about the uncomfortable part: that wall protects you only if the key it's comparing against was the right one. And that key got there when you typed &lt;code&gt;yes&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trust on first use
&lt;/h2&gt;

&lt;p&gt;SSH's model has a name: TOFU, trust on first use. The first time you connect, the client has nothing to compare against, so it asks you. Whatever you accept goes into &lt;code&gt;known_hosts&lt;/code&gt;, and from then on any &lt;em&gt;change&lt;/em&gt; is an alarm.&lt;/p&gt;

&lt;p&gt;It's a reasonable design. The weak point is obvious: if someone is between you and the server on that first connection, you accept &lt;em&gt;their&lt;/em&gt; key, and from then on the alarm protects the impostor. Everything after the first &lt;code&gt;yes&lt;/code&gt; is only as good as the first &lt;code&gt;yes&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Most of the time this is theoretical. Then sometimes it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  March 2023
&lt;/h2&gt;

&lt;p&gt;On March 24, 2023, GitHub replaced its RSA SSH host key. The private half had been briefly exposed in a public repository; anyone who copied it could, in principle, pretend to be github.com to RSA clients.&lt;/p&gt;

&lt;p&gt;So every RSA user got the wall: &lt;code&gt;REMOTE HOST IDENTIFICATION HAS CHANGED&lt;/code&gt;. Exactly the alarm that's supposed to make you stop and think.&lt;/p&gt;

&lt;p&gt;GitHub published the new fingerprint and instructions. But the fix that spread fastest was the short one: &lt;code&gt;ssh-keygen -R github.com&lt;/code&gt;, connect again, type &lt;code&gt;yes&lt;/code&gt;. Which works — and quietly accepts whatever key answered that next connection, without comparing it to anything. The alarm did its job; the habit undid it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "verifying" actually means
&lt;/h2&gt;

&lt;p&gt;The question at the prompt isn't "do you want to connect". It's "does this fingerprint match the one you got some other way". The point is &lt;em&gt;some other way&lt;/em&gt; — a channel an attacker on your network path can't also fake.&lt;/p&gt;

&lt;p&gt;For the big hosts that channel exists. GitHub, GitLab and Bitbucket publish their host key fingerprints in their documentation, served over HTTPS. Don't take them from this post, by the way — a fingerprint in a blog post is exactly as trustworthy as the blog. Open the provider's docs page.&lt;/p&gt;

&lt;p&gt;Then there's a feature I'd used for years without noticing: &lt;strong&gt;you can paste the fingerprint instead of typing &lt;code&gt;yes&lt;/code&gt;.&lt;/strong&gt; Note the &lt;code&gt;[fingerprint]&lt;/code&gt; in the prompt. Copy the published value, paste it, and OpenSSH compares it for you. A mismatch is refused. No squinting at forty base64 characters.&lt;/p&gt;

&lt;p&gt;Already have hosts in &lt;code&gt;known_hosts&lt;/code&gt; and want to check them after the fact?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; github.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;prints the fingerprints of what you stored. Compare with the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;ssh-keyscan&lt;/code&gt; is not verification
&lt;/h2&gt;

&lt;p&gt;In the previous post I used &lt;code&gt;ssh-keyscan&lt;/code&gt; to plant a deliberately wrong key — that's what it's good for. It's also widely used for something it can't do: "trusting" a host in scripts and CI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keyscan github.com &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.ssh/known_hosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This downloads the key from whoever answered on port 22. If that's an impostor, you've just written the impostor's key down as trusted, automatically, with no prompt. It's the scripted version of typing &lt;code&gt;yes&lt;/code&gt; without reading.&lt;/p&gt;

&lt;p&gt;GitHub's own recovery instructions in 2023 did something better: they fetched the keys from the API over HTTPS —&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-L&lt;/span&gt; https://api.github.com/meta | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.ssh_keys | .[]'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'s/^/github.com /'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.ssh/known_hosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— which moves the trust to TLS, a channel you already rely on. Better still for CI: copy the published &lt;code&gt;known_hosts&lt;/code&gt; lines once, check them into the repository, and use that file. A pinned key changes only when you change it.&lt;/p&gt;

&lt;p&gt;Two settings worth knowing while you're there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;StrictHostKeyChecking accept-new&lt;/code&gt; — accept unknown hosts automatically, but still refuse &lt;em&gt;changed&lt;/em&gt; keys. A sane middle ground for automation.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;StrictHostKeyChecking no&lt;/code&gt; — accept everything, including changed keys. If you find it in a pipeline, that pipeline has no host verification at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing the series
&lt;/h2&gt;

&lt;p&gt;Three posts, three places the same thing goes wrong — a secret or a trust decision handled in a way that looks fine and isn't:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The passphrase&lt;/strong&gt; goes through &lt;code&gt;ssh-agent&lt;/code&gt; or &lt;code&gt;SSH_ASKPASS&lt;/code&gt;, never through argv.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The SSH client&lt;/strong&gt; is the one you configured — check that your tools read your config, check host keys, and use your agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first host key&lt;/strong&gt; is compared against a fingerprint from another channel, not accepted with &lt;code&gt;yes&lt;/code&gt; or fetched with &lt;code&gt;ssh-keyscan&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this needs new software. Everything in this series is already in OpenSSH — the askpass hook, the config, the fingerprint prompt, &lt;code&gt;accept-new&lt;/code&gt;. The defaults are good. &lt;strong&gt;The weak spot is almost never the protocol; it's the shortcut around it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As for my smoke test: it now starts by pasting the fingerprint.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ssh</category>
      <category>git</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your Git GUI Isn't Using Your SSH</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Mon, 12 Oct 2026 08:28:15 +0000</pubDate>
      <link>https://dev.to/den0011/your-git-gui-isnt-using-your-ssh-i67</link>
      <guid>https://dev.to/den0011/your-git-gui-isnt-using-your-ssh-i67</guid>
      <description>&lt;p&gt;In the previous post I was building a small GUI for creating SSH keys, and the first decision was how to pass a passphrase to &lt;code&gt;ssh-keygen&lt;/code&gt; without leaking it. The second decision came right after: should the program talk SSH itself, through a library, or just run the system &lt;code&gt;ssh&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;I chose the system binary. Not because the libraries are bad, but because of what I found when I looked at what "use a library" actually means.&lt;/p&gt;

&lt;h2&gt;
  
  
  "SSH" is two different things
&lt;/h2&gt;

&lt;p&gt;When you type &lt;code&gt;git clone git@github.com:...&lt;/code&gt; in a terminal, git runs &lt;code&gt;ssh&lt;/code&gt;, the OpenSSH client. That client reads &lt;code&gt;~/.ssh/config&lt;/code&gt;. It checks &lt;code&gt;~/.ssh/known_hosts&lt;/code&gt;. It asks your &lt;code&gt;ssh-agent&lt;/code&gt; for keys. It follows &lt;code&gt;ProxyJump&lt;/code&gt; through a bastion. All the behaviour you've tuned over years lives there.&lt;/p&gt;

&lt;p&gt;Many GUI clients, IDE plugins and deploy tools don't run that binary. They link an SSH library and speak the protocol themselves. The protocol is the same. The behaviour around it is whatever the application's authors wrote.&lt;/p&gt;

&lt;p&gt;Take libssh2, one of the common ones. It's a solid library, and it has the building blocks: a known-hosts API, an agent API, even security-key signing. But they're building blocks. &lt;strong&gt;Each OpenSSH behaviour you rely on becomes a feature somebody had to remember to implement.&lt;/strong&gt; And some aren't there at all — the library has no &lt;code&gt;ssh_config&lt;/code&gt; parser and no &lt;code&gt;ProxyJump&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The most telling line is in libssh2's own example program. Right after connecting, a comment says to check the host key against your known hosts — "that is your call". The example then prints the fingerprint and carries on.&lt;/p&gt;

&lt;p&gt;That's fine for an example. It's a problem when an application ships the same shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can quietly differ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Host key verification.&lt;/strong&gt; In the terminal, a changed server key stops you with a wall of &lt;code&gt;@@@&lt;/code&gt; and "REMOTE HOST IDENTIFICATION HAS CHANGED". That warning is the whole defence against someone sitting between you and the server. An embedded client might check its own store instead of &lt;code&gt;known_hosts&lt;/code&gt;, accept any new key silently, or not check at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your config.&lt;/strong&gt; &lt;code&gt;Host&lt;/code&gt; aliases, &lt;code&gt;IdentityFile&lt;/code&gt;, &lt;code&gt;IdentitiesOnly&lt;/code&gt;, &lt;code&gt;ProxyJump&lt;/code&gt;, algorithm restrictions — if the client doesn't read &lt;code&gt;~/.ssh/config&lt;/code&gt;, none of it applies, and nothing tells you. You hardened something; the tool doesn't know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where the passphrase lives.&lt;/strong&gt; OpenSSH hands that job to &lt;code&gt;ssh-agent&lt;/code&gt;: the key is unlocked once, the passphrase isn't stored anywhere. An application with its own stack may offer to "remember" the passphrase — in its own settings, encrypted with who knows what.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which keys work.&lt;/strong&gt; Newer key types and hardware keys (&lt;code&gt;ed25519-sk&lt;/code&gt;, &lt;code&gt;ecdsa-sk&lt;/code&gt;) work in OpenSSH long before every embedded stack catches up.&lt;/p&gt;

&lt;p&gt;On Windows there's a bonus version of this: Git for Windows ships its own &lt;code&gt;ssh.exe&lt;/code&gt;, separate from the OpenSSH built into Windows. Two clients, two configs in practice, two agents. A key added to one agent is invisible to the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own tools
&lt;/h2&gt;

&lt;p&gt;I'm not going to name clients here — versions change, and what I'd write today could be wrong next month. Instead, here's how to find out about the tools you actually use. Each check takes a minute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Does it read &lt;code&gt;~/.ssh/config&lt;/code&gt;?&lt;/strong&gt; Add an alias:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; gh-alias
    &lt;span class="k"&gt;HostName&lt;/span&gt; github.com
    &lt;span class="k"&gt;User&lt;/span&gt; git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ssh -T gh-alias&lt;/code&gt; should greet you. Now clone &lt;code&gt;gh-alias:you/some-repo.git&lt;/code&gt; in the GUI. If it fails with "could not resolve host", it isn't reading your config. (&lt;code&gt;ssh -G gh-alias&lt;/code&gt; shows exactly what OpenSSH would use, for comparison.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Does it check host keys?&lt;/strong&gt; This is the important one. Back up your &lt;code&gt;known_hosts&lt;/code&gt;, then put a &lt;em&gt;wrong&lt;/em&gt; key under github.com's name — GitLab's key will do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; ~/.ssh/known_hosts ~/.ssh/known_hosts.bak
ssh-keygen &lt;span class="nt"&gt;-R&lt;/span&gt; github.com
ssh-keyscan &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 gitlab.com 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/^gitlab.com/github.com/'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.ssh/known_hosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ssh -T git@github.com&lt;/code&gt; must now refuse loudly. Fetch from a GitHub repo in the GUI. If it connects without complaint, it would connect just as happily to an impostor. Restore afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; ~/.ssh/known_hosts.bak ~/.ssh/known_hosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Does it use your agent?&lt;/strong&gt; With a passphrase-protected key, empty the agent with &lt;code&gt;ssh-add -D&lt;/code&gt; and fetch in the GUI. If it connects without asking for the passphrase, it has the passphrase stored somewhere. Find out where.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do with the answer
&lt;/h2&gt;

&lt;p&gt;If a tool fails these checks, look for a setting before looking for a replacement. Many have an option like "use system SSH" or "use system Git", and plain git lets you choose the client outright with &lt;code&gt;core.sshCommand&lt;/code&gt; or &lt;code&gt;GIT_SSH_COMMAND&lt;/code&gt;. Tools that simply run your system &lt;code&gt;git&lt;/code&gt; are already using your system &lt;code&gt;ssh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For my own little program that settled it. Running the system &lt;code&gt;ssh&lt;/code&gt; means every check above passes by construction — not because I implemented them, but because I didn't have to. It also means one less library to track for security fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The safest SSH code is the SSH code you didn't write.&lt;/strong&gt; Before trusting a tool with your keys, spend three minutes checking whose SSH it's actually using.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ssh</category>
      <category>git</category>
      <category>linux</category>
    </item>
    <item>
      <title>ssh-keygen -N Puts Your Passphrase in ps</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Mon, 12 Oct 2026 08:19:46 +0000</pubDate>
      <link>https://dev.to/den0011/ssh-keygen-n-puts-your-passphrase-in-ps-2fnd</link>
      <guid>https://dev.to/den0011/ssh-keygen-n-puts-your-passphrase-in-ps-2fnd</guid>
      <description>&lt;p&gt;I'm writing a small GUI that walks a person through creating an SSH key. The core of it is one call to &lt;code&gt;ssh-keygen&lt;/code&gt;, and on paper that call is trivial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/id_ed25519 &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="s1"&gt;'correct horse battery staple'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user types a passphrase into a nice field, the program puts it after &lt;code&gt;-N&lt;/code&gt;, the key appears. Most scripts and wrappers I've looked at do exactly this.&lt;/p&gt;

&lt;p&gt;It's also the one way of doing it that I ruled out first.&lt;/p&gt;

&lt;h2&gt;
  
  
  argv is public
&lt;/h2&gt;

&lt;p&gt;Command-line arguments are not a private channel between you and the program you start. On Linux they live in &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/cmdline&lt;/code&gt;, and by default that file is readable by every user on the machine. &lt;code&gt;ps&lt;/code&gt; just prints it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ps &lt;span class="nt"&gt;-eo&lt;/span&gt; user,args | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'[s]sh-keygen'&lt;/span&gt;
alice  ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-f&lt;/span&gt; /home/alice/.ssh/id_ed25519 &lt;span class="nt"&gt;-N&lt;/span&gt; correct horse battery staple
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"But it only runs for a moment." A passphrase-protected key isn't instant to write — ssh-keygen runs a deliberately slow key derivation to encrypt it — and anyway "the window is short" is not a security property. A loop over &lt;code&gt;ps&lt;/code&gt; costs nothing to leave running.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;ps&lt;/code&gt; is only the most obvious place. The same string can end up in shell history if anyone ever runs the command by hand, in &lt;code&gt;auditd&lt;/code&gt; logs that record every &lt;code&gt;execve&lt;/code&gt; with its arguments, and in any process-monitoring agent that ships command lines to a dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A secret in argv has already left the process.&lt;/strong&gt; The question is only who reads it.&lt;/p&gt;

&lt;p&gt;(You can mount &lt;code&gt;/proc&lt;/code&gt; with &lt;code&gt;hidepid=2&lt;/code&gt; so users only see their own processes. It's a good setting. It's also not something a desktop app can assume about the machine it runs on.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to hand over a passphrase
&lt;/h2&gt;

&lt;p&gt;Once &lt;code&gt;-N&lt;/code&gt; is out, you need another channel. I found three approaches in the wild.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;-N&lt;/code&gt; in argv.&lt;/strong&gt; Covered above. Out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A pseudo-terminal.&lt;/strong&gt; Start ssh-keygen inside a pty, wait for it to ask, type the answer. This is what &lt;code&gt;expect&lt;/code&gt; scripts do, and it works. Two problems for me.&lt;/p&gt;

&lt;p&gt;The first is practical: Qt's &lt;code&gt;QProcess&lt;/code&gt; doesn't do pseudo-terminals, so I'd be writing &lt;code&gt;forkpty&lt;/code&gt; handling by hand.&lt;/p&gt;

&lt;p&gt;The second is worse. The program now depends on the &lt;em&gt;exact text&lt;/em&gt; of the prompt. And that text changes. On Ubuntu 22.04, which ships OpenSSH 8.9, key generation asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Enter passphrase (empty for no passphrase):
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Current OpenSSH asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Enter new passphrase (empty for no passphrase):
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One word. A wrapper that waits for the old string on a newer system just hangs, with no error, waiting for a question that has already been asked differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;SSH_ASKPASS&lt;/code&gt;.&lt;/strong&gt; This is the mechanism OpenSSH itself provides for exactly this case. You point the environment variable at a helper program; when ssh-keygen needs a passphrase, it runs the helper with the prompt as its only argument and reads the answer from the helper's stdout. Seahorse, GNOME's key manager, does it this way — it sets &lt;code&gt;SSH_ASKPASS&lt;/code&gt; to its own helper and calls &lt;code&gt;setsid()&lt;/code&gt; so the child has no controlling terminal to fall back on.&lt;/p&gt;

&lt;p&gt;There used to be a catch: ssh-keygen would only use the helper under certain conditions — a display present, no terminal to ask on. OpenSSH 8.4 added &lt;code&gt;SSH_ASKPASS_REQUIRE=force&lt;/code&gt;, which means "always use the helper, no matter what". Ubuntu 22.04 has 8.9, so for my target it's simply available.&lt;/p&gt;

&lt;p&gt;You can try it from a terminal with a one-line helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="c"&gt;# ~/bin/askpass-zenity&lt;/span&gt;
&lt;span class="nb"&gt;exec &lt;/span&gt;zenity &lt;span class="nt"&gt;--password&lt;/span&gt; &lt;span class="nt"&gt;--title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SSH_ASKPASS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/bin/askpass-zenity &lt;span class="nv"&gt;SSH_ASKPASS_REQUIRE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;force &lt;span class="se"&gt;\&lt;/span&gt;
  ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/id_demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A dialog pops up, twice: once for the passphrase, once to confirm it. Nothing in argv, nothing typed into a terminal you don't control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The helper has to get the passphrase from somewhere
&lt;/h2&gt;

&lt;p&gt;This is the part that's easy to get wrong after getting everything else right.&lt;/p&gt;

&lt;p&gt;If your app already collected the passphrase in its own window and now launches the helper as &lt;code&gt;askpass-helper 'correct horse...'&lt;/code&gt;, you've moved the secret from ssh-keygen's argv to the helper's argv. Same &lt;code&gt;ps&lt;/code&gt;, same leak, one process later.&lt;/p&gt;

&lt;p&gt;The cleanest version is the one Seahorse uses: &lt;strong&gt;the helper is the dialog.&lt;/strong&gt; It asks the user directly and prints the answer. The passphrase never passes through the parent process at all — your app doesn't hold it, log it, or forward it.&lt;/p&gt;

&lt;p&gt;If the design really requires the main window to collect it, hand it over through something that isn't world-readable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A pipe or a Unix socket&lt;/strong&gt; in a directory only your user can access, with the path in an environment variable. The helper connects, reads, prints, exits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An environment variable with the passphrase itself&lt;/strong&gt; is the fallback. It's not great, but it's a different class of exposure: &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/environ&lt;/code&gt; is readable only by the process owner and root, unlike &lt;code&gt;cmdline&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever you pick, remember the helper is called twice for a new key — once per prompt — and has to answer both.&lt;/p&gt;

&lt;h2&gt;
  
  
  It isn't about ssh-keygen
&lt;/h2&gt;

&lt;p&gt;ssh-keygen just happens to be where I met it. The same shape shows up everywhere a tool accepts a secret as an argument: &lt;code&gt;mysql -p&amp;lt;password&amp;gt;&lt;/code&gt;, &lt;code&gt;sshpass -p&lt;/code&gt;, &lt;code&gt;curl -u user:password&lt;/code&gt;. All of them have a quieter alternative — a config file, an environment variable, a prompt, a helper — and all of them still appear in scripts with the secret inline, because the inline version is the one that fits on one line in a README.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If a secret is on the command line, assume it's in a log.&lt;/strong&gt; Then find the channel the tool's authors built for the case where it shouldn't be — there almost always is one.&lt;/p&gt;

</description>
      <category>security</category>
      <category>linux</category>
      <category>ssh</category>
      <category>programming</category>
    </item>
    <item>
      <title>My News Digest Got a Telegram Channel That Forgets Everything After a Week</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Thu, 08 Oct 2026 16:36:36 +0000</pubDate>
      <link>https://dev.to/den0011/my-news-digest-got-a-telegram-channel-that-forgets-everything-after-a-week-1o14</link>
      <guid>https://dev.to/den0011/my-news-digest-got-a-telegram-channel-that-forgets-everything-after-a-week-1o14</guid>
      <description>&lt;p&gt;A while back I &lt;a href="https://dev.to/den0011/i-built-a-free-ai-digest-for-science-and-tech-news-111o"&gt;posted here about RealFeedApp&lt;/a&gt;, a free digest that collects science and tech news, collapses the retellings into one card, ranks by significance and writes a short summary in your language. Last time it &lt;a href="https://dev.to/den0011/a-good-summary-still-left-me-guessing-12ci"&gt;learned to explain itself&lt;/a&gt;: every card got three plain-language lines under the summary.&lt;/p&gt;

&lt;p&gt;This post is about what happened since. The headline is a Telegram channel, and the most interesting thing about it is a setting: &lt;strong&gt;every post deletes itself after a week.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The first version was a wall of text
&lt;/h2&gt;

&lt;p&gt;The idea was obvious. Search engines take months to notice a new site; a channel reaches people the day you post. So the first version did the obvious thing: once a day, the five top stories of the last 24 hours, as one message. Headline, summary, link to the original, repeat five times.&lt;/p&gt;

&lt;p&gt;It worked, technically. I opened the first post in the channel and was disappointed.&lt;/p&gt;

&lt;p&gt;It was a block of text. No pictures. None of the "in plain words" lines the site had just learned to write. No tags, no sense of which field a story belonged to. And every link went straight to the original source, so the site — the thing that actually does the work — was nowhere in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nobody reposts a wall of text.&lt;/strong&gt; You can't forward the one story you care about; it's glued to four others. And a reader who skims it learns nothing they couldn't have got from any aggregator.&lt;/p&gt;

&lt;p&gt;The lesson was simple and slightly embarrassing: I had built a channel that showed less than the product it was supposed to advertise.&lt;/p&gt;

&lt;h2&gt;
  
  
  One story, one post, the whole card
&lt;/h2&gt;

&lt;p&gt;So the channel now posts the way the site shows things. One story per message, spread through the day instead of a morning dump. Each post is the card:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;cover image&lt;/strong&gt; from the site;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;headline&lt;/strong&gt; and the &lt;strong&gt;summary&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;"in plain words" block&lt;/strong&gt; — the field as a tag, &lt;em&gt;what was done&lt;/em&gt;, &lt;em&gt;why it matters&lt;/em&gt; — set off from the summary the way it is on the card;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tags&lt;/strong&gt; you can tap through within the channel;&lt;/li&gt;
&lt;li&gt;a link to the story's &lt;strong&gt;page on the site&lt;/strong&gt;, and from there to the original;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;same story in Russian&lt;/strong&gt;, folded into a collapsed quote at the very bottom.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The link to the site matters most. A post is now a preview of a card, not a replacement for it. If the story is yours, you forward that one post. If you want more, you land on the site, where the rest of the feed is.&lt;/p&gt;

&lt;p&gt;Two small rules came out of looking at real posts before they went out. A story whose translation came out broken doesn't get posted at all — a broken word in a feed is a bug; in a public post it's a broken word in front of every subscriber at once. And the channel leans towards news over dense preprints: the feed is for people who opted in to a field; the channel is for people who just followed a link.&lt;/p&gt;

&lt;h2&gt;
  
  
  One channel, two languages
&lt;/h2&gt;

&lt;p&gt;The site writes every summary in English and Russian, but the first channel was Russian only. That cut out half the readers. Two channels would have split the subscribers instead, and doubled the posting for a project run by one person.&lt;/p&gt;

&lt;p&gt;So each post is now an English card with the Russian version inside it, as a collapsed quote: one line and an arrow until you tap it. An English reader never scrolls past Russian text; a Russian reader is one tap away from the full card in their language, with its own link to the Russian page of the story.&lt;/p&gt;

&lt;p&gt;Fitting two languages into one post had a side effect. A photo caption in Telegram is short, and one card already filled it. So posts went from "photo with a caption" to a text message with the cover shown as a large preview above it. It looks the same, and there is room for both languages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the posts disappear
&lt;/h2&gt;

&lt;p&gt;Here's the setting I mentioned at the top: &lt;strong&gt;the channel's messages auto-delete after a week.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It sounds like throwing work away. It's the opposite.&lt;/p&gt;

&lt;p&gt;A channel that keeps everything slowly becomes the product. Scroll back far enough and it's a second, worse feed: no search, no topics, no "hide this", no memory of what you liked, and nothing that gets better the more you use it. If that archive grows forever, it starts competing with the site and the app — and it wins on convenience while losing on everything else.&lt;/p&gt;

&lt;p&gt;So the channel is a window, not a home. It shows what's happening this week, and points at the place where the rest lives. A week-old science headline is stale anyway; anything that still matters is on the site.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else is new
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;RSS.&lt;/strong&gt; Every story that goes to the channel also goes into an RSS feed: &lt;a href="https://realfeedapp.com/en/rss.xml" rel="noopener noreferrer"&gt;/en/rss.xml&lt;/a&gt; and &lt;a href="https://realfeedapp.com/ru/rss.xml" rel="noopener noreferrer"&gt;/ru/rss.xml&lt;/a&gt;. The footer has a one-click "Follow on Feedly" link. If you'd rather not use Telegram at all, it's the same stream of hand-picked stories in your reader.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bluesky.&lt;/strong&gt; The same stories go to &lt;a href="https://bsky.app/profile/realfeedapp.com" rel="noopener noreferrer"&gt;@realfeedapp.com&lt;/a&gt; as short threads: the summary with a link card to the story page, then what they did and why it matters as replies. English only — that's where a lot of the science crowd moved, and topic feeds there show posts beyond your followers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Story pages.&lt;/strong&gt; Every story now has its own page on the site: cover, summary, the plain-words block, tags, and a clear button to the original. That's where channel posts lead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A daily archive.&lt;/strong&gt; Each finished day gets a page with its top stories, open to everyone, no account needed — and an archive that lists the days. If you missed a week, that's the fastest way to catch up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Links in your language.&lt;/strong&gt; The feed now has its own address per language (&lt;code&gt;/en/&lt;/code&gt;, &lt;code&gt;/ru/&lt;/code&gt;, …). Share the Russian version with a friend and they get the Russian version, regardless of what their browser says.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keywords from a vocabulary.&lt;/strong&gt; Your own keywords are no longer typed in free text; you pick them from the terms the feed actually uses, and the screen explains what they change. Free text looked flexible and mostly matched nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A quieter, more honest daily digest in the app.&lt;/strong&gt; The notification gives you one reason to open it instead of a list nobody can read in a notification shade; the app tells you when the next one arrives; and the last digest can be reopened from settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A cleaner feed.&lt;/strong&gt; Threads and discussions no longer show up as news — nothing &lt;em&gt;happens&lt;/em&gt; in them, someone just talks about something that did. A story is filed by what it's about, not by where it came from. And a headline that says the opposite of its own summary, or names something the article never mentions, doesn't reach the feed.&lt;/p&gt;

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

&lt;p&gt;Still free.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The site: &lt;a href="https://realfeedapp.com" rel="noopener noreferrer"&gt;realfeedapp.com&lt;/a&gt; — a preview of the feed with no account; signing in with Google opens the rest and remembers your topics.&lt;/li&gt;
&lt;li&gt;The channel: &lt;a href="https://t.me/realfeedapp" rel="noopener noreferrer"&gt;t.me/realfeedapp&lt;/a&gt; — English cards, with a Russian version inside each post.&lt;/li&gt;
&lt;li&gt;Bluesky: &lt;a href="https://bsky.app/profile/realfeedapp.com" rel="noopener noreferrer"&gt;@realfeedapp.com&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;RSS: &lt;a href="https://realfeedapp.com/en/rss.xml" rel="noopener noreferrer"&gt;realfeedapp.com/en/rss.xml&lt;/a&gt;, or "Follow on Feedly" in the site's footer.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
