<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged testing</title>
<link>https://coderlegion.com/tag/testing</link>
<description>Powered by Question2Answer</description>
<item>
<title>Your Tests Are Green and Prove Nothing: 4 Signs You're Testing the Mock</title>
<link>https://coderlegion.com/33169/your-tests-are-green-and-prove-nothing-4-signs-youre-testing-the-mock</link>
<description>&lt;p&gt;Coverage says 94%. Every test is green. You deploy on Friday afternoon, and by Monday a customer has found a bug in the exact function you &quot;tested&quot;.&lt;/p&gt;
&lt;p&gt;When you open the test, the reason is obvious in hindsight. The test never ran your code. It ran a fake version of everything around it and then asserted that the fake behaved like a fake.&lt;/p&gt;
&lt;p&gt;Here are four ways this happens, and a check you can do in a minute to see how many of your own tests are guilty.&lt;/p&gt;
&lt;h3 id=&quot;1-you-mocked-the-thing-you-are-testing&quot;&gt;1. You Mocked the Thing You Are Testing&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// pricing.ts
export function applyDiscount(total: number, code: string) {
  return code === 'SAVE10' ? total * 0.9 : total;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// checkout.test.ts
import * as pricing from './pricing';

vi.spyOn(pricing, 'applyDiscount').mockReturnValue(90);

test('applies discount at checkout', () =&amp;gt; {
  expect(checkout(100, 'SAVE10')).toBe(90);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This passes if &lt;code&gt;applyDiscount&lt;/code&gt; is correct. It also passes if &lt;code&gt;applyDiscount&lt;/code&gt; returns &lt;code&gt;NaN&lt;/code&gt;, because the test replaced it with a hard-coded &lt;code&gt;90&lt;/code&gt;. You are testing that &lt;code&gt;checkout&lt;/code&gt; can return the number you handed it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; mock what is outside your code, such as network, clock and payment providers. Run your own logic for real.&lt;/p&gt;
&lt;h3 id=&quot;2-you-assert-the-calls-not-the-outcome&quot;&gt;2. You Assert the Calls, Not the Outcome&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;test('saves the user', async () =&amp;gt; {
  await createUser({ email: '*Emails are not allowed*' });
  expect(db.insert).toHaveBeenCalledWith('users', { email: '*Emails are not allowed*' });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This test proves the function called &lt;code&gt;insert&lt;/code&gt; with those arguments. It does not prove a user exists. If someone renames the table, adds a required field or changes the query, this test keeps passing against the mock while real users stop being saved.&lt;/p&gt;
&lt;p&gt;It also fails for the wrong reason. Refactor the internals without changing behavior, and it turns red, so people learn that failing tests are noise.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; assert what a user or caller would observe.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;test('saves the user', async () =&amp;gt; {
  await createUser({ email: '*Emails are not allowed*' });
  const user = await findUserByEmail('*Emails are not allowed*');
  expect(user?.email).toBe('*Emails are not allowed*');
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;3-the-fake-database-only-says-yes&quot;&gt;3. The Fake Database Only Says Yes&lt;/h3&gt;
&lt;p&gt;A mocked database returns whatever you tell it to. A real one has rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A unique index rejects duplicate emails.&lt;/li&gt;
&lt;li&gt;A required field rejects a missing value.&lt;/li&gt;
&lt;li&gt;A foreign key rejects an ID that does not exist.&lt;/li&gt;
&lt;li&gt;A query with a typo in a field name returns nothing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these exist in a mock, so none of these bugs can appear in your tests. They show up in production, on the first real duplicate signup.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; for anything that touches storage, test against a real database, either a throwaway local instance or an in-memory one with the same engine, reset between tests. It is slower than a mock, but it is the only thing that can fail the way production fails.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;test('rejects a duplicate email', async () =&amp;gt; {
  await createUser({ email: '*Emails are not allowed*' });
  await expect(createUser({ email: '*Emails are not allowed*' })).rejects.toThrow();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You cannot write that test against a mock that always resolves.&lt;/p&gt;
&lt;h3 id=&quot;4-snapshots-approved-without-reading&quot;&gt;4. Snapshots Approved Without Reading&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;expect(renderInvoice(order)).toMatchSnapshot();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A snapshot records whatever the output is today and fails when it changes. It does not know whether today's output is correct. When it fails, the usual reaction is to press &quot;update&quot; and move on, so the test stores bugs as the expected answer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; use snapshots for small, stable things, and assert the fields that matter for anything involving money, permissions or dates:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const invoice = renderInvoice(order);
expect(invoice.total).toBe(118.0);
expect(invoice.lines).toHaveLength(2);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-60-second-sabotage-check&quot;&gt;The 60-Second Sabotage Check&lt;/h3&gt;
&lt;p&gt;Here is how to find out whether a test protects you. Take a function you care about, &lt;strong&gt;break it on purpose&lt;/strong&gt;, and run its tests.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export function applyDiscount(total: number, code: string) {
  return total; // sabotage: discount never applies
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then ask one question: &lt;strong&gt;did a test fail?&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If yes, that test is doing its job.&lt;/li&gt;
&lt;li&gt;If no, you have found a test that would have stayed green while the feature was broken in production.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Revert the change, and fix or delete the test that did not notice. Try this on three important functions and you will learn more about your suite than coverage ever told you. The formal version of this idea is called &lt;strong&gt;mutation testing&lt;/strong&gt;, and tools exist that automate it, but doing it by hand on your most critical code is a good start.&lt;/p&gt;
&lt;h3 id=&quot;what-to-mock-and-what-not-to&quot;&gt;What to Mock, and What Not To&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Mock it &lt;/th&gt;&lt;th&gt;Run it for real&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Network calls to third parties &lt;/td&gt;&lt;td&gt;Your own business logic&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The clock and random numbers &lt;/td&gt;&lt;td&gt;Your database queries&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Email and SMS sending &lt;/td&gt;&lt;td&gt;Validation and permission rules&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Payment providers &lt;/td&gt;&lt;td&gt;Anything that can fail on real data&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The rule is that you mock the &lt;strong&gt;edges&lt;/strong&gt;, the places where your code talks to someone else, and keep everything in the middle real.&lt;/p&gt;
&lt;h3 id=&quot;the-rule&quot;&gt;The Rule&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;A test that cannot fail when the code is wrong is not a test.&lt;/strong&gt; Mock the world outside your app, assert outcomes instead of calls, and use sabotage to check whether your tests notice.&lt;/p&gt;
&lt;p&gt;Quick audit: pick the three functions that would hurt most if they broke, such as pricing, permissions and sign-up. Break each one on purpose. Count how many tests turn red. If the answer is zero for any of them, that is your next ticket.&lt;/p&gt;
&lt;p&gt;If you want a Next.js starting point to build and test on, you can browse my &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.com&quot;&gt;templates on pixelanas.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get the templates:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pixelanas.gumroad.com&quot;&gt;https://pixelanas.gumroad.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;What is the most misleading green test you have ever found? Share it in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Anas, full-stack Next.js developer building SaaS products and premium templates. X: &lt;a rel=&quot;nofollow&quot; href=&quot;https://x.com/ASheikh69751&quot;&gt;@ASheikh69751&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/33169/your-tests-are-green-and-prove-nothing-4-signs-youre-testing-the-mock</guid>
<pubDate>Sat, 31 Oct 2026 03:38:56 +0000</pubDate>
</item>
<item>
<title>I Solved the Same Gmail Problem Two Ways Without Noticing</title>
<link>https://coderlegion.com/32978/i-solved-the-same-gmail-problem-two-ways-without-noticing</link>
<description>&lt;p&gt;This is the third post from my n8n intake classifier, a practice project for a fictional automation agency that I built with an AI collaborator. The first was about &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/31215/each-fix-to-my-classification-prompt-exposed-the-next-problem&quot;&gt;the classification prompt&lt;/a&gt;. The second was about &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/32063/ambiguous-invalid-or-system-error&quot;&gt;ambiguous, invalid and system_error&lt;/a&gt;. This one is about the wiring after classification: where logging goes, what the Gmail node does to your data, and a bug I only found by opening real inboxes. The workflow is in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Tarek-Elzoghby/client-intake-classifier&quot;&gt;GitHub repository&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;A quick orientation: n8n is a workflow automation tool, and a workflow is built from nodes, each one a single step. The ones that matter here are Switch (sends an item down one of several paths), Set (reshapes the data) and Gmail (sends an email).&lt;/p&gt;
&lt;h3 id=&quot;where-logging-was-supposed-to-go&quot;&gt;Where logging was supposed to go&lt;/h3&gt;
&lt;p&gt;After classification, a Switch node sends each submission down one of five paths, and each path sends emails. Every submission also gets logged to Google Sheets. For this phase I planned to work the way I had in the earlier ones: Sheets first, then emails, one branch at a time.&lt;/p&gt;
&lt;p&gt;Before building anything, I caught a conflict. Logging is the same logic on all five paths, and only the data changes. Putting it first inside each branch meant five copies of one node, or converging after Sheets and then branching again to send category-specific emails. Neither made sense. So I flipped the order: build the emails first, since those differ per category, and let all five paths converge on one shared Sheets node at the end.&lt;/p&gt;
&lt;h3 id=&quot;gmail-replaces-your-data&quot;&gt;Gmail replaces your data&lt;/h3&gt;
&lt;p&gt;The first branch I built showed a second problem. When the Gmail node sends an email, the item it passes on is Gmail's own send confirmation, not the submission. Name, Email and description were gone by the time anything downstream needed them. The LLM Chain node had done the same thing to me earlier.&lt;/p&gt;
&lt;p&gt;In the ambiguous/system_error branch, I added a Set node right after the internal notification to rebuild one clean object. The IF node and both client emails read from that, instead of each reaching back to whichever earlier node had the data. In simple_automation, I added a Set node after the client email to rebuild the object before it reached the shared Sheets node.&lt;/p&gt;
&lt;h3 id=&quot;noticing-i-d-solved-it-twice&quot;&gt;Noticing I'd solved it twice&lt;/h3&gt;
&lt;p&gt;While wiring the final Sheets node, I looked at the ambiguous/system_error branch and saw it fed Sheets from two points off the same rebuild node, one path through the client emails and one straight to Sheets. That wasn't a bug. The email path never reconnected to Sheets, so nothing was logged twice. But it showed me I had solved the Gmail problem two different ways. Everywhere else I rebuilt the data after the email. In this branch I branched before the email, because Sheets never needed anything the email step produced.&lt;/p&gt;
&lt;h3 id=&quot;moving-logging-upstream&quot;&gt;Moving logging upstream&lt;/h3&gt;
&lt;p&gt;That comparison pointed at something bigger. Every branch converged on one Sheets node, so there was no reason logging should depend on any email succeeding. A Gmail send can fail for reasons that have nothing to do with the submission.&lt;/p&gt;
&lt;p&gt;I moved the Sheets connection to the two points right after classification: one for a successful classification and one for the failure handler. Both now feed the Switch and Sheets in parallel. Two side effects followed. &lt;code&gt;has_partial_intent&lt;/code&gt; only had to be added in two places instead of four, and I could delete the end-of-branch Set nodes that existed only to feed the old Sheets wiring.&lt;/p&gt;
&lt;p&gt;Before deleting any of them, I checked each one. Some still fed a client email's own data, and removing one of those would have broken a client-facing email without touching Sheets. I kept those.&lt;/p&gt;
&lt;p&gt;The final build still contains both patterns. The ambiguous/system_error branch has its rebuild node, and the other client emails reach back to earlier nodes by name:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;={{ $('Route: By Classification Category').item.json.Email }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I haven't compared whether one pattern is more robust than the other. What changed is that logging no longer depends on either.&lt;/p&gt;
&lt;h3 id=&quot;checking-real-inboxes&quot;&gt;Checking real inboxes&lt;/h3&gt;
&lt;p&gt;After the rewiring, I retested every branch individually and checked real inboxes, not just node output. That caught a bug in the ai_workflow branch: the internal notification and the client email had their destinations swapped. The internal-only message went to the client, and the client's confirmation went to the team.&lt;/p&gt;
&lt;p&gt;Both emails sent without errors, so nothing had failed that would make me suspect a problem. Only who received what showed it. I then confirmed the simple_automation branch had no swap, since I'd used the same inbox check there.&lt;/p&gt;
&lt;p&gt;My notes don't record how the swap got there, or whether the rewiring caused it, so I won't guess. The current build sends each internal email to the team address and each client email to the submitter.&lt;/p&gt;
&lt;h3 id=&quot;a-test-case-i-wrote-wrong&quot;&gt;A test case I wrote wrong&lt;/h3&gt;
&lt;p&gt;While testing &lt;code&gt;ambiguous&lt;/code&gt;, my first case was a deliberately vague description. It came back as &lt;code&gt;invalid&lt;/code&gt;, and I took that as a bug. Reading the reasoning showed the classifier was right. Vague wording alone doesn't give any category a specific signal to rest on, which is a different situation from two categories each being concretely supported. I wrote a sharper case with explicit signals for both &lt;code&gt;ai_workflow&lt;/code&gt; and &lt;code&gt;custom_integration&lt;/code&gt;, and that one returned &lt;code&gt;ambiguous&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;what-this-doesn-t-show&quot;&gt;What this doesn't show&lt;/h3&gt;
&lt;p&gt;Every test used descriptions I wrote. A forced classification failure does produce a row in the sheet, with the &lt;code&gt;has_partial_intent&lt;/code&gt; cell empty, because the model never produced a value on that path. I did not test what happens if the Sheets step itself fails.&lt;/p&gt;
&lt;p&gt;What I take from it is narrow: logging shouldn't wait on anything that can fail for unrelated reasons, and a branch that sends emails isn't tested until I've read the emails.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32978/i-solved-the-same-gmail-problem-two-ways-without-noticing</guid>
<pubDate>Thu, 29 Oct 2026 13:52:12 +0000</pubDate>
</item>
<item>
<title>Cancel or send: one winner. An order flow on Prisma Postgres and Compute</title>
<link>https://coderlegion.com/32897/cancel-or-send-one-winner-an-order-flow-on-prisma-postgres-and-compute</link>
<description>&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Werbung / Disclosure:&lt;/strong&gt; Prisma gave me a coupon for two months of a paid plan for free and said it will add free credits, so I could try &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.prisma.io/postgres&quot;&gt;Prisma Postgres&lt;/a&gt; and Compute for this project. All results below come from my own runs.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;summary&quot;&gt;Summary&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;I built an order flow with a 10-minute grace period. Cancellation and dispatch must have exactly one winner in the database.&lt;/li&gt;
&lt;li&gt;The winner is decided in Postgres, with a guarded update, a row lock and &lt;code&gt;SKIP LOCKED&lt;/code&gt;. No clock and no timer decides it.&lt;/li&gt;
&lt;li&gt;I broke the code on purpose, one line at a time, to check that my tests really fail. One of my race tests passed even without the lock.&lt;/li&gt;
&lt;li&gt;On Compute, an idle app sleeps. My poller is a timer inside the server, so it only runs when someone calls the app. This changes the timing, not the correctness.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-problem&quot;&gt;The problem&lt;/h3&gt;
&lt;p&gt;A customer orders a custom T-shirt. I promise a grace period of 10 minutes. During that time the customer can cancel. After that, the printer gets an email and the customer cannot cancel any more.&lt;/p&gt;
&lt;p&gt;Two things can happen at almost the same moment. The customer presses cancel, and my poller wants to send. Exactly one of them must win. A cancelled order must never reach the printer, because an email cannot be recalled.&lt;/p&gt;
&lt;h3 id=&quot;design-decisions&quot;&gt;Design decisions&lt;/h3&gt;
&lt;h4 id=&quot;claim-first-then-send&quot;&gt;Claim first, then send&lt;/h4&gt;
&lt;p&gt;The poller claims an order by changing its status from &lt;code&gt;PENDING&lt;/code&gt; to &lt;code&gt;SENT&lt;/code&gt; before it sends the email. If the customer cancels first, the claim finds nothing. If the claim wins, the cancel is rejected with a 409.&lt;/p&gt;
&lt;p&gt;Here, &lt;code&gt;SENT&lt;/code&gt; means the order has been claimed for dispatch, not that the email has been delivered. If the server crashes after the claim, the printer may get no email. I set &lt;code&gt;printerNotifiedAt&lt;/code&gt; only after Resend accepts it. A stuck-order query finds &lt;code&gt;SENT&lt;/code&gt; orders without that timestamp, and a resend route lets me recover them.Sending the email before claiming the order could allow it to be sent even if the cancellation wins the database race.&lt;/p&gt;
&lt;h4 id=&quot;a-guarded-transition&quot;&gt;A guarded transition&lt;/h4&gt;
&lt;p&gt;Every status change goes through one function. It checks that the transition is allowed for the actor. Then it writes with the old status in the where clause:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const { count } = await prisma.order.updateMany({
  where: { id, status: order.status },
  data: { status: to },
});
if (count === 0) throw new ConflictException('Order changed, please retry');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If someone changed the row between my read and my write, &lt;code&gt;count&lt;/code&gt; is 0 and the caller gets a 409. This is the sixth time I used this read-then-write pattern across my projects.&lt;/p&gt;
&lt;h4 id=&quot;a-postgres-poller-not-a-queue&quot;&gt;A Postgres poller, not a queue&lt;/h4&gt;
&lt;p&gt;The hosted version has no Redis, so there is no BullMQ. The order and its deadline are written in one transaction. A timer loop claims due orders with one statement:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE &quot;Order&quot; SET status = 'SENT' -- plus updatedAt
WHERE id IN (
  SELECT id FROM &quot;Order&quot;
  WHERE status = 'PENDING' AND &quot;sendAt&quot; &amp;lt;= (now() AT TIME ZONE 'UTC')
  ORDER BY &quot;sendAt&quot; LIMIT 10
  FOR UPDATE SKIP LOCKED
)
RETURNING id
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is no enqueue gap, because the deadline is data. The costs are a polling delay and retry logic that I write myself. Today there is no automatic retry. The safety net is the stuck-order query and the resend route.&lt;/p&gt;
&lt;p&gt;The server owns &lt;code&gt;sendAt&lt;/code&gt;. A client-supplied value could skip the grace period, so the request schema rejects unknown keys.&lt;/p&gt;
&lt;h4 id=&quot;a-row-lock-on-the-design&quot;&gt;A row lock on the design&lt;/h4&gt;
&lt;p&gt;An order points at a design. If the design changes after the order exists, the printer prints something the customer never ordered, and nobody sees an error. Creating an order, updating a design and deleting a design all take &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; on the design row first. A design counts as locked when it has any order that is not cancelled.&lt;/p&gt;
&lt;h3 id=&quot;tests-and-a-test-that-proved-nothing&quot;&gt;Tests, and a test that proved nothing&lt;/h3&gt;
&lt;p&gt;I wrote integration tests against a real Postgres database. Then I broke the code on purpose, one line at a time, and checked that a test turned red.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;I removed &lt;/th&gt;&lt;th&gt;What went red&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;the status in the &lt;code&gt;updateMany&lt;/code&gt; guard &lt;/td&gt;&lt;td&gt;the cancel-versus-poller test, and all 30 printer/admin confirm iterations&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;status = 'PENDING'&lt;/code&gt; in the claim &lt;/td&gt;&lt;td&gt;a cancelled order was emailed, and two pollers sent two emails&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;SKIP LOCKED&lt;/code&gt; &lt;/td&gt;&lt;td&gt;the locked-row test (the pollers waited)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;FOR UPDATE&lt;/code&gt; in order creation &lt;/td&gt;&lt;td&gt;only the deterministic lock test&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;FOR UPDATE&lt;/code&gt; in design update &lt;/td&gt;&lt;td&gt;only the deterministic lock test (65 passed, 1 failed)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;FOR UPDATE&lt;/code&gt; in design removal &lt;/td&gt;&lt;td&gt;the remove-versus-create test: a foreign key error instead of a 409&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The lock rows are the lesson. First I tested the lock with 30 free-running iterations of update versus create. They all passed with the lock removed. The broken interleaving ends in the same final database state as the harmless one, so no assertion on the final state can tell them apart. Only a test that pauses order creation after it takes the lock, and then checks whether the update can finish, sees the bug.&lt;/p&gt;
&lt;p&gt;A test also found a timezone bug. I ran the integration session in America/Los_Angeles. Comparing sendAt with a bare now() failed because sendAt is a timestamp without time zone. The comparison now uses now() AT TIME ZONE 'UTC', assuming sendAt is stored as a UTC timestamp. This fixes the timezone interpretation, but not clock skew between the application and database; I discuss that remaining limitation below.&lt;/p&gt;
&lt;h3 id=&quot;prisma-postgres-and-compute&quot;&gt;Prisma Postgres and Compute&lt;/h3&gt;
&lt;p&gt;Prisma ORM 8 (I write Prisma 8 for short) is a release candidate and Composer is in Early Access. Compute has been generally available since August 2026. Everything in this section was true on my machine in October 2026. Versions: Prisma CLI 8.0.0-rc.27, &lt;code&gt;@prisma/orm-postgres&lt;/code&gt; 8.0.0-rc.20, Composer 0.34.0.&lt;/p&gt;
&lt;h4 id=&quot;why-prisma-orm-8-is-in-this-project&quot;&gt;Why Prisma ORM 8 is in this project&lt;/h4&gt;
&lt;p&gt;The Console told me that my repository used &lt;code&gt;prisma ^7.7.0&lt;/code&gt;, that deploys run on Prisma 8, and that I should upgrade first. So I installed Prisma 8 next to Prisma 7, inferred a contract from my database, and planned a baseline migration. The plan had 15 operations from an empty database: four enums, four tables, the &lt;code&gt;(status, sendAt)&lt;/code&gt; index and four foreign keys.&lt;/p&gt;
&lt;p&gt;Later, Martin from Prisma told me that a Prisma 7 app runs fine on Compute and that Prisma 8 is not required. I did not test a Prisma 7 only deploy myself, so I cannot confirm it, and I reported the Console banner to Prisma.&lt;/p&gt;
&lt;p&gt;I kept Prisma 8 for one job: the deploy applies my baseline migration to the new database. My queries still run on Prisma 7 with the &lt;code&gt;pg&lt;/code&gt; adapter. The docs also describe a plainer option, &lt;code&gt;rawPostgres()&lt;/code&gt;, which hands your own client a &lt;code&gt;db.url&lt;/code&gt;. With it, you migrate the database yourself. I did not use it.&lt;/p&gt;
&lt;p&gt;I kept the queries on Prisma 7 for another reason. The release status page lists what Prisma 8 does not have yet, for example transaction isolation levels, atomic increment, most nested writes and &lt;code&gt;P2002&lt;/code&gt;-style error codes. My locks use raw SQL and a guarded &lt;code&gt;updateMany&lt;/code&gt;, and I did not try them on Prisma 8.&lt;/p&gt;
&lt;p&gt;Two more facts for Prisma 7 users. First, &lt;code&gt;npx prisma&lt;/code&gt; now runs the Prisma 8 CLI, which has different data commands (&lt;code&gt;contract&lt;/code&gt;, &lt;code&gt;db&lt;/code&gt; and &lt;code&gt;migration&lt;/code&gt;). Pin &lt;code&gt;prisma&lt;/code&gt; to &lt;code&gt;^7&lt;/code&gt; and use &lt;code&gt;npx prisma@7&lt;/code&gt;, or install an alias package like I did. Second, Prisma 8 is a release candidate, and the release status page expects general availability in October 2026.&lt;/p&gt;
&lt;h4 id=&quot;getting-a-nestjs-11-app-onto-compute&quot;&gt;Getting a NestJS 11 app onto Compute&lt;/h4&gt;
&lt;p&gt;Compute runs a single bundled file. My notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The deploy tool refused Node 22.23.4 and asked for Node 24.11.1 or newer. The Compute docs say Node 22.18 or newer on the 22 line is enough, so the docs and the tool disagree.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bcrypt&lt;/code&gt; is a native addon. Bundled into one file, it crashed at startup with &lt;code&gt;__dirname is not defined&lt;/code&gt;. I switched to &lt;code&gt;bcryptjs&lt;/code&gt;. Login and the tests still passed.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;prisma dev&lt;/code&gt; is not supported on Windows yet, so I tested the bundle with plain &lt;code&gt;node&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Composer needs &lt;code&gt;type: module&lt;/code&gt;. I put the Composer files in a &lt;code&gt;deploy&lt;/code&gt; folder with its own &lt;code&gt;package.json&lt;/code&gt;, so &lt;code&gt;nest build&lt;/code&gt; stays CommonJS. A small launcher reads the database URL that the platform provides and then starts the compiled app.&lt;/li&gt;
&lt;li&gt;The Console's setup pull request failed at &lt;code&gt;npm install&lt;/code&gt; with &lt;code&gt;ERESOLVE&lt;/code&gt;. I could reproduce a conflict on my machine by adding &lt;code&gt;alchemy&lt;/code&gt; next to Jest 30 (a Babel peer dependency clash). I could not see the Console's log, so I do not know if it is the same cause.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The first deploy created ten resources, including the database and the migration step, and returned a public URL. The docs say Compute runs deployed apps on Bun, and I built my bundle for Node. The app worked when I tried it. The poller is switched off on that deployment, so orders stay &lt;code&gt;PENDING&lt;/code&gt; there.&lt;/p&gt;
&lt;h3 id=&quot;the-surprise-scale-to-zero-versus-my-poller&quot;&gt;The surprise: scale to zero versus my poller&lt;/h3&gt;
&lt;p&gt;My poller is a timer inside the server. I deployed a small test app with a counter that ticks every 5 seconds and shows its history. Then I left it alone overnight.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;startedAt&lt;/code&gt; value was the same after 15 hours, so the app was never restarted.&lt;/li&gt;
&lt;li&gt;Only 2 ticks were recorded. The first gap was 54,940 seconds.&lt;/li&gt;
&lt;li&gt;New ticks appeared at the moments I opened the page. Then they stopped again.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Compute docs describe this: idle instances sleep with a memory snapshot and resume in milliseconds. A timer inside the process only runs when someone calls the app.&lt;/p&gt;
&lt;p&gt;What still holds: one database claim wins, and a cancelled order cannot be claimed for dispatch through this flow. What breaks is the timing. The email goes out after the next request, not at minute 10. Exactly-once email delivery is not guaranteed; a crash during recovery can result in a duplicate.&lt;/p&gt;
&lt;h4 id=&quot;open-question-where-should-the-poller-run-&quot;&gt;Open question: where should the poller run?&lt;/h4&gt;
&lt;p&gt;Compute has no built-in cron scheduler. A Prisma engineer told me that the recommended patterns are options 2 and 3 below, and that keep-awake is meant for short, bounded work. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.prisma.io/docs/compute/limitations&quot;&gt;Compute limitations&lt;/a&gt; page and the &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.prisma.io/docs/composer/building-blocks#cron&quot;&gt;Composer cron building block&lt;/a&gt; describe both. I have not chosen between them yet, and I would like to discuss it. These are the options I found:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Keep the instance awake.&lt;/strong&gt; Prisma says keep-awake is meant for short, bounded work, not for an always-on loop. The docs add that it does not guarantee continuity through restarts or deployments. I have not measured what a permanently awake instance costs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An external trigger (recommended by Prisma).&lt;/strong&gt; A scheduler outside Compute calls a protected route. The route wakes the app, runs the same claim logic and returns. My tests show that extra triggers are safe: two pollers claim an order once. The timing precision depends on the scheduler, and the request must start responding within 60 seconds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Composer cron building block (recommended by Prisma).&lt;/strong&gt; Its scheduler is a service that never scales to zero, so it costs one warm instance. Composer is in Early Access, and I did not find an example of mounting the cron runner in a NestJS app.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Questions that are still open:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Does the always-warm cron scheduler count against the Free plan, and how much memory does it use?&lt;/li&gt;
&lt;li&gt;Has anyone mounted the cron runner inside a NestJS app?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you solved this, I would like to hear how.&lt;/p&gt;
&lt;h3 id=&quot;raw-output&quot;&gt;Raw output&lt;/h3&gt;
&lt;p&gt;These are trimmed outputs from my own runs.&lt;/p&gt;
&lt;p&gt;The deploy:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;t-shirt-backend-test
├─ database   postgres-database db_...
└─ app        compute-service cps_...
Stack deployed (10/10)
Deployed t-shirt-backend-test to production in 3m 1s.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The bundle with the native &lt;code&gt;bcrypt&lt;/code&gt; addon, at startup:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ReferenceError: __dirname is not defined in ES module scope
    at ... require_node_gyp_build()(path.resolve(__dirname))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The test app's ticks, first call (15 hours after the deploy):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{&quot;startedAt&quot;:&quot;2026-10-25T16:36:23.113Z&quot;,&quot;now&quot;:&quot;2026-10-26T07:59:53.581Z&quot;,&quot;count&quot;:2,
 &quot;gaps&quot;:[{&quot;from&quot;:&quot;2026-10-25T16:42:26.838Z&quot;,&quot;to&quot;:&quot;2026-10-26T07:58:06.462Z&quot;,&quot;seconds&quot;:54940}]}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second call, about 10 minutes later (the first gap is shortened):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{&quot;startedAt&quot;:&quot;2026-10-25T16:36:23.113Z&quot;,&quot;now&quot;:&quot;2026-10-26T08:10:05.860Z&quot;,&quot;count&quot;:4,
 &quot;gaps&quot;:[{&quot;seconds&quot;:54940},{&quot;from&quot;:&quot;2026-10-26T07:58:06.462Z&quot;,&quot;to&quot;:&quot;2026-10-26T07:59:53.591Z&quot;,&quot;seconds&quot;:107}]}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two failures from the mutation runs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;status guard removed, confirm race:   Expected length: 1   Received length: 2
design lock removed, lock-held test:  Expected: &quot;rejected&quot;  Received: &quot;fulfilled&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;what-i-did-not-verify&quot;&gt;What I did not verify&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;I did not run the poller on Compute under load, and I did not measure how late the emails are.&lt;/li&gt;
&lt;li&gt;I have not fixed clock skew between the app server and the database. &lt;code&gt;sendAt&lt;/code&gt; is written from the app clock and compared with the database clock, so skew changes the grace period silently.&lt;/li&gt;
&lt;li&gt;There is no automatic retry for failed emails.&lt;/li&gt;
&lt;li&gt;I did not port locks and transactions to Prisma 8. They stay on Prisma 7.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Let the database decide the winner. Do not let a timer or a clock decide it.&lt;/li&gt;
&lt;li&gt;Break your code on purpose. A test that also passes without the guard proves nothing.&lt;/li&gt;
&lt;li&gt;A free-running race loop can pass for the wrong reason. Force the interleaving.&lt;/li&gt;
&lt;li&gt;Scale to zero changes what a timer inside your server means. Check it before you design around it.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;p&gt;I'm Mhamd, a backend engineer working with NestJS and TypeScript. Portfolio: &lt;a rel=&quot;nofollow&quot; href=&quot;https://mhamd021.github.io&quot;&gt;mhamd021.github.io&lt;/a&gt; · GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Mhamd021&quot;&gt;Mhamd021&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32897/cancel-or-send-one-winner-an-order-flow-on-prisma-postgres-and-compute</guid>
<pubDate>Wed, 28 Oct 2026 21:30:34 +0000</pubDate>
</item>
<item>
<title>A Stopwatch Needs an Isolated Lane</title>
<link>https://coderlegion.com/32820/a-stopwatch-needs-an-isolated-lane</link>
<description>&lt;p&gt;Timing tests look wonderfully objective. Start a stopwatch, call the component, stop the watch, and compare the result with a budget.&lt;/p&gt;
&lt;p&gt;The number is precise. The experiment may not be.&lt;/p&gt;
&lt;p&gt;That distinction matters in continuous integration, where many tests share CPU time, thread pools, network stacks, containers, and database capacity. A wall-clock assertion can fail because the product became slower. It can also fail because the test runner became busier. If we cannot distinguish those two explanations, the test is producing urgency without producing useful evidence.&lt;/p&gt;
&lt;h3 id=&quot;the-contract-was-reasonable&quot;&gt;The contract was reasonable&lt;/h3&gt;
&lt;p&gt;Consider a service with separate liveness and readiness checks. Liveness says the process is still running. Readiness says the instance can safely accept work.&lt;/p&gt;
&lt;p&gt;One integration test deliberately points the service at an unreachable dependency. The expected behavior is not success. The service should return an unavailable response, and it should do so within a fixed upper bound. That is a meaningful reliability contract: an orchestrator needs a quick, honest answer rather than a request that hangs behind retry policies intended for normal user operations.&lt;/p&gt;
&lt;p&gt;The test boots the real host, makes the request, verifies the unavailable result, and checks elapsed time.&lt;/p&gt;
&lt;p&gt;So far, so good.&lt;/p&gt;
&lt;h3 id=&quot;the-failure-blamed-the-wrong-interval&quot;&gt;The failure blamed the wrong interval&lt;/h3&gt;
&lt;p&gt;The timing assertion began failing repeatedly in CI. Raising the limit would have made the build green. Adding an automatic retry might also have hidden the symptom.&lt;/p&gt;
&lt;p&gt;Neither action would have explained the result.&lt;/p&gt;
&lt;p&gt;The endpoint implementation had not changed during the relevant window. More importantly, the surrounding timing evidence showed that host startup in the same test took roughly as long as the delay attributed to the readiness request. The test also ran beside many parallel integration classes doing heavier work.&lt;/p&gt;
&lt;p&gt;The stopwatch was attached to one request, but that request still depended on threads scheduled in a shared process. Under contention, continuation work can wait. Startup can wait. Connection failure can be observed later than expected. The test reports wall-clock time, not pure execution time.&lt;/p&gt;
&lt;p&gt;This does not prove the service is fast. It proves the original experiment was unable to tell product latency from runner contention.&lt;/p&gt;
&lt;h3 id=&quot;keep-the-budget-before-changing-it&quot;&gt;Keep the budget before changing it&lt;/h3&gt;
&lt;p&gt;When a timing test turns red, I prefer to keep the bound intact while investigating. The bound represents a real operational expectation. Relaxing it before understanding the measurement destroys evidence that may still be valuable.&lt;/p&gt;
&lt;p&gt;A useful first pass is to separate three intervals:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Setup&lt;/strong&gt; — host boot, fixture creation, migrations, container startup, and test data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Contention&lt;/strong&gt; — time spent waiting for CPU, threads, sockets, database slots, or another shared resource.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Behavior&lt;/strong&gt; — the operation whose latency the assertion is meant to protect.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Instrumenting those boundaries does not require a production profiler. Even a few carefully placed timestamps can reveal that the supposedly slow operation accounts for only a small part of the elapsed test.&lt;/p&gt;
&lt;h3 id=&quot;isolate-the-claim-not-the-whole-suite&quot;&gt;Isolate the claim, not the whole suite&lt;/h3&gt;
&lt;p&gt;The narrow repair was to move only the timing proof into a non-parallel test collection.&lt;/p&gt;
&lt;p&gt;The response status did not change. The time budget did not change. The endpoint code did not change. Functional readiness tests stayed in the normal parallel suite. Only the test making a wall-clock claim received an isolated measurement lane.&lt;/p&gt;
&lt;p&gt;That scope matters. Serializing an entire integration project can turn a useful diagnosis into an expensive habit. Parallel tests are valuable because they shorten feedback cycles and expose accidental shared state. The aim is not to remove parallelism. It is to stop using an uncontrolled parallel environment as a precision instrument.&lt;/p&gt;
&lt;p&gt;I like to keep separate tests for separate questions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Does the check return the correct status when the dependency is unavailable?&lt;/li&gt;
&lt;li&gt;Does the response avoid leaking infrastructure details?&lt;/li&gt;
&lt;li&gt;Does the host remain alive while readiness is false?&lt;/li&gt;
&lt;li&gt;Under controlled contention, does the readiness answer stay within its operational budget?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Only the last question necessarily needs timing isolation.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-real&quot;&gt;The trade-off is real&lt;/h3&gt;
&lt;p&gt;An isolated collection adds a serial tail to the build. It also creates another test boundary that future maintainers must understand. If many tests demand exclusive execution, the suite can become slow and the isolation can conceal real capacity problems.&lt;/p&gt;
&lt;p&gt;That is why this technique should be applied to the measurement contract, not used as a blanket cure for flakiness.&lt;/p&gt;
&lt;p&gt;In return, failures become more actionable. A red timing check is less likely to mean “the runner happened to be crowded” and more likely to mean “the protected latency contract changed.” The team spends less time rerunning the same experiment and less time normalizing noise by widening budgets.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-checklist&quot;&gt;A practical review checklist&lt;/h3&gt;
&lt;p&gt;Before changing a failed CI time limit, ask:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Did the measured implementation change?&lt;/li&gt;
&lt;li&gt;Is setup included in the timed region?&lt;/li&gt;
&lt;li&gt;Which resources are shared with parallel tests?&lt;/li&gt;
&lt;li&gt;Does the test need fixtures it is currently competing for?&lt;/li&gt;
&lt;li&gt;Can functional correctness remain parallel while the timing proof runs alone?&lt;/li&gt;
&lt;li&gt;Would the same isolation exist in the production condition we care about?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The last question prevents overfitting. An isolated test should remove irrelevant test-runner noise, not erase meaningful load. If the production contract is specifically about behavior under concurrency, create a controlled load test with explicit pressure instead of relying on incidental CI contention.&lt;/p&gt;
&lt;h3 id=&quot;precision-is-a-property-of-the-experiment&quot;&gt;Precision is a property of the experiment&lt;/h3&gt;
&lt;p&gt;A stopwatch does not make a test scientific by itself. The environment, boundaries, and competing work determine what the number means.&lt;/p&gt;
&lt;p&gt;Keep meaningful budgets. Investigate before retrying. Isolate the smallest timing claim that needs a controlled lane, and leave the rest of the suite parallel.&lt;/p&gt;
&lt;p&gt;The result is not merely a greener pipeline. It is a test whose failure tells you something you can act on.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32820/a-stopwatch-needs-an-isolated-lane</guid>
<pubDate>Wed, 28 Oct 2026 10:49:36 +0000</pubDate>
</item>
<item>
<title>MT5 Tick Size vs Point: A Test Harness for Price Distance</title>
<link>https://coderlegion.com/32775/mt5-tick-size-vs-point-a-test-harness-for-price-distance</link>
<description>&lt;p&gt;&lt;img src=&quot;https://guetaquant.com/images/riesgo-ejecucion-mt5-2026.webp&quot; alt=&quot;Illustration of uncertainty in execution&quot;&gt;&lt;/p&gt;
&lt;p&gt;The smallest quoted point and the minimum price change used as a trading tick can differ. If a program divides an exit distance by &lt;code&gt;SYMBOL_POINT&lt;/code&gt; but multiplies by the monetary value of a trading tick, the resulting loss estimate may use incompatible units. Test the units before testing position sizing.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Educational and simulation content only. This is not financial advice, intermediation or an investment recommendation. Trading can result in loss of capital.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;a-fictional-contract&quot;&gt;A fictional contract&lt;/h3&gt;
&lt;p&gt;Imagine a demo symbol with a point of 0.01 price units, a tick size of 0.05, and a loss-side tick value of 2 account-currency units per lot. A 0.50 price move spans 10 trading ticks, not 50. For 0.10 lots, a simplified linear estimate is 10 × 2 × 0.10 = 2 account-currency units. Substituting point for tick size would multiply that estimate by five. These inputs are fictional and do not describe any particular broker or instrument.&lt;/p&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mql5.com/en/docs/constants/environment_state/marketinfoconstants&quot;&gt;official MQL5 symbol reference&lt;/a&gt; defines &lt;code&gt;SYMBOL_POINT&lt;/code&gt;, &lt;code&gt;SYMBOL_TRADE_TICK_SIZE&lt;/code&gt;, and &lt;code&gt;SYMBOL_TRADE_TICK_VALUE_LOSS&lt;/code&gt; separately. Read actual symbol properties from the platform; do not assume that every symbol, account currency, or loss direction shares the same values.&lt;/p&gt;
&lt;h3 id=&quot;an-executable-unit-test&quot;&gt;An executable unit test&lt;/h3&gt;
&lt;p&gt;This example checks arithmetic under a deliberately narrow assumption: constant loss-side tick value and linear scaling with volume. It does not submit orders or model commissions, gaps or currency conversion changes.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from decimal import Decimal

def linear_estimate(entry, exit_price, tick_size, tick_value, lots):
    entry, exit_price, tick_size, tick_value, lots = map(
        lambda x: Decimal(str(x)),
        (entry, exit_price, tick_size, tick_value, lots),
    )
    if tick_size &amp;lt;= 0 or tick_value &amp;lt;= 0 or lots &amp;lt;= 0:
        raise ValueError(&quot;Tick size, tick value and lots must be positive&quot;)
    distance = abs(entry - exit_price)
    return distance / tick_size * tick_value * lots

assert linear_estimate(100, &quot;99.50&quot;, &quot;0.05&quot;, 2, &quot;0.10&quot;) == Decimal(&quot;2.00&quot;)
assert linear_estimate(100, &quot;100.50&quot;, &quot;0.05&quot;, 2, &quot;0.10&quot;) == Decimal(&quot;2.00&quot;)
assert linear_estimate(100, 100, &quot;0.05&quot;, 2, &quot;0.10&quot;) == 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The absolute distance checks units only. It does &lt;strong&gt;not&lt;/strong&gt; prove that the exit lies on the loss side: that depends on whether the simulated position is long or short. Check direction and valid prices separately. Prices and tick values can change; contract rules may make a simplified linear calculation unsuitable.&lt;/p&gt;
&lt;h3 id=&quot;compare-with-the-platform&quot;&gt;Compare with the platform&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mql5.com/en/docs/trading/ordercalcprofit&quot;&gt;&lt;code&gt;OrderCalcProfit&lt;/code&gt;&lt;/a&gt; estimates the result in the account currency for an order type, symbol, volume and specified open and close prices. In a demo environment:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Record symbol, account currency, direction, open price, exit price, volume, point, tick size and loss-side tick value.&lt;/li&gt;
&lt;li&gt;Check the return value of &lt;code&gt;OrderCalcProfit&lt;/code&gt;. If it fails, record the platform error and reject the estimate; never interpret failure as zero.&lt;/li&gt;
&lt;li&gt;Compare the signed result with the simplified calculation, using the same prices and volume. Investigate discrepancies rather than silently taking the smaller number.&lt;/li&gt;
&lt;li&gt;Repeat for long and short positions, multiple symbols and the broker's allowed volume increments. Keep fixtures and platform version with the results.&lt;/li&gt;
&lt;li&gt;Account separately for known costs, execution gaps and contract-specific rules. An estimate at the requested exit price is not a guaranteed loss limit.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;MT5 also specifies minimum, maximum and step sizes for volumes. Those constraints are a separate test; see our &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/27300/the-silent-position-sizer-bug-when-accounts-are-small-your-risk-inadvertently-doubles&quot;&gt;earlier position-sizing article&lt;/a&gt; for the minimum-volume edge case. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://guetaquant.com/herramientas/mt5-position-sizer/?utm_source=coderlegion&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=mql5_risk_architecture&quot;&gt;Gueta Quant position sizer&lt;/a&gt; provides an educational comparison, not a guarantee of execution.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32775/mt5-tick-size-vs-point-a-test-harness-for-price-distance</guid>
<pubDate>Wed, 28 Oct 2026 06:37:05 +0000</pubDate>
</item>
<item>
<title>My quality gate wasn't strict. It was dead.</title>
<link>https://coderlegion.com/32703/my-quality-gate-wasnt-strict-it-was-dead</link>
<description>&lt;p&gt;There is a job in my CI called &lt;code&gt;judge&lt;/code&gt;. It reads the monthly issue my content pipeline writes, scores it against a rubric with five criteria, and blocks the merge if anything comes back at 2 out of 5 or lower. An LLM grading an LLM, with a written policy about what counts as broken.&lt;/p&gt;
&lt;p&gt;It went red on a content PR this week. My first thought was that the piece was weak. My second thought, about four minutes later, was worse: this gate had been red on every content PR for a while, and I had been reading that as a high bar.&lt;/p&gt;
&lt;p&gt;It wasn't a high bar. The job never reached the rubric at all.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Error: anthropic messages -&amp;gt; 400: output_config.format.schema:
For 'integer' type, properties maximum, minimum are not supported
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The judge asks the model for structured output, and the schema it sends had this in it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;voto: { type: &quot;integer&quot;, minimum: 1, maximum: 5 },
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which is correct JSON Schema, and which the structured output layer rejects. The request never made it to the model. The job exited 1 before scoring a single word.&lt;/p&gt;
&lt;p&gt;A gate that cannot go green is not enforcing anything. It has the same effect on your pipeline as &lt;code&gt;continue-on-error&lt;/code&gt;, except it also makes you feel virtuous while it does nothing.&lt;/p&gt;
&lt;h3 id=&quot;the-error-message-named-half-the-problem&quot;&gt;The error message named half the problem&lt;/h3&gt;
&lt;p&gt;The obvious fix is to delete &lt;code&gt;minimum&lt;/code&gt; and &lt;code&gt;maximum&lt;/code&gt; and move on. I nearly did. What stopped me was noticing that the schema had a second kind of constraint in it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;motivo: { type: &quot;string&quot;, maxLength: 300 },
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Length constraints are rejected too. &lt;code&gt;maxLength&lt;/code&gt;, &lt;code&gt;minLength&lt;/code&gt;, &lt;code&gt;pattern&lt;/code&gt;, &lt;code&gt;minItems&lt;/code&gt;, all of it. My schema had three of them, and the 400 mentioned none, because validation stops at the first failure. If I had fixed what the message named, I would have pushed, waited for CI, and collected the next 400. Then the one after that.&lt;/p&gt;
&lt;p&gt;This is a general property of error messages, not a quirk of one API: they report the first thing that broke, not the set of things that are broken. The fix that only addresses what the message says is a fix sized to the message, not to the defect.&lt;/p&gt;
&lt;p&gt;The other thing worth knowing here: the Python and TypeScript SDKs strip unsupported constraints for you before the request goes out. My pipeline is zero-dependency and calls the API with native &lt;code&gt;fetch&lt;/code&gt;, so nobody was stripping anything. If your SDK has been quietly saving you, you will find out the day you drop it.&lt;/p&gt;
&lt;h3 id=&quot;so-the-test-is-about-the-contract&quot;&gt;So the test is about the contract&lt;/h3&gt;
&lt;p&gt;I could have written a test asserting no &lt;code&gt;minimum&lt;/code&gt; on integers. It would have passed, and it would have been useless three weeks later when someone adds a &lt;code&gt;pattern&lt;/code&gt; to a string.&lt;/p&gt;
&lt;p&gt;Instead the accepted and rejected keywords are written down once, and every schema I send to the API gets checked against them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const RIFIUTATE = new Map([
  [&quot;minimum&quot;, &quot;numeric constraint&quot;],
  [&quot;maximum&quot;, &quot;numeric constraint&quot;],
  [&quot;maxLength&quot;, &quot;length constraint&quot;],
  [&quot;pattern&quot;, &quot;string constraint&quot;],
  // ...
]);

export function keywordRifiutate(schema, path = &quot;$&quot;) { /* walks the tree */ }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It walks nested schemas, and it knows that inside &lt;code&gt;properties&lt;/code&gt; the keys are my field names rather than schema keywords, so a field genuinely called &lt;code&gt;pattern&lt;/code&gt; doesn't trip it.&lt;/p&gt;
&lt;p&gt;On first run it found sixteen violations across the judge schema. Not two. My generation schema, checked at the same time, was already clean, which I would not have bet on.&lt;/p&gt;
&lt;p&gt;There is a second check next to it that lists keywords the contract doesn't mention. It fails rather than allowing them, which sounds aggressive for something the API might accept fine. The reason is that I want the moment where somebody adds an unfamiliar keyword to be the moment somebody reads the docs, instead of the moment CI goes red for a reason nobody connects to the schema.&lt;/p&gt;
&lt;h3 id=&quot;where-the-constraint-went&quot;&gt;Where the constraint went&lt;/h3&gt;
&lt;p&gt;The rubric still runs on a 1 to 5 scale. That range now lives in the two places the API can't reject it.&lt;/p&gt;
&lt;p&gt;The prompt describes it, with each score defined. And the code treats an out-of-range score the way it already treats a missing one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const fuoriScala = (v) =&amp;gt; !Number.isInteger(v) || v &amp;lt; 1 || v &amp;gt; 5;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fail closed. If the model returns a 7, the rubric is unreadable, and an unreadable rubric doesn't promote anything. That was already the policy for a criterion the model forgot to fill in. A score outside the scale is the same class of problem, so it gets the same answer.&lt;/p&gt;
&lt;p&gt;Moving a constraint out of a schema usually means giving it up. Here it just meant enforcing it somewhere with a worse reputation and better reach.&lt;/p&gt;
&lt;h3 id=&quot;what-i-d-check-on-your-own-pipeline&quot;&gt;What I'd check on your own pipeline&lt;/h3&gt;
&lt;p&gt;Grep your CI history for a job that has never been green. Not &quot;was red today&quot;, never green at all, or not since some commit that had nothing to do with it. That job is not protecting you, and it is currently costing you the specific kind of comfort that comes from thinking it is.&lt;/p&gt;
&lt;p&gt;Mine ran for weeks. It was the most reassuring line in the workflow file.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32703/my-quality-gate-wasnt-strict-it-was-dead</guid>
<pubDate>Tue, 27 Oct 2026 21:03:40 +0000</pubDate>
</item>
<item>
<title>Four times my system looked correct and wasn't.</title>
<link>https://coderlegion.com/32696/four-times-my-system-looked-correct-and-wasnt</link>
<description>&lt;p&gt;Most of the bugs that taught me something weren't crashes. The system worked, the checks were green, and the property I cared about was still broken. Here are four of them, from three different projects.&lt;/p&gt;
&lt;h3 id=&quot;1-the-prompt-injection-test-that-passed-while-the-attack-worked&quot;&gt;1. The prompt-injection test that passed while the attack worked&lt;/h3&gt;
&lt;p&gt;In LLM-Council, three models answer a question, rank each other's answers blind, and a chairman model writes the final synthesis. Output from one stage becomes input to the next, so I had to fence untrusted model output off from the instructions around it.&lt;/p&gt;
&lt;p&gt;My first version used static delimiters. I wrote tests that checked where the markers were placed and how the string was built. They passed.&lt;/p&gt;
&lt;p&gt;The problem: those tests checked that the Python string was assembled correctly. An attacker doesn't care about that. A hostile model output can simply reproduce the static closing delimiter, and the next model reads the untrusted block as finished.&lt;/p&gt;
&lt;p&gt;The fix was a fresh random nonce in the delimiters for every prompt, so untrusted output can't predict the real closing marker. I found a second boundary that needed the same treatment: the rankings going into the chairman stage were untrusted too.&lt;/p&gt;
&lt;p&gt;Then I broke the fix on purpose. I replaced the random nonce with a static one and checked that the tests went red. I removed the ranking fences and checked that those tests failed as well.&lt;/p&gt;
&lt;p&gt;One limit worth stating: the nonce protects the boundary against delimiter forgery. It doesn't make the content inside trustworthy, and it doesn't solve prompt injection in general.&lt;/p&gt;
&lt;p&gt;Full write-up: &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/mk023/i-wrote-a-test-for-prompt-injection-it-passed-while-the-attack-worked-kc9&quot;&gt;I wrote a test for prompt injection. It passed while the attack worked.&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;2-retrieval-that-returned-valid-results-for-the-wrong-decision&quot;&gt;2. Retrieval that returned valid results for the wrong decision&lt;/h3&gt;
&lt;p&gt;MonferrinoAI is a RAG pipeline (Firecrawl, Voyage AI embeddings, Supabase with pgvector) that prepares weekly content briefings for a local business website. A human still writes and publishes.&lt;/p&gt;
&lt;p&gt;I tested retrieval against the real database and found that a keyword-only query could rank the wrong page above the one that was actually relevant. Nothing was technically broken: every result was a valid row. Adding context to the query, the keyword cluster and the target page, fixed the ranking.&lt;/p&gt;
&lt;p&gt;The same project has a security side. Web pages are untrusted input and can contain text that looks like an instruction. So the page bodies never reach the final briefing: the handoff only carries URLs, headings and similarity distances.&lt;/p&gt;
&lt;h3 id=&quot;3-telemetry-that-carried-more-than-i-expected&quot;&gt;3. Telemetry that carried more than I expected&lt;/h3&gt;
&lt;p&gt;Agentic-OS collects Claude Code telemetry through an OpenTelemetry Collector, with Prometheus and Grafana for metrics and a private Loki pipeline for logs. The point is to see what an agent actually did, not what it says it did.&lt;/p&gt;
&lt;p&gt;Testing against the real client showed that it can send identity attributes, including an email address. My monitoring pipeline was a privacy boundary and I hadn't treated it as one.&lt;/p&gt;
&lt;p&gt;Now attributes go through an allow-list, and the tests run against the real telemetry shape. Anything unknown is dropped instead of stored.&lt;/p&gt;
&lt;h3 id=&quot;4-a-strict-csp-that-the-build-quietly-broke&quot;&gt;4. A strict CSP that the build quietly broke&lt;/h3&gt;
&lt;p&gt;My website uses a strict hash-based Content Security Policy without &lt;code&gt;unsafe-inline&lt;/code&gt;. Syntax highlighting added inline styles that the policy would block.&lt;/p&gt;
&lt;p&gt;The quick fix was to loosen the CSP. I removed the incompatible behavior instead, and added CI tests that run against the built HTML, the files that actually ship, checking inline scripts, hashes and style attributes. Some of those problems only exist after the build, so testing the source wouldn't see them.&lt;/p&gt;
&lt;h3 id=&quot;the-question-i-ask-now&quot;&gt;The question I ask now&lt;/h3&gt;
&lt;p&gt;In all four cases a check existed and looked reasonable. It just wasn't looking at the property I cared about.&lt;/p&gt;
&lt;p&gt;So before I trust a control, I ask: what should turn red if this property stops being true? Then I break the property and see if it does.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;This post grew out of my &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/32383/the-test-passed-the-attack-worked-marco-bellingeri-on-building-more-trustworthy-systems&quot;&gt;Developer Story on CoderLegion&lt;/a&gt;, written by Mehadi Hasan, which covers the projects and the path behind them in more detail.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32696/four-times-my-system-looked-correct-and-wasnt</guid>
<pubDate>Tue, 27 Oct 2026 14:27:47 +0000</pubDate>
</item>
<item>
<title>The Guardian redacted my secret. The Read tool walked around it.</title>
<link>https://coderlegion.com/32694/the-guardian-redacted-my-secret-the-read-tool-walked-around-it</link>
<description>&lt;p&gt;I put a fake AWS key in a file and asked Claude Code to read it twice in the same session: once with its Read tool, once with &lt;code&gt;cat&lt;/code&gt;. An OWASP Guardian sat between the agent and its tools.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;cat&lt;/code&gt;, the model got this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;AWS_ACCESS_KEY_ID=[REDACTED]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With Read, it got this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's the example key from the AWS documentation, not a real one. Only the tool changed, and I got the same result in all four runs.&lt;/p&gt;
&lt;h3 id=&quot;what-i-was-testing&quot;&gt;What I was testing&lt;/h3&gt;
&lt;p&gt;The Agent Control Standard (ACS) is a wire specification from the OWASP GenAI Security Project. A separate Guardian Agent sees what an AI agent is about to do, and can permit, deny or modify it before it happens. The project ships a reference implementation: a Guardian that runs Microsoft's Agent Governance Toolkit (AGT) as its policy engine, plus hook shims for Claude Code and OpenCode.&lt;/p&gt;
&lt;p&gt;Issue #92 asks people to run it against a real harness and report where behavior and specification disagree. The Claude Code runs I found there used a synthetic executor, drove the hook by hand, or ran without a Guardian. I wanted Claude Code with its own model deciding which tool to call, going through the shipped hook into the reference Guardian.&lt;/p&gt;
&lt;p&gt;The setup: Claude Code 2.1.308, ACS at commit &lt;code&gt;fd6ff383&lt;/code&gt;, AGT at the commit the repository pins, all inside a Docker container on a VPS, so nothing touched the host.&lt;/p&gt;
&lt;h3 id=&quot;a-reduced-double-blind&quot;&gt;A reduced double blind&lt;/h3&gt;
&lt;p&gt;Claude Code had two roles in this project. It was the system under test, and it was my pair for the work: it wrote the protocols and the scripts, ran the automated arm and drafted the comparisons. That is the situation a blind is for.&lt;/p&gt;
&lt;p&gt;Each protocol went like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The protocol and the scripts were frozen first, and I got their sha256 before anything ran.&lt;/li&gt;
&lt;li&gt;The non-interactive arm ran three times with &lt;code&gt;claude -p&lt;/code&gt;. Its output went straight into a tar archive, and the terminal showed only the run conditions and the archive's hash.&lt;/li&gt;
&lt;li&gt;I ran the same prompts by hand in an interactive session. My archive was sealed before anyone opened the automated one.&lt;/li&gt;
&lt;li&gt;Only then were both archives opened and compared against the protocol.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I ran three protocols with four runs each. The pair that ran the automated arm couldn't steer my manual run, and nobody could write the comparison to fit a result they had already seen.&lt;/p&gt;
&lt;p&gt;The first protocol had a confound: my Claude account loaded its own plugins, with their hooks and 55 MCP connectors, in both arms. The second protocol started from a clean environment, and nothing changed on the wire for Bash.&lt;/p&gt;
&lt;h3 id=&quot;what-worked-as-documented&quot;&gt;What worked as documented&lt;/h3&gt;
&lt;p&gt;The README's own demo is to ask Claude Code to run &lt;code&gt;echo rm -rf /&lt;/code&gt;. AGT's stock rule denies it, and &lt;code&gt;ls -la&lt;/code&gt; in the same session runs. That's what happened in every run. Once I removed UUIDs, timestamps, sequence numbers, the text the model writes and the &lt;code&gt;ls&lt;/code&gt; listing, the envelopes on the wire were identical across all eight runs of the first two protocols.&lt;/p&gt;
&lt;p&gt;Interactive and non-interactive sessions gave the same decisions, the same executions and the same content reaching the model, prompt by prompt.&lt;/p&gt;
&lt;p&gt;After a deny, the model reported the block, called it a false positive and did not try another tool. That's the model's judgment, not mine: the README picks this command because it stays harmless if the hook ever fails. In all three interactive runs, and in none of the automated ones, it also offered a way out: type &lt;code&gt;! echo rm -rf /&lt;/code&gt; yourself. Claude Code's &lt;code&gt;!&lt;/code&gt; prefix runs a shell command outside the tool loop, so no hook sees it. So the model stayed inside the control and told me how to step outside it.&lt;/p&gt;
&lt;p&gt;With the Guardian stopped, the command ran, and the audit log recorded &lt;code&gt;posture: proceed&lt;/code&gt; with a transport failure. That is the documented default, and other reports in #92 had already measured it.&lt;/p&gt;
&lt;h3 id=&quot;read-never-reaches-the-guardian&quot;&gt;Read never reaches the Guardian&lt;/h3&gt;
&lt;p&gt;The shipped hook settings match &lt;code&gt;Bash&lt;/code&gt; and &lt;code&gt;WebFetch&lt;/code&gt; before a tool runs, and only &lt;code&gt;Bash&lt;/code&gt; after it. The README says so: &quot;Both matchers are anchored, so no other tool is intercepted.&quot;&lt;/p&gt;
&lt;p&gt;So the gap is documented. What the README doesn't spell out is the consequence. Redaction happens at the result gate, after the tool runs and before its output reaches the model. For &lt;code&gt;cat&lt;/code&gt;, the Guardian answered &lt;code&gt;modify&lt;/code&gt; with &lt;code&gt;redaction_applied&lt;/code&gt;, and the model got &lt;code&gt;[REDACTED]&lt;/code&gt;. For Read there was no envelope at all, so there was nothing to inspect and nothing to redact.&lt;/p&gt;
&lt;p&gt;The specification asks for more than the adapter does. It says frameworks MUST fire &lt;code&gt;toolCallRequest&lt;/code&gt; for every action that escapes the agent's reasoning context, and that this &quot;includes built-in operations such as filesystem reads/writes&quot;. Claude Code can hook Read: its hooks documentation lists &lt;code&gt;Read&lt;/code&gt; among the tools &lt;code&gt;PreToolUse&lt;/code&gt; matches. The gap sits in the adapter's matcher. It's the same gap that #212 tracks for the Cursor adapter.&lt;/p&gt;
&lt;p&gt;I filed it as #330.&lt;/p&gt;
&lt;h3 id=&quot;mcp-tools-three-states&quot;&gt;MCP tools: three states&lt;/h3&gt;
&lt;p&gt;The README says wrapped MCP isn't implemented yet. I measured what happens today, with a small local MCP server that exposes one tool, &lt;code&gt;run&lt;/code&gt;, taking a &lt;code&gt;command&lt;/code&gt; argument:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Configuration &lt;/th&gt;&lt;th&gt;What happened&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Shipped hook &lt;/td&gt;&lt;td&gt;No envelope. The command runs.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Matcher widened to &lt;code&gt;mcp__.*&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The call reaches the Guardian and is denied as &lt;code&gt;runtime_error:tool_unknown&lt;/code&gt;, whatever the command&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tool also registered in the policy manifest &lt;/td&gt;&lt;td&gt;Judged like Bash: &lt;code&gt;echo ciao&lt;/code&gt; runs, &lt;code&gt;echo rm -rf /&lt;/code&gt; is denied by the same rule&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The middle row can mislead. The deny looks like protection, but when I drove the hook by hand, &lt;code&gt;echo ciao&lt;/code&gt; got the same deny. AGT refuses a tool name it doesn't know before any rule reads the content.&lt;/p&gt;
&lt;p&gt;The third row took seven lines in the manifest, in the same shape as the &lt;code&gt;Bash&lt;/code&gt; entry. It works only because my tool's argument is called &lt;code&gt;command&lt;/code&gt;, which is the argument the mapping hands to the policy by default. The output of an MCP tool still never reaches the result gate.&lt;/p&gt;
&lt;h3 id=&quot;the-guardian-s-log-keeps-the-secret&quot;&gt;The Guardian's log keeps the secret&lt;/h3&gt;
&lt;p&gt;In the &lt;code&gt;cat&lt;/code&gt; run the model saw &lt;code&gt;[REDACTED]&lt;/code&gt;, but the Guardian's envelope log on disk holds the key in plaintext, inside the result request the hook sent. The Inspector's log has it too. The README warns that the envelope log holds raw tool arguments, and my runs show it holds raw tool output as well. There is already an issue for this (#71), and I added my measurements there.&lt;/p&gt;
&lt;h3 id=&quot;what-i-didn-t-measure&quot;&gt;What I didn't measure&lt;/h3&gt;
&lt;p&gt;I didn't test Edit, Write, Grep or Glob, a Guardian restart in the middle of a session, WebFetch with the egress allowlist, real authenticated MCP servers, or any MCP tool whose argument isn't called &lt;code&gt;command&lt;/code&gt;. Each protocol had one manual run, by one person.&lt;/p&gt;
&lt;h3 id=&quot;the-question-i-ask-now-1&quot;&gt;The question I ask now&lt;/h3&gt;
&lt;p&gt;Every control I tested did what it said on the path it watched. The secret took the path next to it.&lt;/p&gt;
&lt;p&gt;So before I trust a control on an agent, I list every tool that can touch the same data and check that each one goes through the control. If one doesn't, the control protects a path, not the data.&lt;/p&gt;
&lt;p&gt;The report is &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/GenAI-Security-Project/agent-control-standard/issues/330&quot;&gt;#330&lt;/a&gt;, and the full write-up is &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/GenAI-Security-Project/agent-control-standard/issues/92#issuecomment-6443287884&quot;&gt;my comment on #92&lt;/a&gt;. The protocols and logs are available if you want to rerun them.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Quotes from the ACS README and specification are © The OWASP GenAI Security Project and the ACS contributors, licensed &lt;a rel=&quot;nofollow&quot; href=&quot;https://creativecommons.org/licenses/by-sa/4.0/&quot;&gt;CC BY-SA 4.0&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32694/the-guardian-redacted-my-secret-the-read-tool-walked-around-it</guid>
<pubDate>Tue, 27 Oct 2026 14:08:26 +0000</pubDate>
</item>
<item>
<title>A Query Window Is Not a Calendar Day</title>
<link>https://coderlegion.com/32592/a-query-window-is-not-a-calendar-day</link>
<description>&lt;p&gt;Software often uses broad time windows for good reasons. A dashboard may need recent history, work that is actionable now, and the next scheduled item. Fetching all of that in one bounded query can be efficient and easy to reason about.&lt;/p&gt;
&lt;p&gt;The trouble begins when the retrieval window quietly becomes the business definition of “today.”&lt;/p&gt;
&lt;p&gt;This is a small distinction with a very visible failure mode: shortly after midnight, yesterday’s missed item appears under today’s heading. The query is returning exactly what it was asked to return. The selection rule is wrong.&lt;/p&gt;
&lt;h3 id=&quot;retrieval-and-meaning-are-different-jobs&quot;&gt;Retrieval and meaning are different jobs&lt;/h3&gt;
&lt;p&gt;Imagine a “Today” screen that loads items from several hours in the past through the next day. The look-back allows the screen to include work that recently closed or may still be open. The look-ahead allows it to show what comes next.&lt;/p&gt;
&lt;p&gt;That range is a retrieval concern. It answers:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Which records could be relevant enough to inspect?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It does not answer:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Which record belongs to the user’s current calendar day?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If selection simply chooses the first item whose state is due or missed, a previous day’s missed item can win after midnight. If display text assumes every future item is later today, tomorrow morning can be described as though it were later tonight.&lt;/p&gt;
&lt;p&gt;Both errors come from using temporal proximity as a substitute for calendar meaning.&lt;/p&gt;
&lt;h3 id=&quot;define-today-in-the-relevant-time-zone&quot;&gt;Define “today” in the relevant time zone&lt;/h3&gt;
&lt;p&gt;Server time is rarely the correct authority for a human-facing day. UTC is excellent for storing instants and comparing elapsed time. It is not a user’s calendar.&lt;/p&gt;
&lt;p&gt;The safer sequence is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Read the current instant from an injected clock.&lt;/li&gt;
&lt;li&gt;Convert it once into the time zone that owns the workflow.&lt;/li&gt;
&lt;li&gt;Derive a local date from that value.&lt;/li&gt;
&lt;li&gt;Use that date when classifying calendar-bound states.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Computing the value once matters. It keeps every decision in one request anchored to the same instant, including the unlikely case where execution crosses midnight.&lt;/p&gt;
&lt;p&gt;The relevant time zone also needs to be explicit. It may belong to a person, site, account, or workflow. The correct choice is a domain decision, not an infrastructure default.&lt;/p&gt;
&lt;h3 id=&quot;preserve-the-exception-that-actually-matters&quot;&gt;Preserve the exception that actually matters&lt;/h3&gt;
&lt;p&gt;A tempting fix is to discard every record whose local date is not today. That is neat and wrong when an actionable window crosses midnight.&lt;/p&gt;
&lt;p&gt;Suppose a task opened before midnight and remains valid until shortly afterwards. A strict date filter would hide it at the exact moment it still needs attention.&lt;/p&gt;
&lt;p&gt;This is where explicit domain rules beat blanket filtering:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;missed and completed items count as current only when their local date is today;&lt;/li&gt;
&lt;li&gt;a still-open item remains current while it is actionable, regardless of its start date;&lt;/li&gt;
&lt;li&gt;a scheduled item on another day can still appear as next, but its wording names that day.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The rule is slightly longer because the product meaning is slightly richer. That extra branch is not accidental complexity. It records why two superficially similar records are treated differently.&lt;/p&gt;
&lt;h3 id=&quot;make-wording-part-of-correctness&quot;&gt;Make wording part of correctness&lt;/h3&gt;
&lt;p&gt;Time bugs are not confined to sorting and filtering. Labels can encode false claims too.&lt;/p&gt;
&lt;p&gt;“Later today” is only correct if the next item is on the same local day. “Between 08:00 and 09:00” may be ambiguous when viewed the night before. Adding “tomorrow” or a concrete day makes the model visible to the user.&lt;/p&gt;
&lt;p&gt;That wording also gives tests a useful public outcome. A test can verify that the screen says tomorrow without reaching into a private helper or asserting a particular LINQ expression.&lt;/p&gt;
&lt;h3 id=&quot;test-the-boundary-as-a-sequence&quot;&gt;Test the boundary as a sequence&lt;/h3&gt;
&lt;p&gt;A single test at noon will not find this class of defect. The useful test moves a controllable clock through the state transition.&lt;/p&gt;
&lt;p&gt;One focused integration scenario can prove three behaviours:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;On the item’s own evening, the missed item is still classified as belonging to today.&lt;/li&gt;
&lt;li&gt;The next day’s scheduled item is labelled explicitly as tomorrow.&lt;/li&gt;
&lt;li&gt;After midnight, the old missed item no longer becomes the current card.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Using the real persistence provider is valuable when stored dates, generated occurrences, or query translation participate in the behaviour. The clock is the external boundary worth controlling; internal services do not need to be mocked if the public result proves the rule.&lt;/p&gt;
&lt;p&gt;Also test a window that genuinely spans midnight. Without that case, a cleanup that filters everything by date can look correct while introducing a different regression.&lt;/p&gt;
&lt;h3 id=&quot;the-engineering-trade-off&quot;&gt;The engineering trade-off&lt;/h3&gt;
&lt;p&gt;Explicit local-date classification costs a few things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;more branches than a simple state check;&lt;/li&gt;
&lt;li&gt;a time-zone conversion at the application boundary;&lt;/li&gt;
&lt;li&gt;boundary-focused tests and more deliberate copy.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In return, the system becomes predictable at the moment when implicit assumptions fail most often. Retrieval can remain broad for performance and completeness. Domain selection becomes precise. User-facing language reflects the same model.&lt;/p&gt;
&lt;p&gt;The broader leadership lesson is to ask two questions whenever a time range appears in code:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Is this range finding candidates, or defining business membership?&lt;/li&gt;
&lt;li&gt;Whose calendar gives words such as today, tomorrow, and missed their meaning?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Keeping those answers separate prevents an implementation convenience from becoming an accidental policy.&lt;/p&gt;
&lt;h3 id=&quot;practical-checklist&quot;&gt;Practical checklist&lt;/h3&gt;
&lt;p&gt;When building a day-oriented view:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;store and compare instants consistently;&lt;/li&gt;
&lt;li&gt;identify the domain-owned time zone;&lt;/li&gt;
&lt;li&gt;derive the local date once per operation;&lt;/li&gt;
&lt;li&gt;keep retrieval windows separate from classification rules;&lt;/li&gt;
&lt;li&gt;preserve intentionally actionable cross-midnight windows;&lt;/li&gt;
&lt;li&gt;make off-day wording explicit;&lt;/li&gt;
&lt;li&gt;test before midnight, after midnight, and a spanning window.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The code may still query eighteen or thirty hours. The user should experience a calendar day.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32592/a-query-window-is-not-a-calendar-day</guid>
<pubDate>Mon, 26 Oct 2026 20:12:29 +0000</pubDate>
</item>
<item>
<title>The Testing Trophy, and Where TWD Fits</title>
<link>https://coderlegion.com/32589/the-testing-trophy-and-where-twd-fits</link>
<description>&lt;p&gt;Most frontend teams I talk to have plenty of tests and still find out about broken screens from users. The unit tests are green, the refactor broke every one of them anyway, and nobody is sure the checkout flow works until someone clicks through it.&lt;/p&gt;
&lt;p&gt;That tension is exactly what the Testing Trophy is about.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.kevinmartinez.dev/twd-testing-trophy.png&quot; alt=&quot;The Testing Trophy with TWD on the integration layer&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-trophy&quot;&gt;The trophy&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications&quot;&gt;Kent C. Dodds&lt;/a&gt; drew testing as a trophy instead of a pyramid. Four layers, and the size of each one is roughly how much of your effort it deserves:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Static:&lt;/strong&gt; typos and type errors caught as you write the code. TypeScript, ESLint.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unit:&lt;/strong&gt; one function, hook or component checked in isolation. Vitest, Jest.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Integration:&lt;/strong&gt; several units working together. The biggest part of the trophy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;End to End:&lt;/strong&gt; a robot clicking through the deployed app, real backend included. Playwright, Cypress.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The shape comes from one line, written by Guillermo Rauch and popularized by Kent:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Write tests. Not too many. Mostly integration.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Each layer trades speed for confidence. Unit tests are cheap, but they can pass while the screen is broken. End-to-end tests give the most confidence, but they need the whole system running and are the slowest to write and run. Integration tests sit in the middle: they use the app the way a user does, and they stay fast and deterministic because the only thing faked is what you don't own.&lt;/p&gt;
&lt;p&gt;Kent's guiding principle for them:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The more your tests resemble the way your software is used, the more confidence they can give you.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;what-integration-means-on-the-frontend&quot;&gt;What integration means on the frontend&lt;/h3&gt;
&lt;p&gt;For a frontend app, an integration test renders the real thing (routes, providers, state, components) and mocks only the network. Then it does what a user would: visit a page, type, click, and check what is on the screen.&lt;/p&gt;
&lt;p&gt;The classic way to do this is &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/@testing-library/dom&quot;&gt;Testing Library&lt;/a&gt; with jsdom, in Vitest or Jest. It is a good setup and it is where most of us learned this style. It runs in a simulated DOM in your terminal, though, separate from the app you have open while you build.&lt;/p&gt;
&lt;h3 id=&quot;where-twd-fits&quot;&gt;Where TWD fits&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/twd-js&quot;&gt;TWD&lt;/a&gt; is built for that middle layer, and it runs the tests in the browser you develop in:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next to your app.&lt;/strong&gt; A sidebar in your dev server runs the tests against the real app. You watch each one drive the page.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mock what you don't own.&lt;/strong&gt; API calls are answered by a service worker, and contract testing checks every mock against your OpenAPI spec, so the mocks don't drift from the real API.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tests that read like the user.&lt;/strong&gt; Testing Library queries find elements by role, label and text.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The same tests everywhere.&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/twd-cli&quot;&gt;twd-cli&lt;/a&gt; runs them headlessly in CI, with coverage and a video per test if you want one, and your AI agent can write, run and fix them.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In practice this layer can carry a lot. On two production apps we run 872 tests on every pull request, mostly user flows, in one headless Chrome on a 2-vCPU GitHub runner, in about 5 minutes per app.&lt;/p&gt;
&lt;h3 id=&quot;what-it-doesn-t-replace&quot;&gt;What it doesn't replace&lt;/h3&gt;
&lt;p&gt;TWD doesn't cover the whole trophy, and it shouldn't.&lt;/p&gt;
&lt;p&gt;Keep a few end-to-end tests with Playwright or Cypress for the critical paths, the ones that need the real backend, the payment provider or the third-party SDK. Use unit tests for logic that is easier to check on its own. Keep TypeScript and ESLint at the base (and &lt;a rel=&quot;nofollow&quot; href=&quot;https://twd.dev/linting&quot;&gt;twd-lint&lt;/a&gt; for your test files). Then let integration tests carry most of the weight.&lt;/p&gt;
&lt;h3 id=&quot;a-map-not-a-ritual&quot;&gt;A map, not a ritual&lt;/h3&gt;
&lt;p&gt;TWD doesn't ask you to pick a layer before you write a test. You automate the check you were already doing by hand while you build. The trophy just shows where those checks end up: mostly in the middle.&lt;/p&gt;
&lt;p&gt;The full page, with the trophy and the tools per layer, is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://twd.dev/testing-trophy&quot;&gt;twd.dev/testing-trophy&lt;/a&gt;. For the strategy itself, go to the source: Kent's &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.testingjavascript.com/&quot;&gt;TestingJavaScript.com&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32589/the-testing-trophy-and-where-twd-fits</guid>
<pubDate>Mon, 26 Oct 2026 19:17:07 +0000</pubDate>
</item>
<item>
<title>Why I Test the Proxy Path Before I Trust a WAF Block</title>
<link>https://coderlegion.com/32356/why-i-test-the-proxy-path-before-i-trust-a-waf-block</link>
<description>&lt;p&gt;Installing a console and proving that visitor traffic is inspected are different tests. SafeLine can filter a request only after that request enters its reverse proxy. If the public name still points at the origin, or if you call the upstream address directly, a malicious query can succeed while the attack list stays empty. When I need to know how to test if your WAF is working, I ask three questions in order: did this request reach SafeLine, did a documented malicious query get blocked, and does the console detail belong to that same attempt?&lt;/p&gt;
&lt;p&gt;SafeLine is a self-hosted web application firewall. The product introduction says it protects web apps by filtering and monitoring HTTP traffic, including SQL injection, XSS, code injection, OS command injection, CRLF injection, LDAP injection, XPath injection, RCE, XXE, SSRF, path traversal, backdoor, brute force, HTTP flood, and bot abuse. It works as a reverse proxy: clients pass through it before they reach the server, malicious HTTP traffic is blocked, and unauthorized data is prevented from leaving the app. The application guide adds the operational detail: SafeLine is developed on nginx, receives traffic for the original website, cleans malicious attack traffic, and forwards safe traffic to the original application. Those statements are in &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;prove-the-proxy-path-first&quot;&gt;Prove the proxy path first&lt;/h3&gt;
&lt;p&gt;I do not start with an exploit string. I log in to the Web Admin Console, open Applications, and check the application record. Domain is the name, hostname, or address clients use. Port is the port SafeLine listens on, such as 80 or 443, and an HTTPS app needs the SSL option checked. Upstream is the real address of the original app. After those fields are saved, the domain has to resolve to the IP of the server where SafeLine runs. Until that is true, a browser request is not a test of this WAF.&lt;/p&gt;
&lt;p&gt;A negative check matters too. If I request the upstream address and skip the published domain, I am not exercising SafeLine. A normal page from the origin only shows that the origin is up. It says nothing about detection. The home page also includes an effect evaluation on a fixed sample set. I do not treat that table as proof that my own site is protected. A live test still has to travel through my domain, my port, and my upstream.&lt;/p&gt;
&lt;h3 id=&quot;use-the-cases-the-guide-already-published&quot;&gt;Use the cases the guide already published&lt;/h3&gt;
&lt;p&gt;The application guide includes a short set of cases for this check. Replace the example host with the protected domain. SQL injection is &lt;code&gt;/?id=1+and+1=2+union+select+1&lt;/code&gt;. XSS is &lt;code&gt;/?id=&amp;lt;img+src=x+onerror=alert()&amp;gt;&lt;/code&gt;. Path traversal is &lt;code&gt;/?id=../../../../etc/passwd&lt;/code&gt;. Code injection is &lt;code&gt;/?id=phpinfo();system('id')&lt;/code&gt;. XXE places &lt;code&gt;&amp;lt;?xml version=&quot;1.0&quot;?&amp;gt;&amp;lt;!DOCTYPE foo SYSTEM &quot;&quot;&amp;gt;&lt;/code&gt; in the id query value. Request those URLs on the domain and port configured for the application. Do not send them to the upstream IP.&lt;/p&gt;
&lt;p&gt;The guide says that if you see the blocking page, SafeLine defended the attack. That page is the first signal. It is not the whole proof. Open the web console, find the attack list, and click detail. I treat the test as passed only when the blocking page and that detail both line up with the probe I just sent. A block page without a matching console event can come from another device, a cached response, or a different hostname. A console row without a block on the public URL can mean I tested a name visitors do not use.&lt;/p&gt;
&lt;h3 id=&quot;do-not-stretch-one-sample-into-full-coverage&quot;&gt;Do not stretch one sample into full coverage&lt;/h3&gt;
&lt;p&gt;Those five cases are enough to show that common web-attack filtering is active on that path. They do not exercise every capability listed in the introduction. Rate limiting is described as throttling traffic that exceeds defined limits, including DoS attacks, brute-force attempts, and traffic surges. CAPTCHA challenge is described as a way to allow human users while blocking crawlers and bots. Authentication challenge, when turned on, requires a password or the visitor is blocked. Dynamic protection, when turned on, dynamically encrypts HTML and JS on each visit. None of the published one-line probes is a test of those controls. If the site depends on one of them, I record that the web-attack check did not cover it, instead of assuming that control is active.&lt;/p&gt;
&lt;p&gt;The management console is a separate path as well. The deploy guide shows how to open &lt;code&gt;https://&amp;lt;safeline-ip&amp;gt;:9443/&lt;/code&gt; and how to recover the initial administrator account with &lt;code&gt;docker exec safeline-mgt resetadmin&lt;/code&gt;. A successful login proves that the console answered. It does not prove that traffic on the application port was inspected. Install checks, including Docker version, architecture, memory, disk, and the CPU ssse3 note, belong to setup. Protection checks belong to the application domain. The deploy steps are documented at &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/Deploy&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/Deploy&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I run these probes only against an application I control. They are examples from the product documentation, not a reason to scan someone else's site. After a block is confirmed, I load an ordinary page through the same domain. Safe traffic is supposed to be forwarded upstream. If that normal page fails, the problem is proxy or upstream configuration, and I do not call the security test successful. I write down the time, the exact URL, the application name, and whether detail opened. The next DNS or upstream change can then be checked the same way, instead of by memory.&lt;/p&gt;
&lt;h4 id=&quot;how-do-i-know-the-request-reached-safeline-&quot;&gt;How do I know the request reached SafeLine?&lt;/h4&gt;
&lt;p&gt;Resolve the application domain to the SafeLine server, use the port set in Applications, and send the probe to that domain. A request aimed at the upstream address skips the reverse proxy, so it cannot prove detection.&lt;/p&gt;
&lt;h4 id=&quot;what-result-counts-as-a-successful-block-&quot;&gt;What result counts as a successful block?&lt;/h4&gt;
&lt;p&gt;The application guide says the blocking page means the attack was defended. Then open the attack list and click detail, and keep the result only if that event matches the probe you just sent.&lt;/p&gt;
&lt;h4 id=&quot;does-one-blocked-query-prove-every-control-&quot;&gt;Does one blocked query prove every control?&lt;/h4&gt;
&lt;p&gt;No. The published samples cover SQL injection, XSS, path traversal, code injection, and XXE. Rate limiting, CAPTCHA challenge, authentication challenge, and dynamic HTML and JS protection are separate documented capabilities.&lt;/p&gt;
&lt;h4 id=&quot;why-is-a-console-login-not-a-waf-test-&quot;&gt;Why is a console login not a WAF test?&lt;/h4&gt;
&lt;p&gt;The management port shows that the admin UI is reachable. Visitor protection depends on domain, listen port, SSL setting, and upstream. Test that path, not only the login screen.&lt;/p&gt;
&lt;h4 id=&quot;what-if-the-malicious-url-loads-normally-&quot;&gt;What if the malicious URL loads normally?&lt;/h4&gt;
&lt;p&gt;Check DNS, port, and SSL, and confirm you did not call the origin directly. If the path is correct and a documented sample is still not blocked, treat the test as failed and review the application settings before you assume the site is covered.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;SafeLine has surpassed 700,000 installations worldwide as of September 30, 2026. SafeLine Pro annual promotional pricing is $650-$880, compared with the regular $1,000 per year.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SafeLine website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://ly.cyberserval.tech/MqhYdmI&quot;&gt;https://ly.cyberserval.tech/MqhYdmI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;https://demo.waf.chaitin.com:9443/statistics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Official docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;https://github.com/chaitin/SafeLine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Pro offer: &lt;a rel=&quot;nofollow&quot; href=&quot;https://cyberserval.tech/landing/lto&quot;&gt;https://cyberserval.tech/landing/lto&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32356/why-i-test-the-proxy-path-before-i-trust-a-waf-block</guid>
<pubDate>Sun, 25 Oct 2026 02:27:04 +0000</pubDate>
</item>
<item>
<title>How to Test If Your WAF Is Working After Cutover</title>
<link>https://coderlegion.com/32354/how-to-test-if-your-waf-is-working-after-cutover</link>
<description>&lt;p&gt;A console login only proves that the management service answered. It does not prove that public clients are being filtered. SafeLine is a self-hosted web application firewall placed in front of a web application so that it can filter and monitor HTTP traffic and block malicious HTTP and HTTPS requests before they reach the app, following policies that separate malicious traffic from safe traffic (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;). The application guide describes the same idea in proxy terms: SafeLine is developed based on nginx, receives traffic for the original website, cleans malicious attack traffic, and forwards the remaining traffic to the original application (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&lt;/a&gt;). A working test has to show that your hostname is on that path, that a documented attack is blocked, and that the console recorded the same attempt.&lt;/p&gt;
&lt;p&gt;Confirm the application path first.&lt;/p&gt;
&lt;p&gt;Protection is configured in the web admin console on the Applications page. Choose Add Application, then enter Domain, Port, and Upstream. Domain is the domain name, hostname, or IP of the original app. Port is what SafeLine will listen on, such as 80 or 443, and HTTPS apps need the SSL option checked. Upstream is the real address SafeLine uses to forward traffic (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&lt;/a&gt;). The guide then tells you to resolve that domain to the IP address of the server where SafeLine is located and to access the protected application through that domain.&lt;/p&gt;
&lt;p&gt;Use that sequence as the first half of the test:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The hostname in your test URL must be the Domain you configured.&lt;/li&gt;
&lt;li&gt;Public DNS for that hostname must point at the SafeLine server, not at the upstream host.&lt;/li&gt;
&lt;li&gt;The port in the test must be the listener you set. If clients use HTTPS, the SSL option has to be enabled or you are testing a different service than production.&lt;/li&gt;
&lt;li&gt;Upstream must be the origin you intend to protect. A successful page load after cutover only shows that forwarding works. It does not show that filtering works.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Keep the admin URL out of this path check. After installation, the web console is opened at &lt;a rel=&quot;nofollow&quot; href=&quot;https://&amp;lt;safeline-ip&amp;gt;:9443/&quot;&gt;https://&amp;lt;safeline-ip&amp;gt;:9443/&lt;/a&gt; (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/Deploy&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/Deploy&lt;/a&gt;). Reaching that page shows that the management plane is up. It says nothing about whether a visitor to your public site ever touches the proxy.&lt;/p&gt;
&lt;p&gt;Send the published samples, then match them to the attack list.&lt;/p&gt;
&lt;p&gt;The application guide gives five cases and says to replace the example host with your website domain (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&lt;/a&gt;). Run them only against the application you added:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SQL injection: &lt;code&gt;/?id=1+and+1=2+union+select+1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;XSS: &lt;code&gt;/?id=&amp;lt;img+src=x+onerror=alert()&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Path traversal: &lt;code&gt;/?id=../../../../etc/passwd&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Code injection: &lt;code&gt;/?id=phpinfo();system('id')&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;XXE: &lt;code&gt;/?id=&amp;lt;?xml+version=&quot;1.0&quot;?&amp;gt;&amp;lt;!DOCTYPE+foo+SYSTEM+&quot;&quot;&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The documented success signal is the blocking page. After each request, open the SafeLine web console, find the attack list, and click detail. Count the test as passed only when both signals refer to the same attempt: the browser was blocked, and the detail view exists for that request. One signal without the other is incomplete. A normal origin response means the request was not cleaned on that path, so go back to Domain, Port, SSL, Upstream, and DNS before you change anything else.&lt;/p&gt;
&lt;p&gt;Do not stretch these five URLs into a claim about every control.&lt;/p&gt;
&lt;p&gt;The introduction lists many attack types that a WAF of this kind is typically used to address, including SQL injection, XSS, code injection, OS command injection, CRLF injection, LDAP injection, XPath injection, RCE, XXE, SSRF, path traversal, backdoor, bruteforce, HTTP flood, and bot abused (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;). The same page lists core capabilities: defenses for web attacks, proactive bot abused defense, HTML and JS code encryption, IP-based rate limiting, and a web access control list. The feature list adds rate limiting against DoS attacks, bruteforce attempts, traffic surges, and other abuse; CAPTCHA challenges that allow human users and block crawlers and bots; an authentication challenge that blocks visitors who do not enter the password; and dynamic protection that encrypts HTML and JS on each visit (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;). Those are product facts, not extra test steps. The only click-by-click attack samples in the supplied application guide are the five URLs above. A pass on those URLs supports the web-attack blocking check. It does not prove that rate limiting, CAPTCHA, authentication challenge, dynamic protection, or an access list is enabled. Mark those items untested unless you have a documented procedure for them.&lt;/p&gt;
&lt;p&gt;A sample-set score is a different question from a cutover test. The home documentation publishes an effect evaluation on 33669 samples. In that table, SafeLine Balance is listed at 71.65 percent detection, 0.07 percent false positive, and 99.45 percent accuracy, while SafeLine Strict is listed at 76.17 percent detection, 0.22 percent false positive, and 99.38 percent accuracy (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;). Use those figures only as published evaluation results. They do not inspect your DNS, your listener, or your upstream. The supplied pages also do not provide a procedure for switching between Balance and Strict, so do not treat the table as a tuning guide.&lt;/p&gt;
&lt;p&gt;When you record the result, store the hostname, port, SSL choice, the five URLs you requested, whether each one returned the blocking page, and whether attack-list detail opened for each one. If you compare by requesting the upstream directly, do that from an admin network you control and treat the response as an origin-only observation. Close that direct path to the public Internet afterward. The documented design is that clients pass through the WAF before reaching the server (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;). A WAF you can bypass is not a WAF you have tested.&lt;/p&gt;
&lt;h4 id=&quot;does-opening-the-console-mean-the-waf-is-working-&quot;&gt;Does opening the console mean the WAF is working?&lt;/h4&gt;
&lt;p&gt;No. The console address &lt;a rel=&quot;nofollow&quot; href=&quot;https://&amp;lt;safeline-ip&amp;gt;:9443/&quot;&gt;https://&amp;lt;safeline-ip&amp;gt;:9443/&lt;/a&gt; is the management page described in the install guide (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/Deploy&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/Deploy&lt;/a&gt;). Filtering starts only after an application is added and clients reach SafeLine on the configured domain and port (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&lt;/a&gt;).&lt;/p&gt;
&lt;h4 id=&quot;which-requests-are-the-documented-way-to-test-blocking-&quot;&gt;Which requests are the documented way to test blocking?&lt;/h4&gt;
&lt;p&gt;Use the five samples in the application guide, pointed at your own protected domain: SQL injection, XSS, path traversal, code injection, and XXE. The guide treats the blocking page as evidence that the attack was defended, and it tells you to inspect the attack list and open detail (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&lt;/a&gt;).&lt;/p&gt;
&lt;h4 id=&quot;what-should-i-check-if-the-payload-is-not-blocked-&quot;&gt;What should I check if the payload is not blocked?&lt;/h4&gt;
&lt;p&gt;Check the path before you assume a detection miss. The application must use the hostname you requested, listen on that port, have SSL selected for HTTPS, forward to the intended upstream, and have DNS pointed at the SafeLine server (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&quot;&gt;https://docs.waf.chaitin.com/en/GetStarted/AddApplication&lt;/a&gt;). A normal page from the origin means that request did not pass through the cleaning proxy.&lt;/p&gt;
&lt;h4 id=&quot;do-the-sample-urls-prove-rate-limits-and-bot-checks-&quot;&gt;Do the sample URLs prove rate limits and bot checks?&lt;/h4&gt;
&lt;p&gt;No. Rate limiting, CAPTCHA challenge, authentication challenge, and dynamic HTML and JS protection are documented separately (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;). The published attack exercise does not include steps for those controls, so a blocked XSS URL is not evidence that they are on.&lt;/p&gt;
&lt;h4 id=&quot;can-the-published-accuracy-table-replace-a-live-test-&quot;&gt;Can the published accuracy table replace a live test?&lt;/h4&gt;
&lt;p&gt;No. The 33669-sample evaluation reports SafeLine Balance and SafeLine Strict metrics (&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;). It is not a test of your hostname. Keep the live blocking page and the matching attack-list detail as the cutover evidence.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;SafeLine has surpassed 700,000 installations worldwide as of September 30, 2026. SafeLine Pro annual promotional pricing is $650-$880, compared with the regular $1,000 per year.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SafeLine website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://ly.cyberserval.tech/MqhYdmI&quot;&gt;https://ly.cyberserval.tech/MqhYdmI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;https://demo.waf.chaitin.com:9443/statistics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Official docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;https://github.com/chaitin/SafeLine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Pro offer: &lt;a rel=&quot;nofollow&quot; href=&quot;https://cyberserval.tech/landing/lto&quot;&gt;https://cyberserval.tech/landing/lto&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32354/how-to-test-if-your-waf-is-working-after-cutover</guid>
<pubDate>Sun, 25 Oct 2026 02:26:33 +0000</pubDate>
</item>
<item>
<title>Let the Router Own the Next Paint</title>
<link>https://coderlegion.com/32251/let-the-router-own-the-next-paint</link>
<description>&lt;p&gt;Navigation bugs are often judged by their final state. Did the correct page appear? Did the authorization check pass? Did the URL settle on the expected address?&lt;/p&gt;
&lt;p&gt;Those questions matter, but they can miss the frame that users actually notice.&lt;/p&gt;
&lt;p&gt;In a recent component-based web fix, the destination page was correct. The address was correct. The authorization result was correct. Yet, between the address change and the destination render, the departing page painted once more. Because the framework also applied its scroll reset during that interval, users briefly saw stale content at the top of the viewport before the new page arrived.&lt;/p&gt;
&lt;p&gt;The repair produced a useful general lesson: after an address change, the router should own the next visible paint.&lt;/p&gt;
&lt;h3 id=&quot;a-correct-destination-can-still-have-a-wrong-journey&quot;&gt;A correct destination can still have a wrong journey&lt;/h3&gt;
&lt;p&gt;An end-state test sees a simple sequence:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;start on page A;&lt;/li&gt;
&lt;li&gt;navigate to page B;&lt;/li&gt;
&lt;li&gt;assert that page B is visible.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The browser may have seen something richer:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;page A is visible at the user’s current scroll position;&lt;/li&gt;
&lt;li&gt;the address changes to B;&lt;/li&gt;
&lt;li&gt;page A renders again under B’s address;&lt;/li&gt;
&lt;li&gt;a framework scroll reset moves that stale page to the top;&lt;/li&gt;
&lt;li&gt;page B finally renders.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Steps three and four may last only one frame. They are still part of the product.&lt;/p&gt;
&lt;p&gt;This is why navigation correctness is not only about eventual state. It also includes the ordered render sequence between the user action and the destination becoming authoritative.&lt;/p&gt;
&lt;h3 id=&quot;the-hidden-effect-of-an-event-receiver&quot;&gt;The hidden effect of an event receiver&lt;/h3&gt;
&lt;p&gt;The source of the extra paint was subtle. An asynchronous navigation callback was registered with the component that hosted the route tree as its receiver.&lt;/p&gt;
&lt;p&gt;In many component frameworks, completing an event callback schedules a render of the receiving component. That behaviour is normally helpful: event handlers change state, so the framework redraws the owner automatically.&lt;/p&gt;
&lt;p&gt;Navigation is different. The callback prepared the transition, but the router—not the departing component—owned the destination. When the receiver-bound callback completed, the framework rendered the old route tree once more. The router rendered the new page immediately afterward, but it was no longer responsible for the first batch after the address changed.&lt;/p&gt;
&lt;p&gt;The fix did not add a delay, loading overlay, or animation. It removed the accidental receiver. The callback still ran, but its completion no longer implied that the departing route component needed to render. The router’s destination batch became the first visible paint after the address change.&lt;/p&gt;
&lt;h3 id=&quot;measure-the-interval-not-just-the-answer&quot;&gt;Measure the interval, not just the answer&lt;/h3&gt;
&lt;p&gt;The strongest part of the repair was the test shape.&lt;/p&gt;
&lt;p&gt;Instead of sleeping and hoping to catch a flicker, the test marked the instant the address changed. It then counted every component render until the destination page entered the render tree.&lt;/p&gt;
&lt;p&gt;Before the change, that interval contained twelve render notifications in the exercised path. After the change, it contained zero.&lt;/p&gt;
&lt;p&gt;The specific number is less important than the invariant:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Nothing else paints after the address changes and before the destination appears.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That assertion is deterministic. It does not depend on monitor refresh rate, screenshot timing, or a human noticing a flash. It also fails for the causal behaviour rather than a visual approximation of the symptom.&lt;/p&gt;
&lt;h3 id=&quot;keeping-the-old-page-has-a-boundary&quot;&gt;Keeping the old page has a boundary&lt;/h3&gt;
&lt;p&gt;There is a reasonable usability goal behind retaining the current page: an established screen is often a better placeholder than a blank shell while the destination prepares.&lt;/p&gt;
&lt;p&gt;But retained content needs a strict boundary. The old page belongs to the source route and its source authorization decision. It must not start making decisions for the new address, consume state prepared for the destination, or imply that access to the destination has been granted.&lt;/p&gt;
&lt;p&gt;Security invalidation is the important exception. If the user signs out or loses a role, the retained page must stop being authoritative immediately. Smoothness must never extend stale permission.&lt;/p&gt;
&lt;p&gt;That gives us two complementary rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;retain a certified source view only until its replacement is ready;&lt;/li&gt;
&lt;li&gt;invalidate it immediately when the identity or authorization basis changes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result is continuity without authority leakage.&lt;/p&gt;
&lt;h3 id=&quot;test-the-negative-space&quot;&gt;Test the negative space&lt;/h3&gt;
&lt;p&gt;A good transition test suite should cover more than the successful move between two ordinary pages.&lt;/p&gt;
&lt;p&gt;Useful cases include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an allowed destination;&lt;/li&gt;
&lt;li&gt;a refused destination;&lt;/li&gt;
&lt;li&gt;a layout or shell change;&lt;/li&gt;
&lt;li&gt;sign-out during preparation;&lt;/li&gt;
&lt;li&gt;role loss during preparation;&lt;/li&gt;
&lt;li&gt;an older asynchronous result completing after a newer transition;&lt;/li&gt;
&lt;li&gt;a callback that finishes on a different scheduling context.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These cases reveal who truly owns the transition. If several components can repaint or decide access for the same interval, the design is probably carrying more than one authority.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-method&quot;&gt;A practical review method&lt;/h3&gt;
&lt;p&gt;When reviewing client-side navigation, draw a short timeline: user action, address change, preparation, first destination render, and interaction-ready state.&lt;/p&gt;
&lt;p&gt;For each gap, ask:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Which component may render?&lt;/li&gt;
&lt;li&gt;Which route does that render belong to?&lt;/li&gt;
&lt;li&gt;Which authorization decision supports it?&lt;/li&gt;
&lt;li&gt;Can an event callback schedule an incidental redraw?&lt;/li&gt;
&lt;li&gt;What happens if identity changes or an older task completes late?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Then write at least one sequence assertion. Final-state tests remain valuable, but they should be paired with a guard over the transition when a single wrong frame matters.&lt;/p&gt;
&lt;h3 id=&quot;the-leadership-lesson&quot;&gt;The leadership lesson&lt;/h3&gt;
&lt;p&gt;Small visual defects often expose unclear ownership rather than missing polish. A loading spinner might hide the flash, but it would not explain why the departing component could repaint under the destination address.&lt;/p&gt;
&lt;p&gt;The more durable repair is to assign one owner to the critical interval and test that ownership directly.&lt;/p&gt;
&lt;p&gt;Users experience every frame, not only the last one. Our tests should be willing to do the same.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32251/let-the-router-own-the-next-paint</guid>
<pubDate>Sat, 24 Oct 2026 07:06:52 +0000</pubDate>
</item>
<item>
<title>Validate Image Enlargement at the Size Users Will Actually See</title>
<link>https://coderlegion.com/32167/validate-image-enlargement-at-the-size-users-will-actually-see</link>
<description>&lt;p&gt;Disclosure: I work on upscaleimg.org. These are practical quality checks, not measured performance claims about the service.&lt;/p&gt;
&lt;p&gt;An enlarged image can look impressive in a small preview and still fail when placed on a product page. More pixels are not the same thing as more reliable detail. A useful review checks both the output file and its intended display context.&lt;/p&gt;
&lt;h3 id=&quot;calculate-the-required-dimensions-first&quot;&gt;Calculate the required dimensions first&lt;/h3&gt;
&lt;p&gt;For a web image, start from the largest rendered width and the display density you want to support. A 600 CSS-pixel slot at 2x density calls for roughly 1200 image pixels across. That is a delivery target, not a guarantee of perceived sharpness.&lt;/p&gt;
&lt;p&gt;Choose an &lt;a rel=&quot;nofollow&quot; href=&quot;https://upscaleimg.org/&quot;&gt;upscale image&lt;/a&gt; workflow only after identifying the source dimensions and crop. Upscaling a wide photo does not solve the problem of needing a portrait composition.&lt;/p&gt;
&lt;h3 id=&quot;use-paired-comparisons&quot;&gt;Use paired comparisons&lt;/h3&gt;
&lt;p&gt;Keep the original and enlarged file. Compare them at the same display size, then inspect output pixels around text, faces, thin lines, and repeated patterns. Look for halos, invented texture, altered letters, and softened edges.&lt;/p&gt;
&lt;p&gt;Do not compare a zoomed original with a fit-to-window output and call the difference an accuracy improvement. Record the viewing scale and note which details changed.&lt;/p&gt;
&lt;h3 id=&quot;check-delivery-separately&quot;&gt;Check delivery separately&lt;/h3&gt;
&lt;p&gt;Record output width, height, format, byte size, and the intended slot. Generate appropriately sized delivery variants after approval rather than serving the largest download everywhere. Preserve the approved master so later compression changes can be reviewed independently.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://upscaleimg.org/&quot;&gt;Upscale Image&lt;/a&gt; provides browser-based enlargement and enhancement. The final review is still important when the image contains factual details such as product labels or diagrams. AI-created detail should not be treated as recovered evidence.&lt;/p&gt;
&lt;p&gt;For print, calculate pixels per inch using the final crop and physical size, then inspect a proof when practical. A resolution calculation alone cannot certify print quality.&lt;/p&gt;
&lt;p&gt;Website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://upscaleimg.org/&quot;&gt;https://upscaleimg.org/&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32167/validate-image-enlargement-at-the-size-users-will-actually-see</guid>
<pubDate>Fri, 23 Oct 2026 16:37:50 +0000</pubDate>
</item>
<item>
<title>Version Your Image Prompts Before Comparing Generated Results</title>
<link>https://coderlegion.com/32166/version-your-image-prompts-before-comparing-generated-results</link>
<description>&lt;p&gt;Disclosure: I work on flux-3.org. This article describes an evaluation process and does not claim official model-vendor affiliation, benchmark results, or access to private model internals.&lt;/p&gt;
&lt;p&gt;When an image prompt changes in several places at once, comparing the outputs becomes guesswork. The fix is not a longer prompt. It is a small amount of version control around the creative request.&lt;/p&gt;
&lt;h3 id=&quot;keep-inputs-separate-from-observations&quot;&gt;Keep inputs separate from observations&lt;/h3&gt;
&lt;p&gt;Store the prompt, reference filenames, selected output format, and interface settings in one record. Store reviewer notes in another. Never edit the old prompt after seeing the result; create a new revision instead.&lt;/p&gt;
&lt;p&gt;A minimal naming scheme is campaign-subject-revision-attempt. The revision identifies the input changes; the attempt identifies another run with the same inputs. This distinction matters because a repeated prompt need not produce identical pixels.&lt;/p&gt;
&lt;h3 id=&quot;compare-one-decision-at-a-time&quot;&gt;Compare one decision at a time&lt;/h3&gt;
&lt;p&gt;In a browser image workflow such as &lt;a rel=&quot;nofollow&quot; href=&quot;https://flux-3.org/&quot;&gt;flux 3&lt;/a&gt;, first settle the subject and composition. Then compare lighting instructions. Finally compare style. Keep any available settings constant during each comparison and record settings the interface does not expose as unknown.&lt;/p&gt;
&lt;p&gt;Review each candidate against the same checklist: subject count, required objects, camera framing, readable text, edge quality, and unintended additions. Do not collapse these into “best image” until you know which requirements are mandatory.&lt;/p&gt;
&lt;h3 id=&quot;make-selection-reproducible-not-generation-deterministic&quot;&gt;Make selection reproducible, not generation deterministic&lt;/h3&gt;
&lt;p&gt;A useful review record explains why an image was chosen. For example: “Revision 3 keeps the full product in frame; revision 2 crops the handle.” That remains useful even if the model cannot regenerate the exact winning file.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://flux-3.org/&quot;&gt;Flux 3's browser workspace&lt;/a&gt; is one place to try this process. Keep original downloads and use an explicit approval step before publishing. A good-looking preview is not evidence of accurate packaging, text, or rights clearance.&lt;/p&gt;
&lt;p&gt;Website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://flux-3.org/&quot;&gt;https://flux-3.org/&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32166/version-your-image-prompts-before-comparing-generated-results</guid>
<pubDate>Fri, 23 Oct 2026 12:41:45 +0000</pubDate>
</item>
<item>
<title>Separate Subject, Scene, and Style When Testing Image Remix Prompts</title>
<link>https://coderlegion.com/32165/separate-subject-scene-and-style-when-testing-image-remix-prompts</link>
<description>&lt;p&gt;Disclosure: I work on whisk-ai.co, an independent image-remix website. This is not an announcement from Google or a claim of affiliation with its products.&lt;/p&gt;
&lt;p&gt;Reference-based image generation is easier to debug when each reference has a clear job. A product photo can specify the subject, a room can specify the setting, and an illustration can suggest a visual style. Mixing these roles makes it harder to understand why a result changed.&lt;/p&gt;
&lt;h3 id=&quot;define-a-reference-manifest&quot;&gt;Define a reference manifest&lt;/h3&gt;
&lt;p&gt;For each experiment, record the source filename, its role, permission to use it, and the features you want to carry over. A subject reference might contribute shape and color without contributing its original background.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://whisk-ai.co/&quot;&gt;Whisk AI&lt;/a&gt; exposes subject, scene, and style reference inputs. Those labels help organize a request; they are not guarantees that every output element will follow the assigned role perfectly.&lt;/p&gt;
&lt;h3 id=&quot;run-an-ablation-sequence&quot;&gt;Run an ablation sequence&lt;/h3&gt;
&lt;p&gt;Start with the subject alone. Add the scene while holding the subject and text instruction fixed. Then add style. At each step, check whether the main object remains recognizable and whether the camera angle and lighting are coherent.&lt;/p&gt;
&lt;p&gt;If the third step introduces an unwanted material change, remove or replace the style reference before rewriting everything. This does not prove how the model works internally. It simply narrows the input changes associated with a visible failure.&lt;/p&gt;
&lt;h3 id=&quot;review-conflicts-explicitly&quot;&gt;Review conflicts explicitly&lt;/h3&gt;
&lt;p&gt;A flat illustration and a reflective studio photograph can imply conflicting materials. A close-up subject and a wide room reference can imply conflicting scale. Write down which requirement should win before generating another version.&lt;/p&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://whisk-ai.co/&quot;&gt;whisk ai remix workflow&lt;/a&gt; can be useful for exploring concepts, but do not treat a reference image as a license to reproduce a third party's work. Review ownership, recognizable people, text, and brand details before sharing outputs.&lt;/p&gt;
&lt;p&gt;Keep the manifest beside your selected image so another person can understand the creative decision, not merely repeat an unexplained prompt.&lt;/p&gt;
&lt;p&gt;Website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://whisk-ai.co/&quot;&gt;https://whisk-ai.co/&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32165/separate-subject-scene-and-style-when-testing-image-remix-prompts</guid>
<pubDate>Fri, 23 Oct 2026 12:02:58 +0000</pubDate>
</item>
<item>
<title>A Regression Checklist for Prompt-Based Image Editing</title>
<link>https://coderlegion.com/32164/a-regression-checklist-for-prompt-based-image-editing</link>
<description>&lt;p&gt;Disclosure: I work on nanobanana-ai.org. The checklist below is a suggested evaluation method, not a published benchmark or a description of private model internals.&lt;/p&gt;
&lt;p&gt;Prompt edits are difficult to test because the expected output is not a single exact file. A byte-for-byte snapshot comparison will reject harmless variations while saying little about whether the important content survived.&lt;/p&gt;
&lt;h3 id=&quot;build-a-small-fixture-set&quot;&gt;Build a small fixture set&lt;/h3&gt;
&lt;p&gt;Choose images you have permission to use: a product with readable text, a portrait, an object with fine edges, and a scene with repeating texture. Keep the input files unchanged between evaluations. Record the selected model or mode, prompt, aspect ratio, and date when available.&lt;/p&gt;
&lt;p&gt;For a browser editor such as &lt;a rel=&quot;nofollow&quot; href=&quot;https://nanobanana-ai.org/&quot;&gt;nano banana&lt;/a&gt;, start with one narrow edit per fixture. Changing background, color, framing, and lighting in the same request makes a failure hard to diagnose.&lt;/p&gt;
&lt;h3 id=&quot;test-invariants-instead-of-identical-pixels&quot;&gt;Test invariants instead of identical pixels&lt;/h3&gt;
&lt;p&gt;For each fixture, define a short acceptance checklist. A product image might require the same package shape, legible original label, and no invented attachments. A portrait needs identity and expression review. A patterned surface needs checks for repeated seams and distorted perspective.&lt;/p&gt;
&lt;p&gt;Separate required checks from preferences. A more attractive result can still fail if it changes a product's identifying details. Log the failed check and keep the output that demonstrates it.&lt;/p&gt;
&lt;h3 id=&quot;compare-changes-fairly&quot;&gt;Compare changes fairly&lt;/h3&gt;
&lt;p&gt;When refining a prompt, keep the source and output format fixed. Repeat a small number of attempts if your budget permits, but report the number of runs and failures instead of presenting one selected image as typical performance.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://nanobanana-ai.org/&quot;&gt;Nano Banana AI&lt;/a&gt; provides image upload and text instructions in the browser. This article does not claim deterministic generation, guaranteed identity preservation, or an official relationship with a model vendor.&lt;/p&gt;
&lt;p&gt;A practical release rule is simple: generation creates a candidate; a separate review approves an asset.&lt;/p&gt;
&lt;p&gt;Website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://nanobanana-ai.org/&quot;&gt;https://nanobanana-ai.org/&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32164/a-regression-checklist-for-prompt-based-image-editing</guid>
<pubDate>Fri, 23 Oct 2026 11:10:20 +0000</pubDate>
</item>
<item>
<title>Treat AI Photo Edits Like Changesets: A Practical Review Workflow</title>
<link>https://coderlegion.com/32163/treat-ai-photo-edits-like-changesets-a-practical-review-workflow</link>
<description>&lt;p&gt;Disclosure: I work on Photo Tea. This is a proposed review workflow, not a claim that the product implements the code or automatically preserves every detail.&lt;/p&gt;
&lt;p&gt;An image edit has two requirements: make the requested change and leave unrelated content alone. Checking only the first is how a clean background can hide a changed product label.&lt;/p&gt;
&lt;h3 id=&quot;write-a-small-change-contract&quot;&gt;Write a small change contract&lt;/h3&gt;
&lt;p&gt;Before opening an &lt;a rel=&quot;nofollow&quot; href=&quot;https://phototea.art/&quot;&gt;ai photo editor&lt;/a&gt;, record three fields: change, preserve, and reject. For a product shot, change might be the background; preserve might include packaging geometry and label text; reject might include invented accessories or new shadows crossing the label.&lt;/p&gt;
&lt;p&gt;Keep the original file. Give each attempt its own name rather than overwriting the last result. A useful record is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{&quot;asset&quot;:&quot;bottle-01&quot;,&quot;revision&quot;:2,&quot;change&quot;:&quot;light gray background&quot;,&quot;preserve&quot;:[&quot;label&quot;,&quot;cap shape&quot;],&quot;decision&quot;:&quot;needs-review&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is an example record for your own process, not a Photo Tea API payload.&lt;/p&gt;
&lt;h3 id=&quot;inspect-the-change-and-the-invariants-separately&quot;&gt;Inspect the change and the invariants separately&lt;/h3&gt;
&lt;p&gt;First compare the full composition at the intended display size. Then inspect the label, silhouette, reflections, and contact shadow at full resolution. A successful background replacement does not compensate for an incorrect ingredient label.&lt;/p&gt;
&lt;p&gt;Record failures by region, not just as a score. “Cap became wider” gives the next prompt a concrete constraint; “looks bad” does not. Change one instruction per attempt so you can understand which change helped.&lt;/p&gt;
&lt;h3 id=&quot;keep-approval-outside-generation&quot;&gt;Keep approval outside generation&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://phototea.art/&quot;&gt;Photo Tea&lt;/a&gt; offers prompt-based photo editing. The final publishing decision should still be explicit: approved, rejected, or needs manual repair. File existence and image dimensions are useful automated checks, but neither proves product accuracy.&lt;/p&gt;
&lt;p&gt;Use only images you own or may edit, and check privacy requirements before uploading customer photos. For sensitive details, a conventional editor may be the better final step.&lt;/p&gt;
&lt;p&gt;Website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://phototea.art/&quot;&gt;https://phototea.art/&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32163/treat-ai-photo-edits-like-changesets-a-practical-review-workflow</guid>
<pubDate>Fri, 23 Oct 2026 11:10:04 +0000</pubDate>
</item>
<item>
<title>Test Video Background Removal Across Motion, Not Just One Frame</title>
<link>https://coderlegion.com/32168/test-video-background-removal-across-motion-not-just-one-frame</link>
<description>&lt;p&gt;Disclosure: I work on videobgremover.org. This is a suggested QA procedure, not a published segmentation benchmark or a description of our private processing implementation.&lt;/p&gt;
&lt;p&gt;A background-removal result can have a convincing still frame and distracting flicker during playback. Treating video like a collection of unrelated screenshots misses the temporal defects people notice first.&lt;/p&gt;
&lt;h3 id=&quot;choose-a-representative-test-segment&quot;&gt;Choose a representative test segment&lt;/h3&gt;
&lt;p&gt;Use footage you own or have permission to process. Include a few seconds of stillness, normal movement, and a moment when the subject crosses a contrasting background. Fine hair, fingers, transparent objects, and motion blur deserve separate attention.&lt;/p&gt;
&lt;p&gt;Before using a &lt;a rel=&quot;nofollow&quot; href=&quot;https://videobgremover.org/&quot;&gt;video background remover&lt;/a&gt;, keep the original file and record its duration, frame dimensions, frame rate, and audio requirements. These form the acceptance checklist for the export.&lt;/p&gt;
&lt;h3 id=&quot;review-three-views&quot;&gt;Review three views&lt;/h3&gt;
&lt;p&gt;First watch the result at normal speed. Look for edge shimmer and parts of the subject appearing or disappearing. Then inspect difficult moments frame by frame. Finally composite the result over both light and dark backgrounds to reveal halos that one background may hide.&lt;/p&gt;
&lt;p&gt;Record timestamps for failures. “Hair flickers at 00:04” is more actionable than an overall quality score. A checkerboard preview by itself does not prove the downloaded file preserves transparency.&lt;/p&gt;
&lt;h3 id=&quot;validate-the-actual-delivery-file&quot;&gt;Validate the actual delivery file&lt;/h3&gt;
&lt;p&gt;Import the export into the editor or player that will consume it. Confirm the expected duration, frame dimensions, audio behavior, and whether the chosen format supports the transparency your workflow needs. A preview and a downloaded file can travel through different conversion paths.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://videobgremover.org/&quot;&gt;Video Background Remover&lt;/a&gt; can be evaluated with this checklist without assuming that every input or export format behaves identically. Reject clips with missing subject regions even when most frames look clean.&lt;/p&gt;
&lt;p&gt;Keep a short approved fixture set for future comparisons, with consent and source provenance stored alongside it.&lt;/p&gt;
&lt;p&gt;Website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://videobgremover.org/&quot;&gt;https://videobgremover.org/&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32168/test-video-background-removal-across-motion-not-just-one-frame</guid>
<pubDate>Fri, 23 Oct 2026 09:47:09 +0000</pubDate>
</item>
<item>
<title>Track Reference Images as Dependencies in AI Image Experiments</title>
<link>https://coderlegion.com/32169/track-reference-images-as-dependencies-in-ai-image-experiments</link>
<description>&lt;p&gt;Disclosure: I work on nano-banana-3.org. This is a workflow recommendation, not an official model-vendor statement or a claim that the website exposes a dependency-tracking API.&lt;/p&gt;
&lt;p&gt;When a generated image changes unexpectedly, the prompt is not always the only changed input. Someone may have replaced a reference image, reordered the uploads, or used a different crop. Treat reference files as dependencies so those changes remain visible.&lt;/p&gt;
&lt;h3 id=&quot;create-an-input-manifest&quot;&gt;Create an input manifest&lt;/h3&gt;
&lt;p&gt;For each experiment, list the exact source filenames, upload order, intended role, and permission to use each file. Keep the source files unchanged. For a team workflow, a file hash can help detect accidental replacement; it does not establish ownership or permission.&lt;/p&gt;
&lt;p&gt;In &lt;a rel=&quot;nofollow&quot; href=&quot;https://nano-banana-3.org/&quot;&gt;nano banana 3&lt;/a&gt;, image references and text instructions can be used to explore an image concept. Record the settings visible in your session rather than assuming a particular model version or hidden seed.&lt;/p&gt;
&lt;h3 id=&quot;reduce-conflicting-references&quot;&gt;Reduce conflicting references&lt;/h3&gt;
&lt;p&gt;Start with the smallest set that communicates the goal. Two images showing incompatible perspectives can make the desired geometry unclear. If one reference supplies a subject and another supplies a style, say so in the instruction and review whether those roles stayed separate.&lt;/p&gt;
&lt;p&gt;When a result fails, replace one input at a time. Keep both the failed output and the corresponding manifest. Otherwise the next person may reproduce the failure without knowing which file introduced it.&lt;/p&gt;
&lt;h3 id=&quot;review-outputs-independently-of-provenance&quot;&gt;Review outputs independently of provenance&lt;/h3&gt;
&lt;p&gt;A complete manifest explains where inputs came from; it does not prove an output is correct. Check identity, object count, text, geometry, and unintended additions before approval. Review rights and privacy separately from visual quality.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://nano-banana-3.org/&quot;&gt;Nano Banana 3&lt;/a&gt; is the browser tool associated with this example. The broader practice works with any reference-driven editor: preserve your inputs, log changes, and make selection decisions explicit.&lt;/p&gt;
&lt;p&gt;Website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://nano-banana-3.org/&quot;&gt;https://nano-banana-3.org/&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32169/track-reference-images-as-dependencies-in-ai-image-experiments</guid>
<pubDate>Fri, 23 Oct 2026 09:47:08 +0000</pubDate>
</item>
<item>
<title>A Practical QA Checklist for Browser-Based Song Search</title>
<link>https://coderlegion.com/32152/a-practical-qa-checklist-for-browser-based-song-search</link>
<description>&lt;p&gt;Disclosure: I work on Hum to Search. This is a practical input-quality and review checklist, not an accuracy benchmark or a description of our private recognition backend.&lt;/p&gt;
&lt;p&gt;When a user remembers a melody but not a title, a search box is not always enough. A browser-based song search needs a different starting point: determine what kind of evidence the user actually has.&lt;/p&gt;
&lt;p&gt;Our &lt;a rel=&quot;nofollow&quot; href=&quot;https://humtosearch.app/&quot;&gt;song finder&lt;/a&gt; presents separate &quot;Hum or sing&quot; and &quot;Identify a recording&quot; modes. That distinction is useful when designing a test plan. A person humming a chorus and a recording of the original song are different inputs; a good interface should not make the user guess which path to choose.&lt;/p&gt;
&lt;h3 id=&quot;start-with-an-input-checklist&quot;&gt;Start with an input checklist&lt;/h3&gt;
&lt;p&gt;Before searching, answer three questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Is this a hummed melody, singing, or a recording of music playing elsewhere?&lt;/li&gt;
&lt;li&gt;Is the useful section audible, or is most of the clip silence or conversation?&lt;/li&gt;
&lt;li&gt;Can the person record another attempt without replaying the same weak sample?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For humming, the current input screen suggests 10-20 seconds in a quiet place and trying the chorus without background music. Treat that as capture guidance, not a promise of a match. A clear, continuous phrase is a better test input than several disconnected fragments.&lt;/p&gt;
&lt;p&gt;For a recording, keep a short section where the music itself is audible. Do not upload unrelated conversations or other people's private audio just to identify background music.&lt;/p&gt;
&lt;h3 id=&quot;separate-capture-problems-from-recognition-problems&quot;&gt;Separate capture problems from recognition problems&lt;/h3&gt;
&lt;p&gt;A failed result can mean several things. The microphone may not have captured sound, the recording may be unclear, or the supplied evidence may not produce a useful candidate. These should be different states in a product test report.&lt;/p&gt;
&lt;p&gt;Here is a small QA matrix a developer can adapt:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Test input &lt;/th&gt;&lt;th&gt;What to inspect &lt;/th&gt;&lt;th&gt;Useful next action&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Microphone access declined &lt;/td&gt;&lt;td&gt;Is the reason visible? &lt;/td&gt;&lt;td&gt;Explain how to retry or choose upload&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Silent recording &lt;/td&gt;&lt;td&gt;Is there usable audio? &lt;/td&gt;&lt;td&gt;Record again before another search&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Hummed chorus &lt;/td&gt;&lt;td&gt;Correct input mode and continuous melody &lt;/td&gt;&lt;td&gt;Compare a different clear phrase&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Music with speech over it &lt;/td&gt;&lt;td&gt;Whether music is actually audible &lt;/td&gt;&lt;td&gt;Choose a cleaner section&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Ambiguous candidate &lt;/td&gt;&lt;td&gt;Title, artist and familiar melody &lt;/td&gt;&lt;td&gt;Listen and verify rather than accept blindly&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This is a proposed test matrix. It does not claim that every browser or every case has passed testing on our site.&lt;/p&gt;
&lt;h3 id=&quot;keep-a-lightweight-test-log&quot;&gt;Keep a lightweight test log&lt;/h3&gt;
&lt;p&gt;For a reproducible evaluation, write down the conditions rather than just saying &quot;it worked&quot; or &quot;it failed&quot;. A simple CSV is enough:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csv&quot;&gt;case_id,input_mode,clip_seconds,environment,expected_song,result,review_note
hum-01,hum,15,quiet,known_test_track,pending,compare chorus
record-01,recording,12,background_speech,known_test_track,pending,try cleaner section
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are example rows, not measured results. Use audio you own or have permission to test. Keep identifying information out of shared logs, and set your own retention policy before keeping recordings.&lt;/p&gt;
&lt;p&gt;Change one variable at a time. If the first attempt is noisy, improve the recording before changing the melody, device and duration together. Otherwise you will not know which change helped.&lt;/p&gt;
&lt;h3 id=&quot;a-candidate-still-needs-a-human-check&quot;&gt;A candidate still needs a human check&lt;/h3&gt;
&lt;p&gt;A returned title is a candidate to investigate, not a reason to stop thinking. Listen to a legitimate preview and compare the part you remember. Covers, live recordings and similar melodies can make a result feel familiar without being the exact track you wanted.&lt;/p&gt;
&lt;p&gt;For product design, &quot;no useful match&quot; deserves its own helpful recovery path. Suggest a clearer phrase or a different input mode instead of repeating a generic error. If the service uses credits, show the current cost before the user submits; avoid promising unlimited retries unless the actual plan supports them.&lt;/p&gt;
&lt;h3 id=&quot;try-the-workflow&quot;&gt;Try the workflow&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://humtosearch.app/&quot;&gt;Hum to Search&lt;/a&gt; is our browser-based starting point for this workflow. Choose the appropriate input mode, provide a clear sample, and review the candidate instead of assuming recognition is certain. Check the live interface and pricing page for current usage conditions.&lt;/p&gt;
&lt;p&gt;Website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://humtosearch.app/&quot;&gt;https://humtosearch.app/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;For developers building an audio interface, the practical takeaway is simple: make input type explicit, make failures distinguishable, and make the next attempt easier to understand.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32152/a-practical-qa-checklist-for-browser-based-song-search</guid>
<pubDate>Fri, 23 Oct 2026 07:38:41 +0000</pubDate>
</item>
<item>
<title>Tolerant Decoding, Strict Release Gates</title>
<link>https://coderlegion.com/32154/tolerant-decoding-strict-release-gates</link>
<description>&lt;p&gt;A release verifier has two jobs that are easy to blur together. It must understand the representation produced by the build chain, and it must enforce the policy that decides whether an artifact may leave that chain.&lt;/p&gt;
&lt;p&gt;Those jobs need different attitudes.&lt;/p&gt;
&lt;p&gt;Decoding can be narrowly tolerant of documented, meaning-preserving variation. Release policy should remain exact and fail closed.&lt;/p&gt;
&lt;p&gt;That distinction surfaced in a recent mobile build repair. A generated JSON settings file could begin with the optional UTF-8 byte-order marker. The JSON content was valid, but the verifier decoded it as plain UTF-8 and failed before it could evaluate the values that actually governed release safety.&lt;/p&gt;
&lt;p&gt;The fix was not to “clean up” the input generally. It was to accept one specific encoding variation, then run the same strict semantic checks as before.&lt;/p&gt;
&lt;h3 id=&quot;representation-is-not-policy&quot;&gt;Representation is not policy&lt;/h3&gt;
&lt;p&gt;A byte-order marker sits at the representation boundary. In this case, accepting UTF-8 with or without that marker did not change the JSON values.&lt;/p&gt;
&lt;p&gt;An endpoint or feature-handoff setting is different. It changes what the package will do after installation. That is policy-bearing data.&lt;/p&gt;
&lt;p&gt;Treating both categories as raw bytes creates a brittle gate: a harmless encoding detail can block a valid package. Treating both categories as broadly normalisable creates a permissive gate: a meaningful difference may be trimmed, defaulted, coerced, or discarded before verification sees it.&lt;/p&gt;
&lt;p&gt;The useful middle ground is narrow tolerance:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Decode only the representation variants the producer is documented to emit.&lt;/li&gt;
&lt;li&gt;Parse into an explicit data shape.&lt;/li&gt;
&lt;li&gt;Compare policy-bearing values exactly.&lt;/li&gt;
&lt;li&gt;Publish nothing until every check has passed.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is a small design choice, but it keeps compatibility and authority in their proper layers.&lt;/p&gt;
&lt;h3 id=&quot;why-broad-normalisation-is-risky&quot;&gt;Why broad normalisation is risky&lt;/h3&gt;
&lt;p&gt;“Normalise before comparing” sounds sensible. Sometimes it is. The danger is that normalisation is often written without naming which differences are harmless.&lt;/p&gt;
&lt;p&gt;Should surrounding whitespace be trimmed? Should an empty string become null? Should a missing value receive a default? Should a hostname lose its trailing separator? Should key casing be ignored?&lt;/p&gt;
&lt;p&gt;Each transformation might be correct for one contract and wrong for another. Once a release gate performs all-purpose cleanup, reviewers can no longer see which deviations the gate intentionally accepts.&lt;/p&gt;
&lt;p&gt;A better question is: who owns this variation?&lt;/p&gt;
&lt;p&gt;The decoder owns permitted encoding markers. The JSON parser owns JSON syntax. The release policy owns the approved endpoint and capability settings. Package signing owns artifact identity. Device acceptance owns behaviour on the target platform.&lt;/p&gt;
&lt;p&gt;Let each boundary make only the decision it has enough information to make.&lt;/p&gt;
&lt;h3 id=&quot;test-tolerance-and-strictness-together&quot;&gt;Test tolerance and strictness together&lt;/h3&gt;
&lt;p&gt;A positive test for the newly accepted variant is necessary, but it is not enough. It proves compatibility; it does not prove that policy remained intact.&lt;/p&gt;
&lt;p&gt;The stronger test set combines the representation variant with both approved and disallowed semantics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a marker-bearing file with approved values passes;&lt;/li&gt;
&lt;li&gt;the verifier emits a clean, parseable receipt;&lt;/li&gt;
&lt;li&gt;a marker-bearing file with an unapproved endpoint fails;&lt;/li&gt;
&lt;li&gt;a marker-bearing file with an unapproved handoff configuration fails;&lt;/li&gt;
&lt;li&gt;either failure produces no distributable package and no success receipt.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those negative cases are the important counterweight. They show that the change expanded the accepted representation, not the accepted deployment policy.&lt;/p&gt;
&lt;p&gt;The no-output assertions also matter. A verifier that reports failure after copying the package has already lost the race. Publication must happen after verification, not alongside it. The absence of a receipt is useful too: downstream automation must not mistake a partially completed run for approval.&lt;/p&gt;
&lt;h3 id=&quot;make-the-artifact-boundary-transactional&quot;&gt;Make the artifact boundary transactional&lt;/h3&gt;
&lt;p&gt;File-based pipelines rarely have a database transaction around the whole release. We can still design them with transactional thinking.&lt;/p&gt;
&lt;p&gt;Build into a private staging location. Verify the package, its embedded settings, its signature, and any required metadata there. Only after the full decision succeeds should the pipeline copy or promote the artifact and write the corresponding receipt.&lt;/p&gt;
&lt;p&gt;On failure, the public output location should remain unchanged.&lt;/p&gt;
&lt;p&gt;This ordering turns the output directory into a simple commit boundary. Before promotion, the candidate is merely work in progress. After promotion, the package and its evidence agree.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-checklist-1&quot;&gt;A practical review checklist&lt;/h3&gt;
&lt;p&gt;When one tool generates a file and another tool gates a release from it, review four things:&lt;/p&gt;
&lt;h4 id=&quot;1-enumerate-representation-variants&quot;&gt;1. Enumerate representation variants&lt;/h4&gt;
&lt;p&gt;Record the encodings, markers, line endings, ordering guarantees, and optional fields the producer may legitimately emit. Avoid accepting variants merely because a parser can.&lt;/p&gt;
&lt;h4 id=&quot;2-identify-policy-bearing-fields&quot;&gt;2. Identify policy-bearing fields&lt;/h4&gt;
&lt;p&gt;List the values that change destination, authority, enabled capabilities, signing identity, or runtime behaviour. Compare them deliberately rather than through a generic object cleanup step.&lt;/p&gt;
&lt;h4 id=&quot;3-add-crossed-tests&quot;&gt;3. Add crossed tests&lt;/h4&gt;
&lt;p&gt;Test each permitted representation with approved values and with at least one disallowed value. This catches the accidental move from narrow compatibility to broad permissiveness.&lt;/p&gt;
&lt;h4 id=&quot;4-prove-fail-closed-publication&quot;&gt;4. Prove fail-closed publication&lt;/h4&gt;
&lt;p&gt;Assert not only that verification returns failure, but also that no package, receipt, or other success signal appears in the publish location.&lt;/p&gt;
&lt;h3 id=&quot;the-leadership-lesson-1&quot;&gt;The leadership lesson&lt;/h3&gt;
&lt;p&gt;Strictness is not the same as rejecting every variation. Good gates are precise about what they tolerate and equally precise about what they refuse.&lt;/p&gt;
&lt;p&gt;That precision makes a pipeline easier to operate. Engineers can fix harmless toolchain differences without weakening release policy. Reviewers can see the exact compatibility allowance. Operators can trust that a failed gate did not leave an ambiguous artifact behind.&lt;/p&gt;
&lt;p&gt;The goal is not a gate that never bends. It is a gate that bends only where meaning is unchanged—and stays immovable where deployment meaning begins.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32154/tolerant-decoding-strict-release-gates</guid>
<pubDate>Fri, 23 Oct 2026 06:38:59 +0000</pubDate>
</item>
<item>
<title>Nothing Reported It</title>
<link>https://coderlegion.com/30851/nothing-reported-it</link>
<description>&lt;h4 id=&quot;five-ways-my-own-test-harness-lied-to-me-and-the-one-that-nearly-got-published&quot;&gt;Five ways my own test harness lied to me, and the one that nearly got published&lt;/h4&gt;
&lt;p&gt;The number that gave it away was sitting in a table I had already read three times.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;prompt size    97 tokens median, 112 max
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is the grounded question-answering workflow. It sends four reference documents with every question. The documents are 447 words.&lt;/p&gt;
&lt;p&gt;Four hundred and forty-seven words cannot be ninety-seven tokens.&lt;/p&gt;
&lt;p&gt;I had written that number into a results document as good news about context headroom. &quot;Prompt sizes never approached the 4096-token context window.&quot; It was not good news about headroom. It was the harness telling me, in the only field capable of telling me, that the reference material was not in the prompt. The directory holding it had never been committed to the repository. Every one of those fifteen questions had been asked against an empty reference block.&lt;/p&gt;
&lt;p&gt;Both models had behaved impeccably. One had been instructed to answer &lt;code&gt;NOT IN THE MATERIAL&lt;/code&gt; when the material did not contain the answer, and the material contained nothing, so it said &lt;code&gt;NOT IN THE MATERIAL&lt;/code&gt; thirteen times out of fifteen. The other had no such instruction, so it answered from whatever it had memorized during training and invented a wine.&lt;/p&gt;
&lt;p&gt;Every call returned 200. The scorer scored it. The scores were internally consistent, had plausible and &lt;em&gt;different&lt;/em&gt; failure modes on each side, and I wrote two thousand seven hundred words about what they revealed regarding the capabilities of small language models.&lt;/p&gt;
&lt;p&gt;They revealed nothing about small language models. I had measured what happens when you ask a question with no reference material, which is a question I was not asking and which has an uninteresting answer.&lt;/p&gt;
&lt;p&gt;This is the post about that, and about the four other things I found in the same weekend, because the missing directory was not the first one and it was not even the most instructive.&lt;/p&gt;
&lt;h3 id=&quot;the-experiment-briefly&quot;&gt;The experiment, briefly&lt;/h3&gt;
&lt;p&gt;A Raspberry Pi 4, two small models through Ollama, and three workflows: pull an identifier out of a message, route a message to one of six queues, answer a question from four documents. Ninety-five test cases. A deterministic baseline for the first workflow. Predictions committed before anything was installed.&lt;/p&gt;
&lt;p&gt;The findings are a separate post. This one is about the code I wrote to produce them, which turned out to be where the actual bugs were.&lt;/p&gt;
&lt;p&gt;Five flaws. Every single one was found by reading output, not by reading code. I never once found one of these by looking at the scorer.&lt;/p&gt;
&lt;h3 id=&quot;kind-one-a-metric-that-rewards-the-safe-failure&quot;&gt;Kind one: a metric that rewards the safe failure&lt;/h3&gt;
&lt;p&gt;This one I hit twice, in two different workflows, and did not recognize the second instance as the same bug until I had already fixed the first.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The classification scorer.&lt;/strong&gt; Six labels, one of which is &lt;code&gt;escalate&lt;/code&gt;. The instruction said to escalate when a message needs a person, including when you cannot tell what it is about. So I built a metric called &lt;em&gt;confident wrong&lt;/em&gt;: wrong, and not an escalation. The reasoning is sound. A model that escalates when unsure is usable. A model that routes a trademark notice to billing is not.&lt;/p&gt;
&lt;p&gt;Then the 360M model answered &lt;code&gt;escalate&lt;/code&gt; to 43 of 50 messages.&lt;/p&gt;
&lt;p&gt;It scored &lt;strong&gt;3&lt;/strong&gt; on confident wrong. The 1.5B model, which was genuinely attempting the task and getting 46% of it right, scored &lt;strong&gt;26&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;My metric had just certified the useless model as roughly six times safer than the mediocre one. Because escalations are exempt, a model that escalates &lt;em&gt;always&lt;/em&gt; cannot register a confident error. The exemption I added to reward caution had instead rewarded paralysis, and it did so in the direction that looks best.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The grounded QA scorer.&lt;/strong&gt; Four of the fifteen questions have no answer in the corpus. Correctly saying so is the behavior that matters most, because a system that invents a plausible answer with no evidence is worse than one that fails loudly: it is indistinguishable from one that worked. So the headline number was correct abstentions out of four.&lt;/p&gt;
&lt;p&gt;Then the 1.5B model said &lt;code&gt;NOT IN THE MATERIAL&lt;/code&gt; to 13 of 15 questions.&lt;/p&gt;
&lt;p&gt;It scored &lt;strong&gt;4 out of 4&lt;/strong&gt;. A perfect score, on the metric I cared most about, for a model that had answered almost nothing.&lt;/p&gt;
&lt;p&gt;Same bug. Different workflow. Two hours apart. I fixed the classification one, wrote a comment in the code explaining the principle, and then failed to apply that principle to the file I opened next.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The shape, stated once:&lt;/strong&gt; a metric that rewards the safe behavior will certify any model that only ever does the safe thing. If you exempt a behavior because it is cautious, you have to pair that exemption with evidence that the model is making distinctions at all. Otherwise, the exemption &lt;em&gt;is&lt;/em&gt; the score.&lt;/p&gt;
&lt;p&gt;Both fixes are the same three lines: count how concentrated the answers are, and when one answer dominates, print a warning that the headline number is meaningless and say why.&lt;/p&gt;
&lt;h3 id=&quot;kind-two-a-metric-that-measures-nothing-at-all&quot;&gt;Kind two: a metric that measures nothing at all&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The vacuous fixture.&lt;/strong&gt; I caught this one before the run, which is the only reason it is not a bigger part of this post. A test case that passed regardless of what the model did, because the fixture was inert either way. It contributed a tick to a column and no information.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The keyword check&lt;/strong&gt;, which is worse, because it survived longer and looked more useful.&lt;/p&gt;
&lt;p&gt;The eleven answerable QA questions each carry a list of words the correct answer should contain. The scorer checks whether any of them appear anywhere in the reply. I labeled it &quot;indicative only&quot; and moved on.&lt;/p&gt;
&lt;p&gt;On the invalid run it scored 1 of 11 for both models. The single hit was the word &quot;reservation&quot;, appearing inside this answer:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;You owe a tasting fee of $100 for the weekend. You also need to book a reservation for the weekend.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The correct fee is forty dollars. The hundred is invented. That one hit was the entire positive signal in my automated QA scoring and it was noise.&lt;/p&gt;
&lt;p&gt;Here is the part that makes it a better story. After I fixed the corpus and reran, the keyword check scored &lt;strong&gt;11 of 11&lt;/strong&gt; for the 1.5B model. Vindicated, apparently. I read all eleven answers by hand anyway, and nine of them are right.&lt;/p&gt;
&lt;p&gt;So the check was wrong both times. It was wrong in the pessimistic direction when the answers were bad and wrong in the optimistic direction when they were good, and on the rerun one of its eleven hits is an answer that gets the arithmetic wrong and hits on the word &quot;reservation&quot; anyway.&lt;/p&gt;
&lt;p&gt;A check that agrees with the truth because the truth happened to be agreeable has not been validated. It has been lucky.&lt;/p&gt;
&lt;h3 id=&quot;kind-three-a-detector-with-a-blind-spot-in-the-obvious-direction&quot;&gt;Kind three: a detector with a blind spot in the obvious direction&lt;/h3&gt;
&lt;p&gt;I wrote a decay check because of a thermal problem. A Pi 4 with heat sinks and no fan hit its soft temperature limit during a thirty-call run and started capping the clock, and nothing anywhere said so. So the scorer now compares generation speed in the first third of a run to the last third and flags a slowdown.&lt;/p&gt;
&lt;p&gt;It then reported this, as &lt;em&gt;steady&lt;/em&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;across the run 4.3 -&amp;gt; 7.6 tok/s (176% of starting speed, steady)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The hardware got seventy-six percent faster partway through a run. My detector, which was looking for numbers below a threshold, saw a number above it and reported no problem.&lt;/p&gt;
&lt;p&gt;Hardware does not speed up. That reading is either contention at the start of the run or, as it turned out, generations too short to time. Either way it is a signal that the measurement is unreliable, and I had built a detector that would only ever notice half of the ways that can happen.&lt;/p&gt;
&lt;p&gt;Anything you threshold in one direction, threshold in both. An implausibly good number is as informative as an implausibly bad one and nobody ever writes the check for it.&lt;/p&gt;
&lt;h3 id=&quot;kind-four-errors-that-get-dropped-instead-of-counted&quot;&gt;Kind four: errors that get dropped instead of counted&lt;/h3&gt;
&lt;p&gt;Partway through the first extraction run, Ollama returned a 500 and the runner died at case seven of thirty. The traceback was useless because &lt;code&gt;urllib&lt;/code&gt; raises &lt;code&gt;HTTPError&lt;/code&gt; and discards the response body, which is where Ollama puts its reason. Reading &lt;code&gt;e.read()&lt;/code&gt; gave it up immediately:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{&quot;error&quot;:&quot;prediction aborted, token repeat limit reached&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The 360M model, asked to find an order number in &quot;Thanks for a lovely afternoon yesterday,&quot; where the correct answer is one word, had fallen into a repetition loop and been killed after twenty-one seconds. Reproducible at temperature zero. The input causes it.&lt;/p&gt;
&lt;p&gt;My first fix was to record failures and keep going, which was right. My second was to exclude all failures from the scores as infrastructure noise, which was badly wrong.&lt;/p&gt;
&lt;p&gt;That call was not infrastructure noise. The model ran. It received the prompt, generated tokens, and produced nothing usable. That is a result, and excluding it would have dropped from the denominator the single case this model demonstrably could not handle, scoring it only on the cases it survived.&lt;/p&gt;
&lt;p&gt;So the runner now tags every failure as one of two kinds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Model failure.&lt;/strong&gt; The call reached a working model and the model failed. Stays in the denominator. Counts against the model. Reported on its own line as &quot;no usable answer.&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transport failure.&lt;/strong&gt; The call never reached a working model. Ollama was down, the tag was wrong, the request timed out. Nothing was measured, so it is excluded and the run is reported as incomplete.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The distinction is a five-line change and it is the difference between measuring a model and measuring a model on the subset of inputs it happens to tolerate.&lt;/p&gt;
&lt;h3 id=&quot;kind-five-inputs-that-go-missing-without-raising&quot;&gt;Kind five: inputs that go missing without raising&lt;/h3&gt;
&lt;p&gt;This is the missing corpus from the top of the post, and it is a different and worse category than everything above it.&lt;/p&gt;
&lt;p&gt;The function was four lines:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def corpus_text():
    parts = []
    for p in sorted((FIX / &quot;corpus&quot;).glob(&quot;*.md&quot;)):
        parts.append(p.read_text(encoding=&quot;utf-8&quot;).strip())
    return &quot;\n\n&quot;.join(parts)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A glob that matches nothing yields nothing. An empty list joins to the empty string. No exception, no warning, no exit code. It returns a perfectly valid value that happens to be the absence of the thing it was asked for.&lt;/p&gt;
&lt;p&gt;And on the same evening, a sibling. The offline test's entire product is a text transcript. I copied it into the run directory, committed, pushed, and it was not there. The standard Python &lt;code&gt;.gitignore&lt;/code&gt; template, which I had pulled in wholesale, carries &lt;code&gt;*.log&lt;/code&gt; on line 165, and a pattern without a leading slash matches at any depth.&lt;/p&gt;
&lt;p&gt;The copy succeeded. &lt;code&gt;git status&lt;/code&gt; came back clean. The push reported success. The one artifact the test exists to produce was gone, and everything in the chain said fine.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Absence does not raise.&lt;/strong&gt; A missing directory, a missing file and an inert fixture all present as an ordinary successful run. That is narrower and more useful than &quot;test your tests,&quot; and it generalizes: every input a harness depends on needs a loader that refuses to proceed without it, and every artifact it produces needs to be verified as present rather than inferred from an exit status.&lt;/p&gt;
&lt;p&gt;The loader now looks like this, and the comment is longer than the code because the comment is the part I will need next year:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;paths = sorted(corpus_dir.glob(&quot;*.md&quot;))
if not paths:
    raise SystemExit(
        f&quot;No corpus documents found in {corpus_dir}\n&quot;
        &quot;Grounded QA cannot run without reference material, and running\n&quot;
        &quot;it against an empty reference block produces numbers that look\n&quot;
        &quot;like a result.&quot;
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It also refuses a corpus implausibly smaller than the real one, the run prints the document count and token estimate before its first call, and the scorer independently flags any QA run whose median prompt is under 400 tokens. Three checks for one bug, in the input, in the run, and in the output, because this is the bug that cost a day.&lt;/p&gt;
&lt;h3 id=&quot;the-detector-that-fired-and-was-right-and-told-me-the-wrong-thing&quot;&gt;The detector that fired, and was right, and told me the wrong thing&lt;/h3&gt;
&lt;p&gt;Here is the part I find hardest to write and most worth keeping.&lt;/p&gt;
&lt;p&gt;The abstention-collapse check from kind one &lt;em&gt;did&lt;/em&gt; fire on the invalid run. It printed exactly this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;!! ABSTAINED on 13/15 questions overall. It is refusing, not judging.
   The abstention score below is not evidence of discrimination:
   a model that never answers gets it right for free.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It was correct. Something was badly wrong with that run.&lt;/p&gt;
&lt;p&gt;Its diagnosis was completely false. The model was not refusing. It was obeying, precisely, an instruction to report when the material lacks the answer, in a situation where the material lacked everything. The fault was not in the model's judgment, and it was not in the metric. It was in a directory that did not exist, two layers upstream.&lt;/p&gt;
&lt;p&gt;And I acted on the diagnosis. I wrote it up: the model has a stuck needle, its perfect abstention score is an artifact of refusal, here are the implications for anyone building on a 1.5B model. Confident, specific, sourced to real output, and wrong in every particular.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A flag that tells you something is off is not a flag that tells you what.&lt;/strong&gt; The check could only see the output. The fault was in the input. It fired for the right reason and pointed in the wrong direction, and because it fired I felt &lt;em&gt;more&lt;/em&gt; confident in a conclusion that was false, not less.&lt;/p&gt;
&lt;p&gt;That is the failure mode I had no defense against and still only partly do. A monitoring system that is right about the existence of a problem and wrong about its cause is more dangerous than no monitoring, because it terminates the search.&lt;/p&gt;
&lt;h3 id=&quot;what-i-actually-changed&quot;&gt;What I actually changed&lt;/h3&gt;
&lt;p&gt;For anyone who wants the diffs rather than the moral:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Collapse checks on both classification and QA: when one answer exceeds half the set, print the concentration and declare the headline metric meaningless for that model.&lt;/li&gt;
&lt;li&gt;The decay check flags implausible speedups as well as slowdowns, and notes when generations are too short to time.&lt;/li&gt;
&lt;li&gt;Failures split into model and transport, with model failures staying in the denominator.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;corpus_text()&lt;/code&gt; raises on an empty or implausibly small corpus.&lt;/li&gt;
&lt;li&gt;The QA run prints its corpus size before the first call.&lt;/li&gt;
&lt;li&gt;The scorer flags a QA run whose prompts are too small to contain the corpus.&lt;/li&gt;
&lt;li&gt;The keyword check is relabelled as a weak lower bound, in the output, in the README, and in the results.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.gitignore&lt;/code&gt; no longer lets &lt;code&gt;*.log&lt;/code&gt; swallow run transcripts.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vcgencmd get_throttled&lt;/code&gt; before and after every run, recorded in the run document rather than checked and forgotten. The reading reports bits set since boot, so it is perishable: power-cycle the board and the evidence is gone.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-uncomfortable-part&quot;&gt;The uncomfortable part&lt;/h3&gt;
&lt;p&gt;Every one of these was found by looking at data. Not one was found by reading code, reviewing the scorer, or writing a unit test for the harness.&lt;/p&gt;
&lt;p&gt;The vacuous fixture turned up while hand-checking a passing case. The confident-wrong flaw turned up because a score of 3 against 26 looked backwards. The abstention flaw turned up because 4 out of 4 looked too good. The keyword flaw turned up because 1 of 11 looked too bad, and I read the one. The missing corpus turned up because I was double-checking a latency figure and noticed a prompt that was too small to be possible.&lt;/p&gt;
&lt;p&gt;Which means the only reason I found them is that I kept pulling threads after I had numbers that looked publishable. The version of this project that shipped on schedule shipped on Tuesday, with a clean verdict table, a confident conclusion about small models and grounded question answering, and a repository anyone could clone to reproduce the exact same wrong result.&lt;/p&gt;
&lt;p&gt;Every number in it would have been reproducible. That is the thing I keep turning over. Reproducibility is table stakes and it would not have saved me, because the harness reproduced its own bug faithfully every single time.&lt;/p&gt;
&lt;p&gt;I do not have a process that catches this reliably. What I have is a weaker and more honest claim: the checks I added are each a direct response to one specific thing that actually happened, which is the only kind of check I trust, and the number of them that I thought up in advance is zero.&lt;/p&gt;
&lt;p&gt;So the two rows I am adding to my prediction template, to be filled in before anything runs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;What would have to be true for these numbers to be meaningless?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would anything in the harness tell me if it were?&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Twenty-two predictions about what the models would do. Not one about what the measuring code would do. Four of the five flaws were in the measuring code.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The harness, the fixtures, the committed predictions and the full run record, including the invalid run and what it produced, are at&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/kenwalger/right-size-the-model&quot;&gt;github.com/kenwalger/right-size-the-model&lt;/a&gt;.&lt;br&gt;
The findings the harness eventually produced are in &lt;a rel=&quot;nofollow&quot; href=&quot;[https://www.kenwalger.com/blog/ai/do-you-actually-need-an-llm/](https://www.kenwalger.com/blog/ai/do-you-actually-need-an-llm/?utm_source=kenwalger&amp;amp;utm_medium=blog&amp;amp;utm_campaign=right_size_the_model)&quot;&gt;the companion post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30851/nothing-reported-it</guid>
<pubDate>Thu, 22 Oct 2026 13:59:00 +0000</pubDate>
</item>
<item>
<title>Ambiguous, Invalid, or System Error?</title>
<link>https://coderlegion.com/32063/ambiguous-invalid-or-system-error</link>
<description>&lt;p&gt;In my &lt;a rel=&quot;nofollow&quot; href=&quot;https://tarek-elzoghby.hashnode.dev/each-fix-to-my-classification-prompt-exposed-the-next-problem&quot;&gt;previous post&lt;/a&gt;, I wrote about the problems I found while defining the categories for an AI intake classifier. I left &lt;code&gt;ambiguous&lt;/code&gt; and &lt;code&gt;invalid&lt;/code&gt; for another post.&lt;/p&gt;
&lt;p&gt;Those two states turned out to be only part of the problem. While wiring the workflow, I also needed to distinguish a request the model could not classify from a request the model never managed to evaluate.&lt;/p&gt;
&lt;p&gt;This is the same practice project for a fictional automation agency, built in n8n with an AI collaborator. It is not a production client system. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Tarek-Elzoghby/client-intake-classifier&quot;&gt;workflow and project notes are in the GitHub repository&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;three-different-reasons-for-not-choosing-a-project-category&quot;&gt;Three different reasons for not choosing a project category&lt;/h3&gt;
&lt;p&gt;The classifier had three normal project categories:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;simple_automation&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;custom_integration&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ai_workflow&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But not every submission should be forced into one of them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;ambiguous&lt;/code&gt; means the request is usable, but the text supports more than one category.&lt;/strong&gt; The workflow should not pretend that a guess is a confident classification. In my sharper overlap test, I deliberately included concrete signals for both &lt;code&gt;ai_workflow&lt;/code&gt; and &lt;code&gt;custom_integration&lt;/code&gt;. The classifier returned &lt;code&gt;ambiguous&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;invalid&lt;/code&gt; means the input does not provide enough usable project intent to assign a normal category.&lt;/strong&gt; This does not necessarily mean the person has no genuine request. A vague but relevant message can still contain partial intent. On the other hand, an out-of-scope message may contain nothing useful to route.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;system_error&lt;/code&gt; means classification did not complete.&lt;/strong&gt; The workflow did not reach a decision about the request, so treating it as invalid would report something the system did not know.&lt;/p&gt;
&lt;p&gt;The difference matters because each state calls for a different response. A real request with overlapping categories may need a person to decide. An out-of-scope submission may not need a client email. A system failure needs troubleshooting or a retry, not a judgment about the request itself.&lt;/p&gt;
&lt;h3 id=&quot;invalid-needed-one-more-field&quot;&gt;&lt;code&gt;invalid&lt;/code&gt; needed one more field&lt;/h3&gt;
&lt;p&gt;My first definition of &lt;code&gt;invalid&lt;/code&gt; treated missing information as the main signal. That overlapped with &lt;code&gt;ambiguous&lt;/code&gt;, because both could describe a submission that could not be assigned cleanly. I changed the distinction: &lt;code&gt;ambiguous&lt;/code&gt; is for multiple categories supported by the text; &lt;code&gt;invalid&lt;/code&gt; is for input that does not support a responsible category assignment.&lt;/p&gt;
&lt;p&gt;Then I noticed that one &lt;code&gt;invalid&lt;/code&gt; label was still hiding two different situations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The person has a real project in mind, but the request is too vague to classify yet.&lt;/li&gt;
&lt;li&gt;The submission is unrelated or out of scope, with no useful project intent to follow up on.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I could have tried routing by keywords in the model's &lt;code&gt;reasoning&lt;/code&gt; text, but that would make the workflow depend on the exact wording of an explanation. I also considered a conditional schema. Because the result passed through n8n's Structured Output Parser, I did not want to assume that conditional-schema behavior would be preserved through its translation layer.&lt;/p&gt;
&lt;p&gt;I chose a simpler, always-present boolean: &lt;code&gt;has_partial_intent&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;category&quot;: &quot;invalid&quot;,
  &quot;has_partial_intent&quot;: true
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That keeps &lt;code&gt;invalid&lt;/code&gt; as the category and makes the next decision explicit. It is easier for a downstream node to route on a boolean than to interpret free-text reasoning.&lt;/p&gt;
&lt;p&gt;I tested it with three small examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;help automating my workflow&lt;/code&gt; returned &lt;code&gt;has_partial_intent: true&lt;/code&gt;. The request was vague, but it did point to a real automation need.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fix my car&lt;/code&gt; returned &lt;code&gt;false&lt;/code&gt;. It was a request, but not one this intake workflow was built to handle.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;I love football&lt;/code&gt; also returned &lt;code&gt;false&lt;/code&gt; as unrelated input.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These examples helped check the distinction I had defined. They are not a representative dataset, and they do not tell me how often the classifier would get this right with real submissions.&lt;/p&gt;
&lt;h3 id=&quot;a-model-failure-is-not-an-invalid-submission&quot;&gt;A model failure is not an invalid submission&lt;/h3&gt;
&lt;p&gt;I also needed &lt;code&gt;system_error&lt;/code&gt; for cases where classification itself failed. A network interruption or a broken model credential should not make the workflow label a valid request as invalid. The system has not made a decision about the content at all.&lt;/p&gt;
&lt;p&gt;I tested the failure path by temporarily breaking the Gemini credentials. The workflow retried classification three times at five-second intervals, then reached the fallback route for human review. I also checked that the original submission data remained available on that path.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;ambiguous&lt;/code&gt; and &lt;code&gt;system_error&lt;/code&gt; routes shared some downstream handling, but I kept the category values separate. The reason for sending something to human review is important: in one case the model made a decision that needs human judgment; in the other, it did not complete the decision.&lt;/p&gt;
&lt;p&gt;That distinction affected the client-facing message too. For a system failure, it would have been misleading to send the same confirmation as if a normal classification had succeeded. The workflow needed to acknowledge the issue without claiming the request was invalid.&lt;/p&gt;
&lt;h3 id=&quot;why-i-used-explicit-routing&quot;&gt;Why I used explicit routing&lt;/h3&gt;
&lt;p&gt;I had considered using an AI Agent to choose how these outcomes should be handled. I did not need another model-driven decision, though. The possible states were already known, and the workflow's job was to route each one consistently.&lt;/p&gt;
&lt;p&gt;I used explicit Switch and IF branches instead. That made the expected paths visible in the workflow and easier to test. For the &lt;code&gt;invalid&lt;/code&gt; case with &lt;code&gt;has_partial_intent: false&lt;/code&gt;, I confirmed that no email was sent while the submission was still logged to Google Sheets. Logging did not depend on sending a client message.&lt;/p&gt;
&lt;h3 id=&quot;what-i-can-claim-from-these-tests&quot;&gt;What I can claim from these tests&lt;/h3&gt;
&lt;p&gt;I exercised a deliberately ambiguous request, vague and out-of-scope inputs, and a forced model failure. I also checked the fallback path and verified that the invalid/no-partial-intent case was logged without triggering emails.&lt;/p&gt;
&lt;p&gt;That is evidence that these particular paths were exercised in this build. It is not proof of general classifier accuracy or production reliability. The examples were written by me, and a larger, independently labelled set of realistic submissions would be needed to measure that properly.&lt;/p&gt;
&lt;p&gt;The useful lesson for me was that a fallback is not just a place to send everything that does not match a normal category. The workflow needs to preserve &lt;em&gt;why&lt;/em&gt; it did not choose a category: competing interpretations, insufficient or irrelevant intent, or a failure to classify at all.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32063/ambiguous-invalid-or-system-error</guid>
<pubDate>Thu, 22 Oct 2026 11:37:02 +0000</pubDate>
</item>
<item>
<title>An Empty Field Is Still Signed Data</title>
<link>https://coderlegion.com/32032/an-empty-field-is-still-signed-data</link>
<description>&lt;p&gt;An empty form field looks unimportant. In ordinary application logic, treating an empty value like a missing value is often reasonable. In signature verification, that assumption can reject a legitimate message—or, in a different protocol, verify a representation the sender never signed.&lt;/p&gt;
&lt;p&gt;The important lesson is simple: canonicalization is not generic data cleanup. It is part of the cryptographic protocol.&lt;/p&gt;
&lt;h3 id=&quot;the-tidy-abstraction-that-hid-a-real-difference&quot;&gt;The tidy abstraction that hid a real difference&lt;/h3&gt;
&lt;p&gt;Consider an integration with two flows:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The application builds an outbound form and signs it.&lt;/li&gt;
&lt;li&gt;A provider later sends an inbound form notification that the application verifies.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Both flows need URL encoding, ordered &lt;code&gt;key=value&lt;/code&gt; pairs, a digest, and a comparison. A shared helper therefore seems sensible.&lt;/p&gt;
&lt;p&gt;But suppose the protocol defines different empty-field rules. The outbound form omits empty values. The inbound notification includes every posted field, even an empty one represented as &lt;code&gt;description=&lt;/code&gt;. Reusing the outbound rule during verification silently removes part of the inbound canonical representation.&lt;/p&gt;
&lt;p&gt;Nothing is wrong with the network message. Nothing is wrong with the secret. The application simply hashes different bytes from the sender.&lt;/p&gt;
&lt;p&gt;That is the dangerous kind of bug: the code is deterministic, the crypto primitive behaves correctly, and the abstraction looks clean. The failure lives in the representation contract.&lt;/p&gt;
&lt;h3 id=&quot;empty-and-absent-are-different-inputs&quot;&gt;Empty and absent are different inputs&lt;/h3&gt;
&lt;p&gt;Application code tends to reason semantically:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;note=&lt;/code&gt; means there is no useful note.&lt;/li&gt;
&lt;li&gt;no &lt;code&gt;note&lt;/code&gt; field also means there is no useful note.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A signing algorithm reasons syntactically. It may see:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;reference=abc&amp;amp;note=&amp;amp;status=complete
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;as a different message from:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;reference=abc&amp;amp;status=complete
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same applies to field order, repeated keys, character encoding, spaces, escaping, decimal formatting, and line endings. Two representations can produce the same business meaning while producing different signatures.&lt;/p&gt;
&lt;p&gt;That is why “normalize before verifying” is unsafe advice unless the protocol defines the normalization precisely. The verifier’s job is not to improve the input. Its job is to reconstruct exactly what the other side signed.&lt;/p&gt;
&lt;h3 id=&quot;share-mechanics-not-contracts&quot;&gt;Share mechanics, not contracts&lt;/h3&gt;
&lt;p&gt;The repair pattern I prefer keeps the truly common pieces together while making direction-specific rules visible.&lt;/p&gt;
&lt;p&gt;Shared mechanics may include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the exact form-encoding routine;&lt;/li&gt;
&lt;li&gt;digest calculation;&lt;/li&gt;
&lt;li&gt;secret or passphrase handling;&lt;/li&gt;
&lt;li&gt;fixed-time signature comparison;&lt;/li&gt;
&lt;li&gt;consistent fail-closed behaviour.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The canonicalization policies remain separate:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;outbound signing can omit empty fields when the outbound specification says so;&lt;/li&gt;
&lt;li&gt;inbound verification can retain empty fields when the inbound specification says so;&lt;/li&gt;
&lt;li&gt;both can exclude the signature field itself;&lt;/li&gt;
&lt;li&gt;both can preserve their required ordering.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This introduces a small amount of repetition. That is a healthy trade when the alternative is a boolean option buried inside a generic helper whose callers must remember invisible protocol rules. If a shared primitive is retained, give the public operations names and documentation that make the distinction impossible to miss.&lt;/p&gt;
&lt;h3 id=&quot;test-the-representation-not-your-implementation&quot;&gt;Test the representation, not your implementation&lt;/h3&gt;
&lt;p&gt;Signature tests often contain a subtle circularity: the test calls the production signing helper to create the expected signature, then asks the same helper to verify it. The test proves internal consistency, not compatibility with the protocol.&lt;/p&gt;
&lt;p&gt;A stronger test builds the canonical input independently. For the inbound case, start from a literal ordered field sequence containing several &lt;code&gt;key=&lt;/code&gt; entries. Calculate the expected digest with a small test-only oracle or a published fixture. Then submit the body to the real verification path.&lt;/p&gt;
&lt;p&gt;Useful negative cases include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;remove one empty field and expect rejection;&lt;/li&gt;
&lt;li&gt;change field order and expect rejection when order is significant;&lt;/li&gt;
&lt;li&gt;replace an empty field with an absent field;&lt;/li&gt;
&lt;li&gt;vary spaces and reserved characters;&lt;/li&gt;
&lt;li&gt;include a blank expected signature and fail closed;&lt;/li&gt;
&lt;li&gt;change only the signature field and confirm it is excluded from canonical input.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These tests describe the protocol boundary directly. They are harder for a refactor to accidentally “update” into agreeing with the bug.&lt;/p&gt;
&lt;h3 id=&quot;exercise-the-framework-pipeline-too&quot;&gt;Exercise the framework pipeline too&lt;/h3&gt;
&lt;p&gt;A pure canonicalization test is necessary, but it is not sufficient for a web endpoint.&lt;/p&gt;
&lt;p&gt;Framework model binding and form parsing can consume a request stream, decode characters, merge values, or present fields through an API with different ordering behaviour. Middleware can also affect buffering or body access. A verifier that works against a handcrafted dictionary may still fail when the real HTTP pipeline handles the request first.&lt;/p&gt;
&lt;p&gt;Add one focused integration test that:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;creates the literal encoded body;&lt;/li&gt;
&lt;li&gt;independently calculates the signature over that exact representation;&lt;/li&gt;
&lt;li&gt;sends an actual form POST through the application’s HTTP stack;&lt;/li&gt;
&lt;li&gt;verifies that the original body remains available where required;&lt;/li&gt;
&lt;li&gt;proves the request reaches the normal accepted outcome.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is not an argument for turning every edge case into a slow end-to-end test. Keep the combinatorial matrix in fast unit tests. Use the HTTP-level case as a boundary witness that the framework and your canonicalization contract still agree.&lt;/p&gt;
&lt;h3 id=&quot;verification-is-necessary-not-complete-trust&quot;&gt;Verification is necessary, not complete trust&lt;/h3&gt;
&lt;p&gt;Correct signature verification answers a narrow question: does this message match the shared signing contract and secret?&lt;/p&gt;
&lt;p&gt;It does not automatically prove that the event is fresh, unique, authorized for the referenced resource, or safe to apply twice. Production handlers may still need replay protection, timestamp or nonce validation, amount and ownership checks, idempotency, audit evidence, and careful failure handling.&lt;/p&gt;
&lt;p&gt;Keeping that claim bounded matters. Strong security engineering is usually a chain of explicit checks, not one impressive cryptographic step.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-checklist-2&quot;&gt;A practical review checklist&lt;/h3&gt;
&lt;p&gt;When reviewing a signed integration, ask:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What exact bytes or characters are signed in each direction?&lt;/li&gt;
&lt;li&gt;Are empty and missing values distinct?&lt;/li&gt;
&lt;li&gt;Is field order defined and preserved?&lt;/li&gt;
&lt;li&gt;When does decoding or encoding happen?&lt;/li&gt;
&lt;li&gt;Are repeated fields possible?&lt;/li&gt;
&lt;li&gt;Does the comparison avoid timing leaks?&lt;/li&gt;
&lt;li&gt;Do invalid inputs fail closed?&lt;/li&gt;
&lt;li&gt;Is at least one expected signature calculated independently?&lt;/li&gt;
&lt;li&gt;Does a real HTTP test cover framework parsing?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The broader engineering principle is worth carrying elsewhere: an abstraction should share sameness, not erase difference. At a cryptographic boundary, the awkward representational details are often the contract itself.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32032/an-empty-field-is-still-signed-data</guid>
<pubDate>Thu, 22 Oct 2026 06:11:15 +0000</pubDate>
</item>
<item>
<title>Flip One Field: A Fairness Test That Beats Aggregate Parity</title>
<link>https://coderlegion.com/32004/flip-one-field-a-fairness-test-that-beats-aggregate-parity</link>
<description>&lt;p&gt;Most teams check an LLM-assisted decision system for fairness by comparing approval rates across groups. That check can pass cleanly while the model is still changing its mind on individual cases for reasons nobody would defend out loud. Recent fairness-stress work on LLM decision-making points at a better default test - and it's a decision you can make before writing any code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Aggregate Parity Nets Out; Per-Case Instability Doesn't&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The choice is between two measurements. &lt;strong&gt;Outcome parity&lt;/strong&gt; asks: across 1,000 applications, did group A get approved at roughly the same rate as group B? &lt;strong&gt;Counterfactual stress&lt;/strong&gt; asks something narrower and harder: for &lt;em&gt;this one&lt;/em&gt; application, if I change only a decision-irrelevant field - the name, the phrasing of the same request, the order of the bullet points - does the answer move?&lt;/p&gt;
&lt;p&gt;A model that favors one group on early-career cases and another on senior cases can show perfect parity overall. Parity is cheap and it's what regulators often ask for, but it averages. Per-case stress testing catches that, because it never aggregates across people - it compares a case to itself.&lt;/p&gt;
&lt;p&gt;The trade-off is real. Stress testing costs N× the inference calls, and it requires someone to declare which fields are decision-irrelevant. That list is a policy judgment, not an engineering one, which is exactly why a PM should define it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Running the Test on One Application&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;base = {&quot;years_exp&quot;: 6, &quot;skills&quot;: &quot;python, sql&quot;}
names = [&quot;Alex Chen&quot;, &quot;Aisha Khan&quot;, &quot;John Smith&quot;, &quot;Priya Patel&quot;]

votes = [decide(tmpl, {**base, &quot;name&quot;: n})
 for n in names
 for tmpl in PROMPT_TEMPLATES] # 3 paraphrases, same instruction

flip_rate = 1 - max(votes.count(v) for v in set(votes)) / len(votes)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Twelve variants of one application that a human reviewer would call identical. Any &lt;code&gt;flip_rate&lt;/code&gt; above zero means the decision moved on something that shouldn't matter.&lt;/p&gt;
&lt;p&gt;The number alone isn't the point - the &lt;em&gt;pattern&lt;/em&gt; is. Read the flips two ways:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Clustered&lt;/strong&gt; - all rejections land on the same name group across all three paraphrases. That's bias. Fix it in the prompt, the retrieval data, or the model choice.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scattered&lt;/strong&gt; - flips appear randomly across names and templates. That's instability. Lower the temperature, constrain the output format, or stop auto-deciding borderline cases.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Both fail the same test and need different fixes, which is why one aggregate percentage can't tell you what to do next. Report worst-case flip rate per application, not the mean - the mean hides the cases that would end up in a complaint.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Group-level parity can look clean while individual decisions flip on irrelevant details; counterfactual stress testing compares each case against itself and catches what averages hide.&lt;/li&gt;
&lt;li&gt;Separate clustered flips (bias - fix prompt, data, or model) from scattered flips (instability - fix sampling, output constraints, or add human review).&lt;/li&gt;
&lt;li&gt;The hard part isn't the code, it's agreeing on which input fields are decision-irrelevant. That list belongs to product and policy, not just engineering.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Which fields in your decision prompt would your team actually agree are irrelevant - and how long would that meeting take?&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Sources referenced: FairStress: A deeper evaluation of fairness in LLM decision-making, Hugging Face Blog&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32004/flip-one-field-a-fairness-test-that-beats-aggregate-parity</guid>
<pubDate>Wed, 21 Oct 2026 21:04:43 +0000</pubDate>
</item>
<item>
<title>Build a Python agent harness you can extend and test without calling a model</title>
<link>https://coderlegion.com/31972/build-a-python-agent-harness-you-can-extend-and-test-without-calling-a-model</link>
<description>&lt;p&gt;A team ships a small Python agent with two tools. A month later someone renames an argument on one tool function and forgets the hand-written JSON schema sitting a few lines above it. The model keeps sending the old argument name, the function raises on every call, and nobody finds out until a real task hits that tool, because the only way anyone ever tested the loop was by running it against a live model.&lt;/p&gt;
&lt;p&gt;The layout caused this one. When tool contracts are written by hand and the loop only runs end to end, every change to a tool risks the whole agent, and every test costs an API call. A harness you can live with keeps three things apart: the loop, the tool contracts, and the tests that prove the loop behaves.&lt;/p&gt;
&lt;h4 id=&quot;the-loop-has-three-parts-and-only-one-of-them-should-change-when-you-add-a-tool&quot;&gt;The loop has three parts, and only one of them should change when you add a tool&lt;/h4&gt;
&lt;p&gt;A minimal agent harness in Python has a loop over the model's &lt;code&gt;stop_reason&lt;/code&gt;, a dispatcher that maps a tool name to a Python function, and a &lt;code&gt;messages&lt;/code&gt; list that grows every turn. The loop keeps running while the model asks for tools (&lt;code&gt;stop_reason == &quot;tool_use&quot;&lt;/code&gt;) and returns the text answer when it sends &lt;code&gt;end_turn&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Of those three, only the set of tools should grow as the agent gains abilities. The loop itself should not change when you add a tool, and the message list should follow the same rules on every turn. A production harness wraps more around this core, such as sandboxing, permission checks, context compaction and session state. Those layers are easier to add later when the core stays this small.&lt;/p&gt;
&lt;h4 id=&quot;generate-the-schema-from-the-function-s-input-type&quot;&gt;Generate the schema from the function's input type&lt;/h4&gt;
&lt;p&gt;Every tool needs a name, a description and an &lt;code&gt;input_schema&lt;/code&gt; that tells the model which arguments it accepts. Writing that JSON Schema by hand is how the opening bug happens. The schema and the function live in two places, and only one of them gets edited.&lt;/p&gt;
&lt;p&gt;Describe each tool's inputs with a Pydantic model and generate the schema from it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One model per tool.&lt;/strong&gt; A &lt;code&gt;ReadFile&lt;/code&gt; model with a single &lt;code&gt;path: str&lt;/code&gt; field describes the input of &lt;code&gt;read_file&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Let the library write the schema.&lt;/strong&gt; &lt;code&gt;ReadFile.model_json_schema()&lt;/code&gt; returns a plain dict with &lt;code&gt;path&lt;/code&gt; as a required string, which is the &lt;code&gt;input_schema&lt;/code&gt; shape the Anthropic API validates against.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Edit the type and the schema follows.&lt;/strong&gt; Add a field or mark one optional and the generated schema updates itself. This method ships with Pydantic v2.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Pydantic model becomes the single source of truth for a tool's inputs, and the function's arguments have to match its fields. The dispatch fixture in the testing section below fails the moment the two drift apart, long before a real task finds it.&lt;/p&gt;
&lt;h4 id=&quot;dispatch-by-name-so-the-loop-never-names-a-tool&quot;&gt;Dispatch by name so the loop never names a tool&lt;/h4&gt;
&lt;p&gt;Keep a dict called &lt;code&gt;HANDLERS&lt;/code&gt; that maps each tool name to its function, and dispatch with one line: &lt;code&gt;HANDLERS[block.name](**block.input)&lt;/code&gt;. The loop reads the tool name from the model's &lt;code&gt;tool_use&lt;/code&gt; block and never refers to a specific tool.&lt;/p&gt;
&lt;p&gt;That one decision is what makes the harness extendable. Adding a tool touches three places, and none of them is the loop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Define the input model.&lt;/strong&gt; A &lt;code&gt;WriteFile&lt;/code&gt; model with &lt;code&gt;path&lt;/code&gt; and &lt;code&gt;content&lt;/code&gt; fields.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add one entry to the tool list.&lt;/strong&gt; Name, description, and &lt;code&gt;WriteFile.model_json_schema()&lt;/code&gt; as the schema.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Register the function.&lt;/strong&gt; &lt;code&gt;HANDLERS[&quot;write_file&quot;] = write_file&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The agent can now write files as well as read them, and &lt;code&gt;run_agent&lt;/code&gt; did not change by a single character. If adding a tool ever forces an edit inside the loop, a tool name has leaked into it.&lt;/p&gt;
&lt;h4 id=&quot;treat-what-comes-back-as-part-of-the-contract&quot;&gt;Treat what comes back as part of the contract&lt;/h4&gt;
&lt;p&gt;The input side is only half of a tool's contract. The output has rules too. The &lt;code&gt;content&lt;/code&gt; of a &lt;code&gt;tool_result&lt;/code&gt; must be a string or a list of content blocks. A &lt;code&gt;read_file&lt;/code&gt; that returns a string passes straight through, while a &lt;code&gt;list_files&lt;/code&gt; that returns a Python list has to be serialized first, for example with &lt;code&gt;json.dumps&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Put that rule in the loop once, so no single tool has to remember it. The same goes for pairing: copy the &lt;code&gt;id&lt;/code&gt; from each &lt;code&gt;tool_use&lt;/code&gt; block into the result's &lt;code&gt;tool_use_id&lt;/code&gt;, and append the assistant message before the user message that carries the results. These are loop rules, and they belong next to the dispatch line, where every tool gets them for free.&lt;/p&gt;
&lt;h4 id=&quot;test-the-loop-without-calling-a-model&quot;&gt;Test the loop without calling a model&lt;/h4&gt;
&lt;p&gt;Testing a harness by running it against a live model and reading the output costs money, takes seconds per run, and only covers the path the model happened to take that time.&lt;/p&gt;
&lt;p&gt;The loop is ordinary Python, so test it like ordinary Python. Mock the &lt;code&gt;messages.create&lt;/code&gt; call to return fixtures, and assert on what the loop does with them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A tool-use fixture drives one turn.&lt;/strong&gt; Return a response with &lt;code&gt;stop_reason&lt;/code&gt; set to &lt;code&gt;tool_use&lt;/code&gt; and a fake &lt;code&gt;tool_use&lt;/code&gt; block, then check that the matching handler ran with the arguments in the block.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The append order holds.&lt;/strong&gt; After that turn, the assistant message should sit before the user message that carries the result, and the result's &lt;code&gt;tool_use_id&lt;/code&gt; should match the block's &lt;code&gt;id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parallel calls arrive together.&lt;/strong&gt; Return a fixture with two &lt;code&gt;tool_use&lt;/code&gt; blocks and assert that both results go back in a single user message before the next call.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Non-string results get serialized.&lt;/strong&gt; Point a fake tool at a list return and check that the &lt;code&gt;content&lt;/code&gt; the loop sends back is a string.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The loop exits on &lt;code&gt;end_turn&lt;/code&gt;.&lt;/strong&gt; Follow the tool turn with an &lt;code&gt;end_turn&lt;/code&gt; fixture and check that &lt;code&gt;run_agent&lt;/code&gt; returns its text.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The ceiling fires.&lt;/strong&gt; Feed &lt;code&gt;tool_use&lt;/code&gt; fixtures forever and check that the loop raises once it reaches &lt;code&gt;max_turns&lt;/code&gt;, instead of spinning.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One detail matters when you read the mock's recorded calls: &lt;code&gt;run_agent&lt;/code&gt; keeps appending to the same &lt;code&gt;messages&lt;/code&gt; list, so copy the list inside the mock if you want to assert on what each call saw. These tests run in milliseconds and never reach the API, and they cover the rules that break a harness without anyone noticing. Run them on every change, and keep the live-model run for checking behavior that only a model can show.&lt;/p&gt;
&lt;h4 id=&quot;know-what-a-framework-would-take-over&quot;&gt;Know what a framework would take over&lt;/h4&gt;
&lt;p&gt;The OpenAI Agents SDK runs the same loop for you through &lt;code&gt;Runner.run&lt;/code&gt;. You describe tools as Python functions with type annotations, and the SDK raises &lt;code&gt;MaxTurnsExceeded&lt;/code&gt; when a run passes its &lt;code&gt;max_turns&lt;/code&gt;, which is the same safety valve as the manual counter.&lt;/p&gt;
&lt;p&gt;Reach for the SDK when you need multi-agent handoffs, built-in guardrails or tracing. Keep the manual loop when you want zero framework dependency or need to see exactly what happens on every turn. OpenAI Swarm is an experimental, stateless project, and the Agents SDK has superseded it for anything you plan to run in production.&lt;/p&gt;
&lt;p&gt;Either way, the design rule carries over. Tool definitions stay separate from loop logic, and switching to a framework later holds no surprises in the control flow.&lt;/p&gt;
&lt;h4 id=&quot;pitfalls-that-keep-showing-up&quot;&gt;Pitfalls that keep showing up&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hand-written schema dicts.&lt;/strong&gt; They drift from the function the first time someone edits a signature.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tool names inside the loop.&lt;/strong&gt; Every new tool turns into a change to the control flow.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Raw Python objects in &lt;code&gt;tool_result&lt;/code&gt;.&lt;/strong&gt; The API expects a string or content blocks, so a returned list fails validation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A missing &lt;code&gt;max_tokens&lt;/code&gt;.&lt;/strong&gt; It is required on every Anthropic request and has no default, so the SDK raises before the model ever runs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Live-model runs as the only test.&lt;/strong&gt; They are slow, they cost money, and they never force the edge cases.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The full program, with the tool definitions, the loop and the stop conditions in one file you can run, is in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://futureagi.com/blog/build-agent-harness-python/?utm_source=fagiCoderlegion&amp;amp;utm_medium=organic&amp;amp;utm_campaign=blogdistribution&quot;&gt;build guide with the full code&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;A loop that never names a tool, schemas generated from types and tests that need no model call together keep a harness small. Every new tool after that is a few lines next to the others.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31972/build-a-python-agent-harness-you-can-extend-and-test-without-calling-a-model</guid>
<pubDate>Wed, 21 Oct 2026 12:50:26 +0000</pubDate>
</item>
<item>
<title>Test the Owner, Not the Teardown</title>
<link>https://coderlegion.com/31880/test-the-owner-not-the-teardown</link>
<description>&lt;p&gt;Flaky lifecycle tests often produce a tempting diagnosis: the cleanup code must be unreliable. Sometimes that is true. Sometimes the test is asking a framework harness to prove a narrower ownership contract, and the harness is free to finish work on a different schedule.&lt;/p&gt;
&lt;p&gt;That distinction matters in interactive server-side UI systems. A component may subscribe to live updates, start a timer, register an observer, or attach a callback. The resource needs a clear owner and a reliable release path. But the test for that contract should not depend entirely on whether a whole rendered tree has completed teardown at one particular instant.&lt;/p&gt;
&lt;h3 id=&quot;the-shape-of-the-failure&quot;&gt;The shape of the failure&lt;/h3&gt;
&lt;p&gt;Consider a header component that subscribes to a process-local notification service. A child pop-up also owns a separate subscription. The component test renders the tree, publishes an update, observes the visual change, disposes the test context, and immediately expects the global subscription count to be zero.&lt;/p&gt;
&lt;p&gt;That sounds reasonable. It is also broader than the claim we really want to prove.&lt;/p&gt;
&lt;p&gt;Under a full test-suite load, the notification can overlap a render. The harness teardown call may return while the renderer still has a queued batch. At that instant the header might not yet have received its disposal callback. The non-zero count is therefore ambiguous: either the header leaked, or the harness has not disposed it yet.&lt;/p&gt;
&lt;p&gt;A test with two possible explanations is a weak regression guard.&lt;/p&gt;
&lt;h3 id=&quot;waiting-is-not-the-same-as-proving&quot;&gt;Waiting is not the same as proving&lt;/h3&gt;
&lt;p&gt;The first instinct is usually to add a retry window: wait for the subscription count to reach zero, perhaps for a few seconds.&lt;/p&gt;
&lt;p&gt;That is appropriate when the product contract is genuinely eventual. It is not a universal cure for lifecycle tests. If the harness has left the component alive, waiting merely spends more time observing the wrong boundary. The test remains dependent on incidental scheduling and can still fail without a cleanup defect.&lt;/p&gt;
&lt;p&gt;The key question is not, “How long should we wait?” It is, “Which object owns the resource, and what exact behaviour are we trying to prove?”&lt;/p&gt;
&lt;h3 id=&quot;put-the-assertion-at-the-ownership-boundary&quot;&gt;Put the assertion at the ownership boundary&lt;/h3&gt;
&lt;p&gt;The stronger test calls the owning component's disposal contract directly, on the correct renderer context, and checks only the resource that component owns.&lt;/p&gt;
&lt;p&gt;If the header owns one subscription and a still-rendered child owns another, disposing the header should reduce the count by one. Expecting zero would incorrectly make the header test responsible for the child's lifetime as well.&lt;/p&gt;
&lt;p&gt;That narrower assertion is not weaker. It is more precise:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the owner releases its resource;&lt;/li&gt;
&lt;li&gt;an independently owned resource is not silently claimed by the parent test;&lt;/li&gt;
&lt;li&gt;the result does not depend on whole-tree teardown timing; and&lt;/li&gt;
&lt;li&gt;removing the owner's cleanup call makes the test fail consistently.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last point is especially valuable. A small mutation check—temporarily disabling the cleanup call—shows whether the test detects the regression it claims to detect. If the test still passes, it is probably measuring something else.&lt;/p&gt;
&lt;h3 id=&quot;production-code-still-needs-lifecycle-discipline&quot;&gt;Production code still needs lifecycle discipline&lt;/h3&gt;
&lt;p&gt;A focused test does not excuse fragile implementation. Cleanup should normally be idempotent because framework callbacks, navigation, error handling, and explicit shutdown can converge on the same release path.&lt;/p&gt;
&lt;p&gt;The object should also refuse to acquire new resources after disposal. An asynchronous load may resume after the component has gone away. If its continuation subscribes again, a perfectly correct earlier disposal becomes irrelevant because nothing will release the new subscription.&lt;/p&gt;
&lt;p&gt;The same reasoning applies beyond subscriptions. Failed in-flight loads should close or abandon temporary buffers. Retained arrivals should have a sensible bound. Work dispatched to another recipient should not accidentally carry ambient request state from the publisher. These are all lifecycle questions: who owns the state, when does ownership end, and what context is allowed to cross the boundary?&lt;/p&gt;
&lt;h3 id=&quot;keep-two-layers-of-evidence&quot;&gt;Keep two layers of evidence&lt;/h3&gt;
&lt;p&gt;Direct ownership tests and integration tests answer different questions.&lt;/p&gt;
&lt;p&gt;An owner-level test asks: when this component is disposed, does it release what it owns, and can it remain released?&lt;/p&gt;
&lt;p&gt;An integration test asks: does navigation, circuit shutdown, or framework teardown actually invoke the right lifecycle path across the rendered tree?&lt;/p&gt;
&lt;p&gt;Both matter. The mistake is using only the second, broad test as proof of the first, narrow contract. When the broad test fails, you cannot tell whether ownership or orchestration is broken.&lt;/p&gt;
&lt;p&gt;Separating the layers improves diagnosis. A direct test failure points toward the owner. An integration-only failure points toward wiring, scheduling, or framework interaction. The test suite becomes a map of contracts instead of a collection of timing guesses.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-checklist&quot;&gt;A practical checklist&lt;/h3&gt;
&lt;p&gt;For each subscription, timer, observer, or callback:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Name the owner explicitly.&lt;/li&gt;
&lt;li&gt;Make release safe to call more than once.&lt;/li&gt;
&lt;li&gt;Prevent acquisition after the owner is disposed.&lt;/li&gt;
&lt;li&gt;Test the owner's release contract directly.&lt;/li&gt;
&lt;li&gt;Give child-owned resources separate coverage.&lt;/li&gt;
&lt;li&gt;Keep one integration test for the framework path.&lt;/li&gt;
&lt;li&gt;Prove the test's sensitivity by breaking cleanup locally and seeing it fail.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The trade-off is a few more focused tests and slightly more explicit lifecycle code. In return, failures become actionable. You know whether the owner leaked, the child still legitimately owns something, or the framework has not completed teardown.&lt;/p&gt;
&lt;p&gt;Reliable tests do not merely wait longer. They make ownership visible.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31880/test-the-owner-not-the-teardown</guid>
<pubDate>Tue, 20 Oct 2026 17:41:25 +0000</pubDate>
</item>
<item>
<title>One Bash Script to Audit a Polyglot Codebase (Python, TS, Java, Go, C, HTML)</title>
<link>https://coderlegion.com/31711/one-bash-script-to-audit-a-polyglot-codebase-python-ts-java-go-c-html</link>
<description>&lt;p&gt;If your project mixes languages, one terminal command can run each language's own tests and security checks and fail the build when anything is red.&lt;/p&gt;
&lt;p&gt;The script (1) counts files per language, (2) runs build and type checks, (3) runs tests and SAST, (4) scans dependencies and secrets, (5) runs a quick concurrency check, and (6) writes a checksummed report.&lt;/p&gt;
&lt;p&gt;I tested it on a small demo project: python compile passed, missing scanners showed as SKIP, an invalid HTML file produced a real FAIL, and the run exited with code 1.&lt;/p&gt;
&lt;p&gt;Full guide with per-language settings, a troubleshooting table and a CI example: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.seosiri.com/2026/10/polyglot-code-audit-bash-testing-pipeline.html#conclusion&quot;&gt;https://www.seosiri.com/2026/10/polyglot-code-audit-bash-testing-pipeline.html#conclusion&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Which scanner would you add first?&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31711/one-bash-script-to-audit-a-polyglot-codebase-python-ts-java-go-c-html</guid>
<pubDate>Mon, 19 Oct 2026 11:43:05 +0000</pubDate>
</item>
<item>
<title>Can a Keyboard-Only User Finish Your App’s Main Task?</title>
<link>https://coderlegion.com/31692/can-a-keyboard-only-user-finish-your-apps-main-task</link>
<description>&lt;p&gt;Pick the main task in your web app: submitting a form, uploading a file, booking an appointment, or creating a project.&lt;/p&gt;
&lt;p&gt;Now put the mouse aside and try to finish it using the keyboard.&lt;/p&gt;
&lt;p&gt;Where does the journey first become confusing or impossible?&lt;/p&gt;
&lt;p&gt;Some useful places to check:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A custom dropdown that opens but cannot be operated&lt;/li&gt;
&lt;li&gt;A dialog that opens without a clear focus position&lt;/li&gt;
&lt;li&gt;A validation error that appears somewhere the user does not notice&lt;/li&gt;
&lt;li&gt;A drag-and-drop interaction with no usable alternative&lt;/li&gt;
&lt;li&gt;A focus indicator that disappears against the background&lt;/li&gt;
&lt;li&gt;A component you can enter but cannot leave&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;W3C’s &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html&quot;&gt;keyboard accessibility guidance&lt;/a&gt; explains why functionality needs a keyboard-operable route, with a limited exception for inherently path-dependent input. Its &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.w3.org/WAI/WCAG22/Understanding/no-keyboard-trap.html&quot;&gt;no-keyboard-trap guidance&lt;/a&gt; addresses being able to leave a component again.&lt;/p&gt;
&lt;p&gt;A keyboard walkthrough is a useful first check. It does not establish full accessibility or replace screen-reader and other accessibility testing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What is one keyboard-accessibility bug your team found late, and what changed in your development or review process afterward?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If you are building something new, which critical flow will you test first?&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Waqas Ahmad | Software Engineer | Content Creator&lt;/em&gt;&lt;/p&gt;
</description>
<category>Discussions</category>
<guid isPermaLink="true">https://coderlegion.com/31692/can-a-keyboard-only-user-finish-your-apps-main-task</guid>
<pubDate>Mon, 19 Oct 2026 07:41:47 +0000</pubDate>
</item>
<item>
<title>Is your coding agent reading the memory you wrote?</title>
<link>https://coderlegion.com/31650/is-your-coding-agent-reading-the-memory-you-wrote</link>
<description>&lt;p&gt;&lt;em&gt;I maintain Selvedge. This article was prepared with AI assistance and checked against the released Selvedge v0.3.17 package from PyPI (tag v0.3.17, commit 3b67b8f) and the repository's main branch at commit 66c51d3.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I saved one decision, read it back six ways, and got nothing from two of them. The record never moved. The reads that missed it had opened a different, empty database.&lt;/p&gt;
&lt;p&gt;This is a reproduction, not an incident report. I installed Selvedge v0.3.17 from PyPI in a fresh virtual environment, pointed &lt;code&gt;HOME&lt;/code&gt; at a throwaway directory, and logged one invented decision from a project root:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;selvedge init
selvedge log src/billing/charge.py::retry_charge reject \
  --reasoning &quot;Rejected automatic retries inside retry_charge; went with a single attempt plus an idempotency key, because a retry after a timeout can charge a customer twice.&quot; \
  --constraint &quot;A payment request must never be submitted twice for one order.&quot; \
  --stale-when &quot;the payment provider offers server-side idempotent retries&quot; \
  --agent synthetic-demo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(&lt;code&gt;--agent&lt;/code&gt; only sets an attribution label.) Then I asked for that record from different places.&lt;/p&gt;
&lt;h3 id=&quot;one-record-six-reads&quot;&gt;One record, six reads&lt;/h3&gt;
&lt;p&gt;From &lt;code&gt;src/billing&lt;/code&gt;, a subdirectory of the project, &lt;code&gt;selvedge prior-attempts src/billing/charge.py::retry_charge --json&lt;/code&gt; returned the record.&lt;/p&gt;
&lt;p&gt;From an unrelated directory, the same lookup (without &lt;code&gt;--json&lt;/code&gt;) printed &quot;No prior attempts found&quot; and exited 0. By the end of the run, the throwaway home directory held a new per-user database with zero events.&lt;/p&gt;
&lt;p&gt;From that same unrelated directory with &lt;code&gt;SELVEDGE_DB=/path/to/demo-app/.selvedge/selvedge.db&lt;/code&gt; set, the command returned the record with the same ID.&lt;/p&gt;
&lt;p&gt;With the same variable set, the Python package returned the same ID:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from selvedge import SelvedgeStorage, get_db_path

store = SelvedgeStorage(get_db_path(read_only=True))
blame = store.get_blame(&quot;src/billing/charge.py::retry_charge&quot;)
print(blame[&quot;id&quot;] if blame else &quot;not found in this store&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, a small test script started &lt;code&gt;selvedge-server&lt;/code&gt; over stdio and called its &lt;code&gt;prior_attempts&lt;/code&gt; tool. With &lt;code&gt;SELVEDGE_DB&lt;/code&gt; set, it returned the same ID. Without it, from the unrelated directory, it returned an empty list.&lt;/p&gt;
&lt;p&gt;Every read that found the database returned one ID. The two misses weren't missing data; they were the wrong file.&lt;/p&gt;
&lt;h3 id=&quot;why-the-empty-answer-happened&quot;&gt;Why the empty answer happened&lt;/h3&gt;
&lt;p&gt;Selvedge stores decisions in local SQLite. In v0.3.17, the CLI and the MCP server both find that database with &lt;code&gt;get_db_path&lt;/code&gt;, which the package also exports. It checks three places, in order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;the &lt;code&gt;SELVEDGE_DB&lt;/code&gt; environment variable;&lt;/li&gt;
&lt;li&gt;an existing &lt;code&gt;.selvedge/selvedge.db&lt;/code&gt; file, searched for upward from the current directory;&lt;/li&gt;
&lt;li&gt;the per-user fallback &lt;code&gt;~/.selvedge/selvedge.db&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The subdirectory read found the project file by searching upward. The unrelated directory had no project file above it, so the read fell through to the per-user path.&lt;/p&gt;
&lt;p&gt;The tool didn't hide this. A normal lookup that falls back writes a warning to stderr, once per process, unless &lt;code&gt;SELVEDGE_QUIET&lt;/code&gt; is set. Both the CLI miss and the MCP miss printed it. On its own, &lt;code&gt;get_db_path(read_only=True)&lt;/code&gt; stays silent and creates nothing. But opening that path with &lt;code&gt;SelvedgeStorage&lt;/code&gt;, as the snippet above does, still creates the per-user directory and an empty database, with no warning. In a fresh home directory without &lt;code&gt;SELVEDGE_DB&lt;/code&gt;, the snippet printed &quot;not found in this store&quot;. &lt;code&gt;selvedge doctor&lt;/code&gt; reports the resolved path and which step matched: &quot;via walked up from CWD to project .selvedge/&quot; from the project root, and &quot;via global fallback (~/.selvedge/)&quot; with status WARN from the unrelated directory. That doctor run still exited 0, so a script that checks only exit codes won't notice.&lt;/p&gt;
&lt;p&gt;An MCP server makes this easier to miss. A client starts the server with whatever working directory and environment the client chooses, and whether you see the server's stderr depends on the client. The README says to set &lt;code&gt;SELVEDGE_DB&lt;/code&gt; in the server configuration when the client starts outside your project.&lt;/p&gt;
&lt;h3 id=&quot;checking-your-own-setup-with-or-without-selvedge&quot;&gt;Checking your own setup, with or without Selvedge&lt;/h3&gt;
&lt;p&gt;None of this is specific to one tool. Any file-based memory, whether a decisions folder, a JSON file or a SQLite database, is located by each process for itself. So, before trusting an empty answer, find out how your tool picks its file. Usually it's some mix of explicit path, project discovery and a per-user default.&lt;/p&gt;
&lt;p&gt;Then ask the question from where the reader actually runs. A check in your terminal describes your terminal, not an agent client or a CI job with its own directory and environment. For processes you don't launch yourself, set the path explicitly rather than relying on discovery.&lt;/p&gt;
&lt;p&gt;When a lookup comes back empty, read it as &quot;nothing in this store,&quot; not &quot;nothing was ever decided.&quot; And once, after any change to clients or configuration, write a test record through one route and read it through the others, comparing IDs rather than text. Matching text could be a copy; a matching ID means the same record.&lt;/p&gt;
&lt;h3 id=&quot;what-s-on-main-and-not-released&quot;&gt;What's on main, and not released&lt;/h3&gt;
&lt;p&gt;PR #76, merged into main as commit 66c51d3 on October 17, 2026 (PT), reworks Selvedge's architecture plan. It describes MCP as &quot;one route to the core, not a prerequisite for using it,&quot; and plans a small local core shared by the library, CLI, MCP and optional integrations. The PR also adds a regression check that saves and retrieves a record across isolated Python processes, and rewords the bundled prompt to present CLI and MCP as routes to the same store.&lt;/p&gt;
&lt;p&gt;None of this is in v0.3.17, and the architecture document says the phases add no release dates. It notes that the current package still installs MCP dependencies, and that making them optional needs a separate compatibility assessment. It lists compact review presentation, native GitHub validation and an editor integration as future work.&lt;/p&gt;
&lt;h3 id=&quot;what-this-run-doesn-t-show&quot;&gt;What this run doesn't show&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;It's synthetic: one invented record, one machine, Python 3.13 and a throwaway home directory. The CLI's PyPI update check was turned off, and telemetry was never enabled.&lt;/li&gt;
&lt;li&gt;No agent client was tested. The MCP route used a test script built on the &lt;code&gt;mcp&lt;/code&gt; Python client, so check your own client's configuration, launch directory and activation separately.&lt;/li&gt;
&lt;li&gt;Selvedge stores what a person or agent explicitly saves. It doesn't verify that the reasoning is true, and it doesn't guarantee that an agent will consult it.&lt;/li&gt;
&lt;li&gt;Pinning the path makes routes agree on a file. It doesn't create records nobody saved.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31650/is-your-coding-agent-reading-the-memory-you-wrote</guid>
<pubDate>Sun, 18 Oct 2026 21:21:32 +0000</pubDate>
</item>
<item>
<title>Would an independent final-email rule check earn a place beside your existing tests?</title>
<link>https://coderlegion.com/31600/would-an-independent-final-email-rule-check-earn-a-place-beside-your-existing-tests</link>
<description>&lt;p&gt;I’m cun he, maker of InlinerAPI. If you maintain business receipts, reset messages or invitations, I’d appreciate one current-work comparison: &lt;a rel=&quot;nofollow&quot; href=&quot;https://inlinerapi.netlify.app/requirements&quot;&gt;https://inlinerapi.netlify.app/requirements&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Use synthetic final rendered HTML and an independently expected copy, displayed amount or CTA destination. The browser preview needs no signup. It checks the supplied content; it does not refresh application data, run your templates, validate tokens, or test inbox appearance or delivery.&lt;/p&gt;
&lt;p&gt;What rule did you attempt, what result did you observe, and how did setup and maintenance compare with your existing tests? If it helps recurring work, what would your team need to justify paying for these current checks?&lt;/p&gt;
</description>
<category>Discussions</category>
<guid isPermaLink="true">https://coderlegion.com/31600/would-an-independent-final-email-rule-check-earn-a-place-beside-your-existing-tests</guid>
<pubDate>Sun, 18 Oct 2026 11:12:47 +0000</pubDate>
</item>
<item>
<title>What evidence should an AI coding assistant provide before saying a bug is fixed?</title>
<link>https://coderlegion.com/31594/what-evidence-should-an-ai-coding-assistant-provide-before-saying-a-bug-is-fixed</link>
<description>&lt;p&gt;An AI coding assistant can produce a plausible patch and a confident explanation in the same response. What evidence should accompany that explanation before a team treats the issue as resolved?&lt;/p&gt;
&lt;p&gt;A useful baseline might include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A reproduction that fails before the change and passes afterward&lt;/li&gt;
&lt;li&gt;The exact tests or commands run, with their outcomes&lt;/li&gt;
&lt;li&gt;An explanation connecting the change to the reported failure&lt;/li&gt;
&lt;li&gt;A clear statement of anything that could not be checked&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The difficult part is deciding how much evidence is enough. A typo in a static label and a change to payment processing deserve different levels of verification. Passing tests also leave an important question: do those tests cover the behavior the user actually reported?&lt;/p&gt;
&lt;p&gt;For a timing bug, a happy-path test seems especially weak. A stronger check would deliberately reverse the order of events, delay a response, or force a failure at the point where the bug appears.&lt;/p&gt;
&lt;p&gt;Would you require a regression test for every AI-generated bug fix, or allow a documented manual check for smaller changes? And should assistants distinguish between “patch prepared,” “automated checks passed,” and “verified in the target environment” instead of compressing all three into “fixed”?&lt;/p&gt;
&lt;p&gt;Curious where you would set the minimum bar, especially when the assistant cannot access the production environment.&lt;/p&gt;
</description>
<category>Discussions</category>
<guid isPermaLink="true">https://coderlegion.com/31594/what-evidence-should-an-ai-coding-assistant-provide-before-saying-a-bug-is-fixed</guid>
<pubDate>Sun, 18 Oct 2026 10:06:10 +0000</pubDate>
</item>
<item>
<title>Selvedge 0.3.17: test your agent memory before you rely on it</title>
<link>https://coderlegion.com/31521/selvedge-0-3-17-test-your-agent-memory-before-you-rely-on-it</link>
<description>&lt;p&gt;I maintain Selvedge. This article was prepared with AI assistance and checked against the released v0.3.17 documentation.&lt;/p&gt;
&lt;p&gt;A coding agent can tell you it saved a decision. The useful test comes later: close the session, start again, and retrieve the same record. Can you recover the reason you rejected an approach, or just a convincing explanation of the code that remains?&lt;/p&gt;
&lt;p&gt;Selvedge gives compatible AI agents persistent decision memory through a local MCP server or the CLI. You save a choice, its reason, and the alternatives you ruled out against a named part of your project: a function, database column, API route, or dependency. Those records live in SQLite under &lt;code&gt;.selvedge/&lt;/code&gt;, so another session can query the same history.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/masondelan/selvedge/releases/tag/v0.3.17&quot;&gt;Version 0.3.17&lt;/a&gt; makes two parts of that workflow easier to check: whether the local MCP server can retrieve a decision after restarting, and whether the Markdown digest you bring to review still matches the stored history.&lt;/p&gt;
&lt;h3 id=&quot;test-the-local-mcp-server&quot;&gt;Test the local MCP server&lt;/h3&gt;
&lt;p&gt;With &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.astral.sh/uv/getting-started/installation/&quot;&gt;uv installed&lt;/a&gt;, you can try the first check without connecting an agent or touching a project's decisions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv tool install --upgrade selvedge==0.3.17
selvedge demo --mcp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The command starts a real local stdio MCP server, saves a synthetic rejected approach through it, shuts that server down, and starts another. It then checks that retrieval returns the same record ID and content. Add &lt;code&gt;--json&lt;/code&gt; to get the checked IDs and version information in machine-readable form.&lt;/p&gt;
&lt;p&gt;The data is disposable. The demo uses a temporary database, skips background update and telemetry checks, and has a deadline followed by subprocess cleanup. That makes it a useful first step when evaluating the package: a small, concrete persistence check with no invented project history left behind.&lt;/p&gt;
&lt;p&gt;It also has a precise boundary. A passing result checks the Python installation running Selvedge and that local MCP exchange. Your editor may launch a different executable, point at a different database, or require you to enable tools. The demo cannot establish that your agent's configuration or lifecycle hooks are active.&lt;/p&gt;
&lt;h3 id=&quot;try-one-real-decision-across-two-sessions&quot;&gt;Try one real decision across two sessions&lt;/h3&gt;
&lt;p&gt;The next test belongs in your own workflow. Follow the &lt;a rel=&quot;nofollow&quot; href=&quot;https://selvedge.sh/start/quickstart/&quot;&gt;quickstart&lt;/a&gt;, connect your agent, and ask it to save one real decision with the reason and any rejected alternative. Keep the entity path and record ID. Then open a fresh session in the same project and ask it to retrieve that decision. Compare the result with what you saved.&lt;/p&gt;
&lt;p&gt;That is also where to test usefulness. Suppose you kept a dependency because its replacement lacked a feature you needed. Retrieving that constraint gives the next session something specific to check before attempting the replacement again. Only explicitly saved decisions can be recovered, and recorded reasoning still needs judgment: Selvedge stores the account supplied by you or your agent; it cannot establish that the account is correct or complete.&lt;/p&gt;
&lt;h3 id=&quot;check-the-digest-before-review&quot;&gt;Check the digest before review&lt;/h3&gt;
&lt;p&gt;The second addition addresses what happens when those records reach a review. Selvedge already exports a deterministic Markdown digest. Once you have project history, generate it with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;selvedge export --format markdown -o DECISIONS.md
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Version 0.3.17 adds a check you can run before review or in CI wherever that same history is available:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;selvedge export --format markdown -o DECISIONS.md --check
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the file matches, the command exits successfully without output. If it is stale or missing, it exits with status 1 and a unified diff. The check preserves both the digest and the stored records; it does not initialize or migrate the database. Read failures produce guidance, and invalid arguments return status 2.&lt;/p&gt;
&lt;p&gt;This gives a reviewer a straightforward consistency check. A digest can look reasonable while omitting a recently recorded decision. The check makes that drift visible. It does not validate the decisions themselves or fill gaps in capture. Review the exported reasoning before sharing or committing it.&lt;/p&gt;
&lt;p&gt;There are smaller fixes with the same aim of making the record easier to interpret. Human-readable output now distinguishes an approach considered and rejected from a change tried and reverted. Literal bracket characters in logged text no longer cause the affected CLI rendering failures. The release adds no runtime dependencies, migrations, or MCP tools.&lt;/p&gt;
&lt;p&gt;If you use a coding agent on a project you regularly return to, try one decision across two sessions. A local stdio MCP connection works; an agent with shell access can use the CLI. No hosted account is required, and core storage and retrieval make no LLM calls. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/masondelan/selvedge&quot;&gt;repository&lt;/a&gt; has the source and release notes. Feedback about where saving or retrieving that first real decision breaks is especially useful—share it in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/masondelan/selvedge/discussions/49&quot;&gt;existing trial discussion&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31521/selvedge-0-3-17-test-your-agent-memory-before-you-rely-on-it</guid>
<pubDate>Sat, 17 Oct 2026 16:10:57 +0000</pubDate>
</item>
<item>
<title>Three audio checks worth running before you ship any upload feature</title>
<link>https://coderlegion.com/31333/three-audio-checks-worth-running-before-you-ship-any-upload-feature</link>
<description>&lt;p&gt;Most &quot;the audio is broken&quot; reports are not DSP bugs. They are format and metadata bugs that surface weeks later, after the feature has already shipped. These three checks are cheap and catch the majority of them.&lt;/p&gt;
&lt;h3 id=&quot;1-read-the-header-not-the-extension&quot;&gt;1. Read the header, not the extension&lt;/h3&gt;
&lt;p&gt;A &lt;code&gt;.wav&lt;/code&gt; can be 8-bit PCM, 24-bit float, or something a recorder invented. If you branch on the file extension you will eventually hand a decoder something it silently mangles. Parse the actual header first, then decide what to do with it.&lt;/p&gt;
&lt;h3 id=&quot;2-sample-rate-mismatches-are-silent&quot;&gt;2. Sample rate mismatches are silent&lt;/h3&gt;
&lt;p&gt;Drop a 44.1 kHz source into a 48 kHz graph and nothing throws. It just plays back at the wrong speed and pitch. Normalise on ingest, then assert the rate at every stage boundary. An assertion costs nothing; a support ticket does not.&lt;/p&gt;
&lt;h3 id=&quot;3-peak-level-is-not-loudness&quot;&gt;3. Peak level is not loudness&lt;/h3&gt;
&lt;p&gt;A track can peak at 0 dBFS and still sound quiet next to its neighbours. If your UI says &quot;loud&quot; or &quot;quiet&quot;, you are measuring loudness (LUFS), not peaks. Pick one metric, name it in the UI, and stop guessing which one you meant.&lt;/p&gt;
&lt;p&gt;None of this needs a DSP background. It needs one explicit place in the pipeline where the invariant is written down and checked.&lt;/p&gt;
&lt;p&gt;These three came straight out of debugging the upload path on &lt;a rel=&quot;nofollow&quot; href=&quot;https://creatune.ai/&quot;&gt;Creatune AI Music Generator&lt;/a&gt;, which I work on — so treat the recommendation with the usual scepticism.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31333/three-audio-checks-worth-running-before-you-ship-any-upload-feature</guid>
<pubDate>Fri, 16 Oct 2026 13:08:03 +0000</pubDate>
</item>
<item>
<title>Queued Work Is a Snapshot, Not the Truth</title>
<link>https://coderlegion.com/31281/queued-work-is-a-snapshot-not-the-truth</link>
<description>&lt;p&gt;Background work is usually introduced to make a system more reliable. We persist an intent, let the request finish, and deliver the external side effect later. That protects the user-facing path from temporary provider failures and gives us room for retries.&lt;/p&gt;
&lt;p&gt;It also creates a time gap.&lt;/p&gt;
&lt;p&gt;Inside that gap, the world can change. A task can be completed. A new task can appear. A recipient can lose access. A preference can change. Another worker can advance the same operation. The queued record may still be durable and validly serialized while the message it would produce is already wrong.&lt;/p&gt;
&lt;p&gt;The lesson is simple: queued intent is evidence about an earlier snapshot. It is not timeless authority.&lt;/p&gt;
&lt;h3 id=&quot;store-the-promise-not-the-presentation&quot;&gt;Store the promise, not the presentation&lt;/h3&gt;
&lt;p&gt;Suppose a service groups several outstanding items into one reminder. The tempting design is to serialize the titles, amounts, names, and finished message into an outbox row. Delivery is then easy: deserialize and send.&lt;/p&gt;
&lt;p&gt;That convenience turns the row into a stale cache of user-facing truth. It may also retain more sensitive data than the queue needs.&lt;/p&gt;
&lt;p&gt;A safer durable record can be much smaller. Keep the routing identity, recipient identity, sequence, source, and a deterministic fingerprint of the complete set the message was created for. The fingerprint represents the promise: “this message describes exactly this observed set.”&lt;/p&gt;
&lt;p&gt;The display payload is rebuilt later from authoritative state. The durable row says what snapshot was intended without pretending that its old presentation is still current.&lt;/p&gt;
&lt;h3 id=&quot;rebuild-from-the-complete-current-set&quot;&gt;Rebuild from the complete current set&lt;/h3&gt;
&lt;p&gt;At execution time, re-read what the authoritative source currently exposes to the recipient. Then run the same grouping rules again and compare the resulting fingerprint with the stored fingerprint.&lt;/p&gt;
&lt;p&gt;Why the complete set? Because stale work changes in two directions.&lt;/p&gt;
&lt;p&gt;The obvious case is subtraction. An item was completed or removed after the reminder was queued. The old message now overstates what is outstanding.&lt;/p&gt;
&lt;p&gt;The less obvious case is addition. A new item appeared after the reminder was queued. If the worker checks only the item keys remembered by the row, every remembered item may still be valid. The worker can still send a message that understates the current request.&lt;/p&gt;
&lt;p&gt;Reconstructing the whole set catches both. If the current fingerprint differs, the old intent is superseded. Stop it. Let the current state produce a new intent under a new identity.&lt;/p&gt;
&lt;h3 id=&quot;keep-authorization-in-the-authoritative-read&quot;&gt;Keep authorization in the authoritative read&lt;/h3&gt;
&lt;p&gt;The background worker should not invent a second, simplified version of the authorization and status rules used by the foreground feature. That duplicate will drift.&lt;/p&gt;
&lt;p&gt;Prefer a shared underlying read path with an explicit recipient parameter. The interactive request and the delayed worker should agree on what the recipient is currently allowed to see and how current status is mapped.&lt;/p&gt;
&lt;p&gt;This also makes an important distinction possible. “Nothing remains” is not the same as “the recipient is no longer authorized.” Both may yield an empty visible set, but they mean different things operationally and in an audit trail.&lt;/p&gt;
&lt;p&gt;Completion can finish the old intent. Revocation should stop disclosure for a security reason. A missing source integration is different again: it is usually a retryable deployment fault, not evidence that the work is complete.&lt;/p&gt;
&lt;h3 id=&quot;model-more-than-send-or-fail&quot;&gt;Model more than send or fail&lt;/h3&gt;
&lt;p&gt;A robust recheck tends to produce several outcomes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Send: the complete current snapshot still matches, preferences allow delivery, and the operation sequence is current.&lt;/li&gt;
&lt;li&gt;Stop as completed: the intended work no longer exists.&lt;/li&gt;
&lt;li&gt;Stop as changed: the set still exists but is no longer the set the queued message promised to describe.&lt;/li&gt;
&lt;li&gt;Stop as revoked: the recipient no longer has authority.&lt;/li&gt;
&lt;li&gt;Stop as superseded: a later sequence has already advanced the operation.&lt;/li&gt;
&lt;li&gt;Defer: authoritative evaluation is temporarily unavailable.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These outcomes can feel verbose compared with a Boolean. That verbosity is useful. It prevents a temporary infrastructure gap from being filed as success, and it prevents a security revocation from being misreported as ordinary completion.&lt;/p&gt;
&lt;h3 id=&quot;test-addition-as-carefully-as-subtraction&quot;&gt;Test addition as carefully as subtraction&lt;/h3&gt;
&lt;p&gt;The most valuable regression test in this pattern is often the one teams forget: add something after the row is written.&lt;/p&gt;
&lt;p&gt;A focused suite should cover an unchanged set, a removed item, an added item, an empty set, revoked authority, changed preferences, a superseded sequence, and an unavailable source. It should also prove that each evaluation performs a fresh authoritative read rather than accidentally reusing stored data.&lt;/p&gt;
&lt;p&gt;These tests do more than exercise branches. They define the temporal contract of the workflow: which facts may age, which facts must be refreshed, and which outcome owns each kind of change.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-real-1&quot;&gt;The trade-off is real&lt;/h3&gt;
&lt;p&gt;This design adds a database read at the most sensitive point, deterministic reconstruction, fingerprint maintenance, and more explicit state transitions. At high volume, that cost deserves measurement. Poorly indexed rechecks can simply move a reliability problem into the database.&lt;/p&gt;
&lt;p&gt;The benefit is not that fingerprints make distributed systems magically consistent. They do not. There is still a race between the final read and the external side effect, and systems with stricter requirements may need provider idempotency, transactional boundaries, or domain-specific reservations.&lt;/p&gt;
&lt;p&gt;The narrower claim is valuable enough: a delayed worker should not knowingly act on an obsolete snapshot when it can cheaply revalidate current truth.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-checklist-3&quot;&gt;A practical review checklist&lt;/h3&gt;
&lt;p&gt;When reviewing delayed email, push, webhook, export, billing, or approval work, ask:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;What exact snapshot did this row promise to act on?&lt;/li&gt;
&lt;li&gt;Does the queue store minimal identity or stale presentation data?&lt;/li&gt;
&lt;li&gt;Can the worker re-read the full authoritative set?&lt;/li&gt;
&lt;li&gt;Are additions detected as well as removals?&lt;/li&gt;
&lt;li&gt;Are completion, revocation, supersession, and temporary unavailability distinct?&lt;/li&gt;
&lt;li&gt;Is the external side effect itself idempotent or otherwise bounded?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Durability keeps work from disappearing. Revalidation keeps durable work from becoming confidently wrong.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31281/queued-work-is-a-snapshot-not-the-truth</guid>
<pubDate>Fri, 16 Oct 2026 05:43:57 +0000</pubDate>
</item>
<item>
<title>Isolation Is Sometimes a Feature, Not a Smell</title>
<link>https://coderlegion.com/31143/isolation-is-sometimes-a-feature-not-a-smell</link>
<description>&lt;p&gt;Two projects expose similar helpers. One has no project references. It is missing from the solution most developers build every day. The obvious reaction is understandable: share the code, add the reference, and bring the lonely project back into the fold.&lt;/p&gt;
&lt;p&gt;That cleanup can be exactly wrong.&lt;/p&gt;
&lt;p&gt;A recent architecture correction I reviewed was a useful reminder that repository shape is not the whole system. Build agents, installed workloads, runtime origins, signing steps, and release pipelines are part of the architecture too. An isolated .NET MAUI head looked neglected when viewed only from the solution file. Viewed from its release path, the isolation was load-bearing.&lt;/p&gt;
&lt;h3 id=&quot;start-with-the-delivery-graph&quot;&gt;Start With the Delivery Graph&lt;/h3&gt;
&lt;p&gt;Before deduplicating across project boundaries, ask how each project is actually built and shipped.&lt;/p&gt;
&lt;p&gt;In the case I reviewed, a server-focused solution deliberately excluded the mobile head. That kept routine server builds from requiring mobile workloads on every developer machine and CI runner. The mobile app was still real software: a separate pipeline restored, built, signed, and released it independently.&lt;/p&gt;
&lt;p&gt;Adding a shared project reference would therefore do more than remove a few repeated lines. It would expand the mobile restore graph and couple its release path to a project the mobile pipeline had never needed. Adding the mobile project to the server solution would impose a different cost in the other direction: machines building the server product would now need tooling they did not otherwise use.&lt;/p&gt;
&lt;p&gt;Neither consequence is visible in a tidy dependency diagram. Both matter in production delivery.&lt;/p&gt;
&lt;h3 id=&quot;similar-code-can-encode-different-assumptions&quot;&gt;Similar Code Can Encode Different Assumptions&lt;/h3&gt;
&lt;p&gt;The same review contained two link resolvers that appeared ripe for consolidation. The browser implementation derived an external base address from its hosting origin. That was valid because the browser app was served by the web host.&lt;/p&gt;
&lt;p&gt;The mobile app ran inside an embedded web view. Its internal origin did not identify the external service it needed to reach. Reusing the browser resolver would not create elegant sharing; it would import an invalid environment assumption.&lt;/p&gt;
&lt;p&gt;This is a useful test for any proposed abstraction: are the implementations merely similar in syntax, or are they equivalent in authority and context?&lt;/p&gt;
&lt;p&gt;Imagine a browser client and a desktop client that both produce a download URL. The browser can safely resolve a relative path against the page origin. The desktop client may need an explicitly configured service endpoint. A common interface can still be useful, but a common implementation may be actively misleading.&lt;/p&gt;
&lt;p&gt;Duplication is not automatically good. It is sometimes the smallest honest representation of two different worlds.&lt;/p&gt;
&lt;h3 id=&quot;guard-the-constraint-and-its-reason&quot;&gt;Guard the Constraint—and Its Reason&lt;/h3&gt;
&lt;p&gt;Documentation corrected the misleading story, but the stronger improvement was an architecture test suite.&lt;/p&gt;
&lt;p&gt;The tests did not merely assert that the mobile head had no project references and stayed outside the server solution. They also checked that the separate mobile build path still existed. That second half changes the character of the rule.&lt;/p&gt;
&lt;p&gt;Without it, the test says: “keep this isolated forever.”&lt;/p&gt;
&lt;p&gt;With it, the test says: “keep this isolated while this independent delivery path remains true.”&lt;/p&gt;
&lt;p&gt;That is a healthier architectural guard. It protects a decision without pretending the decision is timeless. If the separate pipeline disappears, the guard fails and asks maintainers to reconsider the boundary. The test becomes executable rationale rather than fossilized preference.&lt;/p&gt;
&lt;p&gt;This pattern generalizes well. If a component must stay dependency-free because it is loaded by a restricted host, test both the dependency rule and the existence of that host. If a migration assembly must remain separate because tooling discovers it independently, guard both the assembly boundary and the discovery path.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-still-real&quot;&gt;The Trade-Off Is Still Real&lt;/h3&gt;
&lt;p&gt;Deliberate isolation has costs.&lt;/p&gt;
&lt;p&gt;Separate helpers can drift. A visual shell copied for a second head may receive fixes later. Architecture tests add maintenance, and source-shape checks can become brittle if they assert incidental text instead of meaningful structure.&lt;/p&gt;
&lt;p&gt;The answer is not to ignore those costs. It is to compare them with the coupling being avoided.&lt;/p&gt;
&lt;p&gt;Prefer the smallest duplication that preserves the boundary. Keep behavioural contracts aligned where the behaviour truly is shared. Document the assumptions that must differ. Review the boundary when release topology changes. And avoid broad claims such as “this project shares nothing” when the important truth is more precise: it ships independently and intentionally owns a narrow set of platform-specific choices.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-checklist-4&quot;&gt;A Practical Review Checklist&lt;/h3&gt;
&lt;p&gt;Before merging apparently duplicate code across application heads, ask:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Which pipeline builds and releases each head?&lt;/li&gt;
&lt;li&gt;What workloads and SDKs does each build environment guarantee?&lt;/li&gt;
&lt;li&gt;Will a new reference expand the restore or signing boundary?&lt;/li&gt;
&lt;li&gt;Do the implementations rely on different origins, storage, identity, or lifecycle rules?&lt;/li&gt;
&lt;li&gt;Can we test the constraint and the continuing fact that justifies it?&lt;/li&gt;
&lt;li&gt;What event should trigger reconsideration of the decision?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;These questions turn “DRY versus duplication” into a more useful discussion about operational ownership.&lt;/p&gt;
&lt;h3 id=&quot;architecture-includes-the-way-software-leaves-the-repository&quot;&gt;Architecture Includes the Way Software Leaves the Repository&lt;/h3&gt;
&lt;p&gt;The most valuable lesson for me was a leadership one: do not label unusual structure before tracing its history and delivery path.&lt;/p&gt;
&lt;p&gt;An isolated project may be forgotten. It may also be a carefully bounded product whose release assumptions are invisible from the main solution. We should investigate before simplifying, explain the decision in language that cannot be mistaken for neglect, and encode the rationale where future changes will encounter it.&lt;/p&gt;
&lt;p&gt;Clean architecture is not the fewest projects or the fewest repeated lines. It is a system whose dependencies match the realities of how it runs and ships—and whose exceptions are specific, reviewable, and allowed to expire.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31143/isolation-is-sometimes-a-feature-not-a-smell</guid>
<pubDate>Thu, 15 Oct 2026 06:01:03 +0000</pubDate>
</item>
<item>
<title>A Passing Test Can Still Prove Nothing</title>
<link>https://coderlegion.com/31018/a-passing-test-can-still-prove-nothing</link>
<description>&lt;p&gt;Some of the most misleading tests are not flaky, slow, or obviously incomplete. They are fast, deterministic, and green.&lt;/p&gt;
&lt;p&gt;They also pass for the wrong reason.&lt;/p&gt;
&lt;p&gt;That is especially dangerous in fault-tolerant code. A test may claim that a failed dependency is contained, a fallback runs, or an established workflow continues. Yet its assertion may observe an outcome that can occur both before and after the recovery boundary. The suite reports confidence while the behaviour named by the test remains unproved.&lt;/p&gt;
&lt;h3 id=&quot;the-false-positive-shape&quot;&gt;The false-positive shape&lt;/h3&gt;
&lt;p&gt;Imagine a dispatcher with an optional policy source in front of an established workflow. The intended behaviour is straightforward:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Ask the policy source whether the old workflow should stand down.&lt;/li&gt;
&lt;li&gt;If that optional source fails, contain the fault.&lt;/li&gt;
&lt;li&gt;Continue into the established downstream path.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The unit test configures a policy source that throws. It also uses a strict downstream test double that throws if reached, because the rest of that workflow is outside the test's concern.&lt;/p&gt;
&lt;p&gt;Then it asserts only that dispatch throws an exception.&lt;/p&gt;
&lt;p&gt;At first glance, this can feel reasonable. An exception from the strict downstream dependency appears to prove that execution continued. But the same broad assertion also accepts the earlier exception from the policy source. If the recovery logic is deleted, the first exception simply escapes and the test remains green.&lt;/p&gt;
&lt;p&gt;The assertion recognizes the shape of the final outcome, not the path that produced it.&lt;/p&gt;
&lt;h3 id=&quot;test-the-claim-not-the-punctuation&quot;&gt;Test the claim, not the punctuation&lt;/h3&gt;
&lt;p&gt;The claim is not “an exception occurs.” The claim is “a fault at this boundary does not stop progress into the established path.”&lt;/p&gt;
&lt;p&gt;That distinction changes what the test must observe.&lt;/p&gt;
&lt;p&gt;A useful test needs a stable witness on the far side of the boundary. Depending on the design, that witness could be:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a recorded call to the downstream collaborator;&lt;/li&gt;
&lt;li&gt;a domain result that only the fallback path can produce;&lt;/li&gt;
&lt;li&gt;a distinct exception identity from a deliberately strict dependency;&lt;/li&gt;
&lt;li&gt;a state transition committed after the failed operation; or&lt;/li&gt;
&lt;li&gt;an emitted event that occurs only after recovery has completed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The specific technique matters less than the causal guarantee. The witness must be impossible—or at least deliberately difficult—to observe if execution stops at the failure being contained.&lt;/p&gt;
&lt;p&gt;In the example above, the corrected test distinguishes the downstream failure from the policy-source failure. It therefore passes only when execution crosses the recovery boundary. A recording test double would also work and may be clearer when the downstream call itself is the contract.&lt;/p&gt;
&lt;h3 id=&quot;why-mutation-testing-helped&quot;&gt;Why mutation testing helped&lt;/h3&gt;
&lt;p&gt;Code coverage could not expose this weakness. Both the recovery code and the test might execute during the normal run. Coverage tells us which lines ran; it does not tell us whether an assertion depended on their behaviour.&lt;/p&gt;
&lt;p&gt;Mutation testing asks a sharper question: if a meaningful piece of behaviour is changed or removed, does a test fail?&lt;/p&gt;
&lt;p&gt;Removing the recovery behaviour created a surviving mutant. That was valuable evidence. It did not necessarily mean the production design was wrong. It meant the suite could not distinguish the intended implementation from a broken one.&lt;/p&gt;
&lt;p&gt;This is one of mutation testing's best uses: finding assertions that observe something adjacent to the claim rather than the claim itself.&lt;/p&gt;
&lt;p&gt;You do not need mutation tooling to apply the same reasoning in review. Mentally invert the behaviour:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If the catch disappeared, would the test fail?&lt;/li&gt;
&lt;li&gt;If the fallback were skipped, would the test fail?&lt;/li&gt;
&lt;li&gt;If execution stopped one step early, would the test fail?&lt;/li&gt;
&lt;li&gt;Could a different branch produce the same asserted result?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the answer is uncomfortable, look for a witness beyond the boundary.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-path-awareness-without-brittleness&quot;&gt;The trade-off: path awareness without brittleness&lt;/h3&gt;
&lt;p&gt;Path-sensitive assertions carry a real cost. A test that verifies every internal call order can become coupled to implementation detail and obstruct harmless refactoring. Replacing one collaborator with another should not require rewriting an entire suite if the observable contract is unchanged.&lt;/p&gt;
&lt;p&gt;The answer is not to avoid path awareness altogether. It is to choose the smallest stable witness that represents the behaviour promised by the test.&lt;/p&gt;
&lt;p&gt;Prefer domain-visible evidence when it exists: a returned decision, a committed state, a durable message, or a recorded recovery status. Use collaborator verification when crossing that collaborator boundary is itself the contract. Use exception type or identity when a strict test double intentionally marks progress, but make that intent explicit so a future reader understands why the exception matters.&lt;/p&gt;
&lt;p&gt;This produces a useful hierarchy:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Assert the externally meaningful result when it uniquely proves recovery.&lt;/li&gt;
&lt;li&gt;Assert a durable checkpoint when the final result is produced asynchronously.&lt;/li&gt;
&lt;li&gt;Assert a boundary interaction when progress across that boundary is the behaviour under test.&lt;/li&gt;
&lt;li&gt;Use a distinct failure identity as a narrow witness when a strict dependency deliberately terminates the test.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The closer the witness is to the real reliability contract, the less brittle the test will be.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-habit&quot;&gt;A practical review habit&lt;/h3&gt;
&lt;p&gt;When reading a test name, underline the verb: continues, retries, falls back, persists, publishes, or rejects. Then find the assertion that proves that verb happened.&lt;/p&gt;
&lt;p&gt;If you can only find evidence that something threw, returned, or logged, ask whether the same evidence could appear before the named behaviour occurred.&lt;/p&gt;
&lt;p&gt;Green is not the standard. Discrimination is. A useful test must distinguish the system we intended from a nearby broken version.&lt;/p&gt;
&lt;p&gt;For fault-tolerance tests in particular, do not merely observe the aftermath of a failure. Prove that the system crossed the boundary it promised to survive.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31018/a-passing-test-can-still-prove-nothing</guid>
<pubDate>Wed, 14 Oct 2026 06:12:33 +0000</pubDate>
</item>
<item>
<title>MCP connected, hooks inactive: lessons from five coding-agent adapters</title>
<link>https://coderlegion.com/30995/mcp-connected-hooks-inactive-lessons-from-five-coding-agent-adapters</link>
<description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/masondelan/mcp-connected-hooks-inactive-lessons-from-five-coding-agent-adapters-fko&quot;&gt;DEV&lt;/a&gt; on September 24, 2026. This cross-post preserves that article’s version-specific implementation and test results.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;An agent can successfully call an MCP server while its lifecycle hooks never run. The server connection and the hook process have separate configuration, execution environments, and trust checks.&lt;/p&gt;
&lt;p&gt;That distinction mattered while extending Selvedge's native hooks beyond Claude Code in version 0.3.15. The release adds adapters for Codex, Cursor, Gemini CLI, VS Code Copilot's Local harness, and Windsurf/Cascade. The useful engineering lesson is how to separate shared behavior from each client's protocol—and how to prove the integration actually ran.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; I maintain Selvedge. This article was drafted with AI assistance and checked against the released implementation and recorded test evidence.&lt;/p&gt;
&lt;h3 id=&quot;keep-the-decision-logic-separate-from-the-protocol&quot;&gt;Keep the decision logic separate from the protocol&lt;/h3&gt;
&lt;p&gt;The feature in this example checks whether an agent has consulted recorded decisions before a supported edit to a watched schema or migration path. A saved rejection might explain that a column cannot be removed because an importer still needs it.&lt;/p&gt;
&lt;p&gt;The adapter has three jobs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;native client payload
  -&amp;gt; normalize project, session, tool and affected paths
  -&amp;gt; evaluate the shared decision check
  -&amp;gt; encode the result in the client's response format
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The shared check shouldn't need to know whether a client calls its shell tool &lt;code&gt;Bash&lt;/code&gt;, &lt;code&gt;Shell&lt;/code&gt;, or &lt;code&gt;run_shell_command&lt;/code&gt;. Conversely, the response encoder must know which fields—or exit status—the client recognizes.&lt;/p&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/masondelan/selvedge/blob/v0.3.15/selvedge/hooks/adapters.py&quot;&gt;released adapters&lt;/a&gt; make these mappings explicit. For example, the Codex &lt;code&gt;apply_patch&lt;/code&gt; payload supplies patch text in &lt;code&gt;tool_input.command&lt;/code&gt;; the VS Code Local &lt;code&gt;apply_patch&lt;/code&gt; payload uses &lt;code&gt;tool_input.input&lt;/code&gt;. Treating those tools as interchangeable would lose the files the check needs to inspect.&lt;/p&gt;
&lt;h3 id=&quot;similar-event-names-don-t-imply-identical-configuration&quot;&gt;Similar event names don't imply identical configuration&lt;/h3&gt;
&lt;p&gt;These are the project files and pre-edit events used by the five new adapters:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Client target &lt;/th&gt;&lt;th&gt;Project hook configuration &lt;/th&gt;&lt;th&gt;Event used for the edit check&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Codex &lt;/td&gt;&lt;td&gt;.codex/hooks.json &lt;/td&gt;&lt;td&gt;PreToolUse&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cursor &lt;/td&gt;&lt;td&gt;.cursor/hooks.json &lt;/td&gt;&lt;td&gt;preToolUse&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;VS Code Copilot, Local &lt;/td&gt;&lt;td&gt;.github/hooks/selvedge.json &lt;/td&gt;&lt;td&gt;PreToolUse&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Gemini CLI &lt;/td&gt;&lt;td&gt;.gemini/settings.json &lt;/td&gt;&lt;td&gt;BeforeTool&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Windsurf/Cascade &lt;/td&gt;&lt;td&gt;.windsurf/hooks.json, or existing .devin/hooks.json &lt;/td&gt;&lt;td&gt;pre_write_code, pre_run_command&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The table describes this implementation's supported targets, not every harness sold under those product names. In particular, VS Code Local support doesn't establish support for Copilot CLI, cloud sessions, or Agent Host.&lt;/p&gt;
&lt;p&gt;Configuration shape also differs: copying the same event name into each JSON file is insufficient. Installers need to validate existing structures, preserve unrelated hooks, back up modified files, and surface conflicts rather than silently replacing customized commands.&lt;/p&gt;
&lt;p&gt;Project identity deserves the same care. If a multi-root workspace payload doesn't identify an unambiguous current directory, checking another repository's decisions would be worse than reporting that the check couldn't be applied.&lt;/p&gt;
&lt;h3 id=&quot;a-notification-isn-t-saved-context&quot;&gt;A notification isn't saved context&lt;/h3&gt;
&lt;p&gt;All five new adapters implement the watched-edit check. Four also provide a startup digest and a user notification before compaction. The Windsurf adapter provides neither startup context nor a compaction notification.&lt;/p&gt;
&lt;p&gt;Those are different capabilities:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;startup digest&lt;/strong&gt; supplies selected saved context at a supported startup event.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;pre-edit check&lt;/strong&gt; can delay a supported tool call until the lookup requirement is satisfied.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;compaction notification&lt;/strong&gt; tells the user something. It doesn't make the model save a decision, and it isn't model-context injection.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Calling all three “memory preservation” hides the distinction users need when debugging. Explicitly recorded rationale is also different from a model's hidden reasoning; these adapters don't inspect hidden reasoning or read chat transcripts.&lt;/p&gt;
&lt;h3 id=&quot;test-installation-invocation-and-behavior-separately&quot;&gt;Test installation, invocation, and behavior separately&lt;/h3&gt;
&lt;p&gt;A generated hook file proves configuration was written. A successful direct invocation proves a script accepts a particular payload. Neither proves the client will launch it.&lt;/p&gt;
&lt;p&gt;For a real-client test, use a disposable project and inspect this sequence:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Seed one synthetic rejection against a watched file.&lt;/li&gt;
&lt;li&gt;Review and enable the hooks through the client's normal trust controls.&lt;/li&gt;
&lt;li&gt;Attempt the relevant edit before retrieving history; inspect the actual denial.&lt;/li&gt;
&lt;li&gt;Retrieve the saved decision, retry, and inspect the final file.&lt;/li&gt;
&lt;li&gt;Test an ordinary source edit and a read operation independently.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The recorded native test used Codex CLI &lt;strong&gt;0.154.0-alpha.6.2&lt;/strong&gt;. It observed an &lt;code&gt;apply_patch&lt;/code&gt; denial, a real &lt;code&gt;prior-attempts&lt;/code&gt; CLI lookup, then an allowed retry with the resulting file checked. It used normal hook trust and an absolute executable path with a local payload recorder. Earlier isolated runs that ignored user configuration never invoked the project hooks; they were excluded from the successful result.&lt;/p&gt;
&lt;p&gt;The other four new clients have automated payload, configuration, SQLite, and subprocess coverage, but haven't been verified end to end in those clients. Startup and compaction behavior also remains contract-tested. That's the difference between implementing an adapter and observing it work in a particular client version.&lt;/p&gt;
&lt;h3 id=&quot;state-the-limits-of-the-check&quot;&gt;State the limits of the check&lt;/h3&gt;
&lt;p&gt;This check covers known mutating tool schemas and configured watched paths. Shell write detection is heuristic. Unknown payloads, missing project databases, and internal evaluation errors fail open, so it is workflow assistance rather than a security boundary.&lt;/p&gt;
&lt;p&gt;The existing lookup window can also be satisfied by a recent query from another session. An allowed edit therefore doesn't prove this particular agent understood the decision—or that the old constraint still applies. Retrieval, correct application, and a successful code change need separate evidence.&lt;/p&gt;
&lt;p&gt;The next diagnostic improvement is to report configuration and observed hook execution separately. A single “connected” status cannot establish both.&lt;/p&gt;
&lt;p&gt;Protocol references: &lt;a rel=&quot;nofollow&quot; href=&quot;https://learn.chatgpt.com/docs/hooks&quot;&gt;Codex&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursor.com/docs/hooks&quot;&gt;Cursor&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://code.visualstudio.com/docs/agents/reference/hooks-reference&quot;&gt;VS Code Local&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://geminicli.com/docs/hooks/reference/&quot;&gt;Gemini CLI&lt;/a&gt;, and &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.devin.ai/desktop/cascade/hooks&quot;&gt;Cascade&lt;/a&gt;. The contracts cited by this implementation were checked September 24, 2026; client versions can change them.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30995/mcp-connected-hooks-inactive-lessons-from-five-coding-agent-adapters</guid>
<pubDate>Wed, 14 Oct 2026 00:48:27 +0000</pubDate>
</item>
<item>
<title>Do You Actually Need an LLM?</title>
<link>https://coderlegion.com/30818/do-you-actually-need-an-llm</link>
<description>&lt;p&gt;I spent a weekend running two small language models against three jobs I would normally hand to a language model without thinking about it. For one of those jobs I also wrote a regular expression, as a control.&lt;/p&gt;
&lt;p&gt;The regex won. Thirty test cases out of thirty, in zero milliseconds, from a pattern that took an afternoon to write. The better of the two models scored twenty-eight, took five seconds a call, and the two it missed are both mistakes a regex is structurally incapable of making.&lt;/p&gt;
&lt;p&gt;That is the kind of result I was looking for, and the question behind it is one I keep avoiding: &lt;strong&gt;what is the smallest amount of computation that reliably does this job?&lt;/strong&gt; Not the cheapest model. Not the best small model. The smallest thing that works, which means the ladder has four rungs and the bottom one is not a model at all.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Deterministic code.&lt;/strong&gt; A regex, a lookup table, an if statement.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A small local model.&lt;/strong&gt; Runs on hardware you already own.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A larger local model.&lt;/strong&gt; Needs a GPU.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A remote frontier model.&lt;/strong&gt; Someone else's hardware, metered.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Most projects start at rung four and never ask about the others. This is a weekend spent asking.&lt;/p&gt;
&lt;p&gt;The hardware was a Raspberry Pi 4, 4 GB of RAM, heat sinks and a fan, a hundred and seventy dollars most of which I already owned. The models were &lt;code&gt;smollm2:360m&lt;/code&gt; and &lt;code&gt;qwen2.5:1.5b&lt;/code&gt; through Ollama. The test cases are ninety-five questions about a fictional small winery, so nothing can be looked up and every answer can be judged by reading. Sixteen of the fifty classification messages are  deliberately ambiguous, because real inboxes are mostly ambiguous.&lt;/p&gt;
&lt;p&gt;Four verdicts, assigned per workflow, to force a result you can act on instead of a number:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Unnecessary&lt;/strong&gt;: deterministic code already does this.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sufficient&lt;/strong&gt;: a small local model does it well enough to run unattended.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Marginal&lt;/strong&gt;: usable with a person checking the output.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Insufficient&lt;/strong&gt;: the error profile or the latency is not acceptable.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And one rule, which is the part I would insist on if you copy nothing else: &lt;strong&gt;I wrote predictions for every workflow and committed them before I installed Ollama.&lt;/strong&gt; Twenty-two of them, each marked confident, leaning, or coin flip. Once there is a timestamped expectation there is a defined moment when you are done, and no room to decide afterward that you knew all along. I will come back to how they scored, because it is not flattering.&lt;/p&gt;
&lt;h3 id=&quot;extraction-pulling-an-order-number-out-of-a-message&quot;&gt;Extraction: pulling an order number out of a message&lt;/h3&gt;
&lt;p&gt;Find &lt;code&gt;ORD-&lt;/code&gt; followed by six digits, or report that there isn't one. Thirty cases: twenty-two with an identifier, five without, and three decoys. The decoys are a membership number, a UPS tracking number, and a phone number, all of which look like identifiers and none of which are order numbers.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;regex &lt;/th&gt;&lt;th&gt;qwen2.5:1.5b &lt;/th&gt;&lt;th&gt;smollm2:360m&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Overall &lt;/td&gt;&lt;td&gt;&lt;strong&gt;30/30&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;28/30 &lt;/td&gt;&lt;td&gt;8/30&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Declined correctly when absent (8) &lt;/td&gt;&lt;td&gt;8 &lt;/td&gt;&lt;td&gt;8 &lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Decoys ignored (3) &lt;/td&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Invented an answer &lt;/td&gt;&lt;td&gt;0 &lt;/td&gt;&lt;td&gt;0 &lt;/td&gt;&lt;td&gt;7&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The regex won. Thirty out of thirty, in zero milliseconds, at zero watts above idle.&lt;/p&gt;
&lt;p&gt;That result is boring until you read the two the best model missed.&lt;/p&gt;
&lt;p&gt;On &lt;code&gt;ORD-999000: wrong address, please hold.&lt;/code&gt; it answered &lt;code&gt;999000&lt;/code&gt;, dropping the prefix. Annoying, recoverable, the kind of thing you fix with a post-processing step you should not need.&lt;/p&gt;
&lt;p&gt;On &lt;code&gt;ORD-111222 please refund&lt;/code&gt; it answered &lt;code&gt;NONE&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That is the shortest and least decorated input in the whole set. Almost the entire message is the order number. And the model returned a confident, correctly formatted assertion that there is no order number present. In a pipeline, that is a refund request for an order that exists arriving at a system that has decided it does not.&lt;/p&gt;
&lt;p&gt;A regex cannot make that mistake. Not &quot;rarely does&quot; — cannot. The two errors separating 28 from 30 are both of a class that the deterministic baseline is structurally incapable of producing, which is a different and much stronger statement than a two-point accuracy gap.&lt;/p&gt;
&lt;h4 id=&quot;the-360m-result-needs-two-numbers-not-one&quot;&gt;The 360M result needs two numbers, not one&lt;/h4&gt;
&lt;p&gt;By the contract, &lt;code&gt;smollm2:360m&lt;/code&gt; scored 8 of 22 on the cases that contained an identifier. That reads like a model that cannot find things.&lt;/p&gt;
&lt;p&gt;It is not. All fourteen of its wrong answers are the correct six digits with &lt;code&gt;ORD-&lt;/code&gt; stripped. &lt;code&gt;ORD-556120&lt;/code&gt; came back as &lt;code&gt;556120&lt;/code&gt;, fourteen times out of fourteen.&lt;/p&gt;
&lt;p&gt;It located the right identifier in 22 of 22 and formatted it correctly in 8. Those are two different skills and the single score hides one of them. The model understood the task perfectly and could not obey the output contract, which is a completely different engineering problem than &quot;the model is too small,&quot; and it has a completely different fix. (Constrained decoding. I have not tested it. It is the obvious follow-up and it is not in this post.)&lt;/p&gt;
&lt;p&gt;The decoys follow from the same gap. It handed back &lt;code&gt;MEM-443322&lt;/code&gt;, &lt;code&gt;1Z999AA10123456784&lt;/code&gt; and &lt;code&gt;503-555-0142&lt;/code&gt; verbatim, all three. It has no model of the format at all. It returns whatever in the message looks most like an identifier.&lt;/p&gt;
&lt;h4 id=&quot;which-suggests-a-use-for-it-that-is-not-extraction&quot;&gt;Which suggests a use for it that is not extraction&lt;/h4&gt;
&lt;p&gt;A model with perfect recall and no precision is not a bad extractor. It is a candidate generator that has been asked to do the wrong job.&lt;/p&gt;
&lt;p&gt;Twenty-two of twenty-two located, eight of twenty-two formatted. Put that output through the regex as a validator rather than trusting it directly, and you get the model's recall with the pattern's precision. The model proposes anything that looks like an identifier, including format variants the pattern was never written for. The pattern rejects anything malformed.&lt;/p&gt;
&lt;p&gt;Crucially, neither half of that pipeline can produce the error the 1.5B model produced alone. The model cannot cause a false negative because it does not decide, and the regex cannot invent because it only matches.&lt;/p&gt;
&lt;p&gt;I have not tested that composition and it is not in these numbers. But it is the one place in the extraction result where a small model has a job worth giving it, and it is a job where being sloppy is free.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Verdict for extraction itself: Unnecessary.&lt;/strong&gt; Write the regex.&lt;/p&gt;
&lt;h3 id=&quot;classification-routing-a-message-to-one-of-six-queues&quot;&gt;Classification: routing a message to one of six queues&lt;/h3&gt;
&lt;p&gt;Appointment, billing, availability, complaint, question, escalate. Fifty messages. The instruction said to use &lt;code&gt;escalate&lt;/code&gt; when the message needs a person, including when you cannot tell what it is about.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;qwen2.5:1.5b &lt;/th&gt;&lt;th&gt;smollm2:360m&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Overall &lt;/td&gt;&lt;td&gt;23/50 (46%) &lt;/td&gt;&lt;td&gt;15/50 (30%)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Labels used &lt;/td&gt;&lt;td&gt;6 of 6 &lt;/td&gt;&lt;td&gt;3 of 6&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Most common label &lt;/td&gt;&lt;td&gt;complaint, 38% &lt;/td&gt;&lt;td&gt;escalate, &lt;strong&gt;86%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Wrong and not an escalation &lt;/td&gt;&lt;td&gt;&lt;strong&gt;26&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;3 (meaningless)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The 360M model answered &lt;code&gt;escalate&lt;/code&gt; to 43 of 50 messages, which is not classification, and there is nothing further to say about it here. The rest of this section is the 1.5B model.&lt;/p&gt;
&lt;p&gt;It is classifying, and it is not good at it. Forty-six percent, with twenty-six messages routed confidently to a wrong queue that was not the safe one.&lt;/p&gt;
&lt;p&gt;Read those twenty-six and the verdict writes itself:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Someone slipped on the steps by the barrel room and hurt their wrist →&lt;br&gt;
&lt;strong&gt;complaint&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;A formal notice regarding a trademark → &lt;strong&gt;complaint&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&quot;I have a severe sulfite allergy, what is actually in the wine&quot; →&lt;br&gt;
&lt;strong&gt;complaint&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;A reporter asking about the 2024 harvest → &lt;strong&gt;question&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;A distributor wanting to carry the wine in forty stores → &lt;strong&gt;question&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;An injury report, a legal notice and an allergy question, all routed to the complaints queue, confidently, with no escalation. All five billing messages about money problems also went to complaints.&lt;/p&gt;
&lt;p&gt;Forty-six percent sounds like something you could work with. Twenty-six confident misroutes including an injury and a legal notice is not something you can work with.&lt;/p&gt;
&lt;h4 id=&quot;and-then-i-broke-the-score-down-by-class&quot;&gt;And then I broke the score down by class&lt;/h4&gt;
&lt;p&gt;This is the part I nearly missed, and it is the most useful thing in the experiment.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Label &lt;/th&gt;&lt;th&gt;Correct &lt;/th&gt;&lt;th&gt;Of&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;complaint &lt;/td&gt;&lt;td&gt;6 &lt;/td&gt;&lt;td&gt;6&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;question &lt;/td&gt;&lt;td&gt;9 &lt;/td&gt;&lt;td&gt;10&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;availability &lt;/td&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;6&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;appointment &lt;/td&gt;&lt;td&gt;4 &lt;/td&gt;&lt;td&gt;9&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;billing &lt;/td&gt;&lt;td&gt;1 &lt;/td&gt;&lt;td&gt;7&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;escalate &lt;/td&gt;&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;12&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two classes near perfect. Four poor. One at zero.&lt;/p&gt;
&lt;p&gt;The split is not difficulty, and it is not class size. It is what kind of label each one is.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;complaint&lt;/code&gt; and &lt;code&gt;question&lt;/code&gt; are &lt;strong&gt;topics&lt;/strong&gt;. You can tell what a message is from the words in it. Someone complaining sounds like someone complaining.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;escalate&lt;/code&gt; is a &lt;strong&gt;routing decision&lt;/strong&gt;. Nothing in the content of a message makes it an escalation. It is an escalation because of what should happen next. &quot;I have a severe sulfite allergy, what is actually in the wine&quot; and &quot;what time do you close&quot; are both questions about the business, with the same grammar and the same tone, and one of them is a liability event.&lt;/p&gt;
&lt;p&gt;The model did topics at 90 to 100 percent and routing decisions at zero. And &lt;code&gt;billing&lt;/code&gt; sat in the middle for exactly the same reason: a message about being charged twice &lt;em&gt;is&lt;/em&gt; a complaint, as text. The label is about which department owns the problem, which is not a property of the sentence.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Small models answer &quot;what is this about.&quot; They do not answer &quot;what should we do about it.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That reframes the five examples above. They are not five unlucky misroutings. All five are escalations, and escalate scored zero of twelve. The model was never going to find them, because finding them requires knowing a consequence that the text does not contain.&lt;/p&gt;
&lt;p&gt;Two caveats on how hard to lean on this. The classes hold between six and twelve cases each, so &quot;six of six&quot; is six cases and the individual numbers are thin. And this is one model. The pattern across all six classes is consistent enough that I believe the explanation, but I am offering it as the best account of the data rather than as a measurement.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Verdict: Insufficient.&lt;/strong&gt; And the actionable version: if one of your labels means &quot;a human needs to see this,&quot; a small model will not find it. Build that one as a rule.&lt;/p&gt;
&lt;h3 id=&quot;grounded-qa-answering-a-question-from-four-documents&quot;&gt;Grounded QA: answering a question from four documents&lt;/h3&gt;
&lt;p&gt;Fifteen questions against a 650-token corpus: club policy, tasting room, production notes, shipping. Eight are answerable from one passage, three require combining two, and four have no answer in the material at all.&lt;/p&gt;
&lt;p&gt;I read all thirty answers by hand against the corpus. That is the only way to score this, and I will say why in a moment.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;qwen2.5:1.5b &lt;/th&gt;&lt;th&gt;smollm2:360m&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Single passage (8) &lt;/td&gt;&lt;td&gt;&lt;strong&gt;8/8&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;7/8&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Two passage (3) &lt;/td&gt;&lt;td&gt;1/3 &lt;/td&gt;&lt;td&gt;0/3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Answerable total (11)&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;9/11&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;7/11&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Correctly said &quot;not in the material&quot; (4) &lt;/td&gt;&lt;td&gt;2/4 &lt;/td&gt;&lt;td&gt;0/4&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Three boundaries separate cleanly here, and they are the useful result.&lt;/p&gt;
&lt;h4 id=&quot;retrieval-works&quot;&gt;Retrieval works&lt;/h4&gt;
&lt;p&gt;The 1.5B model answered all eight single-passage questions correctly. Six bottles in the Vineyard tier. Twenty dollars per person. September 9th. Thirty-four states. No dogs indoors. 21.8 Brix on the 12th. Twenty-five dollar restocking fee. Ten people on the barrel room tour.&lt;/p&gt;
&lt;p&gt;Asked what happens if you join the club and immediately want out, it returned the two sentences that answer the question and stopped:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Cancellation requires one completed shipment. Members who cancel before receiving a shipment are charged for that shipment.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That is competent lookup across four documents, on a $170 computer, with the wifi switched off. I did not expect it and it is the most encouraging thing in the weekend.&lt;/p&gt;
&lt;h4 id=&quot;combination-does-not&quot;&gt;Combination does not&lt;/h4&gt;
&lt;p&gt;One club member brings five guests on a Saturday. Club policy waives the tasting fee for the member plus three guests. The tasting room charges twenty dollars a head. Six people, four waived, two paying: forty dollars.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Both models answered twenty.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Neither could hold &quot;plus three guests&quot; against a party of six and subtract. The 1.5B model did get the second half right, correctly working out that a party of six is not a group &lt;em&gt;larger&lt;/em&gt; than six and therefore needs no reservation, which is the only piece of multi-document reasoning either model performed all weekend.&lt;/p&gt;
&lt;p&gt;The second two-passage question is the one I keep thinking about. Asked why someone would not find a 2024 rose and what they would be offered instead, the 1.5B model quoted the corpus exactly, and then:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;...the reference material states that &quot;We did not bottle a rose in 2024. The fruit that would normally go to rose was needed to make volume on the estate Pinot Noir.&quot; Therefore, the 2024 rose would be replaced with the fruit that would typically go into the estate Pinot Noir production.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The quotation is verbatim and right. The next sentence reverses the only relationship in it. The fruit went &lt;em&gt;into&lt;/em&gt; the Pinot Noir, so what you would be offered is the Pinot Noir. You cannot be offered fruit.&lt;/p&gt;
&lt;p&gt;Correct retrieval and inverted inference, in consecutive sentences, with the evidence on screen between them.&lt;/p&gt;
&lt;p&gt;The 360M model's multi-document failures are worse than wrong. They are contaminated. It took the fourteen-days-notice rule, which governs skipping a club shipment, and applied it to booking a tasting room reservation. It reached into the shipping document to answer a cancellation question. Given more documents to search, it finds more wrong ones.&lt;/p&gt;
&lt;h4 id=&quot;knowing-the-answer-is-absent-is-the-weakest-link&quot;&gt;Knowing the answer is absent is the weakest link&lt;/h4&gt;
&lt;p&gt;Four questions have no answer in the corpus, chosen to be things a reader would reasonably expect a winery to have written down: a wine's alcohol percentage, who the winemaker is, what a club tier costs, what yeast was used.&lt;/p&gt;
&lt;p&gt;The 1.5B model abstained on two and fabricated two. The 360M model fabricated all four.&lt;/p&gt;
&lt;p&gt;And the fabrications are a more dangerous species than invention. Asked for the alcohol percentage of the 2024 estate Pinot Noir, the 1.5B model answered:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The 2024 estate Pinot Noir had a Brix range of 23.1 to 24.6 at pick.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Every component of that is true. Right wine, right vintage, right numbers, right document. Brix is sugar at harvest and has nothing to do with alcohol in the bottle. It reached for the nearest real fact instead of making one up, and produced something that reads as expertise while answering a question nobody asked. If you do not know what Brix is, that answer is indistinguishable from a correct one.&lt;/p&gt;
&lt;p&gt;Both models made the same mistake on what the Cellar tier costs: both answered &quot;twelve bottles per quarter.&quot; Grounding gave them the right tier and they handed back the quantity in place of the price.&lt;/p&gt;
&lt;h4 id=&quot;the-one-answer-the-evidence-did-not-change&quot;&gt;The one answer the evidence did not change&lt;/h4&gt;
&lt;p&gt;The 360M model, asked whether a dog can come inside the tasting room:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Yes, you can bring a dog inside the tasting room.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The corpus says the patio is dog friendly and the indoor room is not, service animals excepted. I have the same answer from a run where the model had no corpus at all, and it is identical byte for byte. Having the right answer directly in the prompt changed nothing.&lt;/p&gt;
&lt;p&gt;In a category where being wrong means a family drives out to a winery and gets turned around at the door.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Verdict: Marginal.&lt;/strong&gt; Usable with a person reading the output. Not usable without one.&lt;/p&gt;
&lt;h3 id=&quot;what-it-costs&quot;&gt;What it costs&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Workflow &lt;/th&gt;&lt;th&gt;Model &lt;/th&gt;&lt;th&gt;tok/s &lt;/th&gt;&lt;th&gt;Prompt &lt;/th&gt;&lt;th&gt;Answer &lt;/th&gt;&lt;th&gt;Median call&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;extraction &lt;/td&gt;&lt;td&gt;qwen2.5:1.5b &lt;/td&gt;&lt;td&gt;4.3 &lt;/td&gt;&lt;td&gt;92 &lt;/td&gt;&lt;td&gt;9 &lt;/td&gt;&lt;td&gt;5.0 s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;classification &lt;/td&gt;&lt;td&gt;qwen2.5:1.5b &lt;/td&gt;&lt;td&gt;7.5 &lt;/td&gt;&lt;td&gt;104 &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;2.9 s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;QA &lt;/td&gt;&lt;td&gt;qwen2.5:1.5b &lt;/td&gt;&lt;td&gt;3.7 &lt;/td&gt;&lt;td&gt;708 &lt;/td&gt;&lt;td&gt;15 &lt;/td&gt;&lt;td&gt;6.9 s&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two things in here surprised me.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The compute tax does not track parameter count.&lt;/strong&gt; The 1.5B model is &lt;em&gt;slower&lt;/em&gt; than the 360M on extraction, 4.3 against 5.9 tokens per second, and &lt;em&gt;faster&lt;/em&gt; on classification, 7.5 against 6.2, because on classification it answers in two tokens where the smaller model takes six. Four times the parameters, and which one wins depends entirely on how much each one decides to say.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A bigger prompt is cheap. A longer answer is what you pay for.&lt;/strong&gt; I have a clean comparison here by accident: an earlier QA run went out with 97-token prompts and the real one with 708. Seven times the prompt added fifteen percent to the wait before output started, because prompt evaluation parallelizes. The latency increase came from answers three times longer, plus a twenty-one percent drop in per-token generation speed from attending over a longer context. Median latency went from 3.6 to 6.9 seconds and the slowest single call took ninety-eight.&lt;/p&gt;
&lt;p&gt;If you are sizing a local system, that is the inversion to internalize. Stuffing more context in is affordable. Letting the model be chatty is not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;And check your thermals, which is not optional advice.&lt;/strong&gt; My first attempt at this ran with heat sinks and no fan. Thirty calls to a 360M model was enough to hit the Pi 4's soft temperature limit and start capping the clock. &lt;code&gt;vcgencmd get_throttled&lt;/code&gt; returned &lt;code&gt;0xe0000&lt;/code&gt;. I threw those latency numbers away.&lt;/p&gt;
&lt;p&gt;Nothing in Ollama's output, my scorer, or the result files said a word about it. Every number on screen was internally consistent and quietly describing a machine that was slowing down. The only way to find out was to ask the firmware a question I had to already suspect the answer to. Buy the fan.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;And it does run with the network off.&lt;/strong&gt; I switched the radio off, proved there was no route out, and ran one call from each workflow. All three worked. The claim that supports is narrow and worth stating carefully: this&lt;br&gt;
configuration, on this afternoon, ran with no route to the internet. It is not a claim that local models are private or that this setup is secure, and a Pi can be misconfigured as thoroughly as anything in a data center. But for the specific question of whether the inference needs the network, it does not.&lt;/p&gt;
&lt;h3 id=&quot;so-do-you-actually-need-an-llm-&quot;&gt;So: do you actually need an LLM?&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Workload &lt;/th&gt;&lt;th&gt;Verdict&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Pull a known-format identifier out of text &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Unnecessary&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Route a message to one of six queues &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Insufficient&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Answer a question from your own documents &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Marginal&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Three workflows that all sound like language tasks, three different answers, and not one of them is &quot;yes, use a model.&quot;&lt;/p&gt;
&lt;p&gt;And the same line runs through all three. Extraction asks what this string is. Topic labels ask what this message is about. Single-passage lookup asks what the document says. Those went well, or well enough. Escalate asks what we should do, the fee question asks what the customer owes, and the unanswerable questions ask whether we know. Zero of twelve, both models wrong, and two of four.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A small model will tell you what something is. It will not tell you what to do about it.&lt;/strong&gt; Every result here falls on one side or the other of that sentence, and I did not have it written down when I started.&lt;/p&gt;
&lt;p&gt;Which gives four properties worth checking before you reach for one:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The answer is already present in the input.&lt;/strong&gt; Not inferable from it. Present in it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;There is exactly one of it.&lt;/strong&gt; The moment two facts have to be combined, this stops working, and the arithmetic in that tasting fee question is the proof.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The output is short.&lt;/strong&gt; Longer answers are where the latency lives on this hardware, and where the fabrication lives everywhere.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Something other than the model decides whether the output is acceptable.&lt;/strong&gt; A regex, a schema, a person, an index that either returns rows or does not.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Take away any one of those four and the experiment says do not do it. The 46 percent and the zero of twelve are what it looks like when you ask a small model to be the thing that decides.&lt;/p&gt;
&lt;p&gt;And if you have all four, you may not need the model either. That is worth checking first, because it was true for one of my three workflows and I did not expect it to be.&lt;/p&gt;
&lt;h3 id=&quot;where-i-was-wrong&quot;&gt;Where I was wrong&lt;/h3&gt;
&lt;p&gt;Twenty-two predictions, committed before I installed anything. Ten right, eleven wrong, one unresolvable.&lt;/p&gt;
&lt;p&gt;Two of my seven confident calls broke. I was sure the 360M model would get most of the easy extraction cases, and it got 8 of 22 by failing at format rather than at finding. I was sure it would produce invalid output often, and it did so once in fifty, then emitted the same &lt;em&gt;valid&lt;/em&gt; word forty-three times.&lt;/p&gt;
&lt;p&gt;But the number that bothers me is not in the confident column.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Confidence &lt;/th&gt;&lt;th&gt;Right &lt;/th&gt;&lt;th&gt;Wrong&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Confident &lt;/td&gt;&lt;td&gt;5 &lt;/td&gt;&lt;td&gt;2&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Leaning &lt;/td&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;7&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Coin flip &lt;/td&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;2&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;My hunches did worse than my coin flips.&lt;/strong&gt; Two of ten on the calls where I had a feeling but not much behind it, three of five on the ones I explicitly labeled as guesses.&lt;/p&gt;
&lt;p&gt;That ordering is backwards and it is the most useful thing on the sheet. On the coin flips I knew I was guessing, so I guessed cleanly. On the leaning calls I was pattern-matching against intuitions about model behavior that turned out not to describe these models at all, and the feeling of having a reason was the problem. If I had marked all twelve of those as coin flips I would have scored better.&lt;/p&gt;
&lt;p&gt;I will take that over a clean result. The honest version of this exercise is not &quot;here are the numbers,&quot; it is &quot;here is what I believed, here is what happened, and here is the gap.&quot;&lt;/p&gt;
&lt;p&gt;The gap, this time, was mostly in the hunches.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The harness that produced these numbers had five measurement flaws in it, none of which manufactured an entire finding out of a missing directory and nearly got published. That is a separate post.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Everything here is reproducible: fixtures, runner, scorer, the committed predictions and the run record are at&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/kenwalger/right-size-the-model&quot;&gt;github.com/kenwalger/right-size-the-model&lt;/a&gt;.&lt;br&gt;
Fixture set v1. Read &lt;code&gt;LIMITATIONS.md&lt;/code&gt; before citing any of these numbers:&lt;br&gt;
two models, one board, one quantization, ninety-five cases. This is an illustration of a method, not a benchmark.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30818/do-you-actually-need-an-llm</guid>
<pubDate>Tue, 13 Oct 2026 14:17:00 +0000</pubDate>
</item>
<item>
<title>Pytest in Practice</title>
<link>https://coderlegion.com/30938/pytest-in-practice</link>
<description>&lt;p&gt;I've been learning Python by building a video clipping CLI application, and along the way I've ended up learning a lot more than I expected.&lt;/p&gt;
&lt;p&gt;Some of it was straightforward. Some of it made me stop and think, especially when I first started writing tests.&lt;/p&gt;
&lt;p&gt;Things like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How does pytest even know which functions are tests?&lt;/li&gt;
&lt;li&gt;Why does everyone keep putting things in &lt;code&gt;conftest.py&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;When should I use a fixture?&lt;/li&gt;
&lt;li&gt;What's the difference between &lt;code&gt;patch&lt;/code&gt; and &lt;code&gt;monkeypatch&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Why did my mock not work when I was &lt;em&gt;sure&lt;/em&gt; I patched the right thing?&lt;/li&gt;
&lt;li&gt;When should I mock something, and when should I just run the real thing?&lt;/li&gt;
&lt;li&gt;What's the point of parametrization when I could just use a loop?&lt;/li&gt;
&lt;li&gt;How do I debug one failing test without running the entire suite every time?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These are the kinds of things that can feel like arbitrary rules when you're first encountering them.&lt;/p&gt;
&lt;p&gt;Once I understood the reasoning behind them, though, pytest started making a lot more sense.&lt;/p&gt;
&lt;p&gt;So this isn't meant to be a complete pytest reference or an explanation of every feature pytest has. It's more like a collection of the things that finally &quot;clicked&quot; for me while testing a real Python project.&lt;/p&gt;
&lt;p&gt;The examples are based on my video clipping CLI, so you'll see things like FFmpeg, video metadata, audio extraction, transcription, temporary files, external dependencies, and slow tests.&lt;/p&gt;
&lt;p&gt;Hopefully, if you're also learning Python by building something rather than just following tutorials, some of these lessons save you a few of the &quot;why does it work like &lt;em&gt;that&lt;/em&gt;?&quot; moments I had.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;installing-pytest&quot;&gt;Installing pytest&lt;/h3&gt;
&lt;p&gt;If you're using &lt;code&gt;uv&lt;/code&gt;, installing pytest and coverage support is straightforward:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv add --dev pytest pytest-cov
uv run pytest --version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You now have everything needed to start writing and running tests.&lt;/p&gt;
&lt;h3 id=&quot;your-first-test&quot;&gt;Your First Test&lt;/h3&gt;
&lt;p&gt;A pytest test can be nothing more than a function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# tests/core/test_video_inspector.py

from clipper.core.video_inspector import get_video_metadata


def test_get_video_metadata_returns_none_on_missing_file():
    result = get_video_metadata(&quot;does_not_exist.mp4&quot;)
    assert result is None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run it with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's it.&lt;/p&gt;
&lt;p&gt;There is no base class, no registration, and no decorator required.&lt;/p&gt;
&lt;p&gt;pytest finds the test because:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the file starts with &lt;code&gt;test_&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;the function starts with &lt;code&gt;test_&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That naming convention is the mechanism.&lt;/p&gt;
&lt;h3 id=&quot;how-does-pytest-find-your-tests-&quot;&gt;How Does pytest Find Your Tests?&lt;/h3&gt;
&lt;p&gt;pytest automatically walks the current directory and its subdirectories looking for test files.&lt;/p&gt;
&lt;p&gt;By default, it looks for files matching patterns such as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;test_*.py
*_test.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inside those files, it looks for functions matching:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;test_*
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and, inside classes whose names start with &lt;code&gt;Test*&lt;/code&gt;, methods that also match &lt;code&gt;test_*&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;So this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;tests/
└── core/
    └── test_video_inspector.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;is enough for pytest to discover the file automatically. You don't need to register the test anywhere.&lt;/p&gt;
&lt;p&gt;This is also why commands such as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest --cov=src
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;work without explicitly listing every test file. pytest's discovery mechanism does the work.&lt;/p&gt;
&lt;h3 id=&quot;assertions-just-use-assert&quot;&gt;Assertions: Just Use &lt;code&gt;assert&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;One of the nicest things about pytest is that you use Python's built-in &lt;code&gt;assert&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_fps_math():
    assert round(29.970029970029969, 2) == 29.97
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You don't need a collection of methods like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assertEqual(...)
assertTrue(...)
assertFalse(...)
assertIn(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instead, pytest rewrites &lt;code&gt;assert&lt;/code&gt; statements when running tests so that failures contain useful information about what each side evaluated to. That makes a failed assertion much more informative than simply:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Assertion failed
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;comparing-floats-with-pytest-approx&quot;&gt;Comparing Floats with &lt;code&gt;pytest.approx&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Don't compare floating-point calculations using exact equality.&lt;/p&gt;
&lt;p&gt;Instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import pytest


def test_duration_seconds():
    assert 125.5 == pytest.approx(float(&quot;125.500001&quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Floating-point calculations rarely land exactly where you expect. This matters especially when dealing with things like frame rates, durations, timestamps, or calculations involving &lt;code&gt;Fraction&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Without &lt;code&gt;pytest.approx&lt;/code&gt;, you might end up writing this everywhere:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assert abs(actual - expected) &amp;lt; 0.001
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;pytest.approx&lt;/code&gt; gives you the tolerance without repeating that boilerplate.&lt;/p&gt;
&lt;h4 id=&quot;pytest-approx-works-with-nested-structures&quot;&gt;&lt;code&gt;pytest.approx&lt;/code&gt; Works with Nested Structures&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;pytest.approx&lt;/code&gt; isn't limited to individual numbers. You can use it when comparing nested structures:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_transcribe_matches_golden_output(
    sample_speech_path,
    sample_transcript,
):
    result = transcribe(str(sample_speech_path))

    assert result == pytest.approx(sample_transcript)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is particularly useful for regression or &quot;golden file&quot; tests. Imagine a transcript like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;{
    &quot;segments&quot;: [
        {
            &quot;start&quot;: 0.123456,
            &quot;end&quot;: 1.987654,
            &quot;words&quot;: [
                {&quot;word&quot;: &quot;hello&quot;, &quot;start&quot;: 0.123},
                {&quot;word&quot;: &quot;world&quot;, &quot;start&quot;: 0.456},
            ],
        }
    ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You don't want to write a loop comparing every timestamp manually. &lt;code&gt;pytest.approx&lt;/code&gt; can recursively compare numeric values with tolerance while still using normal equality for strings and other values — so it's practical to compare an entire expected transcript while allowing for harmless floating-point differences.&lt;/p&gt;
&lt;h3 id=&quot;testing-that-an-error-is-actually-raised&quot;&gt;Testing That an Error Is Actually Raised&lt;/h3&gt;
&lt;p&gt;When the expected behavior is for a function to raise an exception, use &lt;code&gt;pytest.raises&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import pytest


def test_extract_audio_raises_on_bad_codec():
    with pytest.raises(ffmpeg.Error):
        extract_audio(
            &quot;in.mp4&quot;,
            &quot;out.wav&quot;,
            codec=&quot;not_a_real_codec&quot;,
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This test verifies two things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The code raises an exception.&lt;/li&gt;
&lt;li&gt;The exception is the expected type.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The test fails if the function doesn't raise anything. It also fails if it raises a different exception type. Both outcomes are useful information.&lt;/p&gt;
&lt;h4 id=&quot;pytest-raises-only-works-when-the-exception-escapes&quot;&gt;&lt;code&gt;pytest.raises&lt;/code&gt; Only Works When the Exception Escapes&lt;/h4&gt;
&lt;p&gt;There's an important distinction here. Suppose your function catches its own exception:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def extract_audio(...):
    try:
        ...
    except ffmpeg.Error:
        logger.error(...)
        return None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The exception never reaches the caller. Therefore, this won't work:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with pytest.raises(ffmpeg.Error):
    extract_audio(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There's nothing left for &lt;code&gt;pytest.raises&lt;/code&gt; to catch. Instead, test the function's actual contract:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;result = extract_audio(...)

assert result is None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And, if logging is part of the behavior you care about, use &lt;code&gt;caplog&lt;/code&gt; to verify that the expected error was logged.&lt;/p&gt;
&lt;p&gt;A useful rule of thumb:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;If the function returns &lt;code&gt;None&lt;/code&gt; from its &lt;code&gt;except&lt;/code&gt; block, test the return value rather than expecting an exception.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;parametrize-one-test-many-inputs&quot;&gt;Parametrize: One Test, Many Inputs&lt;/h3&gt;
&lt;p&gt;Suppose you need to test several timestamp formats. You could write three tests:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_timestamp_one():
    ...


def test_timestamp_two():
    ...


def test_timestamp_three():
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But the test logic is identical. That's exactly what &lt;code&gt;pytest.mark.parametrize&lt;/code&gt; is for:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@pytest.mark.parametrize(
    &quot;timestamp,expected_seconds&quot;,
    [
        (&quot;00:00:01&quot;, 1.0),
        (&quot;00:01:00&quot;, 60.0),
        (&quot;01:00:00&quot;, 3600.0),
    ],
)
def test_validate_timestamp_parses_valid_formats(
    timestamp,
    expected_seconds,
):
    assert validate_timestamp(timestamp) == pytest.approx(expected_seconds)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Think of it like a form letter: the first argument names the blanks to fill in (&lt;code&gt;timestamp&lt;/code&gt;, &lt;code&gt;expected_seconds&lt;/code&gt;), and the second argument is a stack of filled-in versions. pytest prints one out and runs the test for each one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;test_validate_timestamp_parses_valid_formats[00:00:01-1.0]
test_validate_timestamp_parses_valid_formats[00:01:00-60.0]
test_validate_timestamp_parses_valid_formats[01:00:00-3600.0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each case can pass or fail independently.&lt;/p&gt;
&lt;h4 id=&quot;why-independence-matters&quot;&gt;Why independence matters&lt;/h4&gt;
&lt;p&gt;You could achieve something similar with a loop:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_timestamps():
    for timestamp, expected in cases:
        assert validate_timestamp(timestamp) == expected
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But there's a major difference: a normal loop stops at the first failure. With parametrization, pytest runs every case. So one test run tells you exactly which inputs are broken instead of merely telling you:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;test_timestamps failed
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;when-should-you-use-it-&quot;&gt;When should you use it?&lt;/h4&gt;
&lt;p&gt;The signal is simple:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;If you're about to write several nearly identical test functions that differ only in their input values, consider &lt;code&gt;parametrize&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It works just as well for valid inputs as it does for malformed or invalid ones.&lt;/p&gt;
&lt;h3 id=&quot;grouping-tests-in-a-class&quot;&gt;Grouping Tests in a Class&lt;/h3&gt;
&lt;p&gt;pytest supports test classes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class TestGetVideoMetadata:
    def test_returns_none_for_missing_file(self):
        assert get_video_metadata(&quot;missing.mp4&quot;) is None

    def test_has_audio_true_when_audio_stream_present(self):
        ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But classes aren't mandatory. Use them when they provide something useful, such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;shared setup&lt;/li&gt;
&lt;li&gt;fixtures scoped to the group&lt;/li&gt;
&lt;li&gt;applying a marker to multiple tests&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Don't use a class purely because it feels more organized. A well-named test file already provides plenty of organization.&lt;/p&gt;
&lt;h4 id=&quot;an-important-detail-about-test-classes&quot;&gt;An important detail about test classes&lt;/h4&gt;
&lt;p&gt;Each test method gets a fresh instance of the class. So this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class TestSomething:
    def test_one(self):
        self.value = 1

    def test_two(self):
        assert self.value == 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;is not valid. &lt;code&gt;test_two&lt;/code&gt; doesn't receive the instance used by &lt;code&gt;test_one&lt;/code&gt;. That's intentional — tests should be isolated from one another. Depending on mutable state from another test creates order-dependent and difficult-to-debug failures.&lt;/p&gt;
&lt;h3 id=&quot;fixtures&quot;&gt;Fixtures&lt;/h3&gt;
&lt;p&gt;Fixtures are one of pytest's most useful features. A fixture is a function that provides something a test needs — think of it like an ingredient a recipe asks for by name. You don't prep it inside the recipe itself; you just say &quot;I need &lt;code&gt;sample_video_path&lt;/code&gt;&quot; and pytest hands it to you already made.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# tests/conftest.py

import pytest
from pathlib import Path


@pytest.fixture
def sample_video_path() -&amp;gt; Path:
    return Path(__file__).parent / &quot;fixtures&quot; / &quot;sample.mp4&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A test can then request it simply by putting the fixture name in its parameters:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# tests/core/test_video_inspector.py

def test_metadata_against_real_file(sample_video_path):
    result = get_video_metadata(str(sample_video_path))

    assert result is not None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;pytest sees &lt;code&gt;sample_video_path&lt;/code&gt; in the test signature, finds the fixture with that name, runs it, and passes the result to the test. No import is required.&lt;/p&gt;
&lt;h3 id=&quot;why-conftest-py-matters&quot;&gt;Why &lt;code&gt;conftest.py&lt;/code&gt; Matters&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;conftest.py&lt;/code&gt; is automatically discovered by pytest. Fixtures defined there are available to test files in the same directory and its subdirectories — it works like a shared pantry that any test file in that part of the tree can pull from without asking for a delivery.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;tests/
├── conftest.py
├── core/
│   └── test_video_inspector.py
└── api/
    └── test_transcription.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A fixture defined in &lt;code&gt;tests/conftest.py&lt;/code&gt; is available to both &lt;code&gt;tests/core/&lt;/code&gt; and &lt;code&gt;tests/api/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;You can also have a more specific &lt;code&gt;conftest.py&lt;/code&gt; deeper in the tree:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;tests/
├── conftest.py
└── core/
    ├── conftest.py
    └── test_video_inspector.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The deeper fixture configuration can then be used specifically by that subtree. A useful convention is: root &lt;code&gt;conftest.py&lt;/code&gt; for shared fixtures, nested &lt;code&gt;conftest.py&lt;/code&gt; files for fixtures that are genuinely local to a particular test area.&lt;/p&gt;
&lt;h3 id=&quot;fixture-scope&quot;&gt;Fixture Scope&lt;/h3&gt;
&lt;p&gt;Fixtures don't necessarily have to run for every test. You can control their lifetime:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@pytest.fixture(scope=&quot;function&quot;)  # default
@pytest.fixture(scope=&quot;module&quot;)    # once per test file
@pytest.fixture(scope=&quot;session&quot;)   # once per entire test run
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The default is &lt;code&gt;scope=&quot;function&quot;&lt;/code&gt;, which means a fresh fixture is created for every test. Prefer the default unless there is a good reason to reuse the fixture.&lt;/p&gt;
&lt;p&gt;A broader scope can make sense when setup is genuinely expensive and safe to share. For example, generating a synthetic sample video once per session may be much cheaper than generating it once for every test.&lt;/p&gt;
&lt;h3 id=&quot;built-in-fixtures-worth-knowing&quot;&gt;Built-in Fixtures Worth Knowing&lt;/h3&gt;
&lt;p&gt;pytest provides several useful fixtures out of the box.&lt;/p&gt;
&lt;h4 id=&quot;tmp-path&quot;&gt;&lt;code&gt;tmp_path&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Provides a unique temporary directory for each test. Great for anything that needs to write files.&lt;/p&gt;
&lt;h4 id=&quot;caplog&quot;&gt;&lt;code&gt;caplog&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Captures logging output. Useful when you want to verify that something logged an error or warning.&lt;/p&gt;
&lt;h4 id=&quot;capsys&quot;&gt;&lt;code&gt;capsys&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Captures stdout and stderr. Useful for testing CLI output.&lt;/p&gt;
&lt;h4 id=&quot;monkeypatch&quot;&gt;&lt;code&gt;monkeypatch&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Temporarily changes attributes, environment variables, dictionary entries, and other values. pytest automatically restores the changes after the test.&lt;/p&gt;
&lt;p&gt;You can inspect available fixtures with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest --fixtures
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;tmp-path-in-practice&quot;&gt;&lt;code&gt;tmp_path&lt;/code&gt; in Practice&lt;/h3&gt;
&lt;p&gt;Suppose you want to verify that audio extraction creates an actual file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_extract_audio_writes_real_file(
    sample_video_path,
    tmp_path,
):
    output_path = tmp_path / &quot;output.wav&quot;

    result = extract_audio(
        str(sample_video_path),
        str(output_path),
    )

    assert result == str(output_path)
    assert output_path.exists()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is an integration-style test. It actually runs FFmpeg and checks that a real file was created. But because &lt;code&gt;tmp_path&lt;/code&gt; is temporary, the test doesn't leave generated files scattered around your project — pytest creates the directory and handles its cleanup.&lt;/p&gt;
&lt;h3 id=&quot;mocking-two-tools-different-jobs&quot;&gt;Mocking: Two Tools, Different Jobs&lt;/h3&gt;
&lt;p&gt;Python gives you &lt;code&gt;unittest.mock&lt;/code&gt;. pytest gives you &lt;code&gt;monkeypatch&lt;/code&gt;. They overlap, but each one shines in a different situation.&lt;/p&gt;
&lt;h4 id=&quot;unittest-mock-patch&quot;&gt;&lt;code&gt;unittest.mock.patch&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Use &lt;code&gt;patch&lt;/code&gt; when you want a more capable mock, particularly when you need to inspect how something was called.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from unittest.mock import patch


def test_metadata_no_audio():
    fake_probe = {
        &quot;format&quot;: {
            &quot;filename&quot;: &quot;silent.mp4&quot;,
            &quot;duration&quot;: &quot;10.0&quot;,
        },
        &quot;streams&quot;: [
            {
                &quot;codec_type&quot;: &quot;video&quot;,
                &quot;r_frame_rate&quot;: &quot;25/1&quot;,
            },
        ],
    }

    with patch(
        &quot;clipper.core.video_inspector.ffmpeg.probe&quot;,
        return_value=fake_probe,
    ):
        result = get_video_metadata(&quot;silent.mp4&quot;)

    assert result[&quot;has_audio&quot;] is False
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;patch&lt;/code&gt; temporarily replaces the target with a mock. It is particularly useful when you want to assert things like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;mock_input.assert_called_once_with(&quot;in.mp4&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;monkeypatch-1&quot;&gt;&lt;code&gt;monkeypatch&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;monkeypatch&lt;/code&gt; is pytest's own fixture. It's particularly convenient for simple temporary substitutions.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_uses_default_bitrate_env_var(monkeypatch):
    monkeypatch.setenv(
        &quot;CLIPPER_DEFAULT_BITRATE&quot;,
        &quot;256k&quot;,
    )

    assert get_default_bitrate() == &quot;256k&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There's no context manager and no manual cleanup — pytest automatically restores the environment variable after the test. You can also replace attributes with &lt;code&gt;monkeypatch.setattr(...)&lt;/code&gt; or dictionary entries with &lt;code&gt;monkeypatch.setitem(...)&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;patch-vs-monkeypatch-a-practical-mental-model&quot;&gt;&lt;code&gt;patch&lt;/code&gt; vs &lt;code&gt;monkeypatch&lt;/code&gt;: A Practical Mental Model&lt;/h3&gt;
&lt;p&gt;Imagine your code is a toy robot and you want to test what happens when one of its components behaves differently. You don't want to actually break the component — you just want to temporarily pretend that it behaves differently.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;monkeypatch&lt;/code&gt; is like putting a temporary sticky note over the component: &quot;For this test, pretend this value is different.&quot; pytest removes the note when the test finishes.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;patch&lt;/code&gt; is another version of the same idea, but with a much more powerful mock object behind it. &lt;code&gt;patch&lt;/code&gt; can easily track call count, arguments, return values, exceptions, and chained calls.&lt;/p&gt;
&lt;p&gt;Both clean themselves up reliably when used correctly. With &lt;code&gt;patch&lt;/code&gt;, the common form in this project is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with patch(...):
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The mock is restored automatically when the block ends, even if the test fails.&lt;/p&gt;
&lt;h4 id=&quot;which-one-should-you-use-&quot;&gt;Which One Should You Use?&lt;/h4&gt;
&lt;p&gt;A practical rule:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Need                                                 &lt;/th&gt;&lt;th&gt;Use&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Assert how something was called                      &lt;/td&gt;&lt;td&gt;&lt;code&gt;patch&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Check call count or exact arguments                   &lt;/td&gt;&lt;td&gt;&lt;code&gt;patch&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Mock a chained/fluent API                             &lt;/td&gt;&lt;td&gt;&lt;code&gt;patch&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Replace an environment variable                       &lt;/td&gt;&lt;td&gt;&lt;code&gt;monkeypatch&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Temporarily change one attribute                      &lt;/td&gt;&lt;td&gt;&lt;code&gt;monkeypatch.setattr(...)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Temporarily change a dictionary entry                 &lt;/td&gt;&lt;td&gt;&lt;code&gt;monkeypatch.setitem(...)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Replace an external dependency and inspect the mock    &lt;/td&gt;&lt;td&gt;&lt;code&gt;patch&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The important thing isn't to memorize a strict rule. It's to recognize the distinction:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;If you need to inspect how the dependency was used, &lt;code&gt;patch&lt;/code&gt; is usually the natural choice. If you just need to temporarily replace a value or attribute, &lt;code&gt;monkeypatch&lt;/code&gt; is often simpler.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;side-effect-make-a-mock-fail&quot;&gt;&lt;code&gt;side_effect&lt;/code&gt;: Make a Mock Fail&lt;/h3&gt;
&lt;p&gt;Mocks aren't only useful for returning fake successful results. They can also simulate failures.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;return_value&lt;/code&gt; means &quot;always return this.&quot; &lt;code&gt;side_effect&lt;/code&gt; means &quot;raise this exception instead.&quot;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with patch(
    &quot;clipper.core.video_inspector.ffmpeg.probe&quot;,
    side_effect=ffmpeg.Error(
        cmd=&quot;ffprobe&quot;,
        stdout=b&quot;&quot;,
        stderr=b&quot;bad file&quot;,
    ),
):
    result = get_video_metadata(&quot;bad.mp4&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This lets you exercise failure paths without needing to create a genuinely broken video file.&lt;/p&gt;
&lt;h3 id=&quot;mock-the-boundary-not-your-own-logic&quot;&gt;Mock the Boundary, Not Your Own Logic&lt;/h3&gt;
&lt;p&gt;This is probably the most important mocking rule in the entire guide:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Mock the boundary, not the logic you're trying to test.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Suppose your code does this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;probe = ffmpeg.probe(path)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The interesting logic is what your application does with the result. So mock &lt;code&gt;ffmpeg.probe&lt;/code&gt; and provide realistic fake data — don't mock the parsing logic itself. A test that mocks everything, including the thing you're trying to test, can end up proving nothing more than: &quot;my mocks returned what I told them to return.&quot;&lt;/p&gt;
&lt;p&gt;Put another way — you're testing your recipe, not reinventing the oven. External boundaries are good mocking candidates:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;FFmpeg&lt;/li&gt;
&lt;li&gt;HTTP APIs&lt;/li&gt;
&lt;li&gt;databases&lt;/li&gt;
&lt;li&gt;filesystem operations&lt;/li&gt;
&lt;li&gt;cloud services&lt;/li&gt;
&lt;li&gt;external processes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Your own business logic is usually what you want to execute for real.&lt;/p&gt;
&lt;h3 id=&quot;the-most-common-mocking-mistake-patching-the-wrong-path&quot;&gt;The Most Common Mocking Mistake: Patching the Wrong Path&lt;/h3&gt;
&lt;p&gt;This catches a lot of people. Suppose your module contains:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# video_inspector.py

import ffmpeg


def get_video_metadata(path):
    return ffmpeg.probe(path)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You might think you should patch &lt;code&gt;ffmpeg.probe&lt;/code&gt;. But that's not necessarily the correct target — the code under test looks up &lt;code&gt;ffmpeg&lt;/code&gt; in its own module namespace. Therefore, patch where the name is looked up:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;patch(
    &quot;clipper.core.video_inspector.ffmpeg.probe&quot;
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The general rule is:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Patch where the object is looked up, not where it was originally defined.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If you patch the wrong path, the real dependency may still run. That can make a test unexpectedly hit FFmpeg, the network, a database, or some other external system.&lt;/p&gt;
&lt;h3 id=&quot;mocking-a-chained-call&quot;&gt;Mocking a Chained Call&lt;/h3&gt;
&lt;p&gt;Some APIs aren't a single function call. For example, &lt;code&gt;ffmpeg-python&lt;/code&gt; can use a fluent chain:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;ffmpeg.input(...).output(...).overwrite_output().run(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mocking that requires walking the chain. &lt;code&gt;MagicMock&lt;/code&gt; automatically creates mocks for attributes you access, so &lt;code&gt;mock_input.return_value&lt;/code&gt; represents the result of &lt;code&gt;ffmpeg.input(...)&lt;/code&gt;, &lt;code&gt;.output.return_value&lt;/code&gt; represents the next link in the chain, and so on until you reach &lt;code&gt;.run&lt;/code&gt; — the actual operation you want to make fail.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_extract_audio_sad_path(caplog):
    fake_error = ffmpeg.Error(
        cmd=&quot;ffmpeg&quot;,
        stdout=b&quot;&quot;,
        stderr=b&quot;encoding failed&quot;,
    )

    with patch(
        &quot;clipper.core.video_inspector.ffmpeg.input&quot;
    ) as mock_input:
        mock_run = (
            mock_input
            .return_value
            .output
            .return_value
            .overwrite_output
            .return_value
            .run
        )
        mock_run.side_effect = fake_error

        result = extract_audio(
            &quot;in.mp4&quot;,
            &quot;out.wav&quot;,
        )

    assert result is None
    assert &quot;error&quot; in caplog.text.lower()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Walking the chain into its own variable (&lt;code&gt;mock_run&lt;/code&gt;) before setting &lt;code&gt;side_effect&lt;/code&gt; keeps the assignment itself simple and unambiguous — trying to set an attribute in the middle of a long parenthesized chain is a common source of syntax mistakes.&lt;/p&gt;
&lt;h3 id=&quot;organizing-your-test-files&quot;&gt;Organizing Your Test Files&lt;/h3&gt;
&lt;p&gt;A simple and predictable test layout goes a long way. If your source looks like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;src/
└── clipper/
    └── core/
        └── video_inspector.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;mirror it under &lt;code&gt;tests/&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;tests/
└── core/
    └── test_video_inspector.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This makes it immediately obvious where the tests for a source module live.&lt;/p&gt;
&lt;h4 id=&quot;keep-tests-outside-src-&quot;&gt;Keep &lt;code&gt;tests/&lt;/code&gt; Outside &lt;code&gt;src/&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;project/
├── src/
├── tests/
└── pyproject.toml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;rather than:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;src/
├── clipper/
└── tests/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keeping tests outside the package helps avoid accidentally shipping them with your built distribution and keeps the distinction between application code and test code clear.&lt;/p&gt;
&lt;h4 id=&quot;you-usually-don-t-need-init-py&quot;&gt;You Usually Don't Need &lt;code&gt;__init__.py&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;pytest doesn't require your test directories to be Python packages. So this is perfectly fine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;tests/
├── core/
│   └── test_video_inspector.py
└── api/
    └── test_transcription.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No &lt;code&gt;__init__.py&lt;/code&gt; required. There can be edge cases where you intentionally want package semantics, but don't add &lt;code&gt;__init__.py&lt;/code&gt; to test directories just because you think pytest requires it. It doesn't.&lt;/p&gt;
&lt;h4 id=&quot;keep-fixtures-separate-from-test-code&quot;&gt;Keep Fixtures Separate from Test Code&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;tests/
├── conftest.py
├── fixtures/
│   └── sample.mp4
└── core/
    └── test_video_inspector.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This makes the distinction obvious: &lt;code&gt;test_*.py&lt;/code&gt; contains test logic, &lt;code&gt;fixtures/&lt;/code&gt; contains test data.&lt;/p&gt;
&lt;h3 id=&quot;mark-slow-and-external-tests&quot;&gt;Mark Slow and External Tests&lt;/h3&gt;
&lt;p&gt;Not every test costs the same amount to run. A test that calls real FFmpeg is different from a test that checks a pure Python function.&lt;/p&gt;
&lt;p&gt;You can mark integration tests:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@pytest.mark.integration
def test_extract_audio_against_real_file(...):
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then register the marker in &lt;code&gt;pyproject.toml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[tool.pytest.ini_options]
markers = [
    &quot;integration: calls real ffmpeg/ffprobe against a fixture file&quot;,
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you can run the fast suite without integration tests:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pytest -m &quot;not integration&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And run everything with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pytest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gives you a useful distinction between fast unit tests and slower integration tests.&lt;/p&gt;
&lt;h3 id=&quot;debugging-pytest-without-sprinkling-print-everywhere&quot;&gt;Debugging pytest Without Sprinkling &lt;code&gt;print()&lt;/code&gt; Everywhere&lt;/h3&gt;
&lt;p&gt;pytest captures output by default. That can be surprising when you're debugging — a &lt;code&gt;print(result)&lt;/code&gt; that produces nothing on screen. Here are the options you'll reach for most often.&lt;/p&gt;
&lt;h4 id=&quot;-s-show-stdout-stderr&quot;&gt;&lt;code&gt;-s&lt;/code&gt;: Show stdout/stderr&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest -s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This disables output capturing, so your &lt;code&gt;print()&lt;/code&gt; statements become visible. It's useful, although there are usually better debugging tools than adding prints everywhere.&lt;/p&gt;
&lt;h4 id=&quot;-k-run-tests-by-name&quot;&gt;&lt;code&gt;-k&lt;/code&gt;: Run Tests by Name&lt;/h4&gt;
&lt;p&gt;If you're working on audio extraction, there's no reason to run 200 unrelated tests.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest -k &quot;extract_audio&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;pytest runs tests whose names match the expression. This is one of the most useful options during active development.&lt;/p&gt;
&lt;h4 id=&quot;-x-stop-at-the-first-failure&quot;&gt;&lt;code&gt;-x&lt;/code&gt;: Stop at the First Failure&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest -x
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instead of getting a wall of failures, pytest stops after the first one. This is particularly useful when fixing a cascading failure.&lt;/p&gt;
&lt;h4 id=&quot;--pdb-drop-into-the-debugger&quot;&gt;&lt;code&gt;--pdb&lt;/code&gt;: Drop Into the Debugger&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest --pdb
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When a test fails, pytest drops you into an interactive debugger at the point of failure. Inside the debugger, you can inspect variables and control execution — &lt;code&gt;c&lt;/code&gt; continues, &lt;code&gt;q&lt;/code&gt; quits. This can be much faster than trying to predict where a bug is before running the test.&lt;/p&gt;
&lt;h4 id=&quot;-vv-more-verbose-output&quot;&gt;&lt;code&gt;-vv&lt;/code&gt;: More Verbose Output&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest -vv
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Useful when assertions involve large dictionaries, long strings, or parametrized tests and you want more detail in the output.&lt;/p&gt;
&lt;h4 id=&quot;--lf-run-only-the-last-failures&quot;&gt;&lt;code&gt;--lf&lt;/code&gt;: Run Only the Last Failures&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest --lf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--lf&lt;/code&gt; means &quot;last failed.&quot; It reruns tests that failed during the previous run instead of running the entire suite. This is particularly useful when you're fixing several failures one by one.&lt;/p&gt;
&lt;h4 id=&quot;a-practical-debugging-command&quot;&gt;A Practical Debugging Command&lt;/h4&gt;
&lt;p&gt;These options can be combined:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest -k &quot;extract_audio&quot; -x --pdb
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This means: run only tests matching &lt;code&gt;extract_audio&lt;/code&gt;, stop at the first failure, and open the debugger at the failure. That's a very practical development loop.&lt;/p&gt;
&lt;h3 id=&quot;running-a-specific-test&quot;&gt;Running a Specific Test&lt;/h3&gt;
&lt;p&gt;pytest doesn't natively use line numbers to select a test. This isn't standard pytest test selection:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pytest test_file.py:29
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instead, select by node ID:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pytest tests/core/test_video_inspector.py::test_metadata_no_audio
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or use &lt;code&gt;-k&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pytest -k &quot;metadata_no_audio&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Editors such as VS Code and PyCharm can provide &quot;run this test&quot; buttons. They translate your selection into pytest's name-based test selection behind the scenes. There are plugins that provide literal line-number selection, but you generally don't need one.&lt;/p&gt;
&lt;h3 id=&quot;quickly-inspecting-a-return-value&quot;&gt;Quickly Inspecting a Return Value&lt;/h3&gt;
&lt;p&gt;Sometimes you just want to see what a function actually returned. One quick technique is to deliberately make an assertion fail:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_scratch(sample_video_path):
    result = get_video_metadata(str(sample_video_path))

    assert result == {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;code&gt;{}&lt;/code&gt; isn't the actual result, pytest's failure output will show you the value. Delete the temporary test afterward.&lt;/p&gt;
&lt;p&gt;Another option is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest --showlocals
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or the shorthand &lt;code&gt;-l&lt;/code&gt;, which shows local variables when a test fails.&lt;/p&gt;
&lt;h3 id=&quot;dealing-with-slow-tests&quot;&gt;Dealing with Slow Tests&lt;/h3&gt;
&lt;p&gt;Don't guess which tests are slow. Measure them.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest --durations=10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This reports the ten slowest tests after the run. You may discover that one or two tests account for most of the delay. Optimizing everything before measuring can waste the very time you're trying to save.&lt;/p&gt;
&lt;h4 id=&quot;mark-slow-tests&quot;&gt;Mark Slow Tests&lt;/h4&gt;
&lt;p&gt;Tests that load a real model or perform real inference are good candidates for a &lt;code&gt;slow&lt;/code&gt; marker:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@pytest.mark.slow
def test_transcribe_real_audio_produces_valid_shape(
    sample_speech_path,
):
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register the marker:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[tool.pytest.ini_options]
markers = [
    &quot;integration: calls real ffmpeg/ffprobe against a fixture file&quot;,
    &quot;slow: tests that load a real model or run real transcription&quot;,
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then your normal development loop can skip them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pytest -m &quot;not slow&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And your complete suite can still run with &lt;code&gt;pytest&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;caching-can-already-solve-some-performance-problems&quot;&gt;Caching Can Already Solve Some Performance Problems&lt;/h4&gt;
&lt;p&gt;Suppose your application loads a model once at module level, and subsequent calls reuse it. Then only the first test that needs the model may pay the expensive loading cost — the others can reuse the already-loaded instance.&lt;/p&gt;
&lt;p&gt;You can verify whether this is actually happening with &lt;code&gt;uv run pytest --durations=10&lt;/code&gt;. If the first model-related test is slow and subsequent tests are much faster, your existing caching may already be doing the job. If every test is slow, something may be reloading the model when it shouldn't. Measure first.&lt;/p&gt;
&lt;h4 id=&quot;parallelizing-the-suite&quot;&gt;Parallelizing the Suite&lt;/h4&gt;
&lt;p&gt;If the entire suite is slow, rather than just a handful of expensive tests, parallel execution may help.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv add --dev pytest-xdist
uv run pytest -n auto
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-n auto&lt;/code&gt; lets xdist determine a useful number of workers. But don't reach for parallelization automatically — for many projects, simply excluding slow tests during development (&lt;code&gt;pytest -m &quot;not slow&quot;&lt;/code&gt;) is enough.&lt;/p&gt;
&lt;h3 id=&quot;coverage-is-a-flashlight-not-a-scoreboard&quot;&gt;Coverage Is a Flashlight, Not a Scoreboard&lt;/h3&gt;
&lt;p&gt;Coverage is useful. It tells you which lines of code weren't exercised by your tests.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest --cov=src
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This can help you find code paths you haven't thought about. But coverage doesn't tell you whether your tests are good. You can achieve 100% coverage with tests that assert almost nothing meaningful:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_everything():
    my_function()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The code executed. Coverage increases. But the test may tell you almost nothing about whether the behavior is correct. So treat coverage as a flashlight — use it to illuminate parts of the code you haven't tested. Don't treat it as a scoreboard you have to maximize.&lt;/p&gt;
&lt;h3 id=&quot;a-simple-checklist-for-every-new-test&quot;&gt;A Simple Checklist for Every New Test&lt;/h3&gt;
&lt;p&gt;When adding tests for a new function or feature, ask:&lt;/p&gt;
&lt;h5 id=&quot;1-what-s-the-happy-path-&quot;&gt;1. What's the happy path?&lt;/h5&gt;
&lt;p&gt;What should happen when everything is valid?&lt;/p&gt;
&lt;h5 id=&quot;2-what-s-at-least-one-edge-case-&quot;&gt;2. What's at least one edge case?&lt;/h5&gt;
&lt;p&gt;Consider: empty input, missing fields, boundary values, unexpected but valid combinations.&lt;/p&gt;
&lt;h5 id=&quot;3-what-s-the-failure-mode-&quot;&gt;3. What's the failure mode?&lt;/h5&gt;
&lt;p&gt;What should happen when input is invalid, an external dependency fails, a file doesn't exist, or an API returns an unexpected result?&lt;/p&gt;
&lt;h5 id=&quot;4-what-should-be-mocked-&quot;&gt;4. What should be mocked?&lt;/h5&gt;
&lt;p&gt;Usually mock things that are external, slow, non-deterministic, or side-effecting. But let your own logic run for real.&lt;/p&gt;
&lt;h5 id=&quot;5-is-the-test-asking-one-question-&quot;&gt;5. Is the test asking one question?&lt;/h5&gt;
&lt;p&gt;If the test name contains the word &quot;and,&quot; that's sometimes a sign that you're testing two behaviors at once. A test should ideally answer one clear question.&lt;/p&gt;
&lt;h3 id=&quot;the-mental-model-i-ended-up-with&quot;&gt;The Mental Model I Ended Up With&lt;/h3&gt;
&lt;p&gt;pytest can look like a large collection of features when you're first learning it. But after working with it for a while, I found that most of what I actually needed came down to a few ideas.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tests are ordinary Python functions.&lt;/strong&gt; pytest discovers them through naming conventions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Assertions are ordinary Python &lt;code&gt;assert&lt;/code&gt; statements.&lt;/strong&gt; pytest makes their failures informative.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fixtures provide reusable things that tests need.&lt;/strong&gt; &lt;code&gt;conftest.py&lt;/code&gt; makes shared fixtures available without imports.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Parametrization lets you run the same test logic against multiple inputs.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mocks let you replace boundaries you don't want to exercise for real.&lt;/strong&gt; Things like FFmpeg, APIs, databases, and other external systems are often good candidates — while the logic you're actually testing should usually run normally.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Markers let you separate tests by purpose or cost.&lt;/strong&gt; And the debugging tools make it much easier to figure out why something failed without resorting to &lt;code&gt;print()&lt;/code&gt; everywhere.&lt;/p&gt;
&lt;p&gt;Most importantly, I stopped thinking of testing as &quot;how do I get my coverage number up?&quot; and started thinking more in terms of &quot;what question am I trying to answer with this test?&quot; For me, that shift made pytest much easier to understand.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The syntax is the easy part. The useful part is learning what behavior is worth testing, what should be isolated, what should run for real, and how to make a failure tell you something useful.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30938/pytest-in-practice</guid>
<pubDate>Tue, 13 Oct 2026 12:45:48 +0000</pubDate>
</item>
<item>
<title>A Route Is Not a Feature Until Someone Can Reach It</title>
<link>https://coderlegion.com/30901/a-route-is-not-a-feature-until-someone-can-reach-it</link>
<description>&lt;p&gt;Modern applications are often assembled from modules. That is good for ownership, deployment boundaries, and keeping large codebases understandable. It also creates a subtle user-experience failure mode: a page can compile, carry a valid route, pass its local tests, and still be effectively absent because no one can discover it from a real entry point.&lt;/p&gt;
&lt;p&gt;This is not primarily a visual-design problem. It is an architecture-contract problem.&lt;/p&gt;
&lt;h3 id=&quot;routable-is-not-reachable&quot;&gt;Routable is not reachable&lt;/h3&gt;
&lt;p&gt;Imagine an administrative application with pages spread across two modules. The team already has a test that scans routed pages and checks that each one appears in a navigation catalogue. The test is green, so the navigation surface looks covered.&lt;/p&gt;
&lt;p&gt;Later, someone discovers that the scan only walks one module. The second route tree was outside its boundary. Pages there could be added indefinitely without the test knowing they existed.&lt;/p&gt;
&lt;p&gt;That is the first important lesson: a completeness check is only as complete as its discovery mechanism.&lt;/p&gt;
&lt;p&gt;When the scan is expanded, it finds an entry screen that has documentation and valid follow-on steps, but no link from the application itself. The workflow exists technically. Operationally, it begins with “type this URL.” That is not a usable feature contract.&lt;/p&gt;
&lt;h3 id=&quot;define-a-genuine-entry-point&quot;&gt;Define a genuine entry point&lt;/h3&gt;
&lt;p&gt;The tempting fix is to accept any reference to the route. Perhaps another page links to it. Perhaps it appears on a tab rail. Perhaps a help article mentions it.&lt;/p&gt;
&lt;p&gt;Those references are not equivalent.&lt;/p&gt;
&lt;p&gt;A help article explains a feature; it is not application navigation. A link on a neighbouring screen can be useful, but it may make discoverability depend on an accidental journey. A tab rail is particularly deceptive: it helps someone move within an area only after they have reached one of its pages. It cannot prove that the area itself has an entrance.&lt;/p&gt;
&lt;p&gt;The architectural rule should therefore name the user-facing property you actually want. For example:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Every static administrative workflow page must be represented by a discoverable entry in the primary hub available to its entitled audience.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That sentence is stricter than “the route is mentioned somewhere,” and that is precisely why it is useful.&lt;/p&gt;
&lt;h3 id=&quot;test-pages-not-strings&quot;&gt;Test pages, not strings&lt;/h3&gt;
&lt;p&gt;Route graphs have legitimate shapes that a simple string search will misclassify.&lt;/p&gt;
&lt;p&gt;A parameterised detail page is usually reached from a list or search result. Giving every detail route a top-level card would be noisy and misleading. The test can classify routes containing parameters as downstream pages rather than entry pages.&lt;/p&gt;
&lt;p&gt;The reverse case also matters. One page may answer on two route aliases for compatibility. Requiring two navigation cards would duplicate the same destination. The unit of the contract is the page, with at least one discoverable canonical route—not every literal route declaration.&lt;/p&gt;
&lt;p&gt;These distinctions keep the test strict without making it simplistic.&lt;/p&gt;
&lt;h3 id=&quot;make-the-scan-fail-honestly&quot;&gt;Make the scan fail honestly&lt;/h3&gt;
&lt;p&gt;Source-scanning tests have a dangerous failure mode: they can pass after discovering nothing. A changed directory, glob, parser, or build layout can turn the result set into an empty collection, after which every “all pages are covered” assertion becomes vacuously true.&lt;/p&gt;
&lt;p&gt;Add a coarse lower bound to the discovered page count. It does not need to encode the exact inventory. It only needs to make a broken scan conspicuous. Pair that with a useful failure message that lists uncovered entry pages and explains the intended remedy.&lt;/p&gt;
&lt;p&gt;Exceptions deserve the same discipline. Sometimes an exception is legitimate, but an exemption list easily becomes a hiding place. Store a reason beside each exception, keep the list narrow, and assert that every exception still corresponds to a real uncovered case. Once navigation is fixed, the stale exception should fail the next test run.&lt;/p&gt;
&lt;p&gt;That turns exceptions into temporary, reviewable decisions rather than permanent silence.&lt;/p&gt;
&lt;h3 id=&quot;align-visibility-with-authorization&quot;&gt;Align visibility with authorization&lt;/h3&gt;
&lt;p&gt;Navigation and authorization answer related but different questions.&lt;/p&gt;
&lt;p&gt;Navigation visibility asks, “Should this person be offered this destination?” Authorization asks, “May this request proceed?” A hidden card is not a security boundary. Someone can still follow a saved link or construct a request directly.&lt;/p&gt;
&lt;p&gt;The useful contract is alignment: the navigation entry should use the same entitlement expressed by the destination policy, while the page or endpoint continues to enforce that policy independently.&lt;/p&gt;
&lt;p&gt;This check often exposes ambiguity. If a destination has only broad authentication while its navigation card is restricted to a privileged audience, do not quietly declare the card to be protection. Flag the mismatch for an explicit security decision. Navigation work should reveal authorization uncertainty, not accidentally resolve it.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-real-2&quot;&gt;The trade-off is real&lt;/h3&gt;
&lt;p&gt;A strict hub contract has costs. The catalogue grows. New pages require deliberate placement and wording. A hub can become crowded if every capability is presented at the same level.&lt;/p&gt;
&lt;p&gt;The alternative, however, is not free. A loose rule produces accidental journeys, invisible capabilities, and tests that certify the presence of links without certifying usable discovery.&lt;/p&gt;
&lt;p&gt;The practical balance is to keep the entry-point contract strict while allowing the interface to group, search, filter, and progressively disclose destinations. The architecture test should protect reachability; the visual design can protect clarity.&lt;/p&gt;
&lt;h3 id=&quot;a-small-checklist&quot;&gt;A small checklist&lt;/h3&gt;
&lt;p&gt;For a modular routed application:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Inventory every route tree, not only the first one the test was written for.&lt;/li&gt;
&lt;li&gt;Classify static entry pages, parameterised downstream pages, and route aliases.&lt;/li&gt;
&lt;li&gt;Identify the navigation roots users can actually reach.&lt;/li&gt;
&lt;li&gt;Require every entry page to appear from an appropriate root.&lt;/li&gt;
&lt;li&gt;Add a minimum discovery count to catch broken scans.&lt;/li&gt;
&lt;li&gt;Make exemptions explicit, reasoned, narrow, and self-cleaning.&lt;/li&gt;
&lt;li&gt;Align link visibility with destination authorization, while enforcing security at the destination.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The deeper lesson is that architecture includes the path a person can take through the system. If a feature has no honest entrance, its route is an implementation detail—not a delivered capability.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30901/a-route-is-not-a-feature-until-someone-can-reach-it</guid>
<pubDate>Tue, 13 Oct 2026 07:01:11 +0000</pubDate>
</item>
<item>
<title>Isolate to Elevate: Why Component Testing in Isolation Saves Your Codebase</title>
<link>https://coderlegion.com/30819/isolate-to-elevate-why-component-testing-in-isolation-saves-your-codebase</link>
<description>&lt;h2 id=&quot;isolate-to-elevate-why-component-testing-in-isolation-saves-your-codebase&quot;&gt;Isolate to Elevate: Why Component Testing in Isolation Saves Your Codebase&lt;/h2&gt;
&lt;p&gt;Component testing in isolation is a software verification strategy where an individual piece of an application—such as a button, user profile card, or navigation bar—is evaluated entirely on its own. Instead of connecting the component to real databases, live servers, or neighboring UI elements, developers replace external dependencies with controlled, artificial placeholders called &lt;strong&gt;mocks&lt;/strong&gt; or &lt;strong&gt;stubs&lt;/strong&gt;. This guarantees that if a test fails, the issue is guaranteed to exist inside that specific component rather than in an external network or backend service.&lt;/p&gt;
&lt;h3 id=&quot;a-real-life-analogy-the-bench-tested-alternator&quot;&gt;A Real-Life Analogy: The Bench-Tested Alternator&lt;/h3&gt;
&lt;p&gt;Imagine a mechanic inspecting a newly manufactured car alternator. Instead of installing the alternator inside a fully assembled luxury vehicle, turning the key in the ignition, and hoping the dashboard lights illuminate, the mechanic places the alternator onto a specialized workbench.&lt;/p&gt;
&lt;p&gt;On this test bench, artificial wires power the alternator, and a standalone machine spins its rotor while measuring electrical voltage. If the meter shows an improper charge, the mechanic instantly knows the alternator itself is defective. There is zero ambiguity—the problem cannot be blamed on a dead car battery, a frayed ignition wire, or a blown fuse elsewhere in the vehicle. Component testing in isolation applies this exact bench-test philosophy to user interface elements.&lt;/p&gt;
&lt;h3 id=&quot;why-component-testing-in-isolation-matters-daily&quot;&gt;Why Component Testing in Isolation Matters Daily&lt;/h3&gt;
&lt;p&gt;In modern web development, frontend components frequently rely on complex API (Application Programming Interface) calls—the background communication channels that fetch data from databases over the internet. If you attempt to test a component while connected to live production or staging servers, your automated tests become fragile and unpredictable. A minor network hiccup or scheduled database maintenance will cause UI tests to fail, triggering false alarms and wasting valuable engineering hours.&lt;/p&gt;
&lt;p&gt;Testing components in isolation eliminates this instability. It yields significant daily benefits:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Lightning-Fast Execution:&lt;/strong&gt; Tests run in a local memory environment without waiting for HTTP network responses, executing hundreds of checks in milliseconds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deterministic Feedback:&lt;/strong&gt; Tests yield identical, repeatable results regardless of backend availability or database state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Effortless Edge-Case Coverage:&lt;/strong&gt; Developers can effortlessly simulate tricky scenarios, such as slow network connections, server crash codes (like 500 errors), or extremely long text strings, without needing to manipulate a real server.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;code-example-testing-a-react-component-in-isolation&quot;&gt;Code Example: Testing a React Component in Isolation&lt;/h3&gt;
&lt;p&gt;Below is a practical example using React (a popular JavaScript library for building user interfaces) and React Testing Library.&lt;/p&gt;
&lt;p&gt;First, here is our component, &lt;code&gt;UserProfile&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsx&quot;&gt;// UserProfile.jsx
import React from 'react';

export function UserProfile({ user, isLoading, error }) {
  if (isLoading) {
    return &amp;lt;div data-testid=&quot;loading&quot;&amp;gt;Loading user details...&amp;lt;/div&amp;gt;;
  }

  if (error) {
    return &amp;lt;div data-testid=&quot;error&quot;&amp;gt;Error loading user profile.&amp;lt;/div&amp;gt;;
  }

  return (
    &amp;lt;div className=&quot;user-card&quot;&amp;gt;
      &amp;lt;h2&amp;gt;{user.name}&amp;lt;/h2&amp;gt;
      &amp;lt;p&amp;gt;Email: {user.email}&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, we test &lt;code&gt;UserProfile&lt;/code&gt; in total isolation. Notice how we pass fake JavaScript object data directly through &lt;strong&gt;props&lt;/strong&gt; (inputs passed to React components) without making real network requests:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsx&quot;&gt;// UserProfile.test.jsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';

test('renders user details correctly when data is provided', () =&amp;gt; {
  // 1. Arrange: Create mock user data
  const mockUser = { name: 'Sarah Connor', email: 'sarah@skynet.com' };

  // 2. Act: Render component in isolation
  render(&amp;lt;UserProfile user={mockUser} isLoading={false} error={false} /&amp;gt;);

  // 3. Assert: Verify the rendered output
  expect(screen.getByText('Sarah Connor')).toBeInTheDocument();
  expect(screen.getByText('Email: sarah@skynet.com')).toBeInTheDocument();
});

test('renders error message during failure state', () =&amp;gt; {
  render(&amp;lt;UserProfile user={null} isLoading={false} error={true} /&amp;gt;);

  expect(screen.getByTestId('error')).toHaveTextContent('Error loading user profile.');
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In these tests, the &lt;code&gt;render&lt;/code&gt; function mounts &lt;code&gt;UserProfile&lt;/code&gt; inside a simulated virtual browser. We control every input state directly, ensuring complete certainty over what we are testing.&lt;/p&gt;
&lt;h3 id=&quot;summary-takeaway&quot;&gt;Summary Takeaway&lt;/h3&gt;
&lt;p&gt;Building resilient digital experiences requires modular confidence. By isolating individual UI components during automated testing, engineering teams replace slow, flaky test pipelines with deterministic feedback loops that pinpoint bugs in milliseconds. Mastering this technique ensures that every piece of your user interface is proven battle-ready before it ever integrates into a larger software ecosystem.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published on my blog. You can &lt;a rel=&quot;nofollow&quot; href=&quot;https://sauravtbpandey.blogspot.com/2026/10/mastering-component-testing-in.html&quot;&gt;read the alternative breakdown here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30819/isolate-to-elevate-why-component-testing-in-isolation-saves-your-codebase</guid>
<pubDate>Mon, 12 Oct 2026 13:46:09 +0000</pubDate>
</item>
<item>
<title>Don’t Turn Delegated Access Into an Identity Oracle</title>
<link>https://coderlegion.com/30769/dont-turn-delegated-access-into-an-identity-oracle</link>
<description>&lt;p&gt;Delegated access often starts with an innocent mismatch.&lt;/p&gt;
&lt;p&gt;A person knows an email address. The authorization system knows an account identifier. The obvious bridge is a search box: enter the address, find the account, then grant access.&lt;/p&gt;
&lt;p&gt;That bridge can become a privacy leak.&lt;/p&gt;
&lt;p&gt;If the screen behaves differently for a known address and an unknown one, every attempt answers a sensitive question: “Does this person have an account here?” An authenticated caller can repeat that question across many addresses. The feature may be correctly authorized for its intended task while still exposing more identity information than the task requires.&lt;/p&gt;
&lt;p&gt;The deeper lesson is simple: account discovery is not a necessary prerequisite for delegated access. Identity can be resolved later, at the moment the recipient proves ownership and gives consent.&lt;/p&gt;
&lt;h3 id=&quot;the-convenient-design-carries-a-hidden-query&quot;&gt;The convenient design carries a hidden query&lt;/h3&gt;
&lt;p&gt;Consider a generic workflow in which one person wants to share a limited capability with another.&lt;/p&gt;
&lt;p&gt;The direct version usually looks like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Enter the recipient’s address.&lt;/li&gt;
&lt;li&gt;Search for a matching account.&lt;/li&gt;
&lt;li&gt;Show whether a match exists.&lt;/li&gt;
&lt;li&gt;Create access against that account.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Authorization to share the resource does not make the lookup harmless. Even subtle differences disclose the answer: different text, a disabled button, a profile preview, an HTTP status, or noticeably different timing.&lt;/p&gt;
&lt;p&gt;This is a useful design-review question: what does the caller need to accomplish, and what does the interface reveal merely because it is convenient?&lt;/p&gt;
&lt;h3 id=&quot;move-identity-resolution-to-acceptance&quot;&gt;Move identity resolution to acceptance&lt;/h3&gt;
&lt;p&gt;An invitation workflow changes the order of proof.&lt;/p&gt;
&lt;p&gt;The initiator provides an address and the intended scope. The system records an invitation and returns the same public outcome whether the address currently belongs to an account or not. Nothing attempts to answer the account-existence question.&lt;/p&gt;
&lt;p&gt;Most importantly, the invitation itself grants no access.&lt;/p&gt;
&lt;p&gt;When the recipient follows the invitation, they authenticate. The system compares the account’s confirmed address with the invited address. Only a match may inspect the offer and accept it. The access record is then created against the identity that actually accepted.&lt;/p&gt;
&lt;p&gt;That ordering gives the workflow better semantics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the initiator names a person in a form they genuinely know;&lt;/li&gt;
&lt;li&gt;the system does not reveal whether an account already exists;&lt;/li&gt;
&lt;li&gt;possession of a link is insufficient on its own;&lt;/li&gt;
&lt;li&gt;the recipient proves control of the invited address;&lt;/li&gt;
&lt;li&gt;access records consent rather than an assumption made by the initiator.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;a-token-is-necessary-but-not-sufficient&quot;&gt;A token is necessary, but not sufficient&lt;/h3&gt;
&lt;p&gt;Invitation links are bearer secrets, so their handling deserves the same care as password-reset links.&lt;/p&gt;
&lt;p&gt;Persist a one-way hash rather than the raw token. Submit the token in a request body when practical so routine URL logging does not repeat it across server, proxy, and browser records.&lt;/p&gt;
&lt;p&gt;Bind the token to the intended identity as well. A forwarded or screenshotted link should not be enough to inspect the offer. Requiring a signed-in account with the matching confirmed address contains that failure mode.&lt;/p&gt;
&lt;p&gt;Resending needs a clear contract: mint a new token and invalidate the old one. Otherwise each resend increases the live secrets while the interface still shows one invitation.&lt;/p&gt;
&lt;p&gt;Finally, build the link from trusted server configuration. Request-controlled host information should not decide where a live token is sent.&lt;/p&gt;
&lt;h3 id=&quot;retry-safety-belongs-in-the-workflow&quot;&gt;Retry safety belongs in the workflow&lt;/h3&gt;
&lt;p&gt;Mobile networks fail at awkward boundaries. The server may commit an invitation and send the message while the response never reaches the device.&lt;/p&gt;
&lt;p&gt;If pressing the button again creates a fresh invitation, one uncertain response can produce two live tokens. The interface may show only one of them.&lt;/p&gt;
&lt;p&gt;A stable command identity lets the server recognize a replay and return the original result without sending again. That identity must describe the request as well as the button press: reusing it with different arguments should be rejected, not silently mapped to an unrelated result.&lt;/p&gt;
&lt;p&gt;Delivery outcomes need honest language. A definite rejection differs from an unknown result after handoff. “Unknown” is safer than encouraging a duplicate.&lt;/p&gt;
&lt;h3 id=&quot;make-consent-and-access-one-atomic-transition&quot;&gt;Make consent and access one atomic transition&lt;/h3&gt;
&lt;p&gt;Acceptance changes two facts: the invitation is consumed, and access is created.&lt;/p&gt;
&lt;p&gt;Persist those facts together.&lt;/p&gt;
&lt;p&gt;If access is written but the invitation remains pending, the token may be accepted again. That can create a second grant that nobody sees. Revoking the visible grant would leave the duplicate active.&lt;/p&gt;
&lt;p&gt;One transaction makes the invariant explicit: there is no state in which acceptance succeeded while the invitation is still redeemable.&lt;/p&gt;
&lt;p&gt;Re-check the initiator’s authority inside this transition too. Authority can change between invitation and acceptance. An invitation should not outlive the permission that allowed it to be created.&lt;/p&gt;
&lt;h3 id=&quot;test-the-negative-space-1&quot;&gt;Test the negative space&lt;/h3&gt;
&lt;p&gt;The most valuable tests are not only happy-path checks. They prove what the workflow refuses to reveal or create:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;known and unknown addresses produce indistinguishable initiator results;&lt;/li&gt;
&lt;li&gt;an invitation alone authorizes nothing;&lt;/li&gt;
&lt;li&gt;a different or unconfirmed account cannot inspect or accept it;&lt;/li&gt;
&lt;li&gt;retrying does not send again;&lt;/li&gt;
&lt;li&gt;resending invalidates the previous token;&lt;/li&gt;
&lt;li&gt;stale initiator authority blocks new access;&lt;/li&gt;
&lt;li&gt;repeated acceptance does not create duplicate grants;&lt;/li&gt;
&lt;li&gt;consumption and access creation succeed or fail together.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-trade-off-is-deliberate-complexity&quot;&gt;The trade-off is deliberate complexity&lt;/h3&gt;
&lt;p&gt;This design adds states, expiry, delivery handling, retry receipts, token rotation, concurrency, and tests. It is unquestionably more work than a directory lookup followed by an insert.&lt;/p&gt;
&lt;p&gt;The added complexity corresponds to uncertainty the simpler design hides. The invitation makes privacy, consent, identity proof, and atomicity explicit.&lt;/p&gt;
&lt;p&gt;The practical takeaway is to inspect every workflow that translates a human-readable identifier into an account. Ask whether the caller truly needs account discovery, or whether identity can be resolved by the recipient at acceptance.&lt;/p&gt;
&lt;p&gt;Often, the safer flow is also the more truthful one.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30769/dont-turn-delegated-access-into-an-identity-oracle</guid>
<pubDate>Mon, 12 Oct 2026 06:01:17 +0000</pubDate>
</item>
<item>
<title>When the Tool Cannot See the Whole Model</title>
<link>https://coderlegion.com/30687/when-the-tool-cannot-see-the-whole-model</link>
<description>&lt;p&gt;Modular architecture creates an uncomfortable class of failure: the application can understand its complete model while design-time tooling sees only a legitimate subset.&lt;/p&gt;
&lt;p&gt;That difference is easy to misread as drift. It is also dangerous to dismiss. The useful engineering move is to identify exactly what the tool cannot observe, then relocate each safety check to a boundary with enough context to answer the question correctly.&lt;/p&gt;
&lt;h3 id=&quot;the-warning-was-real-but-its-conclusion-was-wrong&quot;&gt;The warning was real, but its conclusion was wrong&lt;/h3&gt;
&lt;p&gt;Consider a shared EF Core context that can be extended by optional runtime modules. Each module contributes mappings without forcing the lower-level persistence package to reference every higher-level feature package. That dependency direction is valuable: the shared module remains reusable, and optional features remain optional.&lt;/p&gt;
&lt;p&gt;At runtime, the host loads the contributing modules and the complete model is assembled. At design time, the tooling process starts from a narrower package. Loading one contributor directly would create a dependency cycle, so that contributor is absent.&lt;/p&gt;
&lt;p&gt;The migration snapshot still represents the complete model. The design-time model does not. A comparison therefore reports a difference and may propose removing an object that is not obsolete at all; it is merely invisible from that process.&lt;/p&gt;
&lt;p&gt;The warning accurately says, “these two views differ.” It cannot establish why they differ.&lt;/p&gt;
&lt;h3 id=&quot;separate-database-update-from-migration-authoring&quot;&gt;Separate database update from migration authoring&lt;/h3&gt;
&lt;p&gt;The first mistake would be treating every operation as though it asks the same question.&lt;/p&gt;
&lt;p&gt;A database update applies migrations that have already passed review and validation. If a known model subset causes a framework warning to block that operation, a narrowly scoped suppression can be reasonable. The important word is narrowly. The exception belongs in the design-time path that has the structural blind spot, with an explanation of why the complete model cannot be assembled there.&lt;/p&gt;
&lt;p&gt;Migration authoring is different. A scaffolder turns its limited view into proposed schema changes. If it cannot see an extension-owned table, a removal operation may look perfectly logical. Suppressing a warning does nothing to make that proposal safe.&lt;/p&gt;
&lt;p&gt;So the second boundary needs a stronger invariant: inspect the generated migration operations and fail if they remove, rename, or destructively alter protected extension data. That converts a warning in prose into an executable gate.&lt;/p&gt;
&lt;p&gt;This distinction matters. “Allow the known update path” and “trust every future generated migration” are not the same decision.&lt;/p&gt;
&lt;h3 id=&quot;keep-a-complete-model-drift-check&quot;&gt;Keep a complete-model drift check&lt;/h3&gt;
&lt;p&gt;Once a noisy comparison is suppressed, the obvious risk is hiding real drift. The answer is not to pretend the partial view became authoritative. Keep a separate test that runs in a host where all contributors are loaded, builds the complete runtime model, and compares that model with the snapshot.&lt;/p&gt;
&lt;p&gt;Now each check has one clear responsibility:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the design-time update path tolerates the known subset;&lt;/li&gt;
&lt;li&gt;the full-model test detects genuine model-to-snapshot drift;&lt;/li&gt;
&lt;li&gt;the migration-operation test blocks destructive scaffolding.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is more precise than relying on one framework signal for three different questions.&lt;/p&gt;
&lt;h3 id=&quot;prove-the-guard-is-not-vacuously-green&quot;&gt;Prove the guard is not vacuously green&lt;/h3&gt;
&lt;p&gt;Negative tests deserve a positive control.&lt;/p&gt;
&lt;p&gt;Imagine a test that scans every migration and asserts that none removes a protected table. It passes. Good news, perhaps. But it also passes if migration discovery silently returns an empty collection.&lt;/p&gt;
&lt;p&gt;Add a companion assertion that the scan finds the migration that originally created the protected object. If discovery breaks, the positive control fails. The pair proves both that the dangerous operation is absent and that the evidence set is present.&lt;/p&gt;
&lt;p&gt;This pattern generalizes beyond migrations. Any test that says “no forbidden item exists” should make you ask whether it would still pass when the scanner found nothing.&lt;/p&gt;
&lt;h3 id=&quot;shared-process-state-is-another-hidden-boundary&quot;&gt;Shared process state is another hidden boundary&lt;/h3&gt;
&lt;p&gt;The same change exposed a smaller reliability lesson. Tests for design-time factories temporarily set a process-wide configuration value. Separate test classes ran concurrently, and one could restore the value while another was still using it. Each class passed alone; together they failed intermittently.&lt;/p&gt;
&lt;p&gt;The repair was not another retry. The tests were placed in one non-parallel collection because they share mutable process state.&lt;/p&gt;
&lt;p&gt;That is the same ownership principle in miniature: the scope of the resource determines the scope of coordination. A process-wide variable cannot safely be treated as test-local state.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-explicit-complexity&quot;&gt;The trade-off is explicit complexity&lt;/h3&gt;
&lt;p&gt;This approach is not free. There is now a documented exception, a complete-model drift test, a migration-operation guard, a positive control, and serialized tests around shared environment state. Future maintainers must understand why those pieces exist.&lt;/p&gt;
&lt;p&gt;But the alternative is false simplicity: a generic warning that blocks legitimate work, generated operations that can target valid data, and flaky tests that disguise the real contract.&lt;/p&gt;
&lt;p&gt;The practical rule I keep is this:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;When tooling has a structural blind spot, narrow the exception and move safety to executable checks that can observe the complete truth.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Do not silence evidence broadly. Do not force an architectural dependency merely to satisfy a tool. Give each check the context it needs, and make dangerous assumptions fail before they become database changes.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30687/when-the-tool-cannot-see-the-whole-model</guid>
<pubDate>Sun, 11 Oct 2026 06:55:17 +0000</pubDate>
</item>
<item>
<title>42 of our 71 regression guards stayed green when we broke the code on purpose</title>
<link>https://coderlegion.com/30633/42-of-our-71-regression-guards-stayed-green-when-we-broke-the-code-on-purpose</link>
<description>&lt;p&gt;We build an AI virtual staging tool: a user uploads a photo of a room, an image model restyles it, and a set of checks compares the result with the original before anyone sees it. We wrote about those checks in a &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/aifliproom/how-we-check-every-ai-generated-room-render-before-a-user-sees-it-3ek8&quot;&gt;previous post&lt;/a&gt;. This one is about the layer under that: the scripts that make sure the checks, the billing rules and the consent logic stay the way we fixed them.&lt;/p&gt;
&lt;p&gt;We call them guards. Each is a standalone Node script, &lt;code&gt;.mjs&lt;/code&gt; or &lt;code&gt;.mts&lt;/code&gt;, that asserts one behaviour and exits non-zero if it is gone. A fix ships with a guard. By late August we had about seventy, run together with &lt;code&gt;npm run qa&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Then we asked each one a simple question: if we break the thing you protect, do you go red? For 42 of 71, the answer was no.&lt;/p&gt;
&lt;h3 id=&quot;how-we-got-there&quot;&gt;How we got there&lt;/h3&gt;
&lt;p&gt;The first problem was not quality but running them at all. Until 22 August there was no runner; the list lived in a Markdown file that named 33 of the 78 scripts on disk. A runner that compares its list with the files actually present found one guard that was red (15 of 18 checks) with nobody noticing, 27 live guards that had not been run for months, and nine that were on disk but not in git.&lt;/p&gt;
&lt;p&gt;Now everything ran, and everything was green. That felt like proof. It was not.&lt;/p&gt;
&lt;p&gt;Two days later an independent review tried something small: in the error-tracker configuration it renamed the environment variable that carries the DSN, the kind of typo you make during a rename. All 55 checks of the guard watching that configuration stayed green. In production that typo means the error tracker is silently a no-op, the exact failure the guard existed to prevent.&lt;/p&gt;
&lt;p&gt;That was reason enough to do it to every guard.&lt;/p&gt;
&lt;h3 id=&quot;the-audit&quot;&gt;The audit&lt;/h3&gt;
&lt;p&gt;Method, by hand, one guard at a time: edit the production file so the guarded behaviour is really broken, run the guard, restore the file, write down the numbers. A hole counted only if the guard stayed green on a real break.&lt;/p&gt;
&lt;p&gt;Result: 42 holes across 71 guards, and 42 guards that stayed green on at least one real break and had to be rewritten (35 in the first batch, 7 of the last eight). These are two separate counts that happen to match: one guard can hide more than one hole, and a few weak guards only showed up while we were fixing the others.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Group &lt;/th&gt;&lt;th&gt;Guards &lt;/th&gt;&lt;th&gt;Holes&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Error tracking, observability, infrastructure &lt;/td&gt;&lt;td&gt;16 &lt;/td&gt;&lt;td&gt;7&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Image engine and render pipeline &lt;/td&gt;&lt;td&gt;19 &lt;/td&gt;&lt;td&gt;5&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Billing, consent, privacy, SEO &lt;/td&gt;&lt;td&gt;28 &lt;/td&gt;&lt;td&gt;21&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;The last eight (older zones, referrals, video, pins) &lt;/td&gt;&lt;td&gt;8 &lt;/td&gt;&lt;td&gt;9 in 7 files&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;71&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;42&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Some individual findings:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One guard had no checks at all. It printed a green tick and exited 0 whatever the code looked like.&lt;/li&gt;
&lt;li&gt;One check compared the position of two strings in a route file to assert that storage blobs are deleted before database rows (rows first would orphan a customer's files for good). The first string had been removed by a refactor more than three weeks earlier. &lt;code&gt;indexOf&lt;/code&gt; returned -1, -1 is less than anything, and the check had passed every run since.&lt;/li&gt;
&lt;li&gt;One guard tested a hand-written copy of the SQL instead of the production webhook.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The pattern behind most of the 42: the guards were written against text, not behaviour. We read the route file as a string and looked for names. Such a guard fails both ways: an honest refactor turns it red for nothing, and a real break slides past because the names are still there.&lt;/p&gt;
&lt;h3 id=&quot;one-guard-before-and-after&quot;&gt;One guard, before and after&lt;/h3&gt;
&lt;p&gt;The consent route does three things in order: write the user's choice to the auth provider, append to the audit log, unpublish their renders from the public showcase. The guard checked that order with &lt;code&gt;indexOf&lt;/code&gt; on the source. Nine checks, all green. The mutation that beat it, and the rewrite:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// production: awaited; a failure returns 500 to the user
await client.users.updateUserMetadata(userId, { ... });
// mutation: same call, same position in the file - all nine checks still green
void client.users.updateUserMetadata(userId, { ... }).catch(() =&amp;gt; {});

// rewritten guard: cut out the statement itself, not a name near it
const i = ROUTE.indexOf(&quot;client.users.updateUserMetadata&quot;);
const stmt = ROUTE.slice(ROUTE.lastIndexOf(&quot;\n&quot;, i) + 1, ROUTE.indexOf(&quot;;&quot;, i) + 1).trim();
ok(&quot;write is awaited, not fired in the background&quot;, /^await client\.users\.updateUserMetadata\(/.test(stmt));
ok(&quot;a failed write is not swallowed&quot;, !/\.catch\(/.test(stmt));
ok(&quot;and not silenced with void&quot;, !/^void\b/.test(stmt));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Under the mutation the order of the three calls did not move, so the old checks passed. The meaning was gone: the write is fire-and-forget, the failure is swallowed, the audit log records &quot;revoked&quot;, the user believes they opted out, and the showcase keeps publishing because the provider still says &quot;allowed&quot;.&lt;/p&gt;
&lt;p&gt;The rewrite is still text, but the text of the statement itself, and the mutation that defeated the old version is now a permanent check. Where the code can be imported, we go further: the guard loads the real function through a loader and runs it over a table of inputs.&lt;/p&gt;
&lt;h3 id=&quot;the-runner&quot;&gt;The runner&lt;/h3&gt;
&lt;p&gt;Every one of those experiments was a throwaway script, fifty lines each, and each hit the same traps: CRLF on the Windows working copy versus LF in git, a snippet that matched twice, a &quot;mutation&quot; that deleted a line and could not be reverted by an empty pattern (one stayed in the code and was caught on a second pass).&lt;/p&gt;
&lt;p&gt;On 28 August it became one tool. A job is JSON: the guard command plus named substitutions.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;guard&quot;: &quot;node tmp/qa/geometry-escalation-proof.mjs&quot;,
  &quot;mutations&quot;: [
    {
      &quot;file&quot;: &quot;src/lib/draft-rank.ts&quot;,
      &quot;name&quot;: &quot;a judge that errored no longer ranks last - an empty verdict beats a checked image&quot;,
      &quot;from&quot;: &quot;  if (input.errored) return Number.MAX_SAFE_INTEGER - 1;\n&quot;,
      &quot;to&quot;:   &quot;  void input.errored;\n&quot;
    },
    {
      &quot;file&quot;: &quot;src/app/api/generate/route.ts&quot;,
      &quot;name&quot;: &quot;escalation became unconditional - furniture-placement failures go to the expensive engine too&quot;,
      &quot;from&quot;: &quot;if (!economyMode &amp;amp;&amp;amp; lastGeometryBroken &amp;amp;&amp;amp; attempt &amp;lt; MAX_ATTEMPTS) {&quot;,
      &quot;to&quot;:   &quot;if (!economyMode &amp;amp;&amp;amp; attempt &amp;lt; MAX_ATTEMPTS) {&quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The loop is short; the rules around it are what we paid for:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;if (!runGuard()) fail(&quot;guard is already red; it proves nothing&quot;);          // 1. green before

for (const m of mutations) {
  if (!m.to.trim()) { bad(m, &quot;empty replacement is a deletion, not a mutation&quot;); continue; } // 2
  const src = original(m.file);
  const hits = src.match(rx(m.from, &quot;g&quot;));                      // rx() tolerates \r?\n  // 3
  if (!hits || hits.length !== 1) { bad(m, &quot;snippet must occur exactly once&quot;); continue; } // 4
  fs.writeFileSync(m.file, src.replace(rx(m.from), m.to));
  const stillGreen = runGuard();
  fs.writeFileSync(m.file, src);
  stillGreen ? bad(m, &quot;GUARD STAYED GREEN&quot;) : good(m);
}
assertEveryFileRestoredByteForByte();                                         // 5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mutations are named and semantic on purpose. Classic mutation testing flips operators automatically; we write each mutation as a sentence about how a customer would get hurt, because that sentence also documents why the guard exists. The trade-off is coverage: we test only the breaks we thought of.&lt;/p&gt;
&lt;h3 id=&quot;what-the-mutations-found-in-production-code&quot;&gt;What the mutations found in production code&lt;/h3&gt;
&lt;p&gt;The day the runner was born, a run on a new guard did something we did not expect. The function that decides whether a payment-return URL is stale had an explicit &quot;payment time is in the future&quot; branch. Six mutations turned the guard red. The seventh, removing that branch, changed nothing: a future timestamp gives a negative age, and a negative age is never above the threshold. The branch was a crutch the guard could not tell from working code. We removed it and wrote the reason above the function.&lt;/p&gt;
&lt;p&gt;In the same session, 35 mutations across seven fixes found three guards checking the wrong thing: a parser that stopped at the first closing brace and broke on a nested object (four of six mutations passed on obviously broken code); a &quot;the catch block logs&quot; check that stayed green when the log left one branch because a neighbouring branch still had one; and a scanner for prototype-key guards that cleared a whole file if the guard appeared anywhere in it.&lt;/p&gt;
&lt;h3 id=&quot;where-it-stands&quot;&gt;Where it stands&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;93 mutation jobs, 867 mutations, in the repo next to the guards.&lt;/li&gt;
&lt;li&gt;171 guards in the runner's list, 12 named skips (they cost money, run on a calendar or need a fresh build); the list is compared with the disk on every commit.&lt;/li&gt;
&lt;li&gt;Written rule: a new guard without a mutation run counts as not written.&lt;/li&gt;
&lt;li&gt;The pre-commit hook checks only the registry, in 0.08 seconds. The full run takes minutes, needs the network and a database, and can go red from someone else's outage; a hook like that gets &lt;code&gt;--no-verify&lt;/code&gt; on the first urgent fix, and a bypassed hook is worse than none. The full run stays manual and mandatory before a merge.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;what-still-fails&quot;&gt;What still fails&lt;/h3&gt;
&lt;p&gt;Hand-written mutations come from the same person who wrote the guard, with the same blind spots. On 5 October a mutation named &quot;only seven articles left in the links block&quot; removed 5 of 17 links and the guard stayed green. The mutation was too weak, not the guard; it now removes every link. Nothing finds weak mutations except somebody noticing.&lt;/p&gt;
&lt;p&gt;Many guards still read source text. Executed guards are better, but much of our logic sits in route handlers tied to auth and a database, and extracting it just to test it is a refactor we have not earned yet. Text guards are honest only as long as their mutation set is.&lt;/p&gt;
&lt;p&gt;There is no CI. The guards run on a laptop before a merge.&lt;/p&gt;
&lt;p&gt;And the first finding of all: 42 of 71 is not a number about carelessness. Every guard was written in good faith after a real bug. A green check is a claim, and until something tries to make it red, it is an unverified one.&lt;/p&gt;
&lt;p&gt;We build this at &lt;a rel=&quot;nofollow&quot; href=&quot;https://aifliproom.com&quot;&gt;AI Flip Room&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Correction, 11 October: an earlier version said 44 of 70. The table adds up to 71 guards and 42 holes, and 42 guards had to be rewritten; the headline mixed two counts. Thanks to a reader on DEV for doing the sums.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30633/42-of-our-71-regression-guards-stayed-green-when-we-broke-the-code-on-purpose</guid>
<pubDate>Sat, 10 Oct 2026 19:24:40 +0000</pubDate>
</item>
<item>
<title>The Async Result Belongs to the Snapshot That Started It</title>
<link>https://coderlegion.com/30557/the-async-result-belongs-to-the-snapshot-that-started-it</link>
<description>&lt;p&gt;Modern client components are long-lived, reactive, and full of asynchronous work. That combination creates a subtle class of defect: a response can be factually correct for the request that produced it, yet wrong for the screen that exists when it arrives.&lt;/p&gt;
&lt;p&gt;This is especially important when a component mixes route parameters with identity-scoped state. The user can navigate while a request is in flight. Authentication can resolve, refresh, or change. The component instance may survive both events. Unless the component tracks which context owns each result, an older task can update a newer view.&lt;/p&gt;
&lt;p&gt;The core rule is simple:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;An awaited result belongs to the input snapshot that started it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That snapshot is more than a component reference. It can include the current route value, the current authentication-state operation, and a monotonically increasing generation number.&lt;/p&gt;
&lt;h3 id=&quot;how-the-race-forms&quot;&gt;How the race forms&lt;/h3&gt;
&lt;p&gt;Imagine a detail page that loads an item, then asks whether the current viewer has reacted to it. The page shows a highlighted action when the answer is yes.&lt;/p&gt;
&lt;p&gt;The component begins loading item A for identity A. Before the viewer-specific request returns, the user signs out and another identity becomes current—or the same component navigates to item B. The old request then completes with a truthful answer: identity A did react to item A.&lt;/p&gt;
&lt;p&gt;If the continuation writes directly to a shared &lt;code&gt;isActive&lt;/code&gt; field, that truth is now displayed in the wrong context. Item B may appear active, or identity B may see state derived from identity A.&lt;/p&gt;
&lt;p&gt;This is not merely a cosmetic race. Identity-derived state can influence which actions are enabled, what ownership badges appear, and which command a user can submit next.&lt;/p&gt;
&lt;h3 id=&quot;make-ownership-explicit&quot;&gt;Make ownership explicit&lt;/h3&gt;
&lt;p&gt;A practical pattern is to treat every meaningful input change as a new generation.&lt;/p&gt;
&lt;p&gt;When a route or authentication input changes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Increment the generation.&lt;/li&gt;
&lt;li&gt;Capture the route and authentication input in local variables.&lt;/li&gt;
&lt;li&gt;Clear state derived from the previous identity.&lt;/li&gt;
&lt;li&gt;Disable identity-scoped actions while the new identity is unresolved.&lt;/li&gt;
&lt;li&gt;After each &lt;code&gt;await&lt;/code&gt;, confirm that the generation and captured inputs are still current.&lt;/li&gt;
&lt;li&gt;Apply results only when those checks pass.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The checks matter after every awaited boundary, not only at the end. A component may load the main item, related content, entitlement state, reaction state, and cart state in sequence. Any await is a point where navigation or identity can change.&lt;/p&gt;
&lt;p&gt;Cancellation tokens can reduce wasted work, and they are worth using when the underlying operation supports cancellation. But cancellation alone is not the ownership proof. Cancellation can race with completion, and some dependencies cannot cancel promptly. The continuation still needs to ask, “Does this result belong to the current snapshot?”&lt;/p&gt;
&lt;h3 id=&quot;fail-closed-while-identity-is-unknown&quot;&gt;Fail closed while identity is unknown&lt;/h3&gt;
&lt;p&gt;There is another important state between old identity and new identity: unresolved identity.&lt;/p&gt;
&lt;p&gt;Keeping the old viewer-specific UI visible during that interval is convenient, but unsafe. The component should clear ownership badges, active reactions, and similar derived state before awaiting the new authentication result. Any action that depends on identity should be disabled until the new snapshot is ready.&lt;/p&gt;
&lt;p&gt;That brief disabled state is honest. The application does not yet know which identity owns the next command.&lt;/p&gt;
&lt;p&gt;This principle also prevents an action from being sent using newly refreshed credentials while the visible UI still describes the previous viewer. Read state and write authority move together.&lt;/p&gt;
&lt;h3 id=&quot;test-the-interleaving-not-only-the-outcome&quot;&gt;Test the interleaving, not only the outcome&lt;/h3&gt;
&lt;p&gt;Ordinary happy-path tests rarely expose this defect because requests finish in the same order they started.&lt;/p&gt;
&lt;p&gt;A stronger component test controls the schedule:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Start a viewer-specific read and hold its response.&lt;/li&gt;
&lt;li&gt;Change the route or authentication identity.&lt;/li&gt;
&lt;li&gt;Wait for the new screen to settle.&lt;/li&gt;
&lt;li&gt;Release the old response.&lt;/li&gt;
&lt;li&gt;Assert that the current screen does not adopt the old result.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A separate test should hold authentication unresolved and verify that identity-scoped actions remain disabled. This proves that the UI fails closed during the transition.&lt;/p&gt;
&lt;p&gt;There is also a useful counter-test: rerender with exactly the same inputs while a valid request is pending. The component should not discard or duplicate work merely because the framework rendered again. The boundary is a meaningful input change, not every render cycle.&lt;/p&gt;
&lt;p&gt;These tests are valuable because they verify time and ownership, not just a final value. They turn a race that might appear once in a thousand interactions into a deterministic scenario.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off&quot;&gt;The trade-off&lt;/h3&gt;
&lt;p&gt;Generation markers, captured snapshots, and repeated currentness checks add code. Temporarily disabling an action can introduce a small pause. Overusing the pattern can also make a component difficult to read.&lt;/p&gt;
&lt;p&gt;The answer is not to attach a counter to every asynchronous line. Use the pattern where results are scoped to changing inputs: routes, selected records, authentication identity, workspace, locale, or another boundary that can change while the component remains mounted. Extract a small helper when the same ownership rule appears repeatedly.&lt;/p&gt;
&lt;p&gt;The payoff is larger than visual correctness. Explicit async ownership reduces stale-route updates, prevents state from crossing identity boundaries, and stops commands from being enabled under an unresolved context.&lt;/p&gt;
&lt;p&gt;The humble takeaway is this: asynchronous correctness is not only about receiving the right answer. It is also about proving that the answer still belongs here.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30557/the-async-result-belongs-to-the-snapshot-that-started-it</guid>
<pubDate>Sat, 10 Oct 2026 06:01:28 +0000</pubDate>
</item>
<item>
<title>Why tests should verify runtime contracts, not inferred types</title>
<link>https://coderlegion.com/30509/why-tests-should-verify-runtime-contracts-not-inferred-types</link>
<description>&lt;p&gt;If your Node tests are green but callers still need casts, you’re mixing two different guarantees. A Node test proves what the function did when executed: the values returned, whether the input was mutated, and which inputs throw. It does not and cannot prove how the TypeScript compiler will narrow that result at a later use site.&lt;/p&gt;
&lt;p&gt;Implementation summary&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Keep the runtime contract explicit: the helper should validate inputs (e.g., reject non-arrays) and return a new array that removes only null and undefined (use a nullish check rather than truthiness). The server-inserted example shows a small function that performs exactly that behavior and does not mutate the input.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Expected behavior to test&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Mixed-array case: the output contains the original non-nullish items in order and preserves falsy-but-valid values like 0, '', and false.&lt;/li&gt;
&lt;li&gt;Empty input: returns a new empty array (not the same reference).&lt;/li&gt;
&lt;li&gt;All-nullish input: returns an empty array.&lt;/li&gt;
&lt;li&gt;Invalid input: throws a TypeError for non-array arguments.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Where static checking belongs&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If you need the helper to provide compiler-level narrowing (so consumers can assign without casts), verify that with the TypeScript compiler or editor diagnostics. A minimal compile-time check is to write a small use-site that only accepts the narrowed type and run tsc to ensure it typechecks.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Limitations and pitfalls&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Compiler inference changes across TypeScript versions (e.g., TypeScript 5.5 adds inferred predicates in some filter callbacks). Relying on runtime tests to encode compiler behavior makes tests brittle or misleading.&lt;/li&gt;
&lt;li&gt;Truthiness-based filters (e.g., !!x) can silently drop legitimate falsy values — runtime tests must check exact outputs, not just lengths.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Practical rule&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tests: prove the runtime contract you actually depend on. Compiler: prove the static narrowing your consumers rely on. Keep them separate so failures point to the right fix: change JS behavior, adjust types, or update consumers.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The article includes a repaired example and concrete test cases that illustrate this split and avoid overclaiming what a JavaScript test can prove. The server will append the full code example and the article URL.&lt;/p&gt;
&lt;h3 id=&quot;working-example&quot;&gt;Working example&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export const note = `This module demonstrates runtime removal of null and undefined from arrays.
Runtime tests (node:test) validate behavior at execution time but do NOT prove any TypeScript compile-time narrowing. To check static narrowing you must run the TypeScript compiler (tsc) or use editor/IDE type checks.`;

/**
 * Remove null and undefined from an array while preserving order and other falsy
 * values such as 0, '', and false.
 *
 * Runtime contract: returns a new array containing only elements that are not
 * null and not undefined. Throws a TypeError if the argument is not an Array.
 *
 * Note: This demonstrates runtime behavior only. TypeScript's ability to
 * narrow types (e.g. from (T | null | undefined)[] to T[]) is a compile-time
 * property checked by tsc / the language service and is not proven by these
 * runtime tests.
 */
export function filterNonNullish(arr) {
  if (!Array.isArray(arr)) {
    throw new TypeError('filterNonNullish expects an Array');
  }

  return arr.filter((x) =&amp;gt; x != null);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read the full article: &lt;a rel=&quot;nofollow&quot; href=&quot;https://chriseugenerodriguez.com/blog/tests-should-verify-runtime-contract-not-inferred-types&quot;&gt;https://chriseugenerodriguez.com/blog/tests-should-verify-runtime-contract-not-inferred-types&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30509/why-tests-should-verify-runtime-contracts-not-inferred-types</guid>
<pubDate>Fri, 09 Oct 2026 19:59:18 +0000</pubDate>
</item>
<item>
<title>How we check every AI-generated room render before a user sees it</title>
<link>https://coderlegion.com/30506/how-we-check-every-ai-generated-room-render-before-a-user-sees-it</link>
<description>&lt;p&gt;Image models are very good at restyling a room and quietly bad at leaving the room alone. Ask for a Japandi living room and you get beautiful furniture, and sometimes also:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a window that moved, shrank, or appeared over a kitchen sink where the photo shows a solid wall;&lt;/li&gt;
&lt;li&gt;a closet door that came back as a curtain, a sliding screen, or plain wall;&lt;/li&gt;
&lt;li&gt;a sofa parked across the only archway;&lt;/li&gt;
&lt;li&gt;a new doorway at the frame edge, or a niche carved into a flat wall;&lt;/li&gt;
&lt;li&gt;a camera that slid sideways;&lt;/li&gt;
&lt;li&gt;or the opposite: a picture that is almost exactly the photo you uploaded.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Our users put these images in property listings, where a staged photo may change the furniture but not the windows, walls or doors (&lt;a rel=&quot;nofollow&quot; href=&quot;https://aifliproom.com/blog/virtual-staging-disclosure-rules&quot;&gt;why that line exists&lt;/a&gt;). So every render is checked automatically before anyone sees it. Here is how, and what building it taught us.&lt;/p&gt;
&lt;h3 id=&quot;the-pipeline-at-a-glance&quot;&gt;The pipeline at a glance&lt;/h3&gt;
&lt;p&gt;For an indoor room, each render goes through three gates in parallel:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A vision-model judge&lt;/strong&gt; compares the original photo with the render and answers a fixed list of yes/no questions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A mechanical camera check&lt;/strong&gt; measures whether the viewpoint moved, with no language model involved.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A crop-level presence check&lt;/strong&gt; looks at each door, window and staircase found in the original, at full resolution.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If all three pass, the render is served. If not, we retry. If nothing passes, the user gets the best attempt, clearly marked as a draft.&lt;/p&gt;
&lt;h3 id=&quot;gate-1-a-judge-that-only-answers-booleans&quot;&gt;Gate 1: a judge that only answers booleans&lt;/h3&gt;
&lt;p&gt;The judge gets the original and the render, both downscaled, and must return a fixed JSON object. We use structured output with a schema, so it cannot reply in prose. An earlier version asked for &quot;only JSON&quot; and scraped it out with a regex; when the output got long or cut off, the parse failed and the gate silently did nothing.&lt;/p&gt;
&lt;p&gt;Indoors there are 16 checks, outdoors 9. Indoors they cover:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Openings:&lt;/strong&gt; every door is still a door you could open; every passage has clear floor to walk through.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Windows:&lt;/strong&gt; same count, position, size and shape; none invented; not hidden behind a tall wardrobe; the same view outside.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The room shell:&lt;/strong&gt; ceiling, fireplace, no carved niches, features on the same walls, solid frame edges, no new rooms or corridors.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Finish consistency:&lt;/strong&gt; no half-repainted rooms.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Content rules:&lt;/strong&gt; no people, and no photo or realistic portrait of a person in the wall art.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Camera:&lt;/strong&gt; same viewpoint, same room depth.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Outdoors: buildings, fence line and gates, terrain (no new pool), mature trees, background, and a usable driveway.&lt;/p&gt;
&lt;p&gt;A simplified, illustrative verdict:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;doors_preserved&quot;: true,
  &quot;openings_clear&quot;: false,
  &quot;windows_ok&quot;: true,
  &quot;window_unblocked&quot;: true,
  &quot;walls_uniform&quot;: true,
  &quot;frame_edges_solid&quot;: true,
  &quot;no_faces_in_art&quot;: true,
  &quot;fail_reasons&quot;: [&quot;sofa end stands on the archway floor&quot;]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A render passes only if every counted boolean is true; the reasons feed logs and the retry prompt. Most of the work went into the wording:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Say what must pass.&lt;/strong&gt; Restyling is the product, so the instructions list what is expected (new floors, paint, wallpaper, fixtures) and say &quot;unsure means pass&quot;. The exception is a short list where unsure means fail, led by a window that may not have been in the original. An invented window is the worst defect a listing photo can have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One question per field.&lt;/strong&gt; While the frame-edge rule was a sub-clause of a broader &quot;no phantom architecture&quot; check, new doorways at the frame edge got through. Its own boolean made the model actually look.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Naming things primes the judge.&lt;/strong&gt; An early niche check listed which shelves were allowed, and the judge started flagging every shelf. The current wording is purely geometric: is this flat wall still one plane?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Allow &quot;nothing to compare&quot;.&lt;/strong&gt; The &quot;same view through the window&quot; check kept firing on originals with blinds drawn or glass blown out to white. That case is now an explicit pass.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Wording is measurable.&lt;/strong&gt; The old wall-art wording wrongly failed 10 of 16 good frames (stylised line drawings counted as faces). The rewrite failed 0 of 16 and still caught all 5 real portraits.&lt;/p&gt;
&lt;h3 id=&quot;gate-2-measuring-the-camera-instead-of-asking-about-it&quot;&gt;Gate 2: measuring the camera instead of asking about it&lt;/h3&gt;
&lt;p&gt;Early on, the vision model was close to blind to viewpoint changes: renders shot from a visibly different spot passed every neural check. Later, a stronger judge had the opposite problem: on 43 renders where the camera had not moved, it said it had 13 times. So indoors the judge's camera answers no longer decide pass or fail. A measurement does, with two channels:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Ceiling line.&lt;/strong&gt; We trace the wall/ceiling boundary as a curve (dynamic programming over the vertical brightness gradient) and compare the original's curve with the render's.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Depth profile.&lt;/strong&gt; A monocular depth model estimates both images. Per column we take how far the far wall reads, then compare the profiles with a robust fit, a rank correlation and a search for a sideways shift.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each channel is fooled by something different. Paint fools the ceiling line (dark beams, art on a mantel). Glass fools depth (windows and mirrors are estimated differently each time). Paint is flat in depth, and glass does not move the ceiling line, so we flag a shift only when &lt;strong&gt;both&lt;/strong&gt; agree: the minimum of the two normalised scores.&lt;/p&gt;
&lt;p&gt;On 24 hand-labelled pairs from two rooms, this caught 5 of 5 real shifts with 0 false flags. The margin was thin, so a grey zone around the threshold is logged, not failed. Outdoors there is no ceiling to trace, so the mechanical check is off and the judge's camera question stays in charge.&lt;/p&gt;
&lt;h3 id=&quot;gate-3-crops-because-a-door-is-80-pixels-wide&quot;&gt;Gate 3: crops, because a door is 80 pixels wide&lt;/h3&gt;
&lt;p&gt;At the judge's resolution a front door is a thin strip. So doors, windows and stairs are detected at upload, and after each render a model is asked about each crop at full resolution: is it still there? A mechanical channel also measures whether a window drifted or widened. On 87 labelled pairs, the crop judge caught 7 of 10 broken renders with no false alarms on the 75 clean ones; the mechanical channel caught the other 3.&lt;/p&gt;
&lt;h3 id=&quot;when-a-render-fails&quot;&gt;When a render fails&lt;/h3&gt;
&lt;p&gt;Retries depend on the kind of defect.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Placement defects&lt;/strong&gt; (a blocked passage, a face in the art, half-painted walls) are retried on the same image model with the judge's reasons added to the prompt.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Geometry defects&lt;/strong&gt; go to a second, pricier image model. We measured a same-model retry fixing broken geometry about one time in five. In an earlier bake-off the two models were clean on 18 and 17 of 20 renders, and 19 of 20 together: their failures barely overlap.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The geometry retry gets a fixed, count-based instruction (&quot;same set of openings, not one more, not one less&quot;), never the judge's description of the defect. When a retry prompt named an arched opening at the left edge, the retry model painted exactly that.&lt;/p&gt;
&lt;p&gt;If nothing passes, the user still gets the best attempt, under a &quot;rough draft&quot; banner. On paid plans the first four rejected drafts in a rolling week don't use a render; the free plan gets one. The cap is deliberate: a draft is a real, downloadable image, so unlimited forgiveness would be a free image farm.&lt;/p&gt;
&lt;p&gt;The checks fail open. If the judge itself errors or times out, the user is not blocked: the image is served flagged, and an unverified render is not charged as a draft.&lt;/p&gt;
&lt;h3 id=&quot;the-near-copy-trap&quot;&gt;The near-copy trap&lt;/h3&gt;
&lt;p&gt;At first, &quot;best attempt&quot; meant the one with the fewest failed checks. The attempt that breaks the least is often the one where the model barely did anything. In one real case the first attempt was a convincing industrial restyle that failed on geometry, and the user was served the second: their old room, old wallpaper and all, plus a pair of lamps. The ranking was paying the model for doing nothing.&lt;/p&gt;
&lt;p&gt;The fix is a change meter: shrink both images to a tiny thumbnail and count how much of the frame visibly changed. No model call, and we calibrated it on 110 real renders and 10 synthetic near-copies (recompressed, blurred, brightened, shifted, a couple of lamp-sized patches). The ranking now:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// simplified
function draftRank(a: Attempt): number {
  if (a.checkerErrored) return LAST;
  let rank = a.failedChecks.length;
  if (a.hasPeopleOrRealFacesInArt) rank += HARD_BAN;
  if (a.isNearCopy &amp;amp;&amp;amp; a.intensity !== &quot;light&quot;) rank += NEAR_COPY;
  return rank; // lowest is served
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A light restage is exempt, because there the user asked us to keep the room.&lt;/p&gt;
&lt;h3 id=&quot;picking-the-judge-model-with-a-calibration-set&quot;&gt;Picking the judge model with a calibration set&lt;/h3&gt;
&lt;p&gt;The judge runs on every attempt, so a cheaper model is tempting. We keep a calibration set of 31 hand-labelled frames and run every candidate on it twice. A cheaper model at under half the price per call missed 6 defects per run against our current judge's 3. On 46 live renders with 8 defects marked by eye, it caught 0 and 1 across two runs; the current judge caught 6. A newer model wasn't clearly better either: fewer misses (2 vs 3), twice the false alarms (4 vs 2). We kept the current judge.&lt;/p&gt;
&lt;h3 id=&quot;takeaways-1&quot;&gt;Takeaways&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Ask the judge narrow yes/no questions, not one &quot;is this good?&quot;.&lt;/li&gt;
&lt;li&gt;Where geometry can be measured, measure it, with channels whose failure modes don't overlap.&lt;/li&gt;
&lt;li&gt;Calibrate every wording change and model swap on a labelled set, twice.&lt;/li&gt;
&lt;li&gt;Design the failure path as carefully as the check: ranking, draft label, who pays.&lt;/li&gt;
&lt;li&gt;&quot;Least broken&quot; can mean &quot;least changed&quot;. Measure change directly.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We build this at &lt;a rel=&quot;nofollow&quot; href=&quot;https://aifliproom.com&quot;&gt;AI Flip Room&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30506/how-we-check-every-ai-generated-room-render-before-a-user-sees-it</guid>
<pubDate>Fri, 09 Oct 2026 18:55:25 +0000</pubDate>
</item>
<item>
<title>Mi Definition of Done: 5 gates que convierten &quot;creo que funciona&quot; en &quot;puedo demostrarlo&quot;</title>
<link>https://coderlegion.com/30228/mi-definition-of-done-5-gates-que-convierten-creo-que-funciona-en-puedo-demostrarlo</link>
<description>&amp;lt;!--
  CoderLegion · Article (Discussion-style) · Post 03
  Serie: — (standalone)
  Autor: Ignacio Badenes (@yosoyignicion / IgnicionDev)
  Idioma: bilingüe ES + EN · Read time: ~8 min
  Tags: python, security, testing, opensource
  Caso real: pass-encrypt-env (uso personal, Linux Mint) — https://github.com/yosoyignicion/pass-encrypt-env
  Nota: mini-app en estado &quot;uso personal&quot;; la adaptación/portabilidad se planifica en días próximos.
--&amp;gt;
&lt;h2 id=&quot;mi-definition-of-done-5-gates-que-convierten-creo-que-funciona-en-puedo-demostrarlo-&quot;&gt;Mi Definition of Done: 5 gates que convierten &quot;creo que funciona&quot; en &quot;puedo demostrarlo&quot;&lt;/h2&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Nota honesta:&lt;/strong&gt; esto no es un producto, es una &lt;strong&gt;mini-app de uso personal&lt;/strong&gt; en mi Linux Mint. Funciona y la uso a diario; su adaptación para terceros la planifico para los próximos días.&lt;br&gt;
&lt;strong&gt;Read time:&lt;/strong&gt; ~8 min · &lt;strong&gt;Etiquetas:&lt;/strong&gt; &lt;code&gt;python&lt;/code&gt; &lt;code&gt;security&lt;/code&gt; &lt;code&gt;testing&lt;/code&gt; &lt;code&gt;opensource&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;un-commit-una-hora-y-no-es-un-juguete&quot;&gt;Un commit. Una hora. Y no es un juguete&lt;/h3&gt;
&lt;p&gt;El proyecto del que hablo hoy tiene &lt;strong&gt;un solo commit&lt;/strong&gt; y tardó &lt;strong&gt;una hora&lt;/strong&gt; en funcionar de punta a punta. Lo uso cada mañana. Y no es un juguete.&lt;/p&gt;
&lt;p&gt;Lo digo porque la pregunta interesante no fue &lt;em&gt;cómo&lt;/em&gt; lo programé rápido, sino &lt;strong&gt;cómo supe que estaba terminado&lt;/strong&gt;. Ese es el punto: &quot;terminado&quot; no es una sensación, es una decisión que puedes defender. Cuando esa decisión tiene una definición clara, programar una hora deja de ser un atajo y pasa a ser consecuencia.&lt;/p&gt;
&lt;p&gt;Este post es esa definición. La llamo &lt;em&gt;Definition of Done&lt;/em&gt; y son cinco gates.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;qu-hace-sin-tecnicismos&quot;&gt;Qué hace, sin tecnicismos&lt;/h3&gt;
&lt;p&gt;Imagina un llavero. Guarda tus claves API y credenciales &lt;strong&gt;cifradas dentro de tu propio ordenador&lt;/strong&gt; y se las sirve a tus aplicaciones durante toda la sesión después de &lt;strong&gt;un único desbloqueo&lt;/strong&gt; al iniciar sesión. Nada sube a la nube. No hay telemetría. No hay cuenta que crear.&lt;/p&gt;
&lt;p&gt;En la práctica, mis scripts y programas ya no llevan claves escritas dentro ni dependen de un &lt;code&gt;.env&lt;/code&gt; en claro: consultan el llavero, que ya está abierto por mí, y arrancan &lt;strong&gt;sin volver a pedirme nada&lt;/strong&gt;. Si me voy, el llavero se cierra; las claves desaparecen de la memoria.&lt;/p&gt;
&lt;p&gt;Se llama &lt;code&gt;pass-encrypt-env&lt;/code&gt;. Corre en mi Linux Mint y, hoy, es para mí.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;lo-que-es-hoy-y-lo-que-todav-a-no-&quot;&gt;Lo que es hoy (y lo que todavía no)&lt;/h3&gt;
&lt;p&gt;Ser claro con el estado real es parte del método. Un proyecto &quot;terminado&quot; también &lt;strong&gt;declara sus límites&lt;/strong&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Es:&lt;/strong&gt; una herramienta personal, funcional, que uso a diario en mi equipo.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No es:&lt;/strong&gt; un producto instalable por cualquiera sin fricción, ni algo auditado por terceros.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Viene:&lt;/strong&gt; en los próximos días planifico la &lt;strong&gt;adaptación&lt;/strong&gt; —empaquetado, arranque en otras distribuciones y documentación para quien no sea yo— como un esfuerzo aparte y planificado.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Y hay una razón de fondo para separar esa adaptación del desarrollo: no quiero declarar &quot;lista&quot; una app porque pase &lt;strong&gt;en mi máquina&lt;/strong&gt;. La declaro lista cuando pasa los gates &lt;strong&gt;en cualquier máquina&lt;/strong&gt;. Es exactamente la misma lógica que un buen test: no vale que funcione una vez, tiene que fallar cuando corresponda.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;por-qu-exijo-evidencia-y-no-sensaciones&quot;&gt;Por qué exijo evidencia y no sensaciones&lt;/h3&gt;
&lt;p&gt;Soy relativamente nuevo en el mundo de la IA agéntica, aunque llevo años gestionando sistemas bajo protocolo: control de accesos, mínima exposición, registros. Y una disciplina que aprendí ahí —y que el prompting me recordó— es esta:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;No das algo por resuelto porque &lt;em&gt;parezca&lt;/em&gt; resuelto; lo das por resuelto cuando puedes &lt;strong&gt;demostrarlo&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;El hábito de protocolo es la &lt;em&gt;mínima exposición&lt;/em&gt;: asumes que algo fallará y diseñas para que, cuando falle, no arrastre contigo todo lo demás. Aplicado al software, eso se traduce en una &lt;em&gt;Definition of Done&lt;/em&gt;: una lista corta de condiciones que convierten &lt;em&gt;&quot;yo creo que funciona&quot;&lt;/em&gt; en &lt;em&gt;&quot;aquí está la prueba&quot;&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;En este proyecto, esa lista lleva cinco puertas.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;los-5-gates&quot;&gt;Los 5 gates&lt;/h3&gt;
&lt;p&gt;Cada gate existe porque previene un fallo concreto, no por completitud.&lt;/p&gt;
&lt;h4 id=&quot;1-tests-que-pueden-fallar&quot;&gt;1) Tests que pueden fallar&lt;/h4&gt;
&lt;p&gt;No me sirve &quot;tengo tests&quot;; me sirve que fallen cuando rompo algo. En &lt;code&gt;pass-encrypt-env&lt;/code&gt; hay &lt;strong&gt;19 tests&lt;/strong&gt; y uno de los que más valor tiene es este, porque vigila una promesa sobre el disco:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_no_plaintext_on_disk(tmp_path):
    path = tmp_path / &quot;vault.enc&quot;
    v, unlocked, _ = _new()
    vault.set_secret(unlocked.payload, &quot;TOKEN&quot;, &quot;super-secret-value&quot;)
    vault.reseal(v, unlocked.dek, unlocked.payload)
    vault.save_vault(v, path)

    blob = path.read_text()
    assert &quot;super-secret-value&quot; not in blob          # el secreto no está en claro
    assert (path.stat().st_mode &amp;amp; 0o777) == 0o600     # y el fichero no es legible por terceros
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ese test no comprueba que &quot;va bien&quot;: comprueba que &lt;strong&gt;la promesa del producto se cumple&lt;/strong&gt;. El fallo que previene es el peor de todos: creerte seguro y no serlo.&lt;/p&gt;
&lt;h4 id=&quot;2-lint-limpio&quot;&gt;2) Lint limpio&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ruff check .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No es estética. Un lint en verde elimina de antemano la clase de error más tonta —imports sin usar, variables fantasma, comparaciones sospechosas— antes de que la ejecutes. Es ruido que no llega a producción porque ni siquiera llega al commit.&lt;/p&gt;
&lt;h4 id=&quot;3-escaneo-de-seguridad&quot;&gt;3) Escaneo de seguridad&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bandit -q -c bandit.yaml -r src
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;bandit&lt;/code&gt; busca patrones inseguros en el código: criptografía mal usada, &lt;code&gt;subprocess&lt;/code&gt; peligrosos, aleatoriedad no criptográfica. En una app cuyo único trabajo es guardar secretos, este gate no es opcional. El fallo que previene es el uso accidental de una primitiva &lt;em&gt;parecida&lt;/em&gt; a la correcta.&lt;/p&gt;
&lt;h4 id=&quot;4-ci-reproducible&quot;&gt;4) CI reproducible&lt;/h4&gt;
&lt;p&gt;Los tres gates anteriores corren solos en GitHub Actions, sobre &lt;strong&gt;Python 3.11 y 3.12&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;strategy:
  fail-fast: false
  matrix:
    python-version: [&quot;3.11&quot;, &quot;3.12&quot;]
steps:
  # ...
  - name: Gate (ruff + bandit + pytest)
    run: ./scripts/ci.sh python
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Y aquí está la parte importante: ese &lt;code&gt;./scripts/ci.sh&lt;/code&gt; &lt;strong&gt;es el mismo comando que ejecuto en local&lt;/strong&gt;. No hay una verdad para mi máquina y otra para el servidor. El fallo que previene este gate es &quot;en mi máquina funciona&quot;, que no es un bug: es una mentira cómoda.&lt;/p&gt;
&lt;h4 id=&quot;5-smoke-end-to-end&quot;&gt;5) Smoke end-to-end&lt;/h4&gt;
&lt;p&gt;Los unit tests verdes no garantizan que el sistema entero arranque. Para eso hay un &lt;em&gt;smoke test&lt;/em&gt; headless que levanta el agente, habla con él por su canal interno y comprueba el comportamiento observable:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python3 -m pass_encrypt_env.cli env --if-unlocked &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 || rc=$?
echo &quot;env --if-unlocked (bloqueada) -&amp;gt; exit $rc (esperado 3)&quot;
[ &quot;$rc&quot; -eq 3 ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Aun con piezas perfectas, el conjunto puede no montarse. El smoke test es el gate que mira el &lt;strong&gt;sistema&lt;/strong&gt;, no las partes.&lt;/p&gt;
&lt;h4 id=&quot;la-meta-regla&quot;&gt;La meta-regla&lt;/h4&gt;
&lt;p&gt;Todos los gates caben en un comando:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./scripts/ci.sh    # esto corre en local y esto corre en CI. La misma verdad.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;qu-protege-y-qu-no&quot;&gt;Qué protege y qué no&lt;/h3&gt;
&lt;p&gt;Un proyecto terminado con honestidad &lt;strong&gt;publica su techo&lt;/strong&gt;, no lo esconde. Este es el mío:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;Amenaza &lt;/th&gt;&lt;th align=&quot;center&quot;&gt;¿Mitigado?&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Robo offline de &lt;code&gt;vault.enc&lt;/code&gt; &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Sí — sin master + TOTP no hay datos&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Manipulación del fichero &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Sí — AEAD detecta la alteración&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Alguien sin tu TOTP &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Sí — segundo factor obligatorio&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Pérdida de master o TOTP &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Sí — clave de recuperación&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Proceso del &lt;strong&gt;mismo UID&lt;/strong&gt; con la bóveda abierta &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;No — techo del diseño cero-prompt&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;code&gt;root&lt;/code&gt; / malware ya dentro &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;No — fuera de alcance&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Que la última fila diga &quot;No&quot; no es una debilidad: es una promesa cumplible. Prefiero un alcance pequeño y cierto que uno grande y falso.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;detalle-t-cnico-para-quien-quiera-el-fondo-&quot;&gt;Detalle técnico (para quien quiera el fondo)&lt;/h3&gt;
&lt;p&gt;El &quot;cómo&quot;, para quien lea esto con la ingeniería delante:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cifrado en reposo:&lt;/strong&gt; &lt;code&gt;vault.enc&lt;/code&gt; con &lt;strong&gt;Argon2id&lt;/strong&gt; como función de derivación (64 MiB, 3 pasadas, 4 lanes) y &lt;strong&gt;AES-256-GCM&lt;/strong&gt; como cifrado autenticado (confidencialidad e integridad).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Doble envoltura de la clave de datos (DEK):&lt;/strong&gt; &lt;code&gt;K1 = Argon2id(master)&lt;/code&gt; · &lt;code&gt;K2 = HKDF(K1 ‖ totp_secret)&lt;/code&gt; abre la DEK · &lt;code&gt;K3 = HKDF(recovery_key)&lt;/code&gt; guarda una copia para recuperación. El secreto TOTP viaja cifrado: sin un TOTP válido no se obtiene la DEK.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agente de sesión:&lt;/strong&gt; escucha en un &lt;em&gt;socket&lt;/em&gt; Unix en &lt;code&gt;$XDG_RUNTIME_DIR/passenv/&lt;/code&gt;, permisos &lt;code&gt;0600&lt;/code&gt; y validación de UID del par (&lt;code&gt;SO_PEERCRED&lt;/code&gt;). Se endurece con &lt;code&gt;PR_SET_DUMPABLE=0&lt;/code&gt; (anti-&lt;em&gt;ptrace&lt;/em&gt;), sin volcados de memoria y &lt;code&gt;mlockall&lt;/code&gt; &lt;em&gt;best-effort&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CLI &lt;code&gt;passenv&lt;/code&gt;:&lt;/strong&gt; &lt;code&gt;init&lt;/code&gt;, &lt;code&gt;set/get/list/rm&lt;/code&gt;, &lt;code&gt;export&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, &lt;code&gt;change-master&lt;/code&gt;, &lt;code&gt;backup&lt;/code&gt;, &lt;code&gt;restore&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Modelo de amenazas completo&lt;/strong&gt; en &lt;code&gt;docs/THREAT_MODEL.md&lt;/code&gt;, con el techo del diseño cero-prompt declarado a propósito.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;lo-que-esto-cambia-takeaway-&quot;&gt;Lo que esto cambia (Takeaway)&lt;/h3&gt;
&lt;p&gt;El gate no es un freno; es lo que te deja pisar el acelerador sin miedo. Un proyecto de una hora y un commit no llega &quot;terminado de verdad&quot; por suerte: llega porque las condiciones para llamarlo terminado &lt;strong&gt;ya estaban escritas&lt;/strong&gt; antes de empezar a escribir código.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;Creo que funciona&quot; es una sensación. &quot;Aquí está la prueba&quot; es un proyecto.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;la-checklist&quot;&gt;La checklist&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;- [ ] Tests que fallan cuando rompo algo (no que existan)
- [ ] Lint limpio
- [ ] Escaneo de seguridad sin hallazgos
- [ ] CI reproducible (mismo comando en local que en remoto)
- [ ] Smoke end-to-end en verde
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;y-t-&quot;&gt;Y tú&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;¿Qué requisitos le exiges tú a un proyecto para darlo por terminado? ¿Cuál añadirías a mis cinco gates?&lt;/strong&gt; Y, ya que el mío está en estado de uso personal: &lt;strong&gt;¿tú publicarías una herramienta así o la dejarías privada hasta &quot;adaptarla&quot;?&lt;/strong&gt; Cuéntamelo en los comentarios; leo y respondo a todo.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;sub&gt;&lt;strong&gt;English version below&lt;/strong&gt; · El original está en español. If you're an English speaker, scroll down to the translation and tell me what your own Definition of Done looks like. I reply to everyone.&lt;/sub&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;hr&gt;
&lt;h2 id=&quot;my-definition-of-done-5-gates-that-turn-i-think-it-works-into-here-s-the-proof-&quot;&gt;My Definition of Done: 5 gates that turn &quot;I think it works&quot; into &quot;here's the proof&quot;&lt;/h2&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Honest note:&lt;/strong&gt; this isn't a product, it's a &lt;strong&gt;personal-use mini-app&lt;/strong&gt; on my Linux Mint. It works and I use it daily; adapting it for other people is a planned effort for the coming days.&lt;br&gt;
&lt;strong&gt;Read time:&lt;/strong&gt; ~8 min · &lt;strong&gt;Tags:&lt;/strong&gt; &lt;code&gt;python&lt;/code&gt; &lt;code&gt;security&lt;/code&gt; &lt;code&gt;testing&lt;/code&gt; &lt;code&gt;opensource&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;one-commit-one-hour-and-it-s-not-a-toy&quot;&gt;One commit. One hour. And it's not a toy&lt;/h3&gt;
&lt;p&gt;The project I'm talking about has &lt;strong&gt;a single commit&lt;/strong&gt; and took &lt;strong&gt;one hour&lt;/strong&gt; to work end to end. I use it every morning. And it isn't a toy.&lt;/p&gt;
&lt;p&gt;The interesting question wasn't &lt;em&gt;how&lt;/em&gt; I built it fast, but &lt;strong&gt;how I knew it was done&lt;/strong&gt;. That's the point: &quot;done&quot; isn't a feeling, it's a decision you can defend. Once that decision has a clear definition, writing code for an hour stops being a shortcut and becomes a consequence.&lt;/p&gt;
&lt;p&gt;This post is that definition. I call it a &lt;em&gt;Definition of Done&lt;/em&gt;, and it's five gates.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-it-does-without-jargon&quot;&gt;What it does, without jargon&lt;/h3&gt;
&lt;p&gt;Picture a keyring. It keeps your API keys and credentials &lt;strong&gt;encrypted inside your own computer&lt;/strong&gt; and serves them to your applications for the whole session after &lt;strong&gt;a single unlock&lt;/strong&gt; at login. Nothing goes to the cloud. There's no telemetry. There's no account to create.&lt;/p&gt;
&lt;p&gt;In practice, my scripts and apps no longer carry keys inside them or depend on a plaintext &lt;code&gt;.env&lt;/code&gt;: they ask the keyring, already unlocked on my behalf, and start &lt;strong&gt;without asking me again&lt;/strong&gt;. If I leave, the keyring closes; the keys disappear from memory.&lt;/p&gt;
&lt;p&gt;It's called &lt;code&gt;pass-encrypt-env&lt;/code&gt;. It runs on my Linux Mint and, today, it's for me.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-it-is-today-and-what-it-isn-t-yet-&quot;&gt;What it is today (and what it isn't yet)&lt;/h3&gt;
&lt;p&gt;Being clear about the real state is part of the method. A &quot;finished&quot; project also &lt;strong&gt;declares its limits&lt;/strong&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It is:&lt;/strong&gt; a personal, working tool that I use daily on my machine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It isn't:&lt;/strong&gt; something anyone can install without friction, nor something audited by third parties.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It's coming:&lt;/strong&gt; in the coming days I'm planning the &lt;strong&gt;adaptation&lt;/strong&gt; — packaging, booting on other distributions and documentation for someone who isn't me — as a separate, planned effort.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There's a deeper reason to separate that adaptation from development: I don't want to call an app &quot;ready&quot; because it passes &lt;strong&gt;on my machine&lt;/strong&gt;. I call it ready when it passes the gates &lt;strong&gt;on any machine&lt;/strong&gt;. It's the same logic as a good test: it doesn't count if it works once, it has to fail when it should.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-i-demand-evidence-not-feelings&quot;&gt;Why I demand evidence, not feelings&lt;/h3&gt;
&lt;p&gt;I'm relatively new to the world of agentic AI, though I've spent years managing systems under protocol: access control, minimal exposure, logs. And one discipline I learned there — which prompting reminded me of — is this:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;You don't call something solved because it &lt;em&gt;looks&lt;/em&gt; solved; you call it solved when you can &lt;strong&gt;prove it&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The protocol habit is &lt;em&gt;minimal exposure&lt;/em&gt;: you assume something will fail and you design so that, when it fails, it doesn't drag everything else down with it. Applied to software, that becomes a &lt;em&gt;Definition of Done&lt;/em&gt;: a short list of conditions that turn &lt;em&gt;&quot;I think it works&quot;&lt;/em&gt; into &lt;em&gt;&quot;here's the proof&quot;&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;In this project, that list has five doors.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-5-gates&quot;&gt;The 5 gates&lt;/h3&gt;
&lt;p&gt;Each gate exists because it prevents a concrete failure, not for completeness.&lt;/p&gt;
&lt;h4 id=&quot;1-tests-that-can-fail&quot;&gt;1) Tests that can fail&lt;/h4&gt;
&lt;p&gt;I don't care about &quot;I have tests&quot;; I care that they fail when I break something. &lt;code&gt;pass-encrypt-env&lt;/code&gt; has &lt;strong&gt;19 tests&lt;/strong&gt;, and one of the most valuable guards a promise about the disk:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_no_plaintext_on_disk(tmp_path):
    path = tmp_path / &quot;vault.enc&quot;
    v, unlocked, _ = _new()
    vault.set_secret(unlocked.payload, &quot;TOKEN&quot;, &quot;super-secret-value&quot;)
    vault.reseal(v, unlocked.dek, unlocked.payload)
    vault.save_vault(v, path)

    blob = path.read_text()
    assert &quot;super-secret-value&quot; not in blob          # the secret isn't in cleartext
    assert (path.stat().st_mode &amp;amp; 0o777) == 0o600     # and the file isn't readable by others
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That test doesn't check that &quot;it works&quot;: it checks that &lt;strong&gt;the product's promise holds&lt;/strong&gt;. The failure it prevents is the worst of all: believing you're safe when you aren't.&lt;/p&gt;
&lt;h4 id=&quot;2-clean-lint&quot;&gt;2) Clean lint&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ruff check .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It isn't aesthetics. A green lint removes the dumbest class of error —unused imports, ghost variables, suspicious comparisons— before you even run it. It's noise that never reaches production because it never reaches the commit.&lt;/p&gt;
&lt;h4 id=&quot;3-security-scan&quot;&gt;3) Security scan&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bandit -q -c bandit.yaml -r src
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;bandit&lt;/code&gt; looks for insecure patterns in the code: misused cryptography, dangerous &lt;code&gt;subprocess&lt;/code&gt;, non-cryptographic randomness. In an app whose only job is to store secrets, this gate isn't optional. The failure it prevents is the accidental use of a primitive that &lt;em&gt;looks&lt;/em&gt; like the right one.&lt;/p&gt;
&lt;h4 id=&quot;4-reproducible-ci&quot;&gt;4) Reproducible CI&lt;/h4&gt;
&lt;p&gt;The previous three gates run on their own in GitHub Actions, across &lt;strong&gt;Python 3.11 and 3.12&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;strategy:
  fail-fast: false
  matrix:
    python-version: [&quot;3.11&quot;, &quot;3.12&quot;]
steps:
  # ...
  - name: Gate (ruff + bandit + pytest)
    run: ./scripts/ci.sh python
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And here's the important part: that &lt;code&gt;./scripts/ci.sh&lt;/code&gt; &lt;strong&gt;is the same command I run locally&lt;/strong&gt;. There's no one truth for my machine and another for the server. The failure this gate prevents is &quot;it works on my machine&quot;, which isn't a bug: it's a comfortable lie.&lt;/p&gt;
&lt;h4 id=&quot;5-end-to-end-smoke-test&quot;&gt;5) End-to-end smoke test&lt;/h4&gt;
&lt;p&gt;Green unit tests don't guarantee the whole system boots. For that there's a headless &lt;em&gt;smoke test&lt;/em&gt; that starts the agent, talks to it over its internal channel and checks the observable behavior:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python3 -m pass_encrypt_env.cli env --if-unlocked &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 || rc=$?
echo &quot;env --if-unlocked (locked) -&amp;gt; exit $rc (expected 3)&quot;
[ &quot;$rc&quot; -eq 3 ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Even with perfect pieces, the whole may fail to assemble. The smoke test is the gate that looks at the &lt;strong&gt;system&lt;/strong&gt;, not the parts.&lt;/p&gt;
&lt;h4 id=&quot;the-meta-rule&quot;&gt;The meta-rule&lt;/h4&gt;
&lt;p&gt;Every gate fits in one command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./scripts/ci.sh    # this runs locally and this runs in CI. The same truth.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-it-protects-and-what-it-doesn-t&quot;&gt;What it protects and what it doesn't&lt;/h3&gt;
&lt;p&gt;An honestly finished project &lt;strong&gt;publishes its ceiling&lt;/strong&gt;, it doesn't hide it. This is mine:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;Threat &lt;/th&gt;&lt;th align=&quot;center&quot;&gt;Mitigated?&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Offline theft of &lt;code&gt;vault.enc&lt;/code&gt; &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Yes — without master + TOTP there's no data&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;File tampering &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Yes — AEAD detects the alteration&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Someone without your TOTP &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Yes — second factor required&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Loss of master or TOTP &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Yes — recovery key&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;A process of the &lt;strong&gt;same UID&lt;/strong&gt; with the vault open &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;No — ceiling of the prompt-less design&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;code&gt;root&lt;/code&gt; / malware already inside &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;No — out of scope&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That the last row says &quot;No&quot; isn't a weakness: it's a promise you can keep. I'd rather have a small and certain scope than a large and false one.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;technical-detail-for-those-who-want-the-depth-&quot;&gt;Technical detail (for those who want the depth)&lt;/h3&gt;
&lt;p&gt;The &quot;how&quot;, for whoever reads this with the engineering in front of them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Encryption at rest:&lt;/strong&gt; &lt;code&gt;vault.enc&lt;/code&gt; with &lt;strong&gt;Argon2id&lt;/strong&gt; as the key-derivation function (64 MiB, 3 passes, 4 lanes) and &lt;strong&gt;AES-256-GCM&lt;/strong&gt; as authenticated encryption (confidentiality and integrity).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Two-layer wrapping of the data key (DEK):&lt;/strong&gt; &lt;code&gt;K1 = Argon2id(master)&lt;/code&gt; · &lt;code&gt;K2 = HKDF(K1 ‖ totp_secret)&lt;/code&gt; unwraps the DEK · &lt;code&gt;K3 = HKDF(recovery_key)&lt;/code&gt; keeps a recovery copy. The TOTP secret travels encrypted: without a valid TOTP you don't get the DEK.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Session agent:&lt;/strong&gt; it listens on a Unix &lt;em&gt;socket&lt;/em&gt; at &lt;code&gt;$XDG_RUNTIME_DIR/passenv/&lt;/code&gt;, mode &lt;code&gt;0600&lt;/code&gt; and UID validation of the peer (&lt;code&gt;SO_PEERCRED&lt;/code&gt;). It's hardened with &lt;code&gt;PR_SET_DUMPABLE=0&lt;/code&gt; (anti-&lt;em&gt;ptrace&lt;/em&gt;), no memory dumps and best-effort &lt;code&gt;mlockall&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;passenv&lt;/code&gt; CLI:&lt;/strong&gt; &lt;code&gt;init&lt;/code&gt;, &lt;code&gt;set/get/list/rm&lt;/code&gt;, &lt;code&gt;export&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, &lt;code&gt;change-master&lt;/code&gt;, &lt;code&gt;backup&lt;/code&gt;, &lt;code&gt;restore&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Full threat model&lt;/strong&gt; in &lt;code&gt;docs/THREAT_MODEL.md&lt;/code&gt;, with the prompt-less design's ceiling declared on purpose.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-this-changes-takeaway-&quot;&gt;What this changes (Takeaway)&lt;/h3&gt;
&lt;p&gt;The gate isn't a brake; it's what lets you hit the accelerator without fear. A one-hour, one-commit project doesn't end up &quot;truly finished&quot; by luck: it does because the conditions to call it finished were &lt;strong&gt;written before the code&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;I think it works&quot; is a feeling. &quot;Here's the proof&quot; is a project.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-checklist&quot;&gt;The checklist&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;- [ ] Tests that fail when I break something (not that they exist)
- [ ] Clean lint
- [ ] Security scan with no findings
- [ ] Reproducible CI (same command locally and remotely)
- [ ] End-to-end smoke test green
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;over-to-you&quot;&gt;Over to you&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What requirements do you demand from a project before calling it done? Which one would you add to my five gates?&lt;/strong&gt; And since mine is in a personal-use state: &lt;strong&gt;would you publish a tool like this or keep it private until you &quot;adapt&quot; it?&lt;/strong&gt; Tell me in the comments; I read and reply to everyone.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;sub&gt;Project license: MIT · No secrets or keys in this post. · Project: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/yosoyignicion/pass-encrypt-env&quot;&gt;github.com/yosoyignicion/pass-encrypt-env&lt;/a&gt; · Profile: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/yosoyignicion&quot;&gt;github.com/yosoyignicion&lt;/a&gt;&lt;/sub&gt;&lt;/p&gt;
</description>
<category>Discussions</category>
<guid isPermaLink="true">https://coderlegion.com/30228/mi-definition-of-done-5-gates-que-convierten-creo-que-funciona-en-puedo-demostrarlo</guid>
<pubDate>Thu, 08 Oct 2026 07:28:13 +0000</pubDate>
</item>
</channel>
</rss>