<?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: Matías Denda</title>
    <description>The latest articles on DEV Community by Matías Denda (@mdenda).</description>
    <link>https://dev.to/mdenda</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%2F3601966%2F6aebccb4-b6ac-40c9-98c1-0eb5fa97d314.png</url>
      <title>DEV Community: Matías Denda</title>
      <link>https://dev.to/mdenda</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mdenda"/>
    <language>en</language>
    <item>
      <title>Building a P2P Chat over Tor with Rust's arti-client</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Tue, 23 Jun 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/mdenda/building-a-p2p-chat-over-tor-with-rusts-arti-client-39nf</link>
      <guid>https://dev.to/mdenda/building-a-p2p-chat-over-tor-with-rusts-arti-client-39nf</guid>
      <description>&lt;p&gt;&lt;em&gt;Post 5 of 6 on &lt;a href="https://github.com/matutetandil/anyhide" rel="noopener noreferrer"&gt;Anyhide&lt;/a&gt;. This post is about peer discovery and session setup over Tor.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;There's a surprising amount of work between "I have a cryptographic protocol" and "two people can talk to each other." This post is about that work.&lt;/p&gt;

&lt;p&gt;The protocol from &lt;a href="https://dev.tolink-04"&gt;Post 4&lt;/a&gt; assumes Alice and Bob can send each other bytes. It does not explain how they find each other in the first place, how they agree on a session, or what happens when the network drops messages. For Anyhide's chat, the answer to all three questions involves Tor hidden services, and I'd never written a line of Tor code before starting.&lt;/p&gt;

&lt;p&gt;I used &lt;a href="https://docs.rs/arti-client" rel="noopener noreferrer"&gt;&lt;code&gt;arti-client&lt;/code&gt;&lt;/a&gt;, the Rust-native Tor implementation from the Tor Project. Most content about Tor integration assumes you're running &lt;code&gt;tor&lt;/code&gt; as a separate daemon and talking to its SOCKS port. Arti is different: it's a library you embed directly. You get a real Tor client inside your process, running on your runtime, with circuits you can open and hidden services you can host.&lt;/p&gt;

&lt;p&gt;It's also marked experimental by the Tor Project, which I'll come back to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why hidden services and not just a relay
&lt;/h2&gt;

&lt;p&gt;The first question you'd ask: "Can't Alice just host a regular TCP server and let Bob connect through a Tor circuit?" Yes, but it doesn't give you what you want. A regular server has a public IP and a DNS name. The fact that Bob is anonymous to Alice doesn't help if Alice's server is indexed somewhere.&lt;/p&gt;

&lt;p&gt;Hidden services (officially "onion services") flip the model: &lt;em&gt;both&lt;/em&gt; endpoints are anonymous. The service has a &lt;code&gt;.onion&lt;/code&gt; address, not an IP address. Clients connect through the Tor network to a rendezvous point, where the server is waiting via its own circuit. Neither side learns the other's IP. Neither side has to open a port on their router.&lt;/p&gt;

&lt;p&gt;For a two-person chat where both participants want privacy, that's the only model that makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  The .onion address, by hand
&lt;/h2&gt;

&lt;p&gt;Arti gives you an &lt;code&gt;HsId&lt;/code&gt; when you create a hidden service — it's a 32-byte Ed25519 public key. But humans expect an address string like &lt;code&gt;abcdef123...onion&lt;/code&gt;. Arti doesn't hand you that directly, so I wrote the conversion. It's a nice little exercise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/chat/transport/tor.rs&lt;/span&gt;

&lt;span class="cd"&gt;/// Convert an HsId to its .onion address string.&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;hsid_to_onion_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hsid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;HsId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// HsId is a 32-byte ed25519 public key&lt;/span&gt;
    &lt;span class="c1"&gt;// The .onion address is: base32(PUBKEY | CHECKSUM | VERSION)&lt;/span&gt;
    &lt;span class="c1"&gt;// where CHECKSUM = SHA3_256(".onion checksum" | PUBKEY | VERSION)[:2]&lt;/span&gt;
    &lt;span class="c1"&gt;// and VERSION = 0x03&lt;/span&gt;

    &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;sha3&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Digest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Sha3_256&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;pubkey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hsid&lt;/span&gt;&lt;span class="nf"&gt;.as_ref&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u8&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0x03&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;hasher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Sha3_256&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;hasher&lt;/span&gt;&lt;span class="nf"&gt;.update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;b".onion checksum"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;hasher&lt;/span&gt;&lt;span class="nf"&gt;.update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pubkey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;hasher&lt;/span&gt;&lt;span class="nf"&gt;.update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;checksum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hasher&lt;/span&gt;&lt;span class="nf"&gt;.finalize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;combined&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nf"&gt;.copy_from_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pubkey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;34&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nf"&gt;.copy_from_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;checksum&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;34&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;base32_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}.onion"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoded&lt;/span&gt;&lt;span class="nf"&gt;.to_lowercase&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some things worth calling out:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The checksum uses SHA3-256, not SHA-256.&lt;/em&gt; This got me on the first try. Tor v3 addresses were designed when SHA3 was still new and the spec authors wanted a Keccak-family hash. If you use SHA-256 here, you get an address that looks right but won't resolve through the network.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The string &lt;code&gt;".onion checksum"&lt;/code&gt; includes no null terminator, no trailing whitespace.&lt;/em&gt; Literal bytes. I spent a good twenty minutes debugging my first attempt because I had copied the string from a spec PDF and picked up an invisible character.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Base32 encoding is RFC 4648 with no padding, lowercase output.&lt;/em&gt; There are at least three base32 variants in the wild; using the wrong one gives you an address that parses but doesn't match.&lt;/p&gt;

&lt;p&gt;These aren't bugs in arti — the library is doing the right thing. They're bugs in my first implementation because I treated "convert a pubkey to an onion address" as trivial. It's not. The &lt;a href="https://spec.torproject.org/rend-spec/encoding-onion-addresses.html" rel="noopener noreferrer"&gt;Tor v3 spec&lt;/a&gt; is specific for a reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Starting the Tor client
&lt;/h2&gt;

&lt;p&gt;Bootstrapping arti looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;arti_client&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;TorClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TorClientConfig&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;tor_rtcompat&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;PreferredRuntime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;AnyhideTorClient&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TorClient&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PreferredRuntime&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;AnyhideTorClient&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChatError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;with_profile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;with_profile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChatError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// build config, possibly with a profile-specific state dir&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;TorClientConfig&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;TorClient&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create_bootstrapped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AnyhideTorClient&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first &lt;code&gt;create_bootstrapped&lt;/code&gt; call is slow. It downloads the Tor network consensus, picks relays, builds initial circuits. On my laptop it takes 20-60 seconds depending on network quality.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;profile&lt;/code&gt; parameter was added because I wanted to run multiple Anyhide identities on the same laptop without them sharing circuit state. Each profile gets its own &lt;code&gt;~/.local/share/anyhide/tor/&amp;lt;name&amp;gt;/&lt;/code&gt; directory for persistent state. This matters: if two identities share Tor state, an observer with the right vantage could correlate them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hosting a hidden service
&lt;/h2&gt;

&lt;p&gt;Once the client is up, creating a hidden service is a handful of lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;tor_hsservice&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="nn"&gt;config&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;OnionServiceConfigBuilder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HsNickname&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;nickname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;HsNickname&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"anyhide-alice"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;svc_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;OnionServiceConfigBuilder&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.nickname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nickname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;let&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;request_stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.client&lt;/span&gt;
    &lt;span class="nf"&gt;.launch_onion_service&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;svc_config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;hsid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="nf"&gt;.onion_name&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.ok_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;onion_addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hsid_to_onion_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;hsid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Listening on: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;onion_addr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;request_stream&lt;/code&gt; is an async stream of incoming rendezvous requests. You drive it in a loop: each request becomes a &lt;code&gt;DataStream&lt;/code&gt; (basically a TCP stream, but over Tor) that you hand off to the chat session handler.&lt;/p&gt;

&lt;p&gt;Connecting &lt;em&gt;to&lt;/em&gt; a hidden service is the dual:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.client&lt;/span&gt;
    &lt;span class="nf"&gt;.connect&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;onion_addr&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Arti handles circuit building, rendezvous, all the Tor-specific machinery. You get a stream, you read and write bytes on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bidirectional connection racing
&lt;/h2&gt;

&lt;p&gt;Here's a design decision I'm not sure was right.&lt;/p&gt;

&lt;p&gt;In a classic client/server model, one party listens and the other connects. Clean, obvious. But "who's the server" introduces an asymmetry I didn't like: the server has to be online first, the client has to know the server's address, and you end up with one side structurally more burdensome than the other.&lt;/p&gt;

&lt;p&gt;I went a different way. In Anyhide chat, &lt;em&gt;both parties are equal&lt;/em&gt;. Both host hidden services. Both know each other's &lt;code&gt;.onion&lt;/code&gt; addresses. When Alice wants to talk to Bob, she simultaneously:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Listens on her own hidden service for incoming connections from Bob.&lt;/li&gt;
&lt;li&gt;Dials out to Bob's hidden service.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Whichever succeeds first wins. The other attempt is cancelled. Both parties do this symmetrically, so it's a race between four potential connections collapsed down to one.&lt;/p&gt;

&lt;p&gt;Pros:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nobody has to be "the server."&lt;/li&gt;
&lt;li&gt;You can kick off a chat without coordinating who goes first.&lt;/li&gt;
&lt;li&gt;It works when both parties are behind NATs (Tor handles that for you).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Double the circuits being built. On a cold Tor client, that means double the bootstrapping latency for the initial attempt.&lt;/li&gt;
&lt;li&gt;The logic for "is this incoming stream the one we're expecting?" got hairy. A stranger can still try to connect to my hidden service — I have to figure out whether to accept based on whether I recognize their identity keys in the handshake.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I think on balance it was worth it. The UX is much more pleasant: you open the chat tab and it "just connects," regardless of who opened theirs first. But if I did it again I'd at least prototype the server model first to see whether the symmetry is worth the complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The handshake
&lt;/h2&gt;

&lt;p&gt;Once a stream is established, the two sides need to agree on a session. Anyhide uses a three-message handshake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Initiator --[HandshakeInit]--&amp;gt; Responder
         &amp;lt;--[HandshakeResponse]--
         --[HandshakeComplete]--&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each message carries ephemeral public keys, identity public keys, and an Ed25519 signature over the contents. The struct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/chat/protocol/handshake.rs&lt;/span&gt;

&lt;span class="cd"&gt;/// Handshake initiation message (Initiator -&amp;gt; Responder).&lt;/span&gt;
&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone,&lt;/span&gt; &lt;span class="nd"&gt;Serialize,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;HandshakeInit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;ephemeral_public&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;identity_public&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ChatConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;i_know_you&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;i_know_you&lt;/code&gt; field is interesting. It's a hint: the initiator tells the responder "I have you as a contact." The responder uses this to decide how to label the incoming connection in the UI (a known face vs. a stranger — which ties into the request/accept flow in &lt;a href="https://dev.tolink-06"&gt;Post 6&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The signature covers everything, binding the ephemeral key to the identity key. An attacker can't swap the ephemeral key mid-stream and re-use the signature, because the signed blob is the concatenation of everything that matters.&lt;/p&gt;

&lt;p&gt;After the responder's &lt;code&gt;HandshakeResponse&lt;/code&gt;, both sides have enough material to derive the session keys from &lt;a href="https://dev.tolink-04"&gt;Post 4&lt;/a&gt;. The third message — &lt;code&gt;HandshakeComplete&lt;/code&gt; — exists mostly to acknowledge the carriers and finalize the state transition before real messages start flowing. It's not strictly required for key establishment, but it gives both sides a clear "we're done with setup, switch to message mode" signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What arti gives you, and what it doesn't
&lt;/h2&gt;

&lt;p&gt;Arti is really good at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Building and managing Tor circuits.&lt;/li&gt;
&lt;li&gt;Hosting and connecting to hidden services.&lt;/li&gt;
&lt;li&gt;Handling stream multiplexing.&lt;/li&gt;
&lt;li&gt;Integrating with Tokio/async-std runtimes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it isn't yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fully feature-parity with C-Tor for advanced use cases.&lt;/li&gt;
&lt;li&gt;Marked "stable for security-critical workloads." The docs are explicit: &lt;em&gt;arti's onion services are experimental and not as secure as C-Tor&lt;/em&gt;. I put that warning at the top of my transport module too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a side project like Anyhide, this trade-off is fine. For something handling real dissident traffic or journalism, you probably want C-Tor and a more battle-tested stack. Arti is going to get there — the Tor Project has been iterating hard — but at the time of writing it's a caveat that needs disclosing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Async migration notes
&lt;/h2&gt;

&lt;p&gt;Anyhide started as a synchronous CLI tool. Adding Tor meant going async, which meant either partitioning the codebase or going all-in on Tokio. I went all-in, which had one specific lesson I'll share:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Don't leak async into your crypto primitives.&lt;/em&gt; The &lt;code&gt;kdf_chain&lt;/code&gt; function from &lt;a href="https://dev.tolink-04"&gt;Post 4&lt;/a&gt; is synchronous. It takes bytes, returns bytes, does no I/O. Keep it that way. The moment your crypto is async, you start thinking about cancellation safety, and cancellation safety in crypto is how you end up with partial state bugs that compromise security.&lt;/p&gt;

&lt;p&gt;The seam I drew was: transport (Tor, TCP) is async. Session state (the ratchet, the keys) is sync, wrapped in &lt;code&gt;Mutex&lt;/code&gt; when shared across tasks. The chat session's main loop is async and does &lt;code&gt;select!&lt;/code&gt; on incoming messages, user input, and timers, but the cryptographic work inside each branch is a normal function call.&lt;/p&gt;

&lt;p&gt;This has worked out well. The parts of the code where async matters are small and localized. The parts where security matters are synchronous and testable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Post 6 is the last in the series, and it's about the user interface: building a multi-contact terminal UI with &lt;a href="https://ratatui.rs/" rel="noopener noreferrer"&gt;ratatui&lt;/a&gt;. Tabs, a Doom-style command console, request-accept for incoming connections, and the UX decisions that go into making a TUI feel like a real app rather than a scripting exercise.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/matutetandil/anyhide" rel="noopener noreferrer"&gt;github.com/matutetandil/anyhide&lt;/a&gt;. If you've built on arti and want to compare, drop a comment — the ecosystem is small enough that everyone's insights matter.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tor</category>
      <category>privacy</category>
      <category>networking</category>
    </item>
    <item>
      <title>Why git pull --rebase should probably be your default</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Wed, 17 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mdenda/why-git-pull-rebase-should-probably-be-your-default-32e1</link>
      <guid>https://dev.to/mdenda/why-git-pull-rebase-should-probably-be-your-default-32e1</guid>
      <description>&lt;p&gt;Most developers run &lt;code&gt;git pull&lt;/code&gt; dozens of times a week without thinking about it. And most of the time, it works.&lt;/p&gt;

&lt;p&gt;Then one day you open a PR and the reviewer says "can you clean up the merge commits?" You look at your branch and see three "Merge branch 'main' into feature/login" commits scattered through history. The feature itself is 5 commits. The log is a mess.&lt;/p&gt;

&lt;p&gt;That mess comes from one decision: using &lt;code&gt;git pull&lt;/code&gt; instead of &lt;code&gt;git pull --rebase&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's what's actually happening, and why the rebase variant produces cleaner history for teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: diverged history
&lt;/h2&gt;

&lt;p&gt;You're working on &lt;code&gt;feature/login&lt;/code&gt;. You commit two changes locally (&lt;code&gt;X&lt;/code&gt;, &lt;code&gt;Y&lt;/code&gt;). Meanwhile, your teammate pushes two commits to &lt;code&gt;main&lt;/code&gt; (&lt;code&gt;C&lt;/code&gt;, &lt;code&gt;D&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Your branch and &lt;code&gt;main&lt;/code&gt; have now &lt;strong&gt;diverged&lt;/strong&gt;. Neither is a strict superset of the other. Git needs to reconcile them when you pull.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Shared history: A → B

Your local:     A → B → X → Y       (you added X, Y)
Remote main:    A → B → C → D       (teammate added C, D)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Git has two strategies for this reconciliation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Strategy 1: &lt;code&gt;git pull&lt;/code&gt; (merge)
&lt;/h2&gt;

&lt;p&gt;A plain &lt;code&gt;git pull&lt;/code&gt; creates a &lt;strong&gt;merge commit&lt;/strong&gt; that joins your local history with the remote. Your commits and the remote's commits both appear in the log, connected by a merge node.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqilh8qs6vuj5c85i7iku.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqilh8qs6vuj5c85i7iku.png" alt="Diagram showing diverged history after git pull. The shared commits A and B are at the bottom left. Your local commits X and Y branch upward from B. The remote commits C and D branch downward from B. A new merge commit M on the right joins both lines, with two parents: Y from your local and D from origin." width="800" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;git log&lt;/code&gt; reads:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;M   Merge branch 'main' into feature/login
D   fix: timeout on slow connections
Y   feat: client-side validation
C   chore: upgrade eslint
X   feat: login form
B   (shared)
A   (shared)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is &lt;strong&gt;honest history&lt;/strong&gt; — it records exactly what happened: parallel development that was joined at a specific point.&lt;/p&gt;

&lt;p&gt;But it's also &lt;strong&gt;noisy history&lt;/strong&gt; — the merge commit has no meaningful changes, and the log interleaves commits that weren't conceptually related.&lt;/p&gt;

&lt;h2&gt;
  
  
  Strategy 2: &lt;code&gt;git pull --rebase&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;--rebase&lt;/code&gt;, Git takes a different approach. It:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Temporarily sets aside your local commits (&lt;code&gt;X&lt;/code&gt;, &lt;code&gt;Y&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Fast-forwards your branch to the tip of the remote (&lt;code&gt;D&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Replays your commits on top, one by one, creating new commits (&lt;code&gt;X'&lt;/code&gt;, &lt;code&gt;Y'&lt;/code&gt;)
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F49weypr0ljvhqa0g8ct5.png" alt="Diagram showing linear history after git pull --rebase. Commits A, B, C, D are shown as existing commits in a single horizontal line. X prime and Y prime are new commits (highlighted with hollow circle and green dot) appended at the end, replayed on top of D. No merge commit." width="800" height="311"&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;git log&lt;/code&gt; reads:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Y'  feat: client-side validation
X'  feat: login form
D   fix: timeout on slow connections
C   chore: upgrade eslint
B   (shared)
A   (shared)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Linear.&lt;/strong&gt; No merge commit. Your work appears as if you'd started from the latest &lt;code&gt;main&lt;/code&gt; in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why linear history matters
&lt;/h2&gt;

&lt;p&gt;When your team reviews your PR, linear history means:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The diff is cleaner.&lt;/strong&gt; GitHub/GitLab can show a straightforward diff of what your feature adds. No merge commits cluttering the view.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The PR itself applies cleanly.&lt;/strong&gt; When a reviewer merges your PR, it becomes a simple fast-forward on &lt;code&gt;main&lt;/code&gt; — or a clean &lt;code&gt;--no-ff&lt;/code&gt; if your team uses that policy. No extra merge-of-merges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;History reads like a changelog.&lt;/strong&gt; &lt;code&gt;git log --oneline main&lt;/code&gt; reads like a list of features shipped, one per line, in order. That's what &lt;code&gt;main&lt;/code&gt; should look like.&lt;/p&gt;

&lt;p&gt;Compare that to a &lt;code&gt;git log&lt;/code&gt; full of "Merge branch 'main' into feature/login" commits — it tells you when people synchronized their branches, not what was actually built.&lt;/p&gt;

&lt;h2&gt;
  
  
  The commits X' and Y' are new commits
&lt;/h2&gt;

&lt;p&gt;This is the subtle part that trips people up.&lt;/p&gt;

&lt;p&gt;With rebase, &lt;code&gt;X'&lt;/code&gt; has a different hash than &lt;code&gt;X&lt;/code&gt;. It has the same diff — same changes to the same files — but because its parent is now &lt;code&gt;D&lt;/code&gt; instead of &lt;code&gt;B&lt;/code&gt;, Git considers it a new commit.&lt;/p&gt;

&lt;p&gt;This matters for one reason: &lt;strong&gt;rebase rewrites history&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The golden rule of rebase
&lt;/h2&gt;

&lt;p&gt;Never rebase commits that other people may have pulled.&lt;/p&gt;

&lt;p&gt;If you rebase commits that a teammate already pulled, their local copy has the old hashes and your copy has the new hashes. When they pull again, Git sees two different sets of "the same" commits and produces duplicates, conflicts, or confusion.&lt;/p&gt;

&lt;p&gt;The safe pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your local, unpushed branch:&lt;/strong&gt; rebase freely&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your pushed feature branch, if you're the only one on it:&lt;/strong&gt; rebase + &lt;code&gt;git push --force-with-lease&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared branches like &lt;code&gt;main&lt;/code&gt; or &lt;code&gt;develop&lt;/code&gt;:&lt;/strong&gt; never rebase
## Making rebase your default&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to use rebase for pulls without typing &lt;code&gt;--rebase&lt;/code&gt; every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; pull.rebase &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every &lt;code&gt;git pull&lt;/code&gt; is actually &lt;code&gt;git pull --rebase&lt;/code&gt;. Most teams that care about clean history do this. You can always override with &lt;code&gt;git pull --no-rebase&lt;/code&gt; if you want a specific merge.&lt;/p&gt;

&lt;p&gt;For even stronger safety, combine it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; rebase.autoStash &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This auto-stashes your uncommitted changes before the rebase and pops them back after. Saves you from "cannot rebase: you have unstaged changes" errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  When &lt;code&gt;git pull&lt;/code&gt; (merge) is actually correct
&lt;/h2&gt;

&lt;p&gt;There are cases where a merge is the right choice:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-lived feature branches.&lt;/strong&gt; If your feature branch has been alive for weeks and multiple people have pushed to it, rebasing it rewrites history others depend on. Use merge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explicit integration points.&lt;/strong&gt; Some teams like the merge commit to mark "we integrated main into feature at this point" as a deliberate milestone. That's fine — it's a conscious choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When you want the team to see what happened.&lt;/strong&gt; If a feature took three sprints and main moved a lot during that time, the merge commits show that context. For most features, you don't need that — but for big ones, sometimes you do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mindset shift
&lt;/h2&gt;

&lt;p&gt;Pull is a boring command that most developers run on autopilot. But behind that one word are two very different operations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Merge pull&lt;/strong&gt; preserves history, creating merge commits. History reflects what happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebase pull&lt;/strong&gt; rewrites history, producing a linear log. History reflects the intent.
For daily work on short-lived feature branches, the rebase approach almost always produces a better result — for your PR, for your reviewer, for whoever reads &lt;code&gt;git log&lt;/code&gt; in six months.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Switch your default. Learn the golden rule. Don't look back.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is adapted from &lt;em&gt;&lt;a href="https://mdenda.gumroad.com/l/git-in-depth" rel="noopener noreferrer"&gt;Git in Depth: From Solo Developer to Engineering Teams&lt;/a&gt;&lt;/em&gt;, a 658-page book covering Git the way it's actually used in real engineering teams.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>git</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>productivity</category>
    </item>
    <item>
      <title>git bisect: find the commit that broke production in minutes, not days</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Wed, 10 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mdenda/git-bisect-find-the-commit-that-broke-production-in-minutes-not-days-32em</link>
      <guid>https://dev.to/mdenda/git-bisect-find-the-commit-that-broke-production-in-minutes-not-days-32em</guid>
      <description>&lt;p&gt;Your CI was green last Friday. Today, the payments test is failing. Somewhere between Friday's merge and now, 47 commits landed on &lt;code&gt;main&lt;/code&gt;. Which one broke it?&lt;/p&gt;

&lt;p&gt;Most developers answer this the wrong way: they scroll through &lt;code&gt;git log&lt;/code&gt;, check out suspicious commits one by one, and run the test manually. An hour later, they're still guessing.&lt;/p&gt;

&lt;p&gt;There's a command built for this exact problem. It's called &lt;code&gt;git bisect&lt;/code&gt;, and once you learn it, you'll never debug regressions the old way again.&lt;/p&gt;

&lt;h2&gt;
  
  
  How bisect works
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git bisect&lt;/code&gt; is a binary search across your commit history. You tell Git two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;good&lt;/strong&gt; commit (a known point where the bug didn't exist)&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;bad&lt;/strong&gt; commit (a known point where the bug exists — usually &lt;code&gt;HEAD&lt;/code&gt;)
Git then checks out the commit halfway between them. You test. You mark it as good or bad. Git narrows the range by half. Repeat.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fya2qtay7ygpq0tqoe6yg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fya2qtay7ygpq0tqoe6yg.png" alt="Diagram showing three steps of git bisect. Step 1: a range of 12 commits from good on the left to bad on the right. Step 2: Git checks out the middle commit and runs the test. Step 3: the test passed, so the bug is in the right half, and Git selects a new middle commit in that half to test next." width="800" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With 47 commits between "good" and "bad", it takes at most &lt;strong&gt;6 steps&lt;/strong&gt; (log₂ 47) to find the exact commit that introduced the bug. Versus checking every commit manually, that's the difference between 5 minutes and an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  The manual workflow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Start a bisect session&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git bisect start

&lt;span class="c"&gt;# Mark the current state (HEAD) as bad&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git bisect bad

&lt;span class="c"&gt;# Mark a known-good commit&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git bisect good a3f1d22

&lt;span class="c"&gt;# Bisecting: 23 revisions left to test after this (roughly 5 steps)&lt;/span&gt;
&lt;span class="c"&gt;# [7e4b9c1] refactor: extract payment validator&lt;/span&gt;

&lt;span class="c"&gt;# Git has checked out a commit in the middle. Run your test.&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;npm &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--grep&lt;/span&gt; &lt;span class="s2"&gt;"payments"&lt;/span&gt;

&lt;span class="c"&gt;# Test passed — mark this commit as good&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git bisect good

&lt;span class="c"&gt;# Bisecting: 11 revisions left to test after this (roughly 4 steps)&lt;/span&gt;
&lt;span class="c"&gt;# [b2d8e11] feat: add retry logic to payment API&lt;/span&gt;

&lt;span class="c"&gt;# Test failed — mark this commit as bad&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git bisect bad

&lt;span class="c"&gt;# ... continue until Git announces the first bad commit:&lt;/span&gt;
&lt;span class="c"&gt;# b2d8e11 is the first bad commit&lt;/span&gt;
&lt;span class="c"&gt;# commit b2d8e11&lt;/span&gt;
&lt;span class="c"&gt;# Author: leo@company.com&lt;/span&gt;
&lt;span class="c"&gt;# Date:   Tue Apr 15 11:42:03&lt;/span&gt;
&lt;span class="c"&gt;#     feat: add retry logic to payment API&lt;/span&gt;

&lt;span class="c"&gt;# Done — reset to where you started&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git bisect reset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In 6 commands, you know exactly which commit broke the tests. No guessing. No archaeology through &lt;code&gt;git log&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automating bisect with a script
&lt;/h2&gt;

&lt;p&gt;Here's where it gets powerful. If you have a test that can reliably detect the bug, you can hand the entire bisect to Git:&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;git bisect start
&lt;span class="nv"&gt;$ &lt;/span&gt;git bisect bad
&lt;span class="nv"&gt;$ &lt;/span&gt;git bisect good a3f1d22

&lt;span class="c"&gt;# Git now runs your test automatically at each step&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git bisect run npm &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--grep&lt;/span&gt; &lt;span class="s2"&gt;"payments"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git bisect run&lt;/code&gt; expects a command that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exits 0&lt;/strong&gt; if the commit is good (test passes)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exits non-zero (1-124, 126-127)&lt;/strong&gt; if the commit is bad (test fails)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exits 125&lt;/strong&gt; if the commit can't be tested (e.g., build broken — Git will skip it)
Git runs the command at each bisect step, interprets the exit code, and narrows the range automatically. You walk away, come back 5 minutes later, and Git tells you which commit broke it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a codebase with 50+ commits to search and a full test suite that takes 2-3 minutes per run, this is genuinely life-changing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing a bisect-friendly test script
&lt;/h2&gt;

&lt;p&gt;If your test isn't a simple command, wrap it in a shell script:&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/bash&lt;/span&gt;
&lt;span class="c"&gt;# bisect-test.sh — verify the search feature&lt;/span&gt;

&lt;span class="c"&gt;# Install dependencies (in case they changed between commits)&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;125   &lt;span class="c"&gt;# 125 = skip this commit&lt;/span&gt;

&lt;span class="c"&gt;# Run the build&lt;/span&gt;
npm run build &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;125

&lt;span class="c"&gt;# Run the specific test&lt;/span&gt;
npm &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--grep&lt;/span&gt; &lt;span class="s2"&gt;"search returns correct results"&lt;/span&gt;
&lt;span class="c"&gt;# npm test returns 0 on success, 1 on failure — perfect for bisect&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now bisect 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;git bisect start
&lt;span class="nv"&gt;$ &lt;/span&gt;git bisect bad HEAD
&lt;span class="nv"&gt;$ &lt;/span&gt;git bisect good v2.4.0
&lt;span class="nv"&gt;$ &lt;/span&gt;git bisect run ./bisect-test.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;exit 125&lt;/code&gt; for build failures is important. If a commit doesn't build at all, you don't want Git to mark it as "bad" — you want it to skip to another commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling flaky tests
&lt;/h2&gt;

&lt;p&gt;If your test is flaky (sometimes passes, sometimes fails, even on the same commit), bisect will give you wrong answers. The fix: retry in your script.&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/bash&lt;/span&gt;
&lt;span class="c"&gt;# Try up to 3 times, succeed if any attempt passes&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;npm &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--grep&lt;/span&gt; &lt;span class="s2"&gt;"flaky test"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a hack, not a fix — you should eventually make your tests deterministic. But for finding a regression today, it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  What bisect teaches you about your codebase
&lt;/h2&gt;

&lt;p&gt;After running bisect a few times, you'll start making choices that make your future self's life easier:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Atomic commits matter.&lt;/strong&gt; If one commit mixes a feature, a bugfix, and a refactor, bisect tells you "this commit broke it" — but which part? Small, focused commits make bisect precise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests are investments.&lt;/strong&gt; A codebase with good test coverage turns bisect into a fully automated tool. Without tests, bisect still works but requires manual testing at each step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Main should stay green.&lt;/strong&gt; If your &lt;code&gt;main&lt;/code&gt; has frequent broken builds, &lt;code&gt;git bisect run&lt;/code&gt; hits &lt;code&gt;exit 125&lt;/code&gt; everywhere and degrades into a crawl. Teams that protect main with required CI checks get the full benefit of bisect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mindset shift
&lt;/h2&gt;

&lt;p&gt;The real insight from bisect isn't the command — it's the shift in how you debug regressions.&lt;/p&gt;

&lt;p&gt;Without bisect: "Something changed. Let me scroll through the log and guess."&lt;/p&gt;

&lt;p&gt;With bisect: "Something changed. I'll let Git find it for me in 6 steps."&lt;/p&gt;

&lt;p&gt;Once you internalize that, every "when did this break?" question has a clear procedure. You stop guessing. You start finding.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is adapted from &lt;em&gt;&lt;a href="https://mdenda.gumroad.com/l/git-in-depth" rel="noopener noreferrer"&gt;Git in Depth: From Solo Developer to Engineering Teams&lt;/a&gt;&lt;/em&gt;, a 658-page book covering Git the way it's actually used in real engineering teams.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>git</category>
      <category>debugging</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Implementing Forward Secrecy in Rust: A Double Ratchet and Three Storage Formats</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Tue, 09 Jun 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/mdenda/implementing-forward-secrecy-in-rust-a-double-ratchet-and-three-storage-formats-1314</link>
      <guid>https://dev.to/mdenda/implementing-forward-secrecy-in-rust-a-double-ratchet-and-three-storage-formats-1314</guid>
      <description>&lt;p&gt;&lt;em&gt;Post 4 of 6 on &lt;a href="https://github.com/matutetandil/anyhide" rel="noopener noreferrer"&gt;Anyhide&lt;/a&gt;. This post is about forward secrecy: the property that even if your private key is later compromised, past messages stay unreadable.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Forward secrecy is one of those properties that sounds like it should be easy and absolutely is not.&lt;/p&gt;

&lt;p&gt;The goal is simple: if Alice sends Bob ten messages and then Bob's laptop gets seized and his long-term private key is extracted, the attacker should &lt;em&gt;not&lt;/em&gt; be able to decrypt any of the ten past messages. Not one.&lt;/p&gt;

&lt;p&gt;The way you achieve this is by never using the long-term key to encrypt message content directly. Instead, both sides derive &lt;em&gt;per-message&lt;/em&gt; keys from a chain, and they delete each key after it's used. Extracting the long-term key later gives you nothing, because the keys that actually encrypted the messages are gone.&lt;/p&gt;

&lt;p&gt;Signal pioneered the standard design — the &lt;a href="https://signal.org/docs/specifications/doubleratchet/" rel="noopener noreferrer"&gt;Double Ratchet&lt;/a&gt; — in 2014. Anyhide uses a variant of it. This post walks through what I built and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cryptographic primitives
&lt;/h2&gt;

&lt;p&gt;Anyhide's chat uses three primitives, all of which are in standard Rust crates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;X25519&lt;/em&gt; (&lt;code&gt;x25519-dalek&lt;/code&gt;) for Diffie-Hellman key exchange.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;ChaCha20-Poly1305&lt;/em&gt; (&lt;code&gt;chacha20poly1305&lt;/code&gt;) for authenticated symmetric encryption.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;HKDF-SHA256&lt;/em&gt; (&lt;code&gt;hkdf&lt;/code&gt; + &lt;code&gt;sha2&lt;/code&gt;) for key derivation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are exotic. The interesting part is how they're composed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SessionKeys struct
&lt;/h2&gt;

&lt;p&gt;After the handshake (more on that in &lt;a href="https://dev.tolink-05"&gt;Post 5&lt;/a&gt;), both parties derive a bundle of session keys from the initial DH shared secret. Here's the struct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/chat/protocol/ratchet.rs&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;zeroize&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Zeroize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ZeroizeOnDrop&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="cd"&gt;/// Session keys derived from the initial DH exchange.&lt;/span&gt;
&lt;span class="nd"&gt;#[derive(Zeroize,&lt;/span&gt; &lt;span class="nd"&gt;ZeroizeOnDrop)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;SessionKeys&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/// Key for encrypting/decrypting message headers.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;header_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="cd"&gt;/// Initial sending chain key.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;send_chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="cd"&gt;/// Initial receiving chain key.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;recv_chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="cd"&gt;/// Chain for deterministic carrier selection.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;carrier_chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="cd"&gt;/// Derived passphrase for anyhide encoding (not user-provided).&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;passphrase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;code&gt;#[derive(Zeroize, ZeroizeOnDrop)]&lt;/code&gt;&lt;/em&gt; — when this struct drops, all five &lt;code&gt;[u8; 32]&lt;/code&gt; arrays are overwritten with zeros before the memory is released. This isn't a perfect defense (memory can be paged to disk, copied by the compiler, etc.) but it closes the obvious hole. Every key-bearing struct in Anyhide has this.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Separate send and receive chains&lt;/em&gt; — Alice's "send" chain is Bob's "receive" chain, and vice versa. Both sides advance their own chain independently as they send and receive messages. Each message gets its own key from the current chain position.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A &lt;code&gt;passphrase&lt;/code&gt; field&lt;/em&gt; — this is the part that's specific to Anyhide. Remember, chat messages still get encoded as positions in a carrier file. The encoder needs a passphrase. We don't want to reuse the user's chat passphrase (which might be short), so we derive a 32-byte secret from the DH exchange and use &lt;em&gt;that&lt;/em&gt; as the passphrase. The user never sees it; it's just a high-entropy key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symmetric ratchet
&lt;/h2&gt;

&lt;p&gt;The core "advance one step" operation is this function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="cd"&gt;/// Advance a KDF chain and derive a message key.&lt;/span&gt;
&lt;span class="cd"&gt;///&lt;/span&gt;
&lt;span class="cd"&gt;/// This is the symmetric ratchet step. The chain key is updated and a message&lt;/span&gt;
&lt;span class="cd"&gt;/// key is derived for encrypting/decrypting a single message.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;kdf_chain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;hk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Hkdf&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Sha256&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chain_key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;new_chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;message_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="n"&gt;hk&lt;/span&gt;&lt;span class="nf"&gt;.expand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LABEL_CHAIN_ADVANCE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;new_chain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"32 bytes is valid output length"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;hk&lt;/span&gt;&lt;span class="nf"&gt;.expand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LABEL_MESSAGE_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;message_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"32 bytes is valid output length"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_chain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Input: the current chain key. Output: a new chain key and a message key. The message key encrypts exactly one message and is then discarded. The new chain key replaces the old one.&lt;/p&gt;

&lt;p&gt;The security property: given only &lt;code&gt;new_chain&lt;/code&gt;, you cannot recover &lt;code&gt;chain_key&lt;/code&gt; (HKDF is one-way). Given only &lt;code&gt;message_key&lt;/code&gt;, you cannot recover &lt;code&gt;chain_key&lt;/code&gt; either. And since the old chain key was overwritten by the new one, a later attacker who extracts memory sees only the current chain — not any of the past ones. Past message keys are unrecoverable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Domain separation
&lt;/h2&gt;

&lt;p&gt;Notice those &lt;code&gt;LABEL_*&lt;/code&gt; constants. HKDF uses them as "info" parameters to distinguish different derivations from the same input. Anyhide has seven labels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;LABEL_HEADER_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;b"ANYHIDE-CHAT-HEADER"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;LABEL_SEND_CHAIN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;b"ANYHIDE-CHAT-SEND"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;LABEL_RECV_CHAIN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;b"ANYHIDE-CHAT-RECV"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;LABEL_CARRIER_CHAIN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;b"ANYHIDE-CHAT-CARRIER"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;LABEL_PASSPHRASE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;b"ANYHIDE-CHAT-PASS"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;LABEL_CHAIN_ADVANCE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;b"ANYHIDE-CHAT-CHAIN"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;LABEL_MESSAGE_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;b"ANYHIDE-CHAT-MESSAGE"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why so many? Because HKDF with the same input but different labels produces uncorrelated outputs. Using distinct labels for distinct purposes means that leaking, say, a carrier chain key doesn't help an attacker derive header keys, and vice versa.&lt;/p&gt;

&lt;p&gt;This is cheap to get right and expensive to get wrong. If you reuse the same label for two different purposes, you can end up in the situation where two independent parts of your protocol derive the &lt;em&gt;same&lt;/em&gt; key — and now the two parts can impersonate each other's outputs.&lt;/p&gt;

&lt;p&gt;The rule I follow: every &lt;code&gt;hkdf.expand()&lt;/code&gt; call gets a unique label that names its purpose. No exceptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DH ratchet
&lt;/h2&gt;

&lt;p&gt;The symmetric ratchet is half the story. It protects message keys within a single direction of the conversation. The other half is the &lt;em&gt;DH ratchet&lt;/em&gt;: every time the conversation direction changes (Alice sends, then Bob replies), a new ephemeral keypair is generated and the chain keys are refreshed from a fresh DH output.&lt;/p&gt;

&lt;p&gt;This is what gives you the "double" in Double Ratchet: symmetric KDF advances within a run of same-direction messages, DH refreshes when direction changes.&lt;/p&gt;

&lt;p&gt;The practical effect: even if an attacker compromises the full session state at some point in the conversation, they lose access to future messages as soon as the next DH ratchet step happens. Because the new ephemeral secret is generated locally and immediately mixed into the chains, the attacker can't derive the post-ratchet keys without DH-negotiating with one of the parties.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three storage formats (and why)
&lt;/h2&gt;

&lt;p&gt;Here's the part I didn't see coming when I started building this. Once you have ephemeral keys, you have to &lt;em&gt;store&lt;/em&gt; them somewhere. And "somewhere" turns out to depend a lot on the use case.&lt;/p&gt;

&lt;p&gt;Anyhide supports three formats:&lt;/p&gt;

&lt;h3&gt;
  
  
  Format 1: Individual PEM files
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alice.pub      -----BEGIN ANYHIDE EPHEMERAL PUBLIC KEY-----
alice.key      -----BEGIN ANYHIDE EPHEMERAL SECRET KEY-----
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The header explicitly distinguishes ephemeral keys from long-term keys. If you accidentally try to use an ephemeral key as a long-term identity, the parser yells at you. This format is good for one-off file transfers where you hand-manage keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  Format 2: Separate consolidated JSON
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;mykeys.eph.key&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"contacts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bob"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"base64..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"carol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"base64..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;contacts.eph.pub&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"contacts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bob"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"base64..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"carol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"base64..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One file for your private ephemeral keys, another for the public keys you hold for each contact. This is good when you want to keep private key material in a separate, more-protected file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Format 3: Unified JSON
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;contacts.eph&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"contacts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bob"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"my_private"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="s2"&gt;"base64..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"their_public"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"base64..."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One file per contact, containing both sides of the key pair. This is what the chat client uses, because during a live chat the two things you need — your private key for this contact and their public key — are always used together. Splitting them into separate files would just be pain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why three?
&lt;/h2&gt;

&lt;p&gt;I tried to collapse these down. I couldn't. Each use case has a different access pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Format 1&lt;/em&gt; (individual PEM) is good for &lt;em&gt;batch&lt;/em&gt; operations where you're moving keys between machines. PEM files are human-inspectable, work with standard tools, and can be attached to emails or printed to paper.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Format 2&lt;/em&gt; (separate consolidated) is good for &lt;em&gt;archival&lt;/em&gt; — you want many contacts in one file, but with clear separation between private and public material. The file you back up is different from the file you share.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Format 3&lt;/em&gt; (unified per-contact) is good for &lt;em&gt;runtime&lt;/em&gt; — the chat client needs fast access to the current key pair for one contact at a time. Having both halves co-located eliminates a class of bugs where a reply uses a mismatched pair.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rather than force users into one pattern, I let each use case pick. The cost is some serde code and documentation. The benefit is that the tool never makes the user fight the storage model.&lt;/p&gt;

&lt;p&gt;If I were writing this today, I'd probably unify them behind a &lt;code&gt;KeyStore&lt;/code&gt; trait and let the caller pick a backend. Right now they're three different code paths with similar logic. Not the worst tech debt but it bothers me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ratchet in action
&lt;/h2&gt;

&lt;p&gt;From the library API, using the ratchet looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;anyhide&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;encode_with_config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EncoderConfig&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EncoderConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ratchet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="nn"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encode_with_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;carrier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"hello!"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"pass"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bobs_current_public_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// result.code is what you send on the wire.&lt;/span&gt;
&lt;span class="c1"&gt;// result.next_keypair is your new ephemeral keypair for the next round.&lt;/span&gt;
&lt;span class="c1"&gt;// Save it. When Bob replies, use next_keypair.secret to decrypt.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when Bob decodes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decode_with_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;carrier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"pass"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                  &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;my_secret_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;DecoderConfig&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next_public&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;decoded&lt;/span&gt;&lt;span class="py"&gt;.next_public_key&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Alice included her new public key for next round.&lt;/span&gt;
    &lt;span class="c1"&gt;// Store it and use it for your reply.&lt;/span&gt;
    &lt;span class="nf"&gt;update_contacts_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"bob"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;next_public&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ratchet metadata rides inside the encrypted payload, not alongside it. An attacker on the wire can't see that key rotation is happening, can't see the ephemeral public keys, can't distinguish a ratcheting session from a non-ratcheting one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's coming
&lt;/h2&gt;

&lt;p&gt;Post 5 is about how two parties &lt;em&gt;find each other&lt;/em&gt; in the first place and set up the initial session. That's the handshake, and it runs over Tor hidden services using the &lt;code&gt;arti-client&lt;/code&gt; crate. I haven't seen a lot of content on building on top of arti, so I'll spend some time on the rough edges and on what Tor gives you (and what it doesn't).&lt;/p&gt;

&lt;p&gt;Post 6 closes out the series with the terminal UI: ratatui, multi-contact tabs, a command console overlay modeled on Doom's, and the UX work to make cryptography not feel hostile to use.&lt;/p&gt;

&lt;p&gt;Both dropping every two weeks.&lt;/p&gt;




&lt;p&gt;Repo: &lt;a href="https://github.com/matutetandil/anyhide" rel="noopener noreferrer"&gt;github.com/matutetandil/anyhide&lt;/a&gt;. If you're building with the Double Ratchet or similar constructions in Rust, I'd love to compare notes.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>cryptography</category>
      <category>security</category>
      <category>signal</category>
    </item>
    <item>
      <title>Code review when half the PR is AI-generated</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Wed, 03 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mdenda/code-review-when-half-the-pr-is-ai-generated-5hl4</link>
      <guid>https://dev.to/mdenda/code-review-when-half-the-pr-is-ai-generated-5hl4</guid>
      <description>&lt;p&gt;Picture a reviewer opening a PR on Monday morning. The title says "Add user search endpoint." They click on the Files tab.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;+847 −12&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Eight hundred forty-seven lines added. The reviewer has maybe 15 minutes before the next meeting. They scroll through the diff. The code looks clean — variable names are descriptive, functions are reasonable length, there are tests. They skim, see nothing obviously wrong, and approve.&lt;/p&gt;

&lt;p&gt;Three weeks later, the feature ships. Users complain the search is slow. Two weeks after that, the database team files an urgent ticket: a query is scanning 4 million rows on every request. The fix takes a day. The post-mortem blames "insufficient load testing."&lt;/p&gt;

&lt;p&gt;The post-mortem is wrong. The problem was that a reviewer with 15 minutes can't meaningfully review 847 lines of AI-generated code. And that's the new reality for half the PRs most teams see in 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review problem AI created
&lt;/h2&gt;

&lt;p&gt;Before AI, review bandwidth loosely tracked writing bandwidth. If your team wrote 5 PRs a day, they produced at roughly human speed, which meant they were reviewable at roughly human speed. Reviewers could read at the pace code was written.&lt;/p&gt;

&lt;p&gt;AI broke that equation. Writers can now produce 10x more code in the same time. Reviewers still read at human speed. Same team, same number of reviewers, 10x more code to review.&lt;/p&gt;

&lt;p&gt;The result is what every tech lead I've talked to confirms: &lt;em&gt;review quality has dropped across the industry since AI adoption&lt;/em&gt;. Not because reviewers got lazier. Because the volume became impossible to review well at the bar they used to maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What bad AI-generated code actually looks like
&lt;/h2&gt;

&lt;p&gt;If you've reviewed AI-generated PRs, you've probably noticed the problem isn't the kind of thing that jumps out. AI rarely produces code that's obviously broken. The dangerous code looks fine.&lt;/p&gt;

&lt;p&gt;Some patterns I see repeatedly:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The library that doesn't exist.&lt;/em&gt; AI invents a package. The code imports &lt;code&gt;@acme/super-fast-cache&lt;/code&gt;, uses it throughout, and there's no such package. It fails at install time, so it gets caught — but the reviewer didn't catch it. The reviewer trusted that if it's imported, it exists.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The API that doesn't work that way.&lt;/em&gt; AI uses methods that don't exist on real libraries, or exist but with different signatures. &lt;code&gt;redis.mget(keys, { default: 0 })&lt;/code&gt; — Redis doesn't have a &lt;code&gt;default&lt;/code&gt; option. The code runs, the option gets ignored, defaults don't apply, bugs surface in production.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The silent assumption.&lt;/em&gt; AI writes code that makes assumptions it doesn't verify. It assumes input is UTF-8. It assumes timestamps are in UTC. It assumes the database timeout matches the service timeout. A reviewer reading the code sees nothing wrong because the assumptions are invisible — they exist in what &lt;em&gt;wasn't&lt;/em&gt; written.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The "works in the tutorial" pattern.&lt;/em&gt; AI produces code that works for the tutorial case and fails at scale. The pagination example. The authentication middleware that doesn't handle token refresh. The file upload that buffers everything in memory. Every one of these looks clean in isolation.&lt;/p&gt;

&lt;p&gt;These are not the kinds of bugs a junior reviewer catches. They're the kinds a senior reviewer catches because they've seen them before.&lt;/p&gt;

&lt;h2&gt;
  
  
  The skill that suddenly got very valuable
&lt;/h2&gt;

&lt;p&gt;If AI broke the writing-to-reviewing ratio, the people who unbreak it are reviewers who can go through large diffs fast without losing signal. That's a skill. It's always been valuable. It's now scarce.&lt;/p&gt;

&lt;p&gt;What senior reviewers actually do that juniors don't:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;They read diffs top-down, not linearly.&lt;/em&gt; Junior reviewers start at line 1 and read through. Senior reviewers scan structure first: What files changed? What's the shape of the change? Is the scope what the PR title claims? Only after they understand the shape do they drill into code.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;They look for what's missing, not what's there.&lt;/em&gt; A reviewer who reads AI code line by line will find typos and style issues. A reviewer who asks "what tests would fail if I were trying to break this?" finds the real problems. Missing error handling, missing edge cases, missing cleanup in failure paths.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;They flag assumptions, not just bugs.&lt;/em&gt; Senior reviewers comment: "What happens if the user list is empty?" "What's the behavior when the upstream service times out?" "Is this endpoint idempotent? If not, should it be?" These questions don't point to broken code — they point to the invisible choices AI made by default.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;They know the 80/20 of the codebase.&lt;/em&gt; A senior reviewer who's been on a codebase for a year knows which files are load-bearing, which services are latency-sensitive, which modules have caused production incidents. They weight their attention accordingly — a one-line change in the payment service gets more scrutiny than a 100-line change in the marketing page.&lt;/p&gt;

&lt;p&gt;None of this is new. All of it got more valuable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the review bar has to look like now
&lt;/h2&gt;

&lt;p&gt;The reviewer's mandate has shifted. It used to be "catch obvious bugs." It now has to be "verify the code is correct for this codebase, at this scale, for this business."&lt;/p&gt;

&lt;p&gt;Concrete changes that mature teams are adopting:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Smaller PRs, enforced.&lt;/em&gt; If AI lets developers write 800-line PRs in a morning, the team needs to cap PR size institutionally. Most teams landing on a ~400-line max, split across multiple PRs when exceeded. The rationale: a 400-line PR can still be reviewed well in 20-30 minutes; 800 lines cannot.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Required "context" section in PR description.&lt;/em&gt; Not just "what does this do" — but "what scale does this need to handle? What failure modes did you consider? What did AI write that you verified carefully?" Makes invisible decisions visible.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Pairing AI-heavy code with focused review.&lt;/em&gt; If a PR is substantially AI-generated, it gets a more experienced reviewer by default. Teams assign this through CODEOWNERS or routing rules.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Review budgets in schedules.&lt;/em&gt; Used to be that review was something engineers did in the cracks of their day. With AI volume, review is a first-class activity. Teams that take this seriously reserve 1-2 hours per day for deep review, protect it like any other focused work.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical setup
&lt;/h2&gt;

&lt;p&gt;Here's a GitHub Actions snippet that enforces PR size limits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/pr-size-check.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PR size check&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;opened&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;synchronize&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;check-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check PR size&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;CHANGES=$(git diff --shortstat origin/${{ github.base_ref }}...HEAD \&lt;/span&gt;
            &lt;span class="s"&gt;| awk '{ print $4 + $6 }')&lt;/span&gt;
          &lt;span class="s"&gt;echo "Total changed lines: $CHANGES"&lt;/span&gt;
          &lt;span class="s"&gt;if [ "$CHANGES" -gt 400 ]; then&lt;/span&gt;
            &lt;span class="s"&gt;echo "::error::PR exceeds 400 lines ($CHANGES). Split into smaller PRs."&lt;/span&gt;
            &lt;span class="s"&gt;exit 1&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't a perfect tool. Some legitimate PRs are larger (big refactors, generated code). The point isn't to be strict — it's to force a conversation every time a PR exceeds a reasonable size. Sometimes the conversation concludes "yes, this one is fine." Often it concludes "this could be three PRs."&lt;/p&gt;

&lt;p&gt;And a template for PR descriptions that surfaces invisible AI decisions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- .github/pull_request_template.md --&amp;gt;&lt;/span&gt;

&lt;span class="gu"&gt;## What changed&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- High-level summary --&amp;gt;&lt;/span&gt;

&lt;span class="gu"&gt;## Why this change&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Business or technical reason --&amp;gt;&lt;/span&gt;

&lt;span class="gu"&gt;## Context that doesn't live in the code&lt;/span&gt;

&lt;span class="ge"&gt;*Expected scale:*&lt;/span&gt; &lt;span class="c"&gt;&amp;lt;!-- e.g., 10k req/min, 1M rows --&amp;gt;&lt;/span&gt;
&lt;span class="ge"&gt;*Failure modes considered:*&lt;/span&gt; &lt;span class="c"&gt;&amp;lt;!-- e.g., upstream timeout, DB unavailable --&amp;gt;&lt;/span&gt;
&lt;span class="ge"&gt;*AI-assisted sections:*&lt;/span&gt; &lt;span class="c"&gt;&amp;lt;!-- Which parts used AI, and what you verified --&amp;gt;&lt;/span&gt;

&lt;span class="gu"&gt;## Testing&lt;/span&gt;
&lt;span class="c"&gt;&amp;lt;!-- How you tested this beyond CI --&amp;gt;&lt;/span&gt;

&lt;span class="gu"&gt;## For the reviewer&lt;/span&gt;
&lt;span class="c"&gt;&amp;lt;!-- What to pay special attention to --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This template takes an extra 2 minutes to fill out. It saves reviewers far more than that, and forces the PR author to surface the context AI couldn't generate on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hardest thing about all this
&lt;/h2&gt;

&lt;p&gt;The hardest thing about post-AI code review isn't technical. It's cultural.&lt;/p&gt;

&lt;p&gt;Teams that were getting along fine with casual review are suddenly finding that their production incident rate is climbing, and the incidents trace back to merged PRs that looked fine. The temptation is to blame AI and argue that we should use it less. That's the wrong conclusion — AI is here, and the productivity gains are too real to forgo.&lt;/p&gt;

&lt;p&gt;The right conclusion is harder: &lt;em&gt;the review bar has to go up&lt;/em&gt;, and teams need to invest in reviewing like they invest in writing. That means explicit time budgeted, training for junior reviewers, pairing on complex reviews, and accepting that shipping 10x more code means spending more total time on quality, not less.&lt;/p&gt;

&lt;p&gt;If you're a tech lead reading this: your team needs explicit guidance on what good review looks like in this new context. Nobody can figure it out on their own. The old instincts don't scale to AI-era volume.&lt;/p&gt;

&lt;p&gt;If you're a senior IC: your review skill just became one of your most valuable assets. Invest in it. Slow down on your own writing if necessary. A thoughtful review that catches a scaling issue before production is worth more than a dozen PRs merged without one.&lt;/p&gt;

&lt;p&gt;If you're a junior: reviewing is the fastest way to learn the codebase and develop senior judgment. Pair on reviews with people who catch what you don't. Ask them to explain their reasoning. That's where the real training happens now — not in writing code AI will increasingly write for you, but in evaluating whether AI's output is correct for your specific context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to the amplifier
&lt;/h2&gt;

&lt;p&gt;Two weeks ago I argued that AI amplifies what developers already are. The same applies to reviewers.&lt;/p&gt;

&lt;p&gt;A senior reviewer using the new review practices — smaller PRs, explicit context, reserved time — catches 10x more issues than they used to, because they're reviewing higher-density code more deliberately.&lt;/p&gt;

&lt;p&gt;A junior reviewer rubber-stamping AI-generated PRs misses 10x more issues than they used to, because the volume grew faster than their experience.&lt;/p&gt;

&lt;p&gt;Same AI. Same reviewers. Radically different outcomes.&lt;/p&gt;

&lt;p&gt;The fundamentals of code review — reading for missing cases, flagging assumptions, knowing your codebase — haven't changed. They've just become the thing separating teams that thrive in the AI era from teams drowning in production incidents.&lt;/p&gt;

&lt;p&gt;Don't skip them. They're not optional. They're more important now than ever.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is part of a series on AI and engineering fundamentals. My book&lt;/em&gt; &lt;em&gt;&lt;a href="https://mdenda.gumroad.com/l/git-in-depth" rel="noopener noreferrer"&gt;Git in Depth&lt;/a&gt;&lt;/em&gt; &lt;em&gt;has a full chapter on code review — the principles that don't change whether code is human-written or AI-generated.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related:&lt;/em&gt; &lt;em&gt;&lt;a href="https://dev.to/mdenda/why-ai-made-fundamentals-more-valuable-not-less-3a8f"&gt;Why AI made fundamentals more valuable, not less&lt;/a&gt;&lt;/em&gt; &lt;em&gt;·&lt;/em&gt; &lt;em&gt;&lt;a href="https://dev.to/mdenda/what-happens-when-an-ai-agent-commits-to-your-repo-4cgg"&gt;What happens when an AI agent commits to your repo&lt;/a&gt;&lt;/em&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;See all my articles on Git and engineering practice:&lt;/em&gt; &lt;em&gt;&lt;a href="https://dev.to/mdenda"&gt;dev.to/mdenda&lt;/a&gt;&lt;/em&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>codereview</category>
      <category>softwareengineering</category>
      <category>career</category>
    </item>
    <item>
      <title>Most of Your Microservice Is Plumbing. What If You Stopped Writing It?</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Tue, 02 Jun 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/mdenda/most-of-your-microservice-is-plumbing-what-if-you-stopped-writing-it-4fcf</link>
      <guid>https://dev.to/mdenda/most-of-your-microservice-is-plumbing-what-if-you-stopped-writing-it-4fcf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Every microservice is mostly plumbing: an HTTP server, connection pools, marshalling, retries, health checks, wiring — the same in every service, rewritten with different nouns. &lt;a href="https://github.com/matutetandil/mycel" rel="noopener noreferrer"&gt;Mycel&lt;/a&gt; is a declarative runtime that lets you &lt;em&gt;declare&lt;/em&gt; that plumbing instead of writing it, then runs the result as a real production microservice. When I showed it off, almost everyone assumed "declarative = prototyping toy." This post is why that's backwards — and what the hard parts (auth, retries, migrations) look like when they're config instead of code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Be honest about your last microservice
&lt;/h2&gt;

&lt;p&gt;Think about the last service you wrote. A router. A handler. A DTO struct. A validation layer. A connection pool. A query. Marshalling the result back to JSON. Retry logic around the flaky call. A health endpoint that returns &lt;code&gt;200&lt;/code&gt; whether or not the database is actually reachable. Then the &lt;em&gt;next&lt;/em&gt; service, where you wrote the same dozen things again with different nouns.&lt;/p&gt;

&lt;p&gt;Almost none of that is &lt;em&gt;your&lt;/em&gt; service. It's plumbing — identical in shape across every microservice you'll ever write, just with the nouns swapped. The part that's genuinely yours — the handful of decisions that make this service &lt;em&gt;this&lt;/em&gt; one and not some other — is a thin layer riding on a thick stack of boilerplate you've written a hundred times.&lt;/p&gt;

&lt;p&gt;So a while ago I built a runtime where you &lt;strong&gt;declare&lt;/strong&gt; the plumbing instead of writing it: you describe what connects to what, and it runs as a real microservice. I showed the three-file version in a previous post — and the response taught me something I didn't expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everyone filed it under "prototyping" — and that's on me
&lt;/h2&gt;

&lt;p&gt;The post got a generous, sharp response. And almost every serious reply said some version of the same thing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;"Love the concept for rapid prototyping and internal tools."&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"zero-code-to-running-microservice is the fun demo… the honest test is what happens at file #4."&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"excellent for validating concepts quickly… how does it handle complex logic as it scales beyond three files?"&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read those again and notice the vocabulary: &lt;em&gt;prototyping, internal tools, the fun demo, validating concepts quickly.&lt;/em&gt; That's not three people describing my tool. That's three people describing a &lt;strong&gt;category&lt;/strong&gt; — and concluding, reasonably, that production is where the category gives out.&lt;/p&gt;

&lt;p&gt;I stared at this for a while not understanding why everyone landed on "toy." Then it clicked: they didn't land there. I put them there.&lt;/p&gt;

&lt;p&gt;A reader forms a category in about five seconds, from the headline, before evaluating anything. My headline was &lt;em&gt;"3 files, zero code."&lt;/em&gt; In the mental map of basically every engineer, "no code + something running instantly" is a &lt;strong&gt;settled drawer&lt;/strong&gt;: no-code / low-code — Bubble, Zapier, Retool. And that drawer ships with a verdict already inside it: &lt;em&gt;brilliant for prototypes and internal tools, quietly falls apart when you need a real production service.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So when I handed people "zero code," they didn't run the tool through their judgment and conclude "prototype-only." They dropped it in the drawer and the drawer answered for them. You can see it in the exact words they used — they were describing the shelf, not the software. And you can't argue someone out of a conclusion they imported wholesale with the label.&lt;/p&gt;

&lt;p&gt;That's on me. "Zero code" is a &lt;em&gt;true&lt;/em&gt; sentence that points at the &lt;em&gt;wrong&lt;/em&gt; category. So let me throw the label out and say what the thing actually is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Mycel actually is
&lt;/h2&gt;

&lt;p&gt;Mycel is a &lt;strong&gt;declarative microservice runtime&lt;/strong&gt;. You describe what your service connects to and how data moves between those connections; the runtime &lt;em&gt;interprets that config at runtime&lt;/em&gt; and runs as a real microservice.&lt;/p&gt;

&lt;p&gt;Three things that drawer gets wrong about it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's not a visual builder.&lt;/strong&gt; There are no boxes to click. It's config — text, in version control, reviewed in pull requests, diffable like any other code artifact. Closer in spirit to a Terraform config or an &lt;code&gt;nginx.conf&lt;/code&gt; than to a drag-and-drop canvas. (That's the &lt;em&gt;only&lt;/em&gt; place I'll lean on that comparison — not as a pitch, just to move you out of the wrong drawer.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's not a code generator.&lt;/strong&gt; This is the one I most want to kill. One commenter put the fear precisely: &lt;em&gt;"'zero code' quietly becomes 'a lot of code I didn't write and don't understand.'"&lt;/em&gt; That's a real and rational fear — &lt;strong&gt;of codegen.&lt;/strong&gt; Scaffolding tools spray code you now own and can't fully read. Mycel generates nothing. There is no emitted Go sitting in your repo. The runtime reads your config and &lt;em&gt;does the thing&lt;/em&gt;; "file #4" is more configuration, never a heap of opaque generated source. There's nothing to inherit because nothing was generated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's production-first, not demo-first.&lt;/strong&gt; I don't run it to mock things up. I run it in production today — consuming from a hosted message broker, on Kubernetes, doing real work. The three-file version is the &lt;em&gt;smallest legible example&lt;/em&gt;, not the ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "file #4" test — where the engineering actually lives
&lt;/h2&gt;

&lt;p&gt;The fairest pushback I got named the exact things that separate a demo from a service: &lt;em&gt;validation beyond type-checking, retry logic with specific backoff curves, health checks that test downstream dependencies, env management across services&lt;/em&gt; — plus auth, migrations, rate limiting.&lt;/p&gt;

&lt;p&gt;Here's the whole bet: &lt;strong&gt;those are configuration concerns, not code concerns.&lt;/strong&gt; They stay declarative instead of becoming the 2,000 lines of glue you hand-write and maintain. Quick tour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validation beyond types&lt;/strong&gt; — field constraints plus custom rules (regex, CEL, or WASM):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="s2"&gt;"signup"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;email&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;format&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"email"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;age&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;min_length&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pattern&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;plan&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;enum&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"free"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"pro"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;tax_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;validator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"valid_vat"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;   &lt;span class="c1"&gt;# custom CEL/WASM rule&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;Retry with real backoff curves&lt;/strong&gt; — not just "try N times":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;error_handling&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;retry&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;attempts&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
    &lt;span class="nx"&gt;delay&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1s"&lt;/span&gt;
    &lt;span class="nx"&gt;max_delay&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"30s"&lt;/span&gt;
    &lt;span class="nx"&gt;backoff&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"exponential"&lt;/span&gt;   &lt;span class="c1"&gt;# or linear / constant&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…plus a circuit breaker and per-error-class dispositions (&lt;code&gt;ack&lt;/code&gt; / &lt;code&gt;retry&lt;/code&gt; / &lt;code&gt;requeue&lt;/code&gt; / &lt;code&gt;reject&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Health that actually probes dependencies&lt;/strong&gt; — &lt;code&gt;/health/ready&lt;/code&gt; doesn't just return 200. It pings every connector (DB &lt;code&gt;PingContext&lt;/code&gt;, Redis &lt;code&gt;PING&lt;/code&gt;, and so on) and reports per-component status; &lt;code&gt;mycel check&lt;/code&gt; runs the same probes before the service accepts traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Env across services&lt;/strong&gt; — an &lt;code&gt;env()&lt;/code&gt; function, per-environment overlays (&lt;code&gt;environments/prod.mycel&lt;/code&gt;), &lt;code&gt;.env&lt;/code&gt; support, environment-aware defaults (debug logs + hot reload in dev; JSON logs + locked-down errors in prod), and connector profiles to swap backends per environment.&lt;/p&gt;

&lt;p&gt;And the one that most clearly isn't a toy: a &lt;strong&gt;transactional, multi-statement write&lt;/strong&gt; — clear previous rows, insert a parent, capture its autoincrement id, loop over N children that reference it, all atomic in one DB transaction — declared in config. That's the kind of "complex" that used to force you straight back into code.&lt;/p&gt;

&lt;p&gt;Want file #4 for real instead of my word for it? The &lt;code&gt;auth&lt;/code&gt; example in the repo is a &lt;em&gt;single config&lt;/em&gt; with persistence, JWT, MFA, brute-force lockout, sessions, and audit. That's "what happens when you add the hard stuff," and it's still config.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest tradeoff
&lt;/h2&gt;

&lt;p&gt;I'm not going to pretend complexity evaporates. It doesn't. Auth is inherently complex, so an auth config is genuinely more involved than a three-line flow.&lt;/p&gt;

&lt;p&gt;But be precise about &lt;em&gt;what&lt;/em&gt; the tradeoff is. It is &lt;strong&gt;not&lt;/strong&gt; "clean demo → hidden code you didn't write." It's "clean demo → &lt;strong&gt;more config&lt;/strong&gt;." What you trade is writing and maintaining &lt;em&gt;the how&lt;/em&gt; for learning the vocabulary of &lt;em&gt;the what&lt;/em&gt;. Your &lt;code&gt;nginx.conf&lt;/code&gt; grows as you add TLS, caching, and rate limiting — but it never turns into C you have to maintain. Same shape here.&lt;/p&gt;

&lt;p&gt;That's a real cost — there's a vocabulary to learn — but it's a fundamentally different cost than "a thousand lines of generated Go I'm now on the hook for."&lt;/p&gt;

&lt;h2&gt;
  
  
  When you outgrow the vocabulary
&lt;/h2&gt;

&lt;p&gt;There's a ceiling, and I'd rather name it than pretend it away. When something genuinely doesn't fit declaratively, the escape hatch is a &lt;strong&gt;WASM plugin&lt;/strong&gt;: you write that one piece in Rust or Go, compile it, and the runtime calls it. &lt;em&gt;That&lt;/em&gt; is code you write and own.&lt;/p&gt;

&lt;p&gt;I want that seam to be obvious, not hidden. The declarative layer handles the 90% that's plumbing; the escape hatch covers the 10% that's truly yours — and you never lose the ability to drop to real code for it. A tool that pretends it covers 100% of cases is lying; one with a clear seam is just being honest about where the line is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prototyping vs. production is the wrong axis
&lt;/h2&gt;

&lt;p&gt;Here's the reframe I should have led with.&lt;/p&gt;

&lt;p&gt;"Good for prototypes, bad for production" treats &lt;em&gt;maturity&lt;/em&gt; as the axis. It isn't. The real axis is &lt;strong&gt;plumbing vs. logic that's genuinely yours.&lt;/strong&gt; Every microservice is mostly plumbing — HTTP server, connection pools, marshalling, retries, health, wiring — with a handful of decisions on top that make it &lt;em&gt;this&lt;/em&gt; service and not some other one.&lt;/p&gt;

&lt;p&gt;Mycel removes the plumbing. That's just as true when you're prototyping as when you're on Kubernetes serving real traffic — it's the &lt;em&gt;same tool doing the same job at both ends.&lt;/em&gt; It was never "a prototyping tool that struggles in production." It's a runtime that deletes the part of the work that was identical in both places, and leaves you the part that was always the point.&lt;/p&gt;

&lt;p&gt;The three-file demo wasn't the product. It was the smallest honest look at the product. File #4 is where it gets interesting — and file #4 is still config.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/matutetandil/mycel" rel="noopener noreferrer"&gt;https://github.com/matutetandil/mycel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File #4, for real:&lt;/strong&gt; the &lt;code&gt;auth&lt;/code&gt; example — persistence + JWT + MFA + brute-force + sessions + audit, in config&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker:&lt;/strong&gt; &lt;code&gt;ghcr.io/matutetandil/mycel&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you read the last post and thought "nice toy," this one's my rebuttal — and I'd still rather have you try to poke holes in it than nod along. That's genuinely how it gets to production-grade. Next post: what happens to a service like this when the power actually goes out.&lt;/p&gt;

</description>
      <category>go</category>
      <category>microservices</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>What happens when an AI agent commits to your repo</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Wed, 27 May 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mdenda/what-happens-when-an-ai-agent-commits-to-your-repo-4cgg</link>
      <guid>https://dev.to/mdenda/what-happens-when-an-ai-agent-commits-to-your-repo-4cgg</guid>
      <description>&lt;p&gt;A few weeks ago, I argued that AI is not a great equalizer — it's a great amplifier. It amplifies what developers already are, for better and for worse. Juniors with AI produce junior code at senior speed. Seniors with AI produce senior code at supernatural speed.&lt;/p&gt;

&lt;p&gt;This post is about where that amplification becomes visible: your Git history.&lt;/p&gt;

&lt;p&gt;Every team that's adopted AI coding assistants — Claude Code, Cursor, GitHub Copilot, Windsurf — has introduced a new kind of contributor to their repo. Sometimes it's tagged explicitly (&lt;code&gt;co-authored-by: claude&lt;/code&gt;). Sometimes it's invisible. Either way, the commits AI produces (or AI-assisted developers produce) look different, and Git reveals the difference within weeks.&lt;/p&gt;

&lt;p&gt;Here's what to watch for, and why the fundamentals of Git practice matter more now than ever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two kinds of AI-assisted commits
&lt;/h2&gt;

&lt;p&gt;Open any repository that's been using AI assistance for a few months and run this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"3 months ago"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-50&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see two patterns emerge.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Pattern A — the junior-amplified commit:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a7f3c21 fix stuff
9e2b8f4 more changes
12a4e5c update
8d1f90b fix tests
f5a23de wip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Large commits, vague messages, no clear scope. The developer asked AI "fix this bug" and committed whatever AI produced. A month later, nobody knows what any of these commits actually do.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Pattern B — the senior-amplified commit:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a7f3c21 feat(search): add cursor-based pagination for users endpoint
9e2b8f4 perf(search): replace LIKE '%q%' with full-text index
12a4e5c test(search): add edge cases for empty query and unicode input
8d1f90b refactor(search): extract query builder into reusable module
f5a23de chore(deps): upgrade full-text-search-lib to 2.3.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Small, atomic, well-described commits. The developer guided AI to produce focused changes, committed each unit separately, and wrote messages a future debugger will thank them for.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Same AI. Same prompts, roughly. Radically different commit history.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;A Git history full of Pattern A commits is a Git history that can't be bisected, can't be reverted cleanly, can't be understood by anyone who joins the team later. Every tool that relies on commit granularity — &lt;code&gt;git bisect&lt;/code&gt;, &lt;code&gt;git revert&lt;/code&gt;, &lt;code&gt;git blame&lt;/code&gt;, &lt;code&gt;git log --follow&lt;/code&gt; — degrades to the point of uselessness.&lt;/p&gt;

&lt;p&gt;Before AI, bad commits came from rushed developers. They were relatively rare because writing bad commits took effort too — a huge "fix stuff" commit still required the developer to write the code. Now, bad commits are effortless. AI produces working code fast, and if the developer doesn't pause to structure the commits, everything lands as one undifferentiated blob.&lt;/p&gt;

&lt;p&gt;This is the amplification effect in its purest form: AI doesn't cause bad commit practice, but it removes the friction that used to limit how many bad commits a team could produce per day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What senior AI-assisted developers do differently
&lt;/h2&gt;

&lt;p&gt;If you've worked with a senior developer using Claude Code or Cursor for real production work, you'll notice they don't work the way demos suggest. Demos show a developer asking AI to build a feature, accepting the output, committing, done. Senior developers rarely work that way.&lt;/p&gt;

&lt;p&gt;What they actually do:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;They break work into commits before writing a single line.&lt;/em&gt; Before prompting AI, they think: "This feature needs three commits — the refactor, the new endpoint, the tests. I'll work on one at a time." Then they prompt AI per-commit, not per-feature.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;They review AI output for what it didn't do.&lt;/em&gt; AI produces code that answers the prompt. It doesn't answer the things you didn't ask about. Seniors read AI output asking "what edge case is missing? what error isn't handled? what happens at scale?" — and iterate until the output is actually ready.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;They rewrite commit messages by hand.&lt;/em&gt; Even when tools offer auto-generated messages, seniors rewrite them. Because the message needs to explain &lt;em&gt;why&lt;/em&gt;, not &lt;em&gt;what&lt;/em&gt; — and AI can see &lt;em&gt;what&lt;/em&gt; changed but not &lt;em&gt;why&lt;/em&gt; it was the right change.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;They separate refactors from features.&lt;/em&gt; A change that both refactors existing code and adds a new feature is a bisect nightmare. Seniors do the refactor, commit it, verify nothing broke, then add the feature as a separate commit.&lt;/p&gt;

&lt;p&gt;None of this is specific to AI. These are basic fundamentals of Git hygiene. AI just made them dramatically more important, because AI makes it easier to skip them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Git reveals, three months in
&lt;/h2&gt;

&lt;p&gt;Run these queries on any team that's been using AI assistance for a while, and the amplification effect becomes quantifiable:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Commits per PR — the concentration test:&lt;/em&gt;&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;# How many commits does each PR typically have?&lt;/span&gt;
gh &lt;span class="nb"&gt;pr &lt;/span&gt;list &lt;span class="nt"&gt;--state&lt;/span&gt; merged &lt;span class="nt"&gt;--limit&lt;/span&gt; 100 &lt;span class="nt"&gt;--json&lt;/span&gt; commits &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[] | .commits | length'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A healthy team, AI-assisted or not, has most PRs with 2-6 commits. If suddenly your team has half of PRs with 1 commit of 500+ lines, that's the junior-amplified pattern.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Commit message quality — the scoping test:&lt;/em&gt;&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;# Average commit message length&lt;/span&gt;
git log &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"3 months ago"&lt;/span&gt; &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;%s &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{ sum += length($0); count++ } END { print "avg length:", sum/count, "chars" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Teams writing good commit messages average 50-80 characters on the subject line. Teams that rubber-stamp AI's first suggestion average 20-30. If your team dropped to the lower range after adopting AI, it's a signal.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Changed files per commit — the atomicity test:&lt;/em&gt;&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;# How many files does each commit touch on average?&lt;/span&gt;
git log &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"3 months ago"&lt;/span&gt; &lt;span class="nt"&gt;--name-only&lt;/span&gt; &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;format:&lt;span class="s1"&gt;'---'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/^---$/ { if (count &amp;gt; 0) print count; count = 0; next } { count++ } END { if (count &amp;gt; 0) print count }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{ sum += $1; n++ } END { print "avg files per commit:", sum/n }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Atomic commits touch 1-5 files. If your average jumped to 15+ after AI adoption, commits are no longer scoped to individual changes.&lt;/p&gt;

&lt;p&gt;These aren't vanity metrics. Each one directly correlates with how debuggable, revertable, and understandable your codebase is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three practical habits that preserve commit quality
&lt;/h2&gt;

&lt;p&gt;If you want your team to use AI without destroying your Git history:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;1. Commit before asking AI to do the next thing.&lt;/em&gt; Treat each AI interaction as one logical unit of work. If the unit spans multiple concerns, the unit is too big — break it down first, commit as you go.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;2. Write the commit message yourself.&lt;/em&gt; Tools that auto-generate messages from diffs are convenient, but they miss the "why." Spend 30 seconds writing the message in your own words. Future you will save hours.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;3. Review the diff before committing, even if AI wrote it.&lt;/em&gt; Seniors do this automatically. It's the equivalent of proofreading a translation — AI translated intent to code, you verify the translation matches what you meant. Unreviewed commits are a liability, AI-generated or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small convention that helps
&lt;/h2&gt;

&lt;p&gt;Some teams have adopted a tag in commit messages to mark AI assistance explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;feat(auth): add SAML SSO provider [ai-assisted]

Implemented SAML 2.0 response parsing using python-saml library.
Generated test cases for malformed responses and signature
validation failures.

AI helped with: SAML library integration, test generation
Human decisions: auth flow design, error handling strategy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is optional and your team may or may not want it. But it makes two things explicit: that AI was involved, and what specifically the human brought to the table. When a bug surfaces months later and someone &lt;code&gt;git blame&lt;/code&gt;s the line, they can see immediately whether the code was AI-generated and what context the human applied.&lt;/p&gt;

&lt;p&gt;It's not a defensive measure ("don't blame me, AI wrote it"). It's an informational one ("here's the context you need to understand this change").&lt;/p&gt;

&lt;h2&gt;
  
  
  The quiet thing seniors know
&lt;/h2&gt;

&lt;p&gt;If you've been using AI assistants seriously for a year, you've probably noticed something that doesn't make the headlines: &lt;em&gt;you're more careful about commit hygiene now than you were before&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Pre-AI, if you wrote sloppy commits, you paid the cost linearly. You produced maybe 5-10 commits a day, so sloppy habits had bounded blast radius. Post-AI, you produce 30-50 commits a day. Sloppy habits destroy the codebase in a quarter.&lt;/p&gt;

&lt;p&gt;The seniors who thrive in this new environment aren't the ones using AI the hardest. They're the ones who realized early that AI's productivity gain has to be matched by increased discipline elsewhere. Every minute saved by AI in writing code gets spent in structuring, reviewing, and documenting that code.&lt;/p&gt;

&lt;p&gt;That's the amplifier in action. Use it well, and your output multiplies. Use it carelessly, and your technical debt multiplies just as fast.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is part of a series on AI and engineering fundamentals. My book&lt;/em&gt; &lt;em&gt;&lt;a href="https://mdenda.gumroad.com/l/git-in-depth" rel="noopener noreferrer"&gt;Git in Depth&lt;/a&gt;&lt;/em&gt; &lt;em&gt;is 658 pages on the Git fundamentals that AI assumes you already know — including atomic commits, commit message anatomy, and bisect workflows.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related:&lt;/em&gt; &lt;em&gt;&lt;a href="https://dev.to/mdenda/why-ai-made-fundamentals-more-valuable-not-less-3a8f"&gt;Why AI made fundamentals more valuable, not less&lt;/a&gt;&lt;/em&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;See all my articles on Git and engineering practice:&lt;/em&gt; &lt;em&gt;&lt;a href="https://dev.to/mdenda"&gt;dev.to/mdenda&lt;/a&gt;&lt;/em&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>git</category>
      <category>devops</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>I Built a REST Microservice With a Database in 3 Files — and Wrote Zero Code</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Tue, 26 May 2026 19:00:00 +0000</pubDate>
      <link>https://dev.to/mdenda/i-built-a-rest-microservice-with-a-database-in-3-files-and-wrote-zero-code-59c2</link>
      <guid>https://dev.to/mdenda/i-built-a-rest-microservice-with-a-database-in-3-files-and-wrote-zero-code-59c2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Mycel is an open-source runtime that turns configuration into a real microservice. You describe &lt;em&gt;what&lt;/em&gt; you want (this endpoint reads from that database); Mycel handles the &lt;em&gt;how&lt;/em&gt; (HTTP server, query, marshalling, validation, retries). Same binary for every service — only the config changes. It's pure Go, speaks standard protocols, and there's one running in production behind this post. Repo at the end.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The boilerplate tax
&lt;/h2&gt;

&lt;p&gt;Be honest about how your last microservice started. A router. A handler. A DTO struct. A validation layer. A database pool. A query. Marshalling the result back to JSON. Error handling around all of it. Then the &lt;em&gt;next&lt;/em&gt; service, where you write the same seven things again with different nouns.&lt;/p&gt;

&lt;p&gt;Most microservices aren't interesting code. They're plumbing — data comes in through a protocol, gets reshaped and checked, goes out to a store or another service. We keep rewriting that plumbing because the &lt;em&gt;shape&lt;/em&gt; changes even though the &lt;em&gt;pattern&lt;/em&gt; never does.&lt;/p&gt;

&lt;p&gt;What if you didn't write the plumbing at all? What if you just &lt;strong&gt;declared the shape&lt;/strong&gt; and something else ran it — the way nginx runs a web server from a config file instead of making you write the socket loop?&lt;/p&gt;

&lt;p&gt;That's Mycel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole service, in three files
&lt;/h2&gt;

&lt;p&gt;Here's a complete REST API backed by SQLite. Full CRUD. No application code — just configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;config.mycel&lt;/code&gt;&lt;/strong&gt; — what the service is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"users-service"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.0.0"&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;&lt;code&gt;connectors/connectors.mycel&lt;/code&gt;&lt;/strong&gt; — what it talks to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# An HTTP server on :3000&lt;/span&gt;
&lt;span class="nx"&gt;connector&lt;/span&gt; &lt;span class="s2"&gt;"api"&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="s2"&gt;"rest"&lt;/span&gt;
  &lt;span class="nx"&gt;port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# A SQLite database&lt;/span&gt;
&lt;span class="nx"&gt;connector&lt;/span&gt; &lt;span class="s2"&gt;"sqlite"&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="s2"&gt;"database"&lt;/span&gt;
  &lt;span class="nx"&gt;driver&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sqlite"&lt;/span&gt;
  &lt;span class="nx"&gt;database&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"./data/app.db"&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;&lt;code&gt;flows/flows.mycel&lt;/code&gt;&lt;/strong&gt; — how data moves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;flow&lt;/span&gt; &lt;span class="s2"&gt;"list_users"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;connector&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt;
    &lt;span class="nx"&gt;operation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GET /users"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;connector&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sqlite"&lt;/span&gt;
    &lt;span class="nx"&gt;target&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"users"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;flow&lt;/span&gt; &lt;span class="s2"&gt;"get_user"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;connector&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt;
    &lt;span class="nx"&gt;operation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GET /users/:id"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;connector&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sqlite"&lt;/span&gt;
    &lt;span class="nx"&gt;target&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"users"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;flow&lt;/span&gt; &lt;span class="s2"&gt;"create_user"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;connector&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt;
    &lt;span class="nx"&gt;operation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"POST /users"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;connector&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sqlite"&lt;/span&gt;
    &lt;span class="nx"&gt;target&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"users"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. A &lt;code&gt;connector&lt;/code&gt; is a bidirectional adapter — it can be a source (data comes &lt;em&gt;from&lt;/em&gt; it) or a target (data goes &lt;em&gt;to&lt;/em&gt; it). A &lt;code&gt;flow&lt;/code&gt; wires a source to a target. Read the config out loud and it tells you exactly what the service does: &lt;em&gt;"&lt;code&gt;GET /users&lt;/code&gt; reads from the &lt;code&gt;users&lt;/code&gt; table."&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Mycel scans the config directory recursively, so the file layout is yours to choose — one file or fifty. I keep one flow per file in real projects; here they're grouped to keep the example short.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Running it — in a container
&lt;/h2&gt;

&lt;p&gt;SQLite needs its table to exist first (Mycel serves the schema you give it; it doesn't invent one). One command:&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; data
sqlite3 data/app.db &lt;span class="s1"&gt;'CREATE TABLE users (
  id    INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT,
  name  TEXT
);'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now run Mycel as a container, mounting your config in and exposing the port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:/etc/mycel &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 3000:3000 &lt;span class="se"&gt;\&lt;/span&gt;
  ghcr.io/matutetandil/mycel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It boots and tells you exactly what it wired up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    ███╗   ███╗██╗   ██╗ ██████╗███████╗██╗
    ████╗ ████║╚██╗ ██╔╝██╔════╝██╔════╝██║
    ██╔████╔██║ ╚████╔╝ ██║     █████╗  ██║
    ██║╚██╔╝██║  ╚██╔╝  ██║     ██╔══╝  ██║
    ██║ ╚═╝ ██║   ██║   ╚██████╗███████╗███████╗
    ╚═╝     ╚═╝   ╚═╝    ╚═════╝╚══════╝╚══════╝
    Declarative Microservice Runtime v2.1.0

    Service: users-service v1.0.0
    Environment: development
    Port: 3000

    Connectors:
    ✓ api (rest) listening on :3000
    ✓ sqlite (database) → ./data/app.db

    Flows:
      GET    /users → sqlite:users
      GET    /users/:id → sqlite:users
      POST   /users → sqlite:users
    ✓ admin (http) health + metrics + debug on :9090

    ✓ Ready! Press Ctrl+C to stop.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the last line before &lt;em&gt;Ready&lt;/em&gt;: you also got a &lt;code&gt;/health&lt;/code&gt;, &lt;code&gt;/metrics&lt;/code&gt; (Prometheus), and a debug endpoint on &lt;code&gt;:9090&lt;/code&gt; for free — nobody declared those. Now hit the API like any other REST service:&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;# Create a user&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:3000/users &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"ada@example.com","name":"Ada Lovelace"}'&lt;/span&gt;
&lt;span class="c"&gt;# {"affected":1,"id":1}&lt;/span&gt;

&lt;span class="c"&gt;# List them&lt;/span&gt;
curl localhost:3000/users
&lt;span class="c"&gt;# [{"email":"ada@example.com","id":1,"name":"Ada Lovelace"}]&lt;/span&gt;

&lt;span class="c"&gt;# Fetch by id&lt;/span&gt;
curl localhost:3000/users/1
&lt;span class="c"&gt;# [{"email":"ada@example.com","id":1,"name":"Ada Lovelace"}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A working CRUD microservice. Zero lines of Go, JavaScript, or anything else. From the wire it's &lt;strong&gt;indistinguishable&lt;/strong&gt; from one hand-written in Go or NestJS — it speaks plain HTTP and JSON, and a client can't tell the difference. That's the point.&lt;/p&gt;

&lt;p&gt;(The write returns &lt;code&gt;{"affected":1,"id":1}&lt;/code&gt; — rows affected and the new id — and reads come back as JSON arrays. That's the raw database flow talking; the next section is how you shape it into whatever contract you want.)&lt;/p&gt;

&lt;h2&gt;
  
  
  "Okay, but real services need more than raw CRUD"
&lt;/h2&gt;

&lt;p&gt;They do. And this is where declarative stops being a toy. You add capabilities by declaring &lt;em&gt;more inside the flow&lt;/em&gt; — not by dropping into code. Everything below lives in the same &lt;code&gt;create_user&lt;/code&gt; flow you already saw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validation&lt;/strong&gt; — define a type and attach it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="s2"&gt;"user"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;flow&lt;/span&gt; &lt;span class="s2"&gt;"create_user"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;connector&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt;
    &lt;span class="nx"&gt;operation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"POST /users"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;validate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"type.user"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;connector&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sqlite"&lt;/span&gt;
    &lt;span class="nx"&gt;target&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"users"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a bad request is rejected before it ever reaches the database:&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;-X&lt;/span&gt; POST localhost:3000/users &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"x@y.com"}'&lt;/span&gt;
&lt;span class="c"&gt;# {"error":"validation error on 'name': field is required"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Transforming the data&lt;/strong&gt; — reshape the payload between &lt;code&gt;from&lt;/code&gt; and &lt;code&gt;to&lt;/code&gt;, with CEL expressions. The &lt;code&gt;transform&lt;/code&gt; block sits &lt;em&gt;inside the flow&lt;/em&gt;, right where the data passes through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;flow&lt;/span&gt; &lt;span class="s2"&gt;"create_user"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;connector&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt;
    &lt;span class="nx"&gt;operation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"POST /users"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;validate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"type.user"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;external_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"uuid()"&lt;/span&gt;
    &lt;span class="nx"&gt;email&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"lower(input.email)"&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"trim(input.name)"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;connector&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sqlite"&lt;/span&gt;
    &lt;span class="nx"&gt;target&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"users"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each line is &lt;code&gt;field = "&amp;lt;CEL expression&amp;gt;"&lt;/code&gt;. Send a messy payload and watch it get normalized on the way in:&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;-X&lt;/span&gt; POST localhost:3000/users &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"ADA@EXAMPLE.COM","name":"  Ada Lovelace  "}'&lt;/span&gt;

curl localhost:3000/users
&lt;span class="c"&gt;# [{"email":"ada@example.com","external_id":"870339c1-9e53-498c-8217-c350556f284b","id":1,"name":"Ada Lovelace"}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Email lowercased, name trimmed, a UUID generated — declared in three lines, applied before the write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retries with backoff&lt;/strong&gt; — for when a downstream is flaky, add an &lt;code&gt;error_handling&lt;/code&gt; block to the flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;error_handling&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;retry&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="nx"&gt;delay&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1s"&lt;/span&gt;
    &lt;span class="nx"&gt;backoff&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"exponential"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Want to swap SQLite for PostgreSQL? Change the connector — the flows don't move. Want to consume from RabbitMQ instead of HTTP? Change the &lt;code&gt;from&lt;/code&gt;. The flow is the stable thing; the edges are pluggable. Mycel ships connectors for REST, PostgreSQL, MySQL, MongoDB, Kafka, RabbitMQ, gRPC, GraphQL (Federation v2), Redis, S3, WebSocket, and more — all behind the same &lt;code&gt;connector&lt;/code&gt; block.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this &lt;em&gt;isn't&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Two honest disclaimers, because the concept invites two wrong assumptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It's not an orchestrator.&lt;/strong&gt; Mycel doesn't supervise other services — it &lt;em&gt;is&lt;/em&gt; a microservice. If the process dies, Kubernetes (or Docker, or systemd) restarts it, exactly like any service in any language. What Mycel handles is keeping your in-flight data safe across that restart — broker redelivery, idempotency, retries. (That's its own post.)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It's not magic for genuinely custom logic.&lt;/strong&gt; When you need behavior no connector or transform expresses, Mycel runs WASM plugins — you write that one piece in Rust or Go, compile to WebAssembly, and the runtime calls it. The declarative model bends to real logic; it doesn't pretend logic doesn't exist.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;I got tired of the gap between &lt;em&gt;"this service is conceptually trivial"&lt;/em&gt; and &lt;em&gt;"this service is still 800 lines of boilerplate I have to write, test, and maintain."&lt;/em&gt; nginx closed that gap for web serving. Terraform closed it for infrastructure. Mycel closes it for microservices: the binary is the same everywhere, and the configuration is the program.&lt;/p&gt;

&lt;p&gt;It's pure Go, no CGO, one static binary. There's a real service running on it in production right now — which is what convinced me this wasn't just a neat idea.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/matutetandil/mycel" rel="noopener noreferrer"&gt;https://github.com/matutetandil/mycel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This example:&lt;/strong&gt; it's in &lt;code&gt;examples/basic&lt;/code&gt; — clone it and &lt;code&gt;docker run&lt;/code&gt; (or grab the binary)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker:&lt;/strong&gt; &lt;code&gt;ghcr.io/matutetandil/mycel&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the idea of &lt;em&gt;declaring&lt;/em&gt; a microservice instead of writing one is interesting (or infuriating), I'd genuinely like to hear it in the comments. Next post: what happens to a config-driven service when the power goes out — the part everyone assumes a declarative tool gets wrong.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mycel is open source and early. Stars, issues, and "this would never work because…" arguments all welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>microservices</category>
      <category>devops</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Plausible Deniability in Cryptography: Building a Duress Password in Rust</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Tue, 26 May 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/mdenda/plausible-deniability-in-cryptography-building-a-duress-password-in-rust-1ahg</link>
      <guid>https://dev.to/mdenda/plausible-deniability-in-cryptography-building-a-duress-password-in-rust-1ahg</guid>
      <description>&lt;p&gt;&lt;em&gt;Post 3 of 6 on building &lt;a href="https://github.com/matutetandil/anyhide" rel="noopener noreferrer"&gt;Anyhide&lt;/a&gt;, a Rust steganography tool. This post is about threat models where the adversary isn't a server or a middleman — it's a person with leverage.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Most cryptographic tools are designed against adversaries who intercept things. They sit on the wire and try to decrypt what they see. They mine keys out of RAM. They exploit implementation bugs.&lt;/p&gt;

&lt;p&gt;But there's another adversary that most crypto doesn't help you with: the one who has you in a room and wants you to type the passphrase. This is called "rubber-hose cryptanalysis" in the literature, or sometimes "the &lt;code&gt;$5&lt;/code&gt; wrench attack" after an &lt;a href="https://xkcd.com/538/" rel="noopener noreferrer"&gt;old xkcd&lt;/a&gt;. Neither phrase really captures it. The point is simple: if someone can compel you to unlock the ciphertext, the math doesn't save you.&lt;/p&gt;

&lt;p&gt;What &lt;em&gt;can&lt;/em&gt; save you — partially, imperfectly, but usefully — is &lt;em&gt;plausible deniability&lt;/em&gt;. The idea: design the tool so that the passphrase you give under coercion reveals &lt;em&gt;something&lt;/em&gt;, but not the real thing. And make sure the revealed thing is indistinguishable from what you'd get with the real passphrase.&lt;/p&gt;

&lt;p&gt;In Anyhide this is called the duress password. This post is about how it works and how I implemented it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design goal
&lt;/h2&gt;

&lt;p&gt;When you encode a message, you can optionally supply a second message and a second passphrase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;anyhide encode &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; carrier.mp4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"the drop is at 0400 on wednesday"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"real-passphrase"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--decoy&lt;/span&gt; &lt;span class="s2"&gt;"nothing important here"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--decoy-pass&lt;/span&gt; &lt;span class="s2"&gt;"decoy-passphrase"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--their-key&lt;/span&gt; bob.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The encoder produces &lt;em&gt;one&lt;/em&gt; ciphertext that contains both messages. When Bob decodes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With &lt;code&gt;real-passphrase&lt;/code&gt; → "the drop is at 0400 on wednesday"&lt;/li&gt;
&lt;li&gt;With &lt;code&gt;decoy-passphrase&lt;/code&gt; → "nothing important here"&lt;/li&gt;
&lt;li&gt;With any other passphrase → random-looking bytes that decode cleanly but say nothing meaningful&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The critical security property: an adversary who has the ciphertext can't tell which of the three cases a given output belongs to. The decoy is not marked as "decoy" anywhere. The real message is not marked as "real". They're both just plaintexts that fell out of a decoder which always returns &lt;em&gt;something&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The never-fail decoder
&lt;/h2&gt;

&lt;p&gt;This is the philosophy that makes duress passwords actually work. Most cryptographic libraries throw an &lt;code&gt;InvalidTag&lt;/code&gt; or &lt;code&gt;AuthenticationError&lt;/code&gt; when you give them a bad passphrase. This is &lt;em&gt;great&lt;/em&gt; for debugging and &lt;em&gt;terrible&lt;/em&gt; for deniability, because the error is itself a signal.&lt;/p&gt;

&lt;p&gt;An adversary watching your screen sees "DECRYPTION FAILED" and now knows the passphrase you gave was wrong. They twist harder.&lt;/p&gt;

&lt;p&gt;Anyhide's decoder has an explicit invariant: &lt;em&gt;it never returns an error for any input&lt;/em&gt;. Any passphrase, any code, any carrier — the decoder produces bytes. If those bytes are a valid message, you see the message. If they aren't, you see what looks like random garbage, but is actually deterministic output of the same shape as a real message.&lt;/p&gt;

&lt;p&gt;This means the attack surface for "figure out which passphrase is the real one" is reduced to: does this output look like natural language? And even that gets fuzzy when the real messages are short, encrypted, or structured.&lt;/p&gt;

&lt;h2&gt;
  
  
  The configuration
&lt;/h2&gt;

&lt;p&gt;The duress feature is wired in through a tiny optional field on &lt;code&gt;EncoderConfig&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/encoder.rs&lt;/span&gt;

&lt;span class="cd"&gt;/// Configuration for a decoy message (duress password feature).&lt;/span&gt;
&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;DecoyConfig&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/// The decoy message to encode.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="cd"&gt;/// The passphrase for the decoy message.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;passphrase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// Configuration for the encoder.&lt;/span&gt;
&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;EncoderConfig&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... other fields ...&lt;/span&gt;

    &lt;span class="cd"&gt;/// Optional decoy message configuration (duress password).&lt;/span&gt;
    &lt;span class="cd"&gt;/// If provided, a second message is encoded with a different passphrase.&lt;/span&gt;
    &lt;span class="cd"&gt;/// Using the decoy passphrase reveals the decoy message instead of the real one.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;decoy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DecoyConfig&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole feature is opt-in. If you don't set &lt;code&gt;decoy&lt;/code&gt;, encoding behaves exactly as before. Backwards-compatible by construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the hood
&lt;/h2&gt;

&lt;p&gt;The encoding runs &lt;em&gt;twice&lt;/em&gt;, once with each passphrase. The real message and decoy message are each converted into their own position sequences into the carrier. Both sequences are packed into the same output structure, and the decoder uses the passphrase you provide to select which sequence to extract.&lt;/p&gt;

&lt;p&gt;The elegant part — the one that took me the longest to get right — is that an observer looking at the ciphertext cannot tell whether the decoy is present. There's no "decoy flag" or "has_decoy" field. The output size depends on message length, not on whether a decoy was used.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I want you to learn from
&lt;/h2&gt;

&lt;p&gt;Version 0.9.0 had a subtle flaw that I only spotted after shipping. Here's the scenario:&lt;/p&gt;

&lt;p&gt;Anyhide supports signing messages with Ed25519. If Alice always signs her messages, Bob knows to trust only signed messages from her. Unsigned messages from her are probably forgeries or garbage.&lt;/p&gt;

&lt;p&gt;Now: in v0.9.0, the real message was signed, but the decoy message was not.&lt;/p&gt;

&lt;p&gt;Why? Because the decoy was meant to &lt;em&gt;look&lt;/em&gt; like a normal message, and signing it felt like extra work. But consider what this hands an attacker:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Attacker seizes the laptop and the ciphertext.&lt;/li&gt;
&lt;li&gt;Attacker forces Alice to reveal a passphrase.&lt;/li&gt;
&lt;li&gt;Alice reveals the decoy passphrase. Ciphertext decodes to "nothing important here". No signature on that output.&lt;/li&gt;
&lt;li&gt;Attacker knows Alice always signs her real messages.&lt;/li&gt;
&lt;li&gt;Attacker: "The message you showed me wasn't signed. There must be another passphrase."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;v0.9.1 fixed this by signing both the real and decoy messages with the same key. Now the signature attaches to whichever message the decoder produces, and an attacker sees a valid signature on &lt;em&gt;whatever&lt;/em&gt; comes out — real or decoy — so the signature can't be used to distinguish them.&lt;/p&gt;

&lt;p&gt;The lesson: when you're building a deniability feature, every observable property of the output must be identical across the real and decoy code paths. Not "mostly identical". Not "identical unless you squint". &lt;em&gt;Identical&lt;/em&gt;. Anything else is a channel the adversary can use to distinguish real from decoy.&lt;/p&gt;

&lt;p&gt;I wrote this up in the CHANGELOG at the time because I thought it was a nice case study:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;v0.9.1&lt;/em&gt; — Fix: sign decoy message with same key as real message. Previously only the real message was signed, decoy had no signature. An attacker who knows the sender always signs could distinguish real from decoy. Now both are signed with the same key.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you're building security software, keep a public log of these. It's good for you (forces you to reason explicitly about what you broke), and it's good for your users (teaches them what the threat model looks like under stress).&lt;/p&gt;

&lt;h2&gt;
  
  
  The carrier helps too
&lt;/h2&gt;

&lt;p&gt;There's a second layer that compounds with duress passwords, which I mentioned in &lt;a href="https://dev.tolink-02"&gt;Post 2&lt;/a&gt;: multi-carrier encoding. If you use three carrier files, the attacker now needs the right files, the right order, &lt;em&gt;and&lt;/em&gt; the right passphrase. Any one of those being wrong produces garbage. Multiple of them being wrong still produces garbage. The adversary has no way to tell which axis they're off on.&lt;/p&gt;

&lt;p&gt;The combined space looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wrong passphrase + right order → garbage&lt;/li&gt;
&lt;li&gt;Right decoy passphrase + right order → decoy message&lt;/li&gt;
&lt;li&gt;Right real passphrase + wrong order → garbage&lt;/li&gt;
&lt;li&gt;Right real passphrase + right order → real message&lt;/li&gt;
&lt;li&gt;Wrong passphrase + wrong order → garbage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five cases. Four of them produce garbage that looks alike. One produces the decoy. One produces the real thing. The attacker's job is to find the one that gives them the real thing, and nothing in the output helps them triangulate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does this actually work?
&lt;/h2&gt;

&lt;p&gt;In a literal cryptanalysis sense: this does not replace strong encryption. The math is the math. What plausible deniability adds is a &lt;em&gt;human-layer&lt;/em&gt; defense: a story you can tell that is internally consistent with the ciphertext you're holding.&lt;/p&gt;

&lt;p&gt;Under coercion, you want a story that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is plausible (you can tell it without looking nervous).&lt;/li&gt;
&lt;li&gt;Is consistent with the cryptographic artifacts the adversary has.&lt;/li&gt;
&lt;li&gt;Leaves the adversary unable to prove you're lying without more evidence.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Duress passwords give you (2). You have to supply (1) yourself — the decoy has to be the kind of thing you'd actually say under normal circumstances. "Nothing important here" is weak. "Bring eggs on the way home, love you" is better. The contents matter as much as the crypto.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;If I were starting over, I'd probably support &lt;em&gt;multiple&lt;/em&gt; decoys instead of just one, with a shared-secret mechanism for choosing which one to reveal under which circumstances. This is more complex and I'm not sure it's worth the cognitive load. Single decoy covers most scenarios I can construct.&lt;/p&gt;

&lt;p&gt;The other thing I'd consider: an explicit "panic mode" where one of the passphrases not only reveals a decoy but also zeroizes the real message from the code entirely. Right now both messages are present in the ciphertext forever; if the adversary finds the real passphrase later, they get the real message. Panic mode would destroy the real-message data on first decoy-passphrase use. I haven't implemented it because the UX is tricky — you don't want to accidentally wipe your real messages — but it's on the list.&lt;/p&gt;




&lt;p&gt;Next up: &lt;em&gt;Post 4 — Forward Secrecy and the Double Ratchet&lt;/em&gt;. How Anyhide rotates keys per message so that even if your long-term keys are later compromised, past messages stay unreadable. And why I ended up supporting three different storage formats for ephemeral keys.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/matutetandil/anyhide" rel="noopener noreferrer"&gt;github.com/matutetandil/anyhide&lt;/a&gt;. Issues and discussion welcome.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>security</category>
      <category>cryptography</category>
      <category>privacy</category>
    </item>
    <item>
      <title>The lie of the 80%: why software progress charts don't work</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Thu, 21 May 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mdenda/the-lie-of-the-80-why-software-progress-charts-dont-work-564n</link>
      <guid>https://dev.to/mdenda/the-lie-of-the-80-why-software-progress-charts-dont-work-564n</guid>
      <description>&lt;h2&gt;
  
  
  The lie of the 80%: why software progress charts don't work
&lt;/h2&gt;

&lt;p&gt;Picture the ideal burndown chart. A clean diagonal line, descending steadily from "all the points" down to zero by the end of the sprint. Beautiful. Reassuring. Shareable in a stakeholder meeting.&lt;/p&gt;

&lt;p&gt;Now picture an actual sprint you've lived through. Did the line ever look like that?&lt;/p&gt;

&lt;p&gt;I'm going to guess: no. And not because your team was bad. Not because you're undisciplined. Not because the estimates were off. The reason no real burndown ever matches the ideal one is more fundamental: &lt;strong&gt;software isn't built like that.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Charts of progress assume a model of how software gets made that doesn't reflect reality. And once you see it, you can't unsee it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Software isn't a brick wall
&lt;/h2&gt;

&lt;p&gt;The mental model behind every progress chart — burndown, burnup, Gantt, percent-complete, you name it — is the same: development is a stack of bricks. You add one brick at a time. The wall gets taller. Eventually, it reaches the height you wanted, and you're done.&lt;/p&gt;

&lt;p&gt;This metaphor is comforting and almost entirely wrong.&lt;/p&gt;

&lt;p&gt;Real software development looks more like this: you build half a wall. You realize the foundation is in the wrong place. You tear the wall down. You build a different wall. You realize the &lt;em&gt;room&lt;/em&gt; is in the wrong place. You spend three days reading and thinking and not laying any bricks at all. Then on day four, you build the entire wall in six hours because you finally understood the problem.&lt;/p&gt;

&lt;p&gt;This isn't a failure mode. &lt;strong&gt;This is what software development is.&lt;/strong&gt; Exploration, dead ends, tear-downs, breakthroughs. The progress is non-linear because the work is non-linear. Any chart that assumes a smooth descent from "not done" to "done" is lying about the shape of the work itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 80% lie
&lt;/h2&gt;

&lt;p&gt;Here's the most universally-told lie in software:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"It's basically done. Just need to finish that last 20%."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every developer has said this. Every developer has heard this. And every developer has been on the receiving end of that 20% turning out to be 80% of the actual work.&lt;/p&gt;

&lt;p&gt;It's not that we're all liars. It's that the way development actually unfolds — exploration phase, implementation phase, polish phase — doesn't map cleanly onto percentages. When you say "I'm 80% done", what you usually mean is "I've done a lot, and I have a vague sense that the remaining stuff is smaller than the stuff I did". That's not a measurement. That's a feeling.&lt;/p&gt;

&lt;p&gt;The "remaining 20%" often contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The integration with the system you haven't talked to yet&lt;/li&gt;
&lt;li&gt;The edge case that surfaces only when you wire it up end-to-end&lt;/li&gt;
&lt;li&gt;The performance problem that shows up only at production scale&lt;/li&gt;
&lt;li&gt;The thing the PM forgot to mention until you demoed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are visible from where you stand at the supposed 80% mark. They're not in the chart. They can't be in the chart. They haven't been discovered yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every metric is biased by who produces it
&lt;/h2&gt;

&lt;p&gt;Here's a quieter problem with progress charts: &lt;strong&gt;the person reporting the metric always has a stake in what the metric says.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you want to look productive, you inflate. If you want to avoid getting more work assigned to you, you deflate. If you're protecting a teammate, you smooth. If you're frustrated with management, you let the truth show through harder than it should.&lt;/p&gt;

&lt;p&gt;This isn't dishonesty. It's people responding rationally to incentives. The metric isn't a mirror — it's a message. Once you understand that progress charts are a form of communication, not measurement, you stop expecting them to reflect reality. They reflect the &lt;em&gt;politics&lt;/em&gt; of the team's relationship with whoever's reading the chart.&lt;/p&gt;

&lt;p&gt;The fix isn't "be more honest". The fix is recognizing that any metric whose value depends on the reporter's interpretation will be shaped by the reporter's incentives. Always. Every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fallacy of uniform effort
&lt;/h2&gt;

&lt;p&gt;Charts also assume that everyone on the team is contributing in roughly the same way at roughly the same time. Sprint velocity. Points completed per developer. Burndown lines that smoothly descend.&lt;/p&gt;

&lt;p&gt;Real teams don't work like that. Real teams have weeks where the frontend dev ships forty CMS templates because the work happened to be parallelizable, while the backend dev appears to be drinking mate and playing ping pong for two weeks. And then the next sprint flips: the backend ships a complex migration that took weeks to design, while the frontend dev waits, tests, and unblocks.&lt;/p&gt;

&lt;p&gt;That's not dysfunction. &lt;strong&gt;That's how interdependent work looks.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your chart shows uneven contribution and your interpretation is "the backend dev didn't pull their weight", your chart has lied to you. Maybe the backend dev was the reason the frontend could ship forty templates without blockers. Maybe they were doing the design work that makes next sprint possible. Maybe they were unblocking three other teams off-camera.&lt;/p&gt;

&lt;p&gt;Charts can't see any of that. They show throughput per person and call it productivity. It isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  So who do these charts actually serve?
&lt;/h2&gt;

&lt;p&gt;Once you've internalized all of the above, the question becomes hard to avoid: if these charts don't reflect the work, don't predict the future, and don't measure productivity — &lt;strong&gt;why do we still draw them?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The honest answer: because somebody with money needs to see them.&lt;/p&gt;

&lt;p&gt;Stakeholders need a deliverable. Steering committees need slides. Procurement needs evidence the contract is being honored. Investors want a sense that the burn is producing output. None of these audiences are going to read the codebase. None of them are going to sit in your standup. They need a representation, and the chart is what we hand them.&lt;/p&gt;

&lt;p&gt;That's fine. That's the corporate game, and pretending it can be opted out of is naive. &lt;strong&gt;But let's stop pretending the chart is a tool for the team.&lt;/strong&gt; It isn't. It's a tool for the people the team has to report to. Calling it "project management" obscures what it actually is: status theater, dressed in the language of measurement.&lt;/p&gt;

&lt;p&gt;The team doesn't need the chart to know how the project is going. The team knows. They're the ones doing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually works
&lt;/h2&gt;

&lt;p&gt;If charts are theater, what's the alternative? Three things, in this order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Demos over charts.&lt;/strong&gt; The only honest measurement of progress is working software. Last week the product looked like &lt;em&gt;this&lt;/em&gt;. This week it looks like &lt;em&gt;this&lt;/em&gt;. Did it move? Did it move in the right direction? That's the question, and no chart in the world answers it as well as a five-minute demo. If you can't show anything that works, you're not progressing — no matter what the burndown says.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Narrative over numbers.&lt;/strong&gt; Replace "we're 73% done" with "we explored approach A, it didn't pan out, we're now on approach B and we expect to know if it works by Thursday". This is harder than producing a chart, because it requires the reporter to actually understand the work. That's a feature, not a bug. The narrative forces honest engagement; the chart allows comfortable distance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Output metrics as complement, not substitute.&lt;/strong&gt; What did the user actually receive? Deploys to production. Features released. Bugs closed in the wild. These can be counted because they're real events with real artifacts. The user doesn't lie. The git log doesn't lie. Process metrics — story points completed, sprint percentage, velocity trends — measure the &lt;em&gt;appearance&lt;/em&gt; of work. Output metrics measure work that left the building.&lt;/p&gt;

&lt;p&gt;And if a stakeholder genuinely needs a number — sometimes they do, and that's fair — give them one. Just be honest about what it is. &lt;em&gt;"Let's invent a number over a beer"&lt;/em&gt; is a more accurate framing than &lt;em&gt;"based on our velocity-weighted forecast of remaining story points"&lt;/em&gt;. They're often the same number. The first one tells the truth about its origins.&lt;/p&gt;

&lt;h2&gt;
  
  
  The elephant: but the stakeholders want the chart
&lt;/h2&gt;

&lt;p&gt;I'm not naive. I know that in many companies, you can't just stop producing burndowns. The contract requires it. The PMO requires it. The CFO has a slide template with that chart shape on it and changing the slide is harder than changing the moon.&lt;/p&gt;

&lt;p&gt;So produce the chart. Hand it over. Smile at the meeting.&lt;/p&gt;

&lt;p&gt;But internally, &lt;strong&gt;don't let the team start believing the chart.&lt;/strong&gt; That's where the real damage happens — when developers start optimizing for the line on the screen instead of for the product. When the chart becomes the goal, the chart will start telling you the team is doing great while the codebase is rotting in ways no chart can show.&lt;/p&gt;

&lt;p&gt;The worst version of this is the team building its own charts. When developers start producing the very theater that's being used to manage them, the gap between "what we say is happening" and "what is happening" stops being a stakeholder problem and starts being an internal one. That's how you end up with teams that look productive on paper and ship nothing of value for two quarters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;A chart never built a product.&lt;/p&gt;

&lt;p&gt;The people who build great software don't do it by staring at burndowns. They do it by talking to their teammates, watching the product grow, adjusting direction when something isn't working, and being honest with each other about what they don't yet understand. The chart is, at best, a translation layer between that work and the people who fund it.&lt;/p&gt;

&lt;p&gt;Translation layers aren't bad. But they aren't the work, and they aren't where the work happens.&lt;/p&gt;

&lt;p&gt;If you're a manager: the chart is for your audience, not your team. Don't manage by it.&lt;/p&gt;

&lt;p&gt;If you're a developer: the chart is theater. Produce it if you have to, but don't let it shape how you actually think about the project.&lt;/p&gt;

&lt;p&gt;And if you're at a company where the chart &lt;em&gt;is&lt;/em&gt; the project — where decisions are made off the line on the screen and the actual code is invisible to everyone above the team lead — that's not a measurement problem. That's a much deeper one, and no better chart will fix it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If your team genuinely needs a burndown chart to know how the project is going, the problem isn't the chart. It's that nobody is actually talking about the project.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What does your team use to track progress? Charts you trust, charts you tolerate, or something else entirely? I'd love to hear it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agile</category>
      <category>productivity</category>
      <category>career</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Why AI made fundamentals more valuable, not less</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Wed, 20 May 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mdenda/why-ai-made-fundamentals-more-valuable-not-less-3a8f</link>
      <guid>https://dev.to/mdenda/why-ai-made-fundamentals-more-valuable-not-less-3a8f</guid>
      <description>&lt;p&gt;Two developers sit down to build the same feature: a search endpoint that returns users matching a query string. Both have Claude Code open. Both type roughly the same prompt: &lt;em&gt;"build me a REST endpoint that searches users by name with pagination."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Five minutes later, both have working code. It compiles. The tests pass. The endpoint returns results.&lt;/p&gt;

&lt;p&gt;But the code is not the same.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Developer A&lt;/em&gt; — three years of experience — accepts the first output. It uses &lt;code&gt;SELECT * FROM users WHERE name LIKE '%query%'&lt;/code&gt; with &lt;code&gt;LIMIT&lt;/code&gt; and &lt;code&gt;OFFSET&lt;/code&gt;. It works in development with 50 users. It will break at 50,000 users and make the whole service slow at 500,000.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Developer B&lt;/em&gt; — twelve years of experience — reads the output, asks a follow-up: &lt;em&gt;"What's our expected scale? And what's the user table's index strategy?"&lt;/em&gt; The revised code uses a full-text index, cursor-based pagination, proper query limits, a cache layer for popular searches, and a rate limiter. It works in development with 50 users. It also works at 5 million.&lt;/p&gt;

&lt;p&gt;Same AI. Same prompt structure. Same amount of time. Radically different code.&lt;/p&gt;

&lt;p&gt;This is not an indictment of AI. AI did exactly what it was asked to do — twice. This is about what developers bring to the interaction, and why the "AI democratizes coding" narrative is getting it backwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The assumption everyone made
&lt;/h2&gt;

&lt;p&gt;When ChatGPT exploded in 2022, the dominant story in tech media was: "AI will level the playing field for developers." The junior who can prompt well will match the senior who can architect. Bootcamps will close the gap faster. The economic value of deep expertise will decline.&lt;/p&gt;

&lt;p&gt;This was never true. It's now clearly not true. And the evidence is in every codebase that's been using AI assistance for the past eighteen months.&lt;/p&gt;

&lt;p&gt;AI didn't level the playing field. It tilted it further.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AI actually does
&lt;/h2&gt;

&lt;p&gt;AI coding assistants are extraordinary at a specific kind of task: translating a human's clear intent into working code. That "clear intent" is the critical qualifier.&lt;/p&gt;

&lt;p&gt;A developer who knows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;That their user table will have millions of rows&lt;/li&gt;
&lt;li&gt;That &lt;code&gt;LIKE '%query%'&lt;/code&gt; doesn't use indexes&lt;/li&gt;
&lt;li&gt;That cursor-based pagination is safer than offset-based at scale&lt;/li&gt;
&lt;li&gt;That search traffic will be spiky&lt;/li&gt;
&lt;li&gt;That caching introduces consistency problems&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;...can prompt AI to produce code that reflects all of that knowledge. The prompt is short. The output is sophisticated. The developer spends their time on the parts that matter — decisions, architecture, review — while AI handles the mechanical translation.&lt;/p&gt;

&lt;p&gt;A developer who doesn't know those things produces a prompt that doesn't reflect them. AI has no way to know what the developer didn't think to ask. AI fills in the blanks with the most common patterns in its training data — which are often appropriate for tutorials and small projects, and actively wrong for production systems.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The prompt is not the skill. The knowledge that shapes the prompt is the skill.&lt;/em&gt; AI just made that knowledge more valuable per hour, because now a single developer's knowledge can be applied to 10x more code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invisible gap
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable part. Before AI, the gap between junior and senior code was visible. You could see it in PR diffs. Senior code looked different — tighter, more thoughtful, better error handling, cleaner abstractions. A code reviewer could point at specific lines and say "this is why we do it differently."&lt;/p&gt;

&lt;p&gt;With AI, the gap is hidden in the code that &lt;em&gt;didn't get written&lt;/em&gt;. The junior's code might look clean. It passes linters. It has tests. A cursory review sees nothing wrong. But the architecture is subtly off. The edge cases weren't considered because AI wasn't asked about them. The scaling story doesn't exist because the developer didn't know to ask.&lt;/p&gt;

&lt;p&gt;These bugs don't appear in staging. They appear six months later, at 3 AM, when the service starts timing out under load and nobody can figure out why.&lt;/p&gt;

&lt;p&gt;The productivity gap between juniors and seniors used to be roughly 2-3x for typical code. With AI, it's easy to argue it's become 5-10x — not because seniors got faster (they did), but because juniors started producing code at senior speed that carries hidden problems only a senior would have caught.&lt;/p&gt;

&lt;h2&gt;
  
  
  What seniors quietly know
&lt;/h2&gt;

&lt;p&gt;Talk to experienced engineers who've been using AI for a year, and they'll tell you something that sounds contradictory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"AI has made me dramatically more productive."&lt;/li&gt;
&lt;li&gt;"AI has made me more careful, not less."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are true. The productivity gain comes from AI handling boilerplate, tests, docs, and straightforward implementations. The increased care comes from knowing that AI will happily produce subtly-wrong code with 100% confidence, and there's no prompt you can write that eliminates this risk.&lt;/p&gt;

&lt;p&gt;Seniors I've worked with treat AI output the way they treat a competent but unsupervised junior developer: trust, but verify aggressively. Every assumption checked. Every edge case considered. Every optimization decision made by a human who understands why.&lt;/p&gt;

&lt;p&gt;The irony: AI didn't make seniors obsolete. It made them the critical quality filter in a pipeline that now produces code 10x faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  What juniors are being sold vs. what's actually happening
&lt;/h2&gt;

&lt;p&gt;The narrative sold to juniors is: "Learn AI tools, and you'll be employable. The AI does the coding; you orchestrate it."&lt;/p&gt;

&lt;p&gt;The reality is harsher and more useful: &lt;em&gt;AI makes your coding output visible much faster, which means your coding weaknesses are exposed much faster, too.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A junior shipping AI-assisted code in a professional environment gets feedback on bad code 10x faster than a junior who writes everything by hand. That's good — it's a faster learning loop. But it only works if the junior is actually learning from the feedback, not just accepting AI's next suggestion.&lt;/p&gt;

&lt;p&gt;The juniors who will thrive in the next five years are the ones using AI as a learning tool — asking &lt;em&gt;why&lt;/em&gt; the code works, challenging AI's suggestions, reading the documentation behind what AI produced, running experiments to verify assumptions. The juniors who will plateau are the ones using AI as a crutch — accepting output, shipping what passes, never understanding the underlying patterns.&lt;/p&gt;

&lt;p&gt;AI didn't replace learning fundamentals. It made the difference between developers who learned them and developers who didn't more obvious, faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The skills that appreciated
&lt;/h2&gt;

&lt;p&gt;If you had to invest in skills right now, knowing that AI will keep getting better, where would you invest?&lt;/p&gt;

&lt;p&gt;The wrong answer: prompt engineering. Prompt skills are like knowing how to Google in 2008 — temporarily valuable, eventually invisible.&lt;/p&gt;

&lt;p&gt;The right answer: &lt;em&gt;anything AI can't do well, and won't do well for a long time.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;System design.&lt;/em&gt; AI can produce components. It cannot yet decide what components should exist, how they should interact, or what boundaries to draw. This is architecture, and it's human work.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Debugging production issues.&lt;/em&gt; AI is remarkably bad at problems that require reading logs, correlating events across services, forming hypotheses, and testing them. The developer who can look at a 3 AM alert and narrow down the root cause in 10 minutes is 100x more valuable than the developer who asks AI for help and waits for a useful answer.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Code review under time pressure.&lt;/em&gt; As AI produces more code faster, the bottleneck becomes review. The developer who can skim a 400-line AI-generated PR and immediately spot the assumption that will fail at scale is indispensable.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Understanding systems deeply.&lt;/em&gt; How does your database's query planner work? What happens in your browser between a click and a re-render? What does &lt;code&gt;git bisect&lt;/code&gt; actually do under the hood? AI can give you surface-level answers, but it can't give you the intuition that comes from building and debugging these systems. That intuition is what lets you write correct prompts in the first place.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Writing.&lt;/em&gt; Not just code — prose. The developer who can explain why a decision matters, write a clear RFC, document a system so the next person understands it, will outperform the developer who produces 5x more code that nobody can maintain.&lt;/p&gt;

&lt;p&gt;Notice what these have in common: none of them are about AI. All of them are fundamentals that AI happens to highlight the value of.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical implication for your career
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;If you're a junior:&lt;/em&gt; AI is a fantastic learning tool if you use it as one. Ask AI to explain every line it produces. Ask &lt;em&gt;why&lt;/em&gt; it chose this pattern over alternatives. Run the code and predict the behavior before executing. Challenge the first suggestion. If you're just shipping what AI produces and moving on, you're not learning — you're delegating your growth to a black box.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you're a mid-level:&lt;/em&gt; This is the moment to invest in depth, not width. You probably know twelve frameworks superficially. Pick one area — a database, a protocol, a design pattern — and learn it to the level where you could write a book about it. That depth is what will differentiate you when AI makes surface knowledge free.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you're a senior or tech lead:&lt;/em&gt; Your job description changed, and most companies haven't updated the title. You're no longer the person who writes the critical code — you're the person who ensures critical code is correct, no matter who or what produced it. The review bar has to go up. Your standards have to be explicit and enforced. Your juniors need more mentorship, not less, because AI can hide how much they still need to learn.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you're hiring:&lt;/em&gt; Stop filtering candidates by "can they use AI tools?" That's a non-filter. Filter by "can they evaluate whether AI produced good code?" That's the job now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for what I write
&lt;/h2&gt;

&lt;p&gt;You may have noticed that my book, &lt;em&gt;Git in Depth&lt;/em&gt;, does not contain a chapter on AI. That wasn't an oversight — it was a deliberate choice.&lt;/p&gt;

&lt;p&gt;The book covers fundamentals: how Git actually works, how teams coordinate, how CI/CD pipelines protect production, how to align methodology with workflow. These are the things AI assumes you already know when it produces code.&lt;/p&gt;

&lt;p&gt;If AI is going to be my co-pilot on the next decade of engineering, I want the foundation under me to be solid. I want to understand what &lt;code&gt;git bisect&lt;/code&gt; does well enough to know when AI's suggestion to use it is wrong. I want to understand branch strategies well enough to tell AI "no, we don't use Git Flow here, adapt the suggestion." I want to understand production debugging well enough to know when AI is guessing versus reasoning.&lt;/p&gt;

&lt;p&gt;That's the book I wrote. And if the thesis in this post is right, it's more valuable today than it would have been three years ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-sentence version
&lt;/h2&gt;

&lt;p&gt;AI is not a great equalizer. It is a great amplifier. It amplifies what you already are — for better and for worse. The developers who will thrive in the next decade are the ones who invested in fundamentals while AI was making surface skills look disposable.&lt;/p&gt;

&lt;p&gt;Don't skip the fundamentals. They're not optional. They're more important now than ever.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about Git and engineering practice for working developers. My book &lt;em&gt;&lt;a href="https://mdenda.gumroad.com/l/git-in-depth" rel="noopener noreferrer"&gt;Git in Depth&lt;/a&gt;&lt;/em&gt; is 658 pages of the fundamentals AI assumes you already know.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;See all my articles on Git and engineering practice: &lt;a href="https://dev.to/mdenda"&gt;dev.to/mdenda&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>softwareengineering</category>
      <category>productivity</category>
      <category>career</category>
    </item>
    <item>
      <title>Procedurally generating marble dice textures with simplex noise</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Fri, 15 May 2026 16:27:29 +0000</pubDate>
      <link>https://dev.to/mdenda/procedurally-generating-marble-dice-textures-with-simplex-noise-23pp</link>
      <guid>https://dev.to/mdenda/procedurally-generating-marble-dice-textures-with-simplex-noise-23pp</guid>
      <description>&lt;p&gt;I built a 3D dice roller as a Chrome extension and wanted dice that look like the marbled Chessex ones — those rich, swirling, Old-World-stone dice every tabletop player covets. The catch: the renderer (&lt;code&gt;@3d-dice/dice-box&lt;/code&gt;) lets the user pick a &lt;strong&gt;color per die kind&lt;/strong&gt; (d4 red, d6 blue, d20 gold, etc.), so the texture can't just be a flat painted bitmap. The marble pattern has to stay, but the &lt;em&gt;color&lt;/em&gt; underneath has to follow whatever the user picked.&lt;/p&gt;

&lt;p&gt;This post is the recipe for generating those textures from scratch with simplex noise — no Photoshop, no marble photo, just code that produces something like this for every die:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs13e8x6ypfbj2usrbs5s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs13e8x6ypfbj2usrbs5s.png" alt="Marble dice in different colors, generated procedurally" width="800" height="848"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The pipeline is three layers stacked on top of plain noise, plus one trick about the alpha channel that took me a while to spot. Let's walk through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: Multi-octave simplex noise
&lt;/h2&gt;

&lt;p&gt;Start with the basic noise field. One sample of &lt;a href="https://en.wikipedia.org/wiki/Simplex_noise" rel="noopener noreferrer"&gt;simplex noise&lt;/a&gt; gives you smooth, organic blobs that look more like cloud cover than rock. To get the multi-scale detail real marble has — broad slabs of color &lt;em&gt;and&lt;/em&gt; fine hairline streaks — you sum several octaves of noise at doubling frequencies, halving the amplitude each time:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createNoise2D&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;simplex-noise&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseNoise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createNoise2D&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* seeded PRNG */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sample&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="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;amp&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;freqX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BASE_FREQ&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// mild horizontal stretch&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;freqY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BASE_FREQ&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;acc&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;totalAmp&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;o&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="nx"&gt;o&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;OCTAVES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;amp&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;baseNoise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;freqX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;freqY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;totalAmp&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;amp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;amp&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;freqX&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;freqY&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;totalAmp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// in roughly [-1, 1]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The slight asymmetry between &lt;code&gt;freqX&lt;/code&gt; and &lt;code&gt;freqY&lt;/code&gt; gives the streaks a hint of horizontal flow, like the grain in cut stone. Here's what that looks like as a grayscale field:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv9k918y3812m2guz3yvr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv9k918y3812m2guz3yvr.png" alt="Stage 1: multi-octave simplex noise, grayscale" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's already organic-looking, but it's also too &lt;em&gt;uniform&lt;/em&gt; — straight parallel-ish bands like wood grain, not the curling whorls of marble. Real marble veins bend, swirl, and double back on themselves. That's the next layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: Domain warping
&lt;/h2&gt;

&lt;p&gt;Domain warping is one of those tricks that feels like cheating because it's so simple and the result is so dramatic. Instead of sampling the noise on a straight (x, y) grid, you &lt;strong&gt;offset every (x, y) by another noise field&lt;/strong&gt; before sampling:&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;warpA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createNoise2D&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* different seed */&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;warpB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createNoise2D&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* yet another seed */&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;WARP_FREQ&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.003&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// broad, slow swirls&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WARP_AMOUNT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// pixels of displacement at peak&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y&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="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;H&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;x&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="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;W&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;warpA&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;WARP_FREQ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;WARP_FREQ&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;WARP_AMOUNT&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;wy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;warpB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;WARP_FREQ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;WARP_FREQ&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;WARP_AMOUNT&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;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;wx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;wy&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You're literally bending the input space before reading the noise. Two independent low-frequency noise fields (one per axis) push each pixel up to 90 pixels in some random direction, so the patterns above get twisted into curls. Same noise, same algorithm, different coords:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh0ht2g5w3ii40fywwh68.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh0ht2g5w3ii40fywwh68.png" alt="Stage 2: noise with domain warping applied to the coordinates" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now we're getting marble whorls. But the field is still soft — gradual gradients everywhere. Marble has &lt;em&gt;veins&lt;/em&gt;: sharp edges where dark meets light. That's the third layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: Ridged transform
&lt;/h2&gt;

&lt;p&gt;The ridged transform is a one-line operation: &lt;code&gt;1 - |n|&lt;/code&gt;. It folds the noise field around zero and inverts it, so what used to be a smooth roll between -1 and +1 becomes a series of &lt;strong&gt;sharp peaks&lt;/strong&gt; at every zero-crossing of the original noise. Mathematically, the zero-crossings of a smooth random field form a set of curves — and &lt;code&gt;1 - |n|&lt;/code&gt; lights those curves up.&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;function&lt;/span&gt; &lt;span class="nf"&gt;sample&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="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ridged&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ... octave summing as before ...&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;totalAmp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ridged&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&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="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply that and the soft swirls turn into something with backbone:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2hm18xmyi9zg19sdi3wa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2hm18xmyi9zg19sdi3wa.png" alt="Stage 3: same field, now with the ridged transform applied" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's the geometry of marble veins — long, curving ridges with a clear edge between "vein" and "background". Now we have a usable pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Composition: dual fields + thresholds
&lt;/h2&gt;

&lt;p&gt;The actual marble texture uses &lt;strong&gt;two&lt;/strong&gt; of these fields with different seeds — one for dark veins, one for light highlights — and per pixel picks whichever signal is stronger. Threshold each field at around 0.86 so only the sharpest ridge crests become streaks:&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;darkStrength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dn&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;DARK_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;STREAK_CONTRAST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lightStrength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ln&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;LIGHT_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;STREAK_CONTRAST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;darkAlpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MAX_DARK_ALPHA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;darkStrength&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;DARK_GAIN&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;lightAlpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MAX_LIGHT_ALPHA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lightStrength&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;LIGHT_GAIN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lightAlpha&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;darkAlpha&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// light streak wins&lt;/span&gt;
  &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;255&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="nx"&gt;lightAlpha&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;darkAlpha&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// dark vein wins&lt;/span&gt;
  &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&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="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;darkAlpha&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// nothing — see "the trick" below&lt;/span&gt;
  &lt;span class="nx"&gt;a&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MAX_DARK_ALPHA&lt;/code&gt; is higher than &lt;code&gt;MAX_LIGHT_ALPHA&lt;/code&gt; (215 vs 160) so the dark veins read as proper marbling and the light streaks stay subtle highlights — flip those and the dice look chalky.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: alpha as the actual lever
&lt;/h2&gt;

&lt;p&gt;Here's the part that bit me for half an evening. &lt;code&gt;@3d-dice/dice-box&lt;/code&gt;'s color shader is essentially this line of GLSL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="n"&gt;finalColor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;themeColor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;textureColor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;textureColor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the texture's alpha is &lt;code&gt;1.0&lt;/code&gt; (opaque), the result is &lt;strong&gt;100% texture, 0% themeColor&lt;/strong&gt;. Which means: if you generate a normal RGB marble texture — black veins on a white background, fully opaque — the user's color choice (the &lt;code&gt;themeColor&lt;/code&gt;) is &lt;strong&gt;completely invisible&lt;/strong&gt;. Every die comes out the same washed-out gray, no matter what color is selected.&lt;/p&gt;

&lt;p&gt;The fix isn't in the colors — it's the alpha channel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write the marble pattern into alpha, not RGB.&lt;/strong&gt; Where you want a dark vein, the RGB is black &lt;em&gt;and&lt;/em&gt; the alpha is high (the texture covers the themeColor). Where you want a light streak, RGB is white with moderate alpha. Where you want pure themeColor, &lt;strong&gt;alpha is zero&lt;/strong&gt; and RGB doesn't matter. The texture is essentially a black-and-white-and-transparent stencil through which the themeColor shows.&lt;/p&gt;

&lt;p&gt;That's why the snippet above sets &lt;code&gt;r = g = b = 0&lt;/code&gt; (or 255) and &lt;em&gt;modulates the alpha&lt;/em&gt;. The alpha channel is doing the actual work; the RGB just decides whether visible pixels are "darker than themeColor" or "lighter than themeColor".&lt;/p&gt;

&lt;p&gt;One more wrinkle: the original dice texture has &lt;em&gt;numbers&lt;/em&gt; on the faces, and those need to stay readable. The fix is a simple override — wherever the source atlas has a high-alpha pixel (a number glyph), force the output to opaque black:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;numbersAlpha&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&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="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;numbersAlpha&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;   &lt;span class="c1"&gt;// preserve anti-aliasing&lt;/span&gt;
  &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Numbers always win.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Stack all of that — two warped, ridged, thresholded noise fields composed into a single texture with the alpha trick — and the renderer produces dice that share a consistent marble pattern but pick up whatever per-die color you've configured:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs13e8x6ypfbj2usrbs5s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs13e8x6ypfbj2usrbs5s.png" alt="Final marble dice rendered in the extension, in their per-kind colors" width="800" height="848"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The whole texture-generation pass runs offline as a build step (one Node script with &lt;code&gt;simplex-noise&lt;/code&gt; and &lt;code&gt;sharp&lt;/code&gt;), so there's zero runtime cost — the extension just ships the resulting 1024×1024 PNG.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it / read the code
&lt;/h2&gt;

&lt;p&gt;If you want to see this running in a real Chrome extension, I published the dice roller it's a part of here:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🎲 &lt;strong&gt;Dice Roller&lt;/strong&gt; on the Chrome Web Store: &lt;a href="https://chromewebstore.google.com/detail/dice-roller/oiknfbfchalpjggppamjfchhlplaieol" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/dice-roller/oiknfbfchalpjggppamjfchhlplaieol&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>javascript</category>
      <category>webgl</category>
      <category>graphics</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
