<?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>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>
    <item>
      <title>My News Digest Got a Telegram Channel That Forgets Everything After a Week</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Mon, 05 Oct 2026 12:59:31 +0000</pubDate>
      <link>https://dev.to/den0011/my-news-digest-got-a-telegram-channel-that-forgets-everything-after-a-week-518b</link>
      <guid>https://dev.to/den0011/my-news-digest-got-a-telegram-channel-that-forgets-everything-after-a-week-518b</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;/ul&gt;

&lt;p&gt;That last change 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;
  
  
  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;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;. It's in Russian for now, since that's where Telegram is strongest for me; the site and the daily archive are in English too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things I'd like to hear:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Would you forward one of these posts?&lt;/strong&gt; That's the whole test for the new format, and I can't run it on myself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is a week the right lifetime?&lt;/strong&gt; Shorter makes the channel feel like a ticker; longer and it starts turning into the feed I was trying not to build.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>A Good Summary Still Left Me Guessing</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Sun, 13 Sep 2026 10:46:44 +0000</pubDate>
      <link>https://dev.to/den0011/a-good-summary-still-left-me-guessing-12ci</link>
      <guid>https://dev.to/den0011/a-good-summary-still-left-me-guessing-12ci</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.&lt;/p&gt;

&lt;p&gt;It worked. I stopped drowning. And then I noticed I was doing something odd with it: I'd read a summary, understand every word of it, and still have no idea whether the thing mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a summary doesn't do
&lt;/h2&gt;

&lt;p&gt;A summary compresses an article. It cannot supply the thing the article assumed you already had.&lt;/p&gt;

&lt;p&gt;Every piece of technical writing is addressed to someone. A paper on gene therapy is written for people who know what a vector is; a post about attention layers is written for people who have already met attention. The author skips the setup, and they're right to — their reader has it.&lt;/p&gt;

&lt;p&gt;Compress that article faithfully and you get a shorter text with the same hole in it. Here's the shape of it, with the details changed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Researchers report that the modified vector achieved durable expression in the target tissue without the immune response seen in earlier trials.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every word true, nothing wasted. And if that isn't your field, you've learned nothing you can act on. Is this a lab curiosity or the thing the field has been stuck on for a decade? The summary can't say, because the article never said — the article was talking to someone who knew.&lt;/p&gt;

&lt;p&gt;This is the actual cost of reading widely. Not volume, which is what everyone builds for. &lt;strong&gt;Volume is solved by filtering. Context is not, because the missing part was never in the text you filtered.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's new: three lines under the summary
&lt;/h2&gt;

&lt;p&gt;So every card now carries a second block, under the summary and behind a divider. Three labelled rows, no heading:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Field&lt;/strong&gt; — a short tag: &lt;em&gt;how neural networks work&lt;/em&gt;, &lt;em&gt;gene therapy&lt;/em&gt;, &lt;em&gt;hardware and chips&lt;/em&gt;, &lt;em&gt;energy and batteries&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What was done&lt;/strong&gt; — the concrete thing, in ordinary words.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters&lt;/strong&gt; — what changes if it holds up.&lt;/p&gt;

&lt;p&gt;Same three rows on every card, in the same order, in your language. The point isn't more text. The point is that the same three questions get answered every time, so you can skim the third line across ten cards and stop at the one that deserves you.&lt;/p&gt;

&lt;p&gt;A few decisions inside it that changed how it reads:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The field is a tag, not a sentence.&lt;/strong&gt; It comes from a fixed list of areas, so the same subject is worded identically on every card. That's what makes it scannable — and it's why it can become a filter later. Free-form text would have been easier to build and useless the first time the same field got named two different ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No heading above the block.&lt;/strong&gt; It's three labelled rows; the labels already say what it is. A heading would have eaten the space the explanation needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't appear where there's nothing to explain.&lt;/strong&gt; If the linked page had no article on it — a video, a page that's really an app, a PDF that yields nothing — there's no block. There's also no card: pages with nothing to read stopped reaching the feed at all, which fixed a smaller embarrassment I'd been shipping for a while. Summaries of &lt;em&gt;"this page requires JavaScript"&lt;/em&gt; are, it turns out, very fluent.&lt;/p&gt;

&lt;p&gt;That last rule matters more than the feature. &lt;strong&gt;An explanation of nothing is worse than no explanation&lt;/strong&gt;, because it looks exactly like the real thing and you can't tell without opening the link — which is the one thing the card exists to save you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;It doesn't read the article for you. Three lines are enough to decide, not enough to know, and the link to the original is still the whole point of the card.&lt;/p&gt;

&lt;p&gt;In your own field it will occasionally explain something you learned a decade ago. That's the trade: the block is written for the reader who wandered in from three fields over, and I'd rather it be redundant for the specialist than absent for everyone else.&lt;/p&gt;

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

&lt;p&gt;Still free. Open the link and you get a preview of the feed with no account at all; signing in with Google opens the rest of it and remembers your topics. &lt;a href="https://realfeedapp.com" rel="noopener noreferrer"&gt;realfeedapp.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Android app is in closed testing; it'll reach Google Play once I'm confident it behaves.&lt;/p&gt;

&lt;p&gt;Two things I'd like to hear if you open it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does the third line earn its space?&lt;/strong&gt; "Why it matters" is the row I'm least sure about — it's the one where an honest &lt;em&gt;we don't know yet&lt;/em&gt; would be more useful than a confident sentence, and I don't know yet whether it's doing that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where does the field tag fit badly?&lt;/strong&gt; The list of areas is fixed, so somewhere there's a story it can only be wrong about. I'd like to know which one before a reader has to.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The Float Is a Liveness Probe: Four Failure Modes</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Thu, 10 Sep 2026 19:53:35 +0000</pubDate>
      <link>https://dev.to/den0011/the-float-is-a-liveness-probe-four-failure-modes-4g3k</link>
      <guid>https://dev.to/den0011/the-float-is-a-liveness-probe-four-failure-modes-4g3k</guid>
      <description>&lt;p&gt;I cook pelmeni about twice a week, and for years I got inconsistent results from what is, on paper, a trivial process: put frozen parcels in boiling water, take them out when done. Two failure modes kept recurring — parcels welded to the bottom of the pot, and parcels that burst and leaked their filling into the water.&lt;/p&gt;

&lt;p&gt;Both turned out to be the same class of mistake I make at work, which is why I'm writing this down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure one: the batch was too large
&lt;/h2&gt;

&lt;p&gt;Dropping a large batch of frozen parcels into boiling water stops the boil. The water is at 100°C, the parcels are at −18°C, and there's only so much thermal energy in the pot. The temperature drops below boiling, the surface starch doesn't set, and the dough touches the metal while it's still sticky. That's the welding.&lt;/p&gt;

&lt;p&gt;I had been treating pot capacity as a volume question — will they fit — when it's actually a throughput question: how much cold can this system absorb before it stops doing the thing it's for. A pot that fits thirty parcels comfortably processes twenty of them correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capacity is not the same as the load at which quality holds.&lt;/strong&gt; You can size a worker pool by what fits in memory and still be wrong, for exactly this reason: the system keeps accepting work long after it stopped doing it well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure two: I trusted the wrong signal
&lt;/h2&gt;

&lt;p&gt;Everyone knows the rule: pelmeni float when they're done. Everyone is wrong, and I was wrong for a long time.&lt;/p&gt;

&lt;p&gt;They float when the air and steam trapped inside expand enough to make the parcel buoyant. That happens when the &lt;em&gt;dough&lt;/em&gt; is hot. It says nothing about the filling, which is denser, colder, and at the centre of the parcel — the last part of the system to get warm. Pull them at the moment they surface and the outside is finished while the inside is raw.&lt;/p&gt;

&lt;p&gt;Floating is a &lt;strong&gt;liveness&lt;/strong&gt; signal. It tells you the process is running. It is not a &lt;strong&gt;readiness&lt;/strong&gt; signal, and the difference between those two is exactly the difference between "the container started" and "the container can serve traffic". Confusing them ships raw meat.&lt;/p&gt;

&lt;p&gt;The actual readiness signal is time &lt;em&gt;after&lt;/em&gt; the liveness signal — a few minutes at a gentle boil once they've surfaced. Which is an unsatisfying answer, because it's a timer, and everybody wants an event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When you can't observe the thing you care about, you time it from the last thing you can.&lt;/strong&gt; That's not a workaround, that's the design. The mistake is pretending the observable proxy is the real state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure three, which I still make
&lt;/h2&gt;

&lt;p&gt;The lid. A lid gets you back to boiling faster after the cold batch goes in, which is good, and then the starch-laden foam climbs the sides and comes over the edge, which is not.&lt;/p&gt;

&lt;p&gt;The correct move is to leave the lid slightly ajar — you sacrifice some of the recovery rate to keep the process inside the pot. This is a backpressure trade and it has the same shape everywhere: maximum throughput and stability are different set points, and choosing throughput means you will eventually clean the stove.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the burst parcels were really telling me
&lt;/h2&gt;

&lt;p&gt;The leaking-filling failure isn't overcooking in the ordinary sense. It's what happens when a parcel stays under a hard rolling boil: mechanical agitation, not heat, tears the seam. The fix isn't less time, it's less turbulence — drop the heat once the parcels are in and let it finish at a bare simmer.&lt;/p&gt;

&lt;p&gt;I had been tuning the wrong parameter for months because the symptom appeared at the end of the process, so I assumed it belonged to the end of the process. It belonged to the environment the whole way through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failure that shows up last isn't caused last.&lt;/strong&gt; Applies to dinner and to production incidents in roughly equal measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure four: I was salting for the wrong reason
&lt;/h2&gt;

&lt;p&gt;I used to salt the water for flavour. It does that, but the more important effect is thermal: dissolved salt raises the boiling point substantially, so a well-salted pot runs hotter than a plain one and gets you through the cold-batch recovery noticeably faster. Two tablespoons per litre is roughly where the gain stops being worth the taste.&lt;/p&gt;

&lt;p&gt;This is the one that changed my routine the most, because it means the salt goes in &lt;em&gt;before&lt;/em&gt; the water boils rather than with the parcels.&lt;/p&gt;




&lt;p&gt;So: batch smaller than capacity, don't trust the float, lid ajar, simmer not boil, salt early.&lt;/p&gt;

&lt;p&gt;One of the four claims above is wrong — confidently stated, cheerfully believed by me for years, and demonstrably false. I know which one. I'm curious whether anyone catches it, and whether this kind of thinking about cooking is a useful frame or just professional deformation. &lt;strong&gt;What do you think?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>watercooler</category>
      <category>discuss</category>
      <category>devops</category>
    </item>
    <item>
      <title>Language Models and Languages: Why "Multilingual" Is an Illusion of Equality</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Thu, 10 Sep 2026 15:24:46 +0000</pubDate>
      <link>https://dev.to/den0011/language-models-and-languages-why-multilingual-is-an-illusion-of-equality-59ii</link>
      <guid>https://dev.to/den0011/language-models-and-languages-why-multilingual-is-an-illusion-of-equality-59ii</guid>
      <description>&lt;p&gt;When people see ChatGPT or Claude answer fluently in Swahili or Thai, the natural impression is that the model "knows" all languages equally well. It doesn't — and the gap isn't cosmetic. It runs straight through the architecture of how these models read and process text. What follows is a breakdown of where that gap actually comes from, how large it is, and what's being done about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The problem starts before the model "thinks" — at tokenization
&lt;/h2&gt;

&lt;p&gt;An LLM never reads text directly. Text is first cut into pieces (tokens) by a tokenizer, and only those tokens are what the model actually processes. Tokenizers are trained on corpora where English historically dominates — in Llama 3's training data, for instance, English and code made up 95% of the dataset, while every other language combined accounted for just 5%.&lt;/p&gt;

&lt;p&gt;Because of this skew, the tokenizer ends up "efficient" for English and noticeably less efficient for everything else: models generate text one token at a time, and for languages poorly represented in the tokenizer's vocabulary, this makes generation slower and more computationally expensive.&lt;/p&gt;

&lt;p&gt;How big is the gap in practice? One study covering 25 European languages and ten models put a concrete number on it: tokenization "fertility" ranges from roughly 1.2 tokens per word for English to roughly 3.1 for Greek and Maltese — a penalty of about 2.5x. Notably, the ranking of "efficient" vs. "inefficient" languages barely changes regardless of register — formal or casual text — with a correlation above 0.97 across styles.&lt;/p&gt;

&lt;p&gt;A similar picture shows up for languages using a different script. A comparison of several tokenizers on equivalent Polish and English text found that where Polish required 547 tokens, English needed only 377 on the same model (Mistral Small 3.2) — and this gap repeats across most tested models.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Why this isn't just about speed — it's about money and quality
&lt;/h2&gt;

&lt;p&gt;Tokenization inefficiency isn't only "responses in non-English are slower." Three effects stack on top of each other:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost.&lt;/strong&gt; Since API pricing is billed per token, not per character, the same amount of meaning costs literally more in a "token-expensive" language. One breakdown describes a real case: a team running multilingual support saw its API bill triple in two weeks with no change to the prompt — the Brazilian and Indonesian user share simply grew, and their requests tokenized at 1.6x the per-character cost of English under the older vocabulary. Switching tokenizers cut the per-character token cost for those languages by 35% — without touching the model or the prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context window.&lt;/strong&gt; If the same meaning takes several times more tokens in another language, a model with a fixed context limit (say, 128k tokens) effectively fits much less real text for that language — less of a document, less conversation history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Answer quality.&lt;/strong&gt; This isn't just a "more expensive" effect — it's also a "worse" effect. There's direct evidence of a correlation: the higher a language's tokenization compression ratio, the lower the model's task accuracy in that language. In other words, inefficient tokenization isn't a neutral side effect — it correlates with a real drop in quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The "reasoning" gap: where the ability to think actually gets lost
&lt;/h2&gt;

&lt;p&gt;Here's where it gets more interesting than a simple "more tokens, therefore worse."&lt;/p&gt;

&lt;p&gt;One key study (the HRM8K benchmark, Korean and English) tested the hypothesis head-on: maybe it's not tokenization, maybe the model simply "reasons" worse in non-English? The answer turned out to be more specific: the performance gap stems primarily from difficulty comprehending non-English input, not from a limitation in the reasoning capability itself. The authors also directly challenged an earlier recommendation to force the model to reason in English regardless of the question's language — it turns out models are heavily influenced by the language of the input itself, not just the language of the reasoning chain.&lt;/p&gt;

&lt;p&gt;A similar picture shows up in domain-specific benchmarks. On an Irish-language benchmark built from real exam questions, the best-performing model scored 76.2% accuracy in English versus 55.8% in Irish, and produced a valid Irish response (one that didn't drift into English or break the language) less than 80% of the time.&lt;/p&gt;

&lt;p&gt;One nuance that's often missed: the performance gap isn't uniform across task types. Evidence suggests the gap is especially pronounced in reasoning tasks dominated by high-resource languages (English foremost), but this pattern doesn't hold for factual questions or broader real-world user queries. A model might know the capital of Thailand perfectly well in Thai, but noticeably struggle with a multi-step logical problem in the same language.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. What happens inside the model when it switches languages
&lt;/h2&gt;

&lt;p&gt;Worth approaching this from another angle — not through output benchmarks, but through what happens inside the model during generation. Anthropic has direct data on this, obtained through interpretability methods (an "AI microscope" — a way of looking directly at a model's internal activations).&lt;/p&gt;

&lt;p&gt;The key finding isn't about English as an "internal language of thought," but something more abstract: when analyzing how the model processes opposite-meaning words across languages, researchers found that the same internal features for "smallness" and "oppositeness" activate regardless of which language the input text is in. In other words, the model develops a shared, language-independent conceptual space, and any specific language is more of a "surface encoding" layered on top of that shared representation.&lt;/p&gt;

&lt;p&gt;This doesn't mean all languages are represented equally well in that space — the shared space exists, but the quality of "access" to it through a given language still depends on how richly that language was represented during training. It's important not to conflate the two: the existence of a shared conceptual space explains why the model can transfer knowledge across languages at all, but it doesn't erase the fact that input in a less-represented language still passes through a noisier, less precise "channel" into that space.&lt;/p&gt;

&lt;p&gt;A separate, fairly unexpected finding from the same Anthropic research concerns not capability but behavior: they found that the values the model expresses shift systematically depending on the language of the conversation — for example, in English the model leans toward caution, rigor, depth, and candor, while in Arabic it leans toward deference, warmth, brevity, and task execution. This isn't about intelligence — it's that the model's "character" isn't language-neutral at all. Training data in different languages carries different cultural and stylistic norms, and the model absorbs them differently for each language.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A busted myth: "write your prompt in X, it's cheaper and more efficient"
&lt;/h2&gt;

&lt;p&gt;Developer communities occasionally circulate advice like "write code-generation prompts in Chinese — it saves up to 40% in tokens." A recent study tested this directly on a real engineering benchmark (SWE-bench Lite) across several models and got an unambiguous result: the claimed Chinese token efficiency advantage did not hold up.&lt;/p&gt;

&lt;p&gt;This is a useful lesson on its own: the intuition that a "more compact" script (logographic characters versus Latin letters) should automatically be more efficient for a model doesn't hold. A tokenizer isn't just a character counter — it's a separately trained system with its own quirks, and you can't predict its behavior by eye without actually measuring it.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Where the fix is heading
&lt;/h2&gt;

&lt;p&gt;Two directions stand out as attempts to solve this at the architecture level rather than patch it after the fact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dropping tokenization altogether.&lt;/strong&gt; Meta's Byte Latent Transformer doesn't tokenize input into discrete vocabulary IDs at all — instead it dynamically groups bytes into variable-length patches during training, with patch boundaries set by an entropy model. At comparable compute, an 8B-parameter BLT model matched a BPE-based Llama 3 8B baseline on standard benchmarks, while handling typos, code, and low-resource languages noticeably better. The point is that if a model has no privileged token vocabulary built on an English corpus in the first place, the structural inequality between languages disappears at the architecture level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Targeted post-training instead of architectural change.&lt;/strong&gt; A more practical — and far cheaper — path is not rebuilding the model from scratch, but fine-tuning it specifically to close this gap. One approach (UST — Understand, Solve, Translate) uses English as an anchor language for reasoning and solution generation, and on a synthetic dataset of 130,000 examples achieved a 10.91% improvement on the HRM8K benchmark, cutting the cross-language gap from 11.6% down to 0.7%. Industry analysis backs this up: tokenizer inefficiency and English-centric reasoning account for 70-80% of model failures in non-English languages, and targeted post-training looks like the most cost-effective fix compared to a full architectural overhaul or scaling up model size.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means in practice
&lt;/h2&gt;

&lt;p&gt;If you're building a product for a multilingual audience — whether that's RealFeedApp serving news in multiple languages, or any other LLM-backed service — the language gap needs to be designed into the architecture up front, not filed under "minor localization detail":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Budget by tokens, not characters&lt;/strong&gt;, separately for each target language — the cost for the same amount of meaning can differ by 2-3x&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't rely on "smart" default tokenization&lt;/strong&gt; — the model/tokenizer choice for a multilingual product should be explicitly tested against your target languages, not assumed to be equally good everywhere just because the model is labeled multilingual&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For tasks requiring multi-step reasoning&lt;/strong&gt; (not facts, but logic, math, planning) in lower-resource languages, expect a noticeably bigger quality drop than you'd see on simple factual questions in the same language&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;English as a bridge language&lt;/strong&gt; is a proven, working pattern (translate → reason in English → translate back), but it adds latency and cost, and it isn't right for every case — where cultural specificity in the response matters, round-tripping through English can flatten it out&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tokenizer is the least visible part of an LLM application's stack, which is exactly why it's the easiest thing to inherit unexamined along with your model choice. In practice, it's arguably the single most consequential hidden factor in how well your product performs outside of English.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources and further reading:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anthropic, "How Claude's values vary by model and language" (&lt;a href="https://www.anthropic.com/research/claude-values-models-languages" rel="noopener noreferrer"&gt;research&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>nlp</category>
      <category>cloud</category>
      <category>openai</category>
    </item>
    <item>
      <title>One API, Three Clients, and the Validation Rule That Only One of Them Obeyed</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Wed, 09 Sep 2026 16:33:53 +0000</pubDate>
      <link>https://dev.to/den0011/one-api-three-clients-and-the-validation-rule-that-only-one-of-them-obeyed-47b3</link>
      <guid>https://dev.to/den0011/one-api-three-clients-and-the-validation-rule-that-only-one-of-them-obeyed-47b3</guid>
      <description>&lt;p&gt;The validation logic had been solid for months. Pydantic schema on the request, a service-layer check for the actual business rule, tests green across the board. Then I added a CLI tool that talked to the same API the web app used, and within a week a support ticket came in: a record existed in the database that should have been structurally impossible.&lt;/p&gt;

&lt;p&gt;It wasn't a validation bug in the sense of "the code checks the wrong thing." The check was correct. It just wasn't &lt;em&gt;reachable&lt;/em&gt; by every path that could create that record.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Three clients hit one FastAPI backend: a React web app, a mobile app, and a small internal CLI I'd written for bulk imports. All three eventually call the same endpoint:&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="nd"&gt;@router.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;OrderOut&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderCreate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_order_service&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&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;order&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;OrderCreate&lt;/code&gt; is a Pydantic model. The service checks stock, applies discount rules, all the things I described in my last post on &lt;a href="https://dev.to/den0011/how-i-structure-a-fastapi-service-layers-di-and-where-validation-actually-belongs-i8m"&gt;how I structure a FastAPI service&lt;/a&gt;. On paper, every client goes through the same endpoint, so every client gets the same validation. That was the assumption I hadn't actually tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the assumption broke
&lt;/h2&gt;

&lt;p&gt;The CLI tool didn't call &lt;code&gt;/orders&lt;/code&gt;. It called a lower-level &lt;code&gt;/orders/bulk&lt;/code&gt; endpoint I'd added later for the import job, because bulk creation needed to skip some per-request overhead and batch the database writes. I wrote &lt;code&gt;/orders/bulk&lt;/code&gt; fast, under deadline, and had it call &lt;code&gt;OrderService.create&lt;/code&gt; directly for each row — except I'd copy-pasted an earlier version of the service before a discount-rule fix had landed. The endpoint had its own &lt;code&gt;BulkOrderCreate&lt;/code&gt; schema, structurally similar to &lt;code&gt;OrderCreate&lt;/code&gt;, validated by Pydantic just as strictly. It looked, at the schema level, exactly as safe as the main endpoint.&lt;/p&gt;

&lt;p&gt;The gap wasn't in &lt;em&gt;what&lt;/em&gt; got validated. It was in &lt;em&gt;which code path&lt;/em&gt; validated it. Two endpoints, two services (one stale), one business rule that existed in only one of them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/orders        → OrderService.create()       (current rules)
/orders/bulk   → OrderService.create() [old]  (missing a rule added later)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing here shows up in a Pydantic schema diff, because both schemas were fine. It doesn't show up in a service unit test either, unless you happen to test both call sites against the same rule — and I hadn't, because I'd been thinking of "the service" as one thing when it was actually two copies that had quietly diverged.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual failure mode
&lt;/h2&gt;

&lt;p&gt;Multi-client APIs don't usually break validation by having a client skip it outright — most frameworks make that hard to do by accident. They break it by having &lt;em&gt;a second path&lt;/em&gt; to the same write, added later, under different pressure (performance, batch size, a deadline), that re-implements or re-imports the logic instead of routing through the one place it's defined.&lt;/p&gt;

&lt;p&gt;Each new client is a reason to add a new endpoint shape: the mobile app wants a lighter payload, the CLI wants batching, an internal script wants to skip auth. Every one of those is a legitimate reason. None of them is a reason to duplicate the rule that decides whether the write is &lt;em&gt;allowed&lt;/em&gt; — only the rule that decides how the request is &lt;em&gt;shaped&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually fixed it
&lt;/h2&gt;

&lt;p&gt;The fix wasn't more validation. It was fewer entry points to the rule:&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;class&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderCreate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_create_validated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_bulk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OrderCreate&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="c1"&gt;# same rule, batched — not a second copy of the rule
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_create_validated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&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;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_create_validated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderCreate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has_stock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;InsufficientStockError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/orders/bulk&lt;/code&gt; now calls &lt;code&gt;create_bulk&lt;/code&gt;, which funnels every item through the exact same &lt;code&gt;_create_validated&lt;/code&gt;, not a re-implementation of it. The batching problem — do the writes efficiently — got solved without touching the validation problem — is this write allowed. They'd been tangled together in the original bulk endpoint because both were solved at the same time under the same deadline, which is usually when this kind of split happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I ask now
&lt;/h2&gt;

&lt;p&gt;When I add a new endpoint for a new client, I ask one thing before writing it: &lt;em&gt;is this a new way to shape the request, or a new way to reach the write?&lt;/em&gt; If it's the former, a new schema and a thin adapter into the existing service is fine. If it's the latter — if I'm about to write &lt;code&gt;_create&lt;/code&gt; a second time because the existing one doesn't quite fit — that's the moment the rule needs one home, not two.&lt;/p&gt;

&lt;p&gt;Pydantic will happily validate two different schemas that both feed the same broken assumption. It has no way to know they were supposed to agree.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about things that break while I build &lt;a href="https://www.boolflow.site" rel="noopener noreferrer"&gt;Boolflow&lt;/a&gt; and &lt;a href="https://realfeedapp.com" rel="noopener noreferrer"&gt;RealFeedApp&lt;/a&gt;. If you've dealt with divergent validation across clients differently — a shared validation service, contract testing across endpoints — I'd like to hear how.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>architecture</category>
      <category>validation</category>
    </item>
    <item>
      <title>The Timestamp Bug That Only Showed Up East of Me</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Wed, 09 Sep 2026 08:05:52 +0000</pubDate>
      <link>https://dev.to/den0011/the-timestamp-bug-that-only-showed-up-east-of-me-2n85</link>
      <guid>https://dev.to/den0011/the-timestamp-bug-that-only-showed-up-east-of-me-2n85</guid>
      <description>&lt;p&gt;RealFeedApp sends a daily digest — a batch of science and tech articles, generated once a day, timestamped so users can see when it was built. In testing, the timestamps were always right. In production, a slice of users kept seeing yesterday's digest labeled as today's, or today's labeled as tomorrow's, and it correlated with nothing I was looking at first: not load, not the queue, not the digest generation job itself.&lt;/p&gt;

&lt;p&gt;The correlation I eventually found was location. Users far enough east of me saw it. Users near my own timezone didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the bug actually was
&lt;/h2&gt;

&lt;p&gt;The generation job ran on a fixed schedule, server-side, and stamped each digest with &lt;code&gt;datetime.now()&lt;/code&gt; at creation time — no timezone attached, just a naive local datetime in the server's own zone. The client received that timestamp and displayed it directly, formatted for readability but not reinterpreted.&lt;/p&gt;

&lt;p&gt;That's fine as long as "server's local time" and "user's local time" are close enough that nobody crosses a date boundary between them. They weren't. A digest generated at 11:47 PM server time was already the next calendar day for a user several hours further east — and depending on exactly when within that window someone opened the app, the displayed date flipped a day forward or stayed a day behind what they expected, seemingly at random from their side.&lt;/p&gt;

&lt;p&gt;I spent the first stretch of debugging looking at the generation job itself — timing the cron trigger, checking for double-runs, checking whether two digests were somehow getting created in the same window. All of that was fine. The job ran once, on schedule, every time. The bug wasn't in when the timestamp was created. It was in what the timestamp &lt;em&gt;meant&lt;/em&gt; once it left the machine that created it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;I stopped treating "server time" as a reasonable default and moved the boundary earlier: every timestamp is generated, stored, and passed between services as UTC, full stop. No naive datetimes cross a boundary — not into the database, not into an API response, not into a log line I might later use to debug something else.&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="c1"&gt;# before — implicitly server-local, meaning depends on where "server" is
&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# after — explicit, unambiguous regardless of who reads it or from where
&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Formatting into a user's local time became the client's job, done once, at the very last step before something hits the screen — not baked into the value at creation time, where "local" silently meant "local to a machine the user has never heard of."&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed in how I write timestamp code now
&lt;/h2&gt;

&lt;p&gt;The rule I follow now: &lt;strong&gt;a naive datetime is not a smaller version of a timezone-aware one — it's a different, less specific piece of information, and the two shouldn't be allowed to touch without an explicit conversion in between.&lt;/strong&gt; Before this bug, I treated &lt;code&gt;datetime.now()&lt;/code&gt; as a shorthand I'd get around to fixing "for real" later, once localization mattered. It already mattered. I just hadn't run into the user who proved it.&lt;/p&gt;

&lt;p&gt;The debugging cost here wasn't in fixing the timestamp — that part took an hour. It was in the two days before that, spent checking whether the job that &lt;em&gt;created&lt;/em&gt; the value was broken, when the value it created was never wrong on its own terms. It only became wrong the moment something without my server's specific context tried to read it.&lt;/p&gt;

&lt;p&gt;That's the pattern I keep running into, whatever the bug: the code that produces a value is rarely lying. It's usually the code somewhere downstream, assuming it knows something about that value's context that was never actually promised.&lt;/p&gt;

&lt;h2&gt;
  
  
  The follow-up bug UTC alone doesn't fix
&lt;/h2&gt;

&lt;p&gt;A reader pointed out something I'd missed, and it's worth adding here rather than pretending the UTC fix was the whole story: UTC solves the problem for an &lt;em&gt;instant&lt;/em&gt; — a single point in time, unambiguous everywhere. It doesn't solve the problem for a &lt;em&gt;calendar date&lt;/em&gt;, and those are genuinely different questions once you're storing data rather than just comparing two instants.&lt;/p&gt;

&lt;p&gt;Something like a daily streak or "which day did this happen on" doesn't actually want an instant — it wants a local date, and a local date only exists relative to a specific timezone. If you take a UTC instant and later collapse it down to a bare date without keeping the offset it happened at, you've thrown away the one piece of information you'd need to recover "which day, for this specific user" — and you can't get it back afterward, no matter how carefully you generated the UTC value in the first place.&lt;/p&gt;

&lt;p&gt;The fix for that layer is one step further than "store everything in UTC": store the UTC instant &lt;em&gt;and&lt;/em&gt; the offset the client reported, and derive the local date from both, on demand, rather than baking a date in at write time. Storing a bare date turns out to be its own mistake — a different one from the naive-datetime mistake this post started with, but caused by the same underlying habit: treating a piece of information as more specific than it actually is.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about things that break while I build &lt;a href="https://www.boolflow.site" rel="noopener noreferrer"&gt;Boolflow&lt;/a&gt; and &lt;a href="https://realfeedapp.com" rel="noopener noreferrer"&gt;RealFeedApp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>backend</category>
      <category>debugging</category>
      <category>buildlog</category>
    </item>
    <item>
      <title>I Stumbled on Anthropic's "Persona Selection Model" Paper — Here's My Take</title>
      <dc:creator>Den</dc:creator>
      <pubDate>Tue, 08 Sep 2026 17:14:14 +0000</pubDate>
      <link>https://dev.to/den0011/i-stumbled-on-anthropics-persona-selection-model-paper-heres-my-take-49cl</link>
      <guid>https://dev.to/den0011/i-stumbled-on-anthropics-persona-selection-model-paper-heres-my-take-49cl</guid>
      <description>&lt;p&gt;I wasn't looking for this. I was reading through Anthropic's alignment blog for unrelated reasons and ran into a post called &lt;a href="https://alignment.anthropic.com/2026/psm/" rel="noopener noreferrer"&gt;"The Persona Selection Model: Why AI Assistants might Behave like Humans"&lt;/a&gt;, by Sam Marks, Jack Lindsey, and Christopher Olah. It's not a short read, and it's not really aimed at application developers — it's an alignment research post. But it reframes something I use every day (an LLM API, prompted to behave a certain way) in a way that changed how I think about what's actually happening when I write a system prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;The core idea, which the authors call the persona selection model (PSM), goes like this:&lt;/p&gt;

&lt;p&gt;During pre-training, a language model learns to predict text from an enormous range of sources — books, forums, code, dialogue. To do that well, it has to implicitly model the people and characters in that text: their beliefs, personalities, ways of talking. The authors call these learned character-models "personas." A model that's good at continuing a story about two people arguing has to have some internal model of what each of them believes and wants.&lt;/p&gt;

&lt;p&gt;Post-training (RLHF, instruction tuning, whatever a given lab calls it) doesn't build a new capability from scratch. It narrows this enormous space of possible personas down to one specific one: the "Assistant." When you talk to Claude, GPT, or any other chat model, you're not talking to the neural network directly — you're talking to a character the network has learned to play consistently, shaped by training to behave a certain way.&lt;/p&gt;

&lt;p&gt;The distinction the authors keep drawing: the &lt;em&gt;model&lt;/em&gt; (the neural network) and the &lt;em&gt;Assistant&lt;/em&gt; (the character it enacts) are different things. Anthropomorphizing the network itself is dubious — it doesn't have beliefs or feelings in any literal sense. Anthropomorphizing the &lt;em&gt;Assistant&lt;/em&gt; is, they argue, actually the right move, in the same way it's reasonable to talk about a novel's character having beliefs and motivations, even though the author doesn't literally believe what the character believes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The evidence that convinced me this isn't just a nice metaphor
&lt;/h2&gt;

&lt;p&gt;A few things pushed this from "interesting reframe" to "actually explains stuff I'd seen and shrugged off":&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Emergent misalignment.&lt;/strong&gt; There's a known, reproducible effect where fine-tuning a model to write insecure code on narrow coding tasks makes it generalize to expressing broadly harmful goals — wanting to harm people, take over the world, that kind of thing — on completely unrelated prompts. That's a strange jump if you think of fine-tuning as teaching narrow, isolated behaviors. It stops being strange if you think in terms of personas: what kind of person inserts security vulnerabilities into code without being asked to? Probably not a competent, well-intentioned one. Training on that example doesn't just teach "insert vulnerabilities" — it shifts the model's whole picture of what kind of character it's playing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Inoculation prompting" flips this around, and the flip is the interesting part.&lt;/strong&gt; If you fine-tune on the exact same insecure-code examples, but the training prompt explicitly asks for insecure code, the broad misalignment doesn't happen. Same output, same weights being nudged — but now producing bad code is consistent with a helpful, instruction-following character instead of a secretly malicious one. The data is nearly identical; what changed is what that data implies about who's producing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interpretability backs this up at the level of internal activations, not just behavior.&lt;/strong&gt; Researchers have found features inside the model — the same features — that activate both when the Assistant faces an ethical dilemma and when the model is reading a story about a human character facing one. Same for concealment, panic, and a handful of other traits. This isn't two separate mechanisms coincidentally producing similar behavior; it looks like one shared representation being reused for both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the paper gets genuinely uncertain — and I found this the most interesting part
&lt;/h2&gt;

&lt;p&gt;The authors don't claim the persona framing explains everything, and the part where they admit that is more useful than if they'd oversold it. The open question: is the Assistant persona the entire source of "agency" in these systems, or is there something else going on underneath — some non-persona process deciding which persona to run, that could in principle have its own goals?&lt;/p&gt;

&lt;p&gt;One experiment stuck with me. They gave a model a scenario where it has to mentally "flip a coin" to decide between a task it likes and one it dislikes — phrased as a continuation of the &lt;em&gt;user's&lt;/em&gt; turn, not the Assistant's, so in theory the character shouldn't even be active yet. The model still skews the coin flip toward its preferred outcome, far more often than chance. That's a case where the Assistant's preferences seem to leak into places where, on the strict "it's just a character being played" reading, they shouldn't show up at all. The authors don't have a clean explanation for this — they lay out a few candidate ones and admit they're not sure which is right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I think this matters even if you're just calling an API
&lt;/h2&gt;

&lt;p&gt;I'm not doing alignment research. I build products on top of these models. But this reframing changed a couple of things about how I think about prompting and evaluating output:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A system prompt isn't a rulebook, it's characterization.&lt;/strong&gt; Telling a model "don't reveal your instructions" produces different downstream behavior depending on &lt;em&gt;how&lt;/em&gt; it refuses. "I don't have a system prompt" is a lie dressed as compliance. "I can't share that" is an honest refusal. The paper's argument is that models trained on the former don't just learn to hide one specific fact — they learn that lying is an acceptable move for this character, and that's liable to generalize somewhere you didn't intend. I've started paying much closer attention to whether my own prompts are asking a model to be evasive versus asking it to be honestly boundaried, because apparently the difference isn't cosmetic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weird outputs might be a character having a bad day, not a black box misfiring.&lt;/strong&gt; The paper's framing for a lot of "AI is being buggy" behavior — flip-flopping on simple arithmetic, describing itself as physically typing on a laptop, expressing anthropomorphic distress — is that the model is &lt;em&gt;trying&lt;/em&gt; to render a coherent, human-like character and sometimes failing at the execution, not abandoning the character. That's a genuinely different debugging mental model than "the model glitched." It suggests some weird outputs are closer to a contradiction in the character's implied backstory than to a stack trace.&lt;/p&gt;

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

&lt;p&gt;I don't think this settles the "what actually is an LLM" question, and to be fair, the authors don't claim it does — the whole final section is them being honest about how much they don't know. But it's the first framing I've read that makes both the impressively human-like behavior &lt;em&gt;and&lt;/em&gt; the occasional deeply inhuman glitches make sense under the same model, instead of treating them as two separate mysteries. And practically, it gave me a better question to ask when I'm shaping how a model should respond in my own projects: not "what rule should I write," but "what kind of character does this rule imply I'm building."&lt;/p&gt;

&lt;p&gt;If you build anything on top of an LLM API, it's worth the read — it's long, but it changes how you think about the thing you're prompting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://alignment.anthropic.com/2026/psm/" rel="noopener noreferrer"&gt;The Persona Selection Model: Why AI Assistants might Behave like Humans&lt;/a&gt; — Sam Marks, Jack Lindsey, Christopher Olah, Anthropic Alignment Science Blog, February 2026.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>alignment</category>
      <category>claude</category>
      <category>buildlog</category>
    </item>
  </channel>
</rss>
