<?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.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>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>
    <item>
      <title>The four decisions every team makes about Git (whether they realize it or not)</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Wed, 13 May 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mdenda/the-four-decisions-every-team-makes-about-git-whether-they-realize-it-or-not-9hb</link>
      <guid>https://dev.to/mdenda/the-four-decisions-every-team-makes-about-git-whether-they-realize-it-or-not-9hb</guid>
      <description>&lt;p&gt;Most teams think they have a "Git workflow." What they actually have is a set of habits that accumulated over time, often made by people who left the company years ago, based on assumptions that no longer hold.&lt;/p&gt;

&lt;p&gt;Ask three developers on the same team to explain "how we use Git" and you'll get three different answers. Not because anyone is wrong — because the team never actually made the decisions explicitly. They made them by accident.&lt;/p&gt;

&lt;p&gt;This article is about surfacing those decisions. Not telling you what's right — telling you &lt;strong&gt;what you're implicitly choosing&lt;/strong&gt;, so you can choose deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four decisions
&lt;/h2&gt;

&lt;p&gt;Every team, whether they know it or not, has made a choice about four things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Branching strategy&lt;/strong&gt; — How does code move from idea to production?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tracking methodology&lt;/strong&gt; — How do you decide what to work on and measure progress?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release cadence&lt;/strong&gt; — How often does code reach users?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automation level&lt;/strong&gt; — How much of the workflow is human-driven vs event-driven?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each decision constrains the next. Each one, if not made explicitly, gets made implicitly — and implicit decisions tend to contradict each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 1: Branching strategy
&lt;/h2&gt;

&lt;p&gt;Your options, roughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trunk-based development&lt;/strong&gt; — everyone commits to (or merges small changes into) main, multiple times a day&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Flow&lt;/strong&gt; — feature branches, short-lived, merged via PR to main&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitLab Flow&lt;/strong&gt; — feature branches + environment branches (staging, production)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git Flow&lt;/strong&gt; — long-lived develop + main branches, release branches, hotfix branches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom hybrid&lt;/strong&gt; — some combination of the above&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most tutorials frame this as "which is best?" The honest answer is: &lt;strong&gt;it depends on the next three decisions&lt;/strong&gt;. Git Flow with continuous deployment is a contradiction. Trunk-based with a monthly release train is a waste.&lt;/p&gt;

&lt;p&gt;The right question isn't "which strategy should we use?" — it's "&lt;strong&gt;which strategy supports the other three decisions?&lt;/strong&gt;"&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 2: Tracking methodology
&lt;/h2&gt;

&lt;p&gt;This one most teams think they've decided, but haven't.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scrum&lt;/strong&gt; — sprints, story points, planning/retro ceremonies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kanban&lt;/strong&gt; — continuous flow, WIP limits, no sprints&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Waterfall or phase-gated&lt;/strong&gt; — requirements → design → build → test → deploy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SAFe or other scaled framework&lt;/strong&gt; — multiple teams coordinating via ARTs or similar&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The question that reveals your actual methodology: &lt;strong&gt;what triggers work to start?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If it's "this sprint's plan" → you're doing Scrum (or pretending to)&lt;/li&gt;
&lt;li&gt;If it's "capacity is available and this is prioritized" → you're doing Kanban (or should be)&lt;/li&gt;
&lt;li&gt;If it's "this phase is complete and signed off" → you're doing Waterfall (admit it)&lt;/li&gt;
&lt;li&gt;If it's "a manager said so" → you're in a methodology vacuum, which is its own problem&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The implications for Git are direct. Scrum with 2-week sprints wants branches that close before sprint end. Kanban doesn't care about time — it cares about WIP limits. Waterfall expects phase-gated tags. These are incompatible constraints on how your branches live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 3: Release cadence
&lt;/h2&gt;

&lt;p&gt;This is the one most teams lie to themselves about.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Continuous deployment&lt;/strong&gt; — every merge to main goes to production (with canary, feature flags, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continuous delivery&lt;/strong&gt; — every merge to main is production-ready, but deployment is a manual trigger&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release train&lt;/strong&gt; — scheduled releases (weekly, biweekly, monthly)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ad-hoc releases&lt;/strong&gt; — "when it's ready," which usually means "when someone has time"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The diagnostic question: &lt;strong&gt;how long from a developer's merge to users seeing the change?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hours → you're on continuous deployment&lt;/li&gt;
&lt;li&gt;A day or two → continuous delivery&lt;/li&gt;
&lt;li&gt;Consistent schedule → release train&lt;/li&gt;
&lt;li&gt;"Varies" → ad-hoc (and your lead time is probably terrible)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Release cadence is the cruellest decision to get wrong, because it interacts with everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Long cadence + trunk-based = huge batch risk&lt;/li&gt;
&lt;li&gt;Short cadence + Git Flow = unnecessary overhead&lt;/li&gt;
&lt;li&gt;Ad-hoc + Scrum = sprint goals that never ship at sprint end&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Decision 4: Automation level
&lt;/h2&gt;

&lt;p&gt;Unlike the others, this is a spectrum, not a discrete choice. But at a high level:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fully automated&lt;/strong&gt; — PR opens → CI runs → auto-merge on approval → auto-deploy to staging → auto-promote to prod (with gates)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event-driven&lt;/strong&gt; — Git events trigger most transitions; humans handle exceptions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semi-manual&lt;/strong&gt; — CI runs, humans approve and merge, humans decide when to deploy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fully manual&lt;/strong&gt; — every step requires a human action&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test of your automation level: &lt;strong&gt;what happens at 2 AM when a hotfix needs to ship?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the answer is "we page someone who manually runs the deploy script," your automation level is semi-manual at best. If the answer is "the hotfix workflow handles it; we review the result in the morning," you're much closer to the automated end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision table
&lt;/h2&gt;

&lt;p&gt;Here's what it looks like when a team makes all four decisions consistently:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Team Type&lt;/th&gt;
&lt;th&gt;Branching&lt;/th&gt;
&lt;th&gt;Tracking&lt;/th&gt;
&lt;th&gt;Release&lt;/th&gt;
&lt;th&gt;Automation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SaaS startup&lt;/td&gt;
&lt;td&gt;Trunk-based&lt;/td&gt;
&lt;td&gt;Kanban&lt;/td&gt;
&lt;td&gt;Continuous&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regulated fintech&lt;/td&gt;
&lt;td&gt;GitLab Flow&lt;/td&gt;
&lt;td&gt;Scrum&lt;/td&gt;
&lt;td&gt;Biweekly&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise product&lt;/td&gt;
&lt;td&gt;Git Flow&lt;/td&gt;
&lt;td&gt;SAFe&lt;/td&gt;
&lt;td&gt;Quarterly&lt;/td&gt;
&lt;td&gt;Medium-high&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open source project&lt;/td&gt;
&lt;td&gt;GitHub Flow&lt;/td&gt;
&lt;td&gt;Issue-driven&lt;/td&gt;
&lt;td&gt;Ad-hoc&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regulated hardware&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;Waterfall-hybrid&lt;/td&gt;
&lt;td&gt;Milestone-based&lt;/td&gt;
&lt;td&gt;Low-medium&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice that each row is &lt;strong&gt;internally consistent&lt;/strong&gt;. The branching strategy supports the cadence. The tracking methodology matches the release frequency. The automation level is appropriate for the regulatory context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Natural combinations that work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Trunk-based + Kanban + continuous deployment + high automation.&lt;/strong&gt; This is the SaaS ideal. Works when the team is small-to-medium, the product tolerates continuous change, and there's sufficient test coverage. Fastest feedback loop, but requires discipline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Flow + Scrum + continuous delivery + medium-high automation.&lt;/strong&gt; The most common mid-size-team setup. Feature branches align with sprint items. Continuous delivery means each sprint can ship if the business decides. Good for product teams at B2B SaaS companies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Git Flow + SAFe + release train + medium automation.&lt;/strong&gt; Enterprise reality. Multiple teams coordinating, scheduled releases, version-branching for support of multiple production versions. Unfashionable, but genuinely correct for some contexts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combinations that create friction
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Git Flow + continuous deployment.&lt;/strong&gt; Your long-lived develop branch creates unnecessary batching. Why have &lt;code&gt;develop&lt;/code&gt; if you deploy on every merge? You're paying the cost of Git Flow without getting its benefits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trunk-based + quarterly release train.&lt;/strong&gt; You're merging to main continuously, but users don't see the changes for 3 months. Either deploy more often, or use longer-lived branches. The current setup batches risk without batching value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scrum + ad-hoc releases.&lt;/strong&gt; You commit to sprint goals, but the output doesn't reach users at sprint end. The team's measurement of success (sprint completion) is divorced from actual delivery. Over time, developers stop believing sprints matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kanban + Git Flow.&lt;/strong&gt; Kanban is about continuous flow; Git Flow creates flow-blocking integration points. Every release branch is a ceremony Kanban is designed to eliminate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test: can you write down your answer?
&lt;/h2&gt;

&lt;p&gt;Here's a diagnostic exercise that takes 10 minutes and is worth more than most consulting engagements:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write down the four decisions for your team, as one sentence each.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"We use GitHub Flow."&lt;/li&gt;
&lt;li&gt;"We use Scrum with 2-week sprints."&lt;/li&gt;
&lt;li&gt;"We release every Thursday at 2 PM."&lt;/li&gt;
&lt;li&gt;"PR opens trigger CI automatically; merges trigger deploy-to-staging automatically; promotion to production requires manual approval."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can write these four sentences confidently, and every senior person on the team would write approximately the same four sentences, &lt;strong&gt;you have a workflow&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If different people would write different sentences, or if you find yourself writing "it depends," or "we kind of do X but sometimes Y" — you don't have a workflow. You have accumulated habits pretending to be a workflow.&lt;/p&gt;

&lt;p&gt;And that's fine to discover. The discovery is the start of fixing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your workflow will evolve
&lt;/h2&gt;

&lt;p&gt;One more thing: these four decisions aren't permanent. Teams grow, products mature, regulations change.&lt;/p&gt;

&lt;p&gt;A startup that started with trunk-based + continuous deployment may, at 50 developers, realize that coordination requires feature branches. That's not a failure — that's scale demanding a new set of decisions.&lt;/p&gt;

&lt;p&gt;A mature enterprise product might, after adopting feature flags and improving test coverage, discover it can move from quarterly release trains to biweekly. That's not a rebellion — that's capability unlocking a new set of decisions.&lt;/p&gt;

&lt;p&gt;The mistake isn't in the specific decisions. The mistake is making them implicitly, letting them drift, and then wondering why delivery is slow and everyone's frustrated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make them explicit. Revisit them quarterly. Change them when the context changes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's how you go from "we have a Git workflow" to actually having one.&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 with a full chapter on the complete map connecting board columns, Git states, and deployment environments — plus decision frameworks for choosing the right combination for your team.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>git</category>
      <category>devops</category>
      <category>architecture</category>
      <category>teams</category>
    </item>
    <item>
      <title>N! Ways to Hide a Message: Multi-Carrier Encoding in Rust</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Tue, 12 May 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/mdenda/n-ways-to-hide-a-message-multi-carrier-encoding-in-rust-23mo</link>
      <guid>https://dev.to/mdenda/n-ways-to-hide-a-message-multi-carrier-encoding-in-rust-23mo</guid>
      <description>&lt;p&gt;&lt;em&gt;Post 2 of 6 in the series 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 a small feature that multiplies your adversary's work by a factorial.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I like features that give you an outsized security improvement for very little code. Multi-carrier encoding is one of those. The implementation is maybe twelve lines. The effect is that the set of carriers you use becomes an &lt;em&gt;ordered&lt;/em&gt; secret — and getting that order wrong produces deterministic garbage, which an attacker can't distinguish from using the wrong files entirely.&lt;/p&gt;

&lt;p&gt;Let me explain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The single-carrier recap
&lt;/h2&gt;

&lt;p&gt;In a normal Anyhide flow, both parties share one file. They use it as a reference, and the sender encodes a message by computing byte positions into that file. Single file, single shared secret.&lt;/p&gt;

&lt;p&gt;But "the carrier is the file we both have" is a constraint I wanted to relax. What if the carrier is &lt;em&gt;a set of files&lt;/em&gt;? What if the set is ordered, and the order itself is a secret nobody but the two parties knows?&lt;/p&gt;

&lt;p&gt;That's multi-carrier encoding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API
&lt;/h2&gt;

&lt;p&gt;From the command line, you just pass &lt;code&gt;-c&lt;/code&gt; multiple times:&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;# Sender&lt;/span&gt;
anyhide encode &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; song.mp3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; photo.jpg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; document.pdf &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"meeting moved to 9pm"&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;"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;span class="c"&gt;# Receiver needs the SAME files in the SAME order&lt;/span&gt;
anyhide decode &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--code&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; song.mp3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; photo.jpg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; document.pdf &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"passphrase"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--my-key&lt;/span&gt; bob.key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Bob swaps &lt;code&gt;photo.jpg&lt;/code&gt; and &lt;code&gt;document.pdf&lt;/code&gt;, he does not get an error. He gets garbage — random-looking bytes that happen to decode cleanly from the wrong carrier. He won't know whether the message was the wrong passphrase, the wrong key, the wrong files, or the wrong &lt;em&gt;order&lt;/em&gt; of the right files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The math
&lt;/h2&gt;

&lt;p&gt;The number of ways to order N distinct files is N! (N factorial). That means if an adversary has all four of your carrier files and the passphrase and the private key, they &lt;em&gt;still&lt;/em&gt; have to try up to 24 orderings to recover a 4-carrier message.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;N carriers&lt;/th&gt;
&lt;th&gt;Orderings to try&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;5,040&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;3,628,800&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This isn't cryptographic security — factorial growth is polynomial next to exponential keyspaces. But it's not &lt;em&gt;meant&lt;/em&gt; to be. It's an additional layer of ambiguity, and more importantly, it's ambiguity that an attacker can't distinguish from other failures. They can't test "is this the wrong order?" without a ciphertext oracle, which they don't have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The implementation
&lt;/h2&gt;

&lt;p&gt;Here's the actual code. It lives in &lt;code&gt;src/text/carrier.rs&lt;/code&gt; and is 17 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="cd"&gt;/// Creates a carrier from multiple files concatenated in order.&lt;/span&gt;
&lt;span class="cd"&gt;///&lt;/span&gt;
&lt;span class="cd"&gt;/// *Order matters!* Different order = different carrier = different decoding result.&lt;/span&gt;
&lt;span class="cd"&gt;/// This provides N! additional security combinations for N carriers.&lt;/span&gt;
&lt;span class="cd"&gt;///&lt;/span&gt;
&lt;span class="cd"&gt;/// - Single file: Delegates to `from_file()` (preserves text vs binary detection)&lt;/span&gt;
&lt;span class="cd"&gt;/// - Multiple files: All read as bytes and concatenated (always binary carrier)&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;from_files&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paths&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="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;path&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;PathBuf&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;io&lt;/span&gt;&lt;span class="p"&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&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="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Carrier&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[]));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&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;from_file&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;paths&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Multiple files: read all as bytes and concatenate in order&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="nn"&gt;Vec&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;paths&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="nf"&gt;.extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="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="nn"&gt;Carrier&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_bytes&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Read each file as bytes, concatenate them in the provided order, hand the resulting buffer to the binary carrier machinery, and move on. The rest of the encoder and decoder doesn't even know it's dealing with multiple files — to them, it's just a longer buffer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the single-file branch matters
&lt;/h2&gt;

&lt;p&gt;Notice the early return at &lt;code&gt;paths.len() == 1&lt;/code&gt;. Without it, a single-carrier call would lose the text/binary autodetection that &lt;code&gt;from_file&lt;/code&gt; gives you. A &lt;code&gt;.txt&lt;/code&gt; with one carrier would be treated as binary, and suddenly substring matching becomes byte matching. The search still works, but you lose the case-insensitive lookup that text carriers give you for free.&lt;/p&gt;

&lt;p&gt;Keeping the single-file case routed through &lt;code&gt;from_file&lt;/code&gt; means the multi-carrier feature is a pure extension — existing callers and existing encoded codes are untouched. This is the kind of thing I care about when adding features to a tool people might already depend on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why ordered concatenation instead of something cleverer
&lt;/h2&gt;

&lt;p&gt;I considered a few alternatives while designing this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Hashing the carrier set and using the hash as a seed&lt;/em&gt;. Too indirect. The whole appeal of Anyhide is that the carrier is &lt;em&gt;the file&lt;/em&gt;, not a digest of it. Hashing would also make the "why wrong order gives garbage" property harder to reason about.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Interleaving bytes from each file in a passphrase-derived pattern&lt;/em&gt;. More complex, marginally better, and it made the error surface much bigger. Every bug would be a silent decoder failure, which is exactly what Anyhide is designed to &lt;em&gt;never&lt;/em&gt; surface.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Each carrier gets its own search, and fragments are distributed across carriers&lt;/em&gt;. Elegant on paper, but it changes the security model: now partial knowledge of one carrier compromises the whole message instead of just its fraction. Worse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Concatenation in caller-provided order is the simplest model that works and the easiest to reason about. Boring. Correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plausible deniability across the factorial
&lt;/h2&gt;

&lt;p&gt;Here's where this gets fun. Imagine you encode a message with &lt;code&gt;[A, B, C]&lt;/code&gt;. Six orderings are possible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[A, B, C]  → "meeting at 9pm"  (real)
[A, C, B]  → garbage
[B, A, C]  → garbage
[B, C, A]  → garbage
[C, A, B]  → garbage
[C, B, A]  → garbage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All five "wrong" orderings produce outputs &lt;em&gt;indistinguishable from random&lt;/em&gt;. An attacker who recovers your files, your key, and your passphrase — but not the ordering — sees six plausible-looking plaintexts. None of them says "ERROR". One says "meeting at 9pm". The other five say things like &lt;code&gt;\x8f\x3aq#w...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now pair this with the duress-password feature from &lt;a href="https://dev.tolink"&gt;Post 3&lt;/a&gt; and the adversary's problem gets harder still: they can't tell whether a given output is "the real message in the right order", "the decoy message in the right order", or "the wrong ordering producing noise".&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on testing
&lt;/h2&gt;

&lt;p&gt;The integration test that verifies this is one line of logic:&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="nd"&gt;#[test]&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;test_multi_carrier_wrong_order_produces_garbage&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;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"top secret"&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;carriers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"a.txt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"b.txt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"c.txt"&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;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encode_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;carriers&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Wrong order&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;wrong&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"b.txt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"a.txt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"c.txt"&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;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decode_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wrong&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="c1"&gt;// Does not return Err(); returns Ok with garbage&lt;/span&gt;
    &lt;span class="nd"&gt;assert_ne!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// And it's "garbage" in the sense that re-encoding it&lt;/span&gt;
    &lt;span class="c1"&gt;// wouldn't round-trip either&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting assertion isn't &lt;code&gt;assert_ne!&lt;/code&gt;. It's that the function returns &lt;code&gt;Ok&lt;/code&gt; with bytes, not an error. That's the "never-fail decoder" invariant Anyhide is built around, and multi-carrier inherits it for free because it's just a longer buffer going through the same pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get for 17 lines of Rust
&lt;/h2&gt;

&lt;p&gt;To recap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;N! additional orderings an adversary must exhaustively search through.&lt;/li&gt;
&lt;li&gt;Zero impact on single-carrier code paths (backwards compatible by design).&lt;/li&gt;
&lt;li&gt;Order is a &lt;em&gt;new&lt;/em&gt; secret, composable with the passphrase, key, and (see Post 3) duress password.&lt;/li&gt;
&lt;li&gt;Wrong order produces garbage indistinguishable from any other failure mode.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson for me, writing this: the best additions to a security tool are the ones you can explain in two sentences and implement without touching the core. If your new feature reshapes the pipeline, you've probably made a mistake.&lt;/p&gt;

&lt;p&gt;Next up in the series: &lt;em&gt;Post 3 — Plausible Deniability and Duress Passwords&lt;/em&gt;. How to encode &lt;em&gt;two&lt;/em&gt; messages under two different passphrases, so that under coercion you can reveal a decoy that's cryptographically indistinguishable from the real one. Dropping in two weeks.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Repo: &lt;a href="https://github.com/matutetandil/anyhide" rel="noopener noreferrer"&gt;github.com/matutetandil/anyhide&lt;/a&gt;. If you spot a way multi-carrier breaks the security properties I claimed above, open an issue — I want to know.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>security</category>
      <category>cryptography</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Stop estimating in hours. Start estimating in complexity.</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Thu, 07 May 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mdenda/stop-estimating-in-hours-start-estimating-in-complexity-10ep</link>
      <guid>https://dev.to/mdenda/stop-estimating-in-hours-start-estimating-in-complexity-10ep</guid>
      <description>&lt;h2&gt;
  
  
  Stop Estimating in Hours. Start Estimating in Complexity.
&lt;/h2&gt;

&lt;p&gt;There's a quiet truth every developer knows but nobody says out loud at sprint planning:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When we estimate in hours, we always estimate low.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Always. The senior estimates low because they want to look efficient. The junior estimates low because they don't want to seem slow. The team estimates low because the PM is in the room. And then the sprint ends, half the tickets roll over, and everyone pretends to be surprised.&lt;/p&gt;

&lt;p&gt;After years of watching this play out across teams, languages, and stacks, I've come to believe that the problem isn't that we're bad at estimating hours. The problem is that &lt;strong&gt;hours are the wrong unit&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Let me explain why I now estimate in complexity, and why I think it leads to better software, better teams, and — surprisingly — better deadlines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The misunderstanding at the core
&lt;/h2&gt;

&lt;p&gt;Here's the trap most teams fall into: they treat &lt;strong&gt;complexity&lt;/strong&gt; and &lt;strong&gt;time&lt;/strong&gt; as the same thing measured with different rulers. They're not. They're two independent axes.&lt;/p&gt;

&lt;p&gt;Consider translation. Translating a paragraph from English to Spanish is &lt;em&gt;easy&lt;/em&gt;. There's almost no complexity. But translating the entire Bible? That's still easy — the per-sentence cognitive load hasn't changed — it's just &lt;em&gt;long&lt;/em&gt;. Easy doesn't mean fast.&lt;/p&gt;

&lt;p&gt;Now flip it. A complex distributed-systems migration sounds like it should take weeks. But if your platform happens to have the right tooling already in place, you might pull it off in an afternoon. Complex doesn't mean slow.&lt;/p&gt;

&lt;p&gt;Once you internalize this, the whole hour-based estimation game starts looking absurd. You're collapsing two dimensions into one and pretending the result is meaningful.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what is complexity, then?
&lt;/h2&gt;

&lt;p&gt;In the teams I've worked on, we settled on a Fibonacci-ish scale: &lt;strong&gt;3, 5, 8, 13&lt;/strong&gt;. Anything bigger than 13 wasn't an estimate — it was a signal to break the task down.&lt;/p&gt;

&lt;p&gt;The numbers themselves don't matter much. What matters is what they represent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3&lt;/strong&gt; — Well-understood. We've done this kind of thing before. Few moving pieces. Low risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5&lt;/strong&gt; — Some unknowns, or more pieces involved, but nothing scary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;8&lt;/strong&gt; — Several systems touched, real risk, or genuinely new territory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;13&lt;/strong&gt; — Too big. Stop. Break it apart.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can layer in dimensions like uncertainty, coupling, blast radius, dependencies, team familiarity — but the goal isn't to build a precise rubric. The goal is to give the team a shared vocabulary for talking about how &lt;em&gt;hard&lt;/em&gt; something is, separate from how &lt;em&gt;long&lt;/em&gt; it takes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real magic isn't the number — it's the conversation
&lt;/h2&gt;

&lt;p&gt;Here's what nobody tells you about story points: they're not better than hours because they're more accurate. Honestly, they're probably &lt;em&gt;less&lt;/em&gt; accurate in absolute terms.&lt;/p&gt;

&lt;p&gt;They're better because they change the conversation.&lt;/p&gt;

&lt;p&gt;When you ask someone "how long will this take?", the conversation is individual and defensive. Whoever knows the most throws out a number. Everyone else nods. The junior who's actually going to do the work quietly panics, because they know they can't hit that number, but pushing back means admitting they're slower.&lt;/p&gt;

&lt;p&gt;When you ask "how complex is this?", the conversation is collective. Why is this a 5 and not a 3? What pieces does it have? What could go wrong? Juniors learn by watching seniors reason through problems. Seniors occasionally discover that something they called "trivial" wasn't trivial at all. The team understands what they're about to build &lt;em&gt;before&lt;/em&gt; they build it.&lt;/p&gt;

&lt;p&gt;That's what hours don't give you, no matter how precise they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The split that changes everything
&lt;/h2&gt;

&lt;p&gt;Here's the part of my workflow that I think is genuinely underrated:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The team estimates complexity. The individual developer estimates their own hours.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Complexity is a property of the &lt;em&gt;problem&lt;/em&gt;. Hours are a property of the &lt;em&gt;person solving it&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I'm a senior architect. A junior on my team is not going to take the same time I do on the same task. That's not a flaw — it's reality. Telling a junior "this should take you 3 hours because the senior said so" is one of the cruelest, most counterproductive things we do in this industry. They burn out trying to hit a number that was never theirs to hit.&lt;/p&gt;

&lt;p&gt;So instead: the team agrees this task is a 5. Then the developer who picks it up estimates &lt;em&gt;their own hours&lt;/em&gt;. Those hours are mostly for them — to plan their day, to learn calibration, to flag early when they're slipping. We sum them as a sanity check against the sprint capacity, but the commitment to the business doesn't come from those numbers. It comes from velocity (more on that in a sec).&lt;/p&gt;

&lt;p&gt;Junior devs get the low-complexity tasks first. Not because we don't trust them, but because &lt;strong&gt;low-complexity tasks are where it's cheap to be wrong&lt;/strong&gt;. That's where you learn to estimate without blowing up the sprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But the junior will estimate wrong too"
&lt;/h2&gt;

&lt;p&gt;Yes. They will. That's the point.&lt;/p&gt;

&lt;p&gt;I get this objection every time I describe this system: &lt;em&gt;"if the dev estimates their own hours, they can still get it wrong — for any of the reasons people get hours wrong in the first place."&lt;/em&gt; True. A junior estimating their own hours will probably underestimate 9 times out of 10. A senior in unfamiliar territory will do the same.&lt;/p&gt;

&lt;p&gt;The difference isn't that the estimate magically becomes correct. The difference is &lt;strong&gt;what happens when it's wrong&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When hours are imposed top-down by whoever-knows-most, a missed estimate is a personal failure. The junior is just behind. Tough luck, work weekends.&lt;/p&gt;

&lt;p&gt;When the dev estimates their own hours, a missed estimate is a &lt;strong&gt;calibration signal&lt;/strong&gt;. It's the moment the team lead — the architect, the TL, the assigned senior — steps in. Not to scold, but to give context. To explain what the dev didn't see. To walk through why this task that looked like 4 hours was actually 12.&lt;/p&gt;

&lt;p&gt;This is where the didactic side of the senior matters, and where teams really differ. Some leads let juniors slam their heads against the wall and call it "learning by doing". Others sit down and unpack the problem with them. The system doesn't fix that for you — but at least it makes the moment &lt;em&gt;visible&lt;/em&gt;, instead of burying it under a missed deadline that nobody wanted to admit was unrealistic from the start.&lt;/p&gt;

&lt;p&gt;Over time, the junior's estimates get sharper. Not because they got faster, but because they learned to &lt;em&gt;see&lt;/em&gt; more of the task before starting it. That's a skill hours-based estimation never teaches, because in hours-based estimation, the junior never gets to estimate at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about the unknown?
&lt;/h2&gt;

&lt;p&gt;Every estimation system breaks at the same place: how do you estimate something nobody has done before?&lt;/p&gt;

&lt;p&gt;You don't. You &lt;strong&gt;spike&lt;/strong&gt; it.&lt;/p&gt;

&lt;p&gt;A spike is a timeboxed investigation. "Spend 4 hours figuring out if this is feasible, then come back." The output isn't an estimate — the output is &lt;em&gt;enough understanding to estimate&lt;/em&gt;. And honestly, half the time the spike basically solves the problem, because the hard part wasn't the doing, it was the figuring out.&lt;/p&gt;

&lt;p&gt;This is the part I think most teams miss. They try to estimate the unknown anyway, padding numbers "just in case", and end up with stories that are 80% mystery and 20% work. Spikes are the escape valve. Use them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually do this
&lt;/h2&gt;

&lt;p&gt;If you're sold on the idea but wondering how it looks in practice, there's no single right answer. Here are a few techniques teams use — pick whichever fits your group's vibe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Planning poker.&lt;/strong&gt; Everyone on the team has a deck of cards with the values (3, 5, 8, 13, plus a "?" for "I have no idea, we need a spike"). Someone reads the task. Everyone picks a card face-down, then reveals at the same time. If the numbers diverge wildly, the highest and lowest explain their reasoning, and you re-vote. The simultaneous reveal is the whole point — it stops people from anchoring on whatever the most senior person said first.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;T-shirt sizes.&lt;/strong&gt; Same idea, but with S / M / L / XL instead of numbers. Useful for teams that find numbers feel falsely precise, or for early-stage estimation where you just want a rough bucket. You can always map sizes to points later if you need velocity tracking.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Affinity estimation.&lt;/strong&gt; Print all the tasks on cards, lay them on a table, and have the team physically group them by relative complexity — "this feels about as hard as that one". Fast for large backlogs, and surprisingly accurate, because humans are much better at &lt;em&gt;comparing&lt;/em&gt; than at &lt;em&gt;measuring&lt;/em&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can mix these. Some teams use affinity estimation for backlog grooming and planning poker for sprint refinement. Others just default to a quick t-shirt sizing in a 15-minute meeting and call it done.&lt;/p&gt;

&lt;p&gt;The technique matters less than the conversation it produces. If your team is genuinely talking about the problem — surfacing risks, sharing context, learning from each other — the format is just scaffolding. Pick whatever scaffolding gets you there.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But the business needs dates"
&lt;/h2&gt;

&lt;p&gt;This is the objection that always comes up, and it's a fair one.&lt;/p&gt;

&lt;p&gt;The answer is &lt;strong&gt;velocity&lt;/strong&gt;. Track how many points your team actually completes per sprint over time. After a few sprints, you have a reasonable estimate. Multiply points-remaining by velocity and you have a date range.&lt;/p&gt;

&lt;p&gt;I want to be honest, though: velocity isn't magic. It has real problems. It can be gamed by inflating points. It assumes a stable team — when people leave or join, it breaks. It works badly for highly exploratory work. And in the wrong hands it stops being a planning tool and becomes a productivity stick to beat people with.&lt;/p&gt;

&lt;p&gt;But used carefully, it gives you something hour-based estimation never does: &lt;strong&gt;a system that gets more accurate over time instead of less&lt;/strong&gt;. The curve is bumpy at first, and then it smooths out. With hours, the curve never smooths out, because the underlying signal was noise from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this doesn't apply
&lt;/h2&gt;

&lt;p&gt;I'm not selling a silver bullet. Complexity-based estimation is overkill when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your team is 1–3 people and you all have the same context anyway&lt;/li&gt;
&lt;li&gt;The work is repetitive (pure bug fixing, low-novelty maintenance)&lt;/li&gt;
&lt;li&gt;You're in an early prototype phase where everything is changing every day&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In those cases, hours — or no estimates at all — are probably fine. Don't impose ceremony where it doesn't earn its keep.&lt;/p&gt;

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

&lt;p&gt;After years of doing this, I don't think estimating in complexity is &lt;em&gt;more accurate&lt;/em&gt; than estimating in hours. Probably it isn't. But that was never the right question.&lt;/p&gt;

&lt;p&gt;The right question is: &lt;strong&gt;what kind of conversation do you want your team to have?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you estimate in hours, the conversation is individual and defensive. Whoever knows the most throws out a number, everyone nods, and the person with the least experience ends up trapped trying to hit a commitment they never had a real say in. Nobody learns. Nobody talks about the problem itself. Numbers just get distributed.&lt;/p&gt;

&lt;p&gt;When you estimate in complexity, the conversation is about the problem. Why is this a 5 and not a 3. What's hiding inside it. What risks it carries. Juniors learn by watching seniors reason. Seniors sometimes realize the "trivial" thing wasn't trivial. The team understands what they're about to do — together — before they do it.&lt;/p&gt;

&lt;p&gt;That's what hours don't give you, no matter how precise.&lt;/p&gt;




&lt;p&gt;If your team estimates in hours and it works for you, great — keep going. But if you find yourselves fighting estimates that never land, devs burning out, and PMs disappointed sprint after sprint, maybe the hours aren't being measured wrong.&lt;/p&gt;

&lt;p&gt;Maybe &lt;strong&gt;hours are just the wrong question.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What does estimation look like on your team? Do you fight with hours, swear by points, or have you found something else that works? 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>Your team isn’t slow — your WIP is too high (Little’s Law explained)</title>
      <dc:creator>Matías Denda</dc:creator>
      <pubDate>Wed, 06 May 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mdenda/littles-law-the-math-that-explains-why-your-team-delivers-slowly-31j3</link>
      <guid>https://dev.to/mdenda/littles-law-the-math-that-explains-why-your-team-delivers-slowly-31j3</guid>
      <description>&lt;p&gt;A team with 20 open PRs is &lt;strong&gt;mathematically slower&lt;/strong&gt; than a team with 3.&lt;/p&gt;

&lt;p&gt;Not “feels slower.” Not “probably slower.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is slower.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your tickets take two weeks to cross the board while coding takes a few hours, the problem isn’t effort — it’s how much work your team keeps in flight at the same time.&lt;/p&gt;

&lt;p&gt;And there’s a 65-year-old law that explains exactly why.&lt;/p&gt;




&lt;h2&gt;
  
  
  The equation
&lt;/h2&gt;

&lt;p&gt;Little’s Law, proven by MIT professor John Little in 1961, applies to any stable flow system over a long enough interval.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lead Time = WIP / Throughput&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In plain English:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lead Time&lt;/strong&gt; — how long it takes for a ticket to go from started → merged → deployed
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WIP&lt;/strong&gt; — Work In Progress, the number of items currently in flight
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throughput&lt;/strong&gt; — how many items your team completes per unit time (usually per week)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not a metaphor. It governs factories, airport security lines, and fast-food drive-throughs.&lt;/p&gt;

&lt;p&gt;It also governs your Git workflow — whether you acknowledge it or not.&lt;/p&gt;




&lt;h2&gt;
  
  
  The example that breaks the illusion
&lt;/h2&gt;

&lt;p&gt;Let’s run the numbers on a real team.&lt;/p&gt;

&lt;p&gt;Priya’s team closes &lt;strong&gt;10 PRs per week&lt;/strong&gt; on average (measured over the last 8 weeks). At any given moment, they have &lt;strong&gt;20 PRs open&lt;/strong&gt; (in progress + in review).&lt;/p&gt;

&lt;p&gt;Their lead time is:&lt;/p&gt;

&lt;p&gt;20 WIP / 10 PRs per week = 2 weeks&lt;/p&gt;

&lt;p&gt;That means every ticket entering the system today is &lt;strong&gt;guaranteed&lt;/strong&gt; to take about two weeks to ship — even if the code itself takes 3 hours.&lt;/p&gt;

&lt;p&gt;When someone asks:&lt;/p&gt;

&lt;p&gt;“Why does this take two weeks if coding takes half a day?”&lt;/p&gt;

&lt;p&gt;This is the answer.&lt;/p&gt;

&lt;p&gt;The code is not slow.&lt;br&gt;&lt;br&gt;
The developers are not slow.  &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The queue is long.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The part that actually changes how you work
&lt;/h2&gt;

&lt;p&gt;If throughput is roughly stable (your team works at a certain pace), then:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lowering WIP lowers lead time proportionally.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Same team:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WIP 20 → 10 → lead time drops from 2 weeks to &lt;strong&gt;1 week&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;WIP 20 → 6 → lead time drops to &lt;strong&gt;~3 days&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing else changes.&lt;/p&gt;

&lt;p&gt;No overtime.&lt;br&gt;&lt;br&gt;
No process overhaul.  &lt;/p&gt;

&lt;p&gt;Just finishing work before starting new work.&lt;/p&gt;

&lt;p&gt;This is why WIP limits exist in Kanban — not as a constraint on developers, but as a way to &lt;strong&gt;shorten delivery time by physics&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  “If I limit WIP, people will sit idle”
&lt;/h2&gt;

&lt;p&gt;This is the most common objection — and it’s backwards.&lt;/p&gt;

&lt;p&gt;A team with 10 open PRs is &lt;strong&gt;slower&lt;/strong&gt; than a team with 3.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Context switching is expensive&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Every open branch is cognitive load. Ten branches means ten partial states competing for attention.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Half-done work has zero value&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
A PR at 90% is still 0% delivered.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Waiting PRs are inventory, not progress&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
In manufacturing, inventory is waste. In software, a PR sitting in review for days is exactly that.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Limiting WIP forces a simple rule:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finish something before starting something new.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That alone compresses your delivery timeline.&lt;/p&gt;




&lt;h2&gt;
  
  
  You can measure this from Git (no fancy tools needed)
&lt;/h2&gt;

&lt;p&gt;You don’t need Jira dashboards or expensive analytics. You can get all three variables directly from Git.&lt;/p&gt;




&lt;h3&gt;
  
  
  Throughput — PRs merged per week
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&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; 200 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--json&lt;/span&gt; mergedAt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'[.[] | .mergedAt | fromdate | strftime("%Y-W%V")] 
        | group_by(.) 
        | map({week: .[0], count: length})'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Average the last 8 weeks for a stable number.&lt;/p&gt;




&lt;h3&gt;
  
  
  WIP — what’s currently in flight
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# PRs in review&lt;/span&gt;
gh &lt;span class="nb"&gt;pr &lt;/span&gt;list &lt;span class="nt"&gt;--state&lt;/span&gt; open | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;

&lt;span class="c"&gt;# Branches not yet merged (likely in progress)&lt;/span&gt;
git fetch &lt;span class="nt"&gt;--prune&lt;/span&gt;
git branch &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;--no-merged&lt;/span&gt; origin/main | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add both — that’s your WIP.&lt;/p&gt;




&lt;h3&gt;
  
  
  Lead time — created → merged
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&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; 50 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--json&lt;/span&gt; number,createdAt,mergedAt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[] | {n: .number, hours: (((.mergedAt|fromdate) - (.createdAt|fromdate))/3600|floor)}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don’t just look at the average — look at the spread. A few PRs taking 10+ days can dominate the system.&lt;/p&gt;




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

&lt;p&gt;Once you have all three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the equation matches your lead time → your system is stable. Lower WIP to go faster.
&lt;/li&gt;
&lt;li&gt;If your lead time is &lt;strong&gt;longer&lt;/strong&gt; → you have bottlenecks (reviews, CI, environments). Fix those first.
&lt;/li&gt;
&lt;li&gt;If it’s &lt;strong&gt;shorter&lt;/strong&gt; → you’re likely measuring WIP wrong or hiding variance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re leading a team and this number is higher than expected, you’re not alone — most teams never measure it.&lt;/p&gt;




&lt;h2&gt;
  
  
  A practical starting point for WIP limits
&lt;/h2&gt;

&lt;p&gt;There’s no universal number, but these are solid defaults:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In Progress ≈ team size&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;In Review ≈ team size ÷ 2&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example (6 devs):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In Progress = 6
&lt;/li&gt;
&lt;li&gt;In Review = 3
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From there, adjust slowly. Not weekly — &lt;strong&gt;quarterly&lt;/strong&gt;. The signal takes time.&lt;/p&gt;

&lt;p&gt;If lead time is too long:&lt;br&gt;
Don’t add people — lower WIP.&lt;/p&gt;




&lt;h2&gt;
  
  
  When perception and math disagree
&lt;/h2&gt;

&lt;p&gt;This is the uncomfortable part.&lt;/p&gt;

&lt;p&gt;A team with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WIP = 20
&lt;/li&gt;
&lt;li&gt;Throughput = 5/week
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Has a &lt;strong&gt;4-week lead time&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It doesn’t matter if individual PRs “feel fast.”&lt;/p&gt;

&lt;p&gt;Users don’t feel individual PR speed.&lt;br&gt;&lt;br&gt;
They feel &lt;strong&gt;system lead time&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When the board says “we’re delivering” but the math says 4 weeks — the business experiences 4 weeks.&lt;/p&gt;




&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;You don’t need a new process.&lt;/p&gt;

&lt;p&gt;You don’t need better estimates.&lt;/p&gt;

&lt;p&gt;You need a constraint:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stop starting. Start finishing.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is adapted from &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;, a 658-page book covering Git the way it’s actually used in real engineering teams.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Little’s Law looks simple — until you apply it and realize your entire workflow is shaped by it. In the book, I go deeper into how this connects to branching strategies, PR flows, and why some teams get faster as they scale while others slow down.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>git</category>
      <category>devops</category>
      <category>productivity</category>
      <category>teams</category>
    </item>
  </channel>
</rss>
