<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jonathan Bajrami</title>
    <description>The latest articles on DEV Community by Jonathan Bajrami (@jonathan_bajrami_687037af).</description>
    <link>https://dev.to/jonathan_bajrami_687037af</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4207440%2Fb13e9fd7-d920-4a26-8591-d6d31d46918e.png</url>
      <title>DEV Community: Jonathan Bajrami</title>
      <link>https://dev.to/jonathan_bajrami_687037af</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jonathan_bajrami_687037af"/>
    <language>en</language>
    <item>
      <title>DETACHED_PROCESS made my background jobs open more windows, not fewer</title>
      <dc:creator>Jonathan Bajrami</dc:creator>
      <pubDate>Mon, 26 Oct 2026 00:34:05 +0000</pubDate>
      <link>https://dev.to/jonathan_bajrami_687037af/detachedprocess-made-my-background-jobs-open-more-windows-not-fewer-5dae</link>
      <guid>https://dev.to/jonathan_bajrami_687037af/detachedprocess-made-my-background-jobs-open-more-windows-not-fewer-5dae</guid>
      <description>&lt;p&gt;A good share of my automation runs on a Windows desktop, not a server. Some jobs need things only that machine has: a signed-in browser, local credentials, a checkout of the repo. The rest run on a small Linux server under systemd. Windows Task Scheduler starts the desktop ones, some as often as every ten minutes.&lt;/p&gt;

&lt;p&gt;Two problems kept coming back. Terminal windows popped up over whatever I was doing. And when a job stopped running, nothing told me. This is how each one broke and what fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Window one: the task itself
&lt;/h2&gt;

&lt;p&gt;The first case was simple. A sync job ran every ten minutes, and its scheduled task pointed at &lt;code&gt;cmd.exe&lt;/code&gt; running a &lt;code&gt;.bat&lt;/code&gt; file. Every ten minutes a console window opened, ran for a few seconds and closed, usually while I was typing.&lt;/p&gt;

&lt;p&gt;The usual advice is &lt;code&gt;powershell.exe -WindowStyle Hidden&lt;/code&gt;. In my experience it still flashes. PowerShell is a console program, so Windows gives it a console window before PowerShell has a chance to read its own arguments and hide it.&lt;/p&gt;

&lt;p&gt;What worked was launching through &lt;code&gt;wscript.exe&lt;/code&gt;, which is a GUI program and gets no console, and having a tiny VBScript start the real command with window style 0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vb"&gt;&lt;code&gt;&lt;span class="c1"&gt;' run-hidden.vbs: wscript.exe run-hidden.vbs "C:\path\to\job.bat"&lt;/span&gt;
&lt;span class="k"&gt;Set&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WScript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Arguments&lt;/span&gt;
&lt;span class="k"&gt;If&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;Then&lt;/span&gt; &lt;span class="n"&gt;WScript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quit&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;

&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;For&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;To&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;If&lt;/span&gt; &lt;span class="n"&gt;InStr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;Or&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="k"&gt;Then&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;""&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;""&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;
&lt;span class="k"&gt;Next&lt;/span&gt;
&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;ext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Right&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;If&lt;/span&gt; &lt;span class="n"&gt;ext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;".bat"&lt;/span&gt; &lt;span class="ow"&gt;Or&lt;/span&gt; &lt;span class="n"&gt;ext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;".cmd"&lt;/span&gt; &lt;span class="k"&gt;Then&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"cmd.exe /c &lt;/span&gt;&lt;span class="se"&gt;""&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;""&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;WScript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quit&lt;/span&gt; &lt;span class="n"&gt;CreateObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WScript.Shell"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last argument, &lt;code&gt;True&lt;/code&gt;, matters more than it looks. It makes the script wait for the job to finish. That keeps the task in the Running state for the whole job, which lets the "do not start a new instance" setting skip an overlapping run, and it passes the job's exit code back as the task's last result. Without the wait, every run reports success in a fraction of a second, whatever the job did.&lt;/p&gt;

&lt;p&gt;The scheduled action looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;New-ScheduledTaskAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Execute&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'wscript.exe'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-Argument&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'"C:\path\to\run-hidden.vbs" "C:\path\to\job.bat"'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a Python entry point with no child processes, &lt;code&gt;pythonw.exe&lt;/code&gt; does the same job directly. I moved every task over and the windows stopped. For a while.&lt;/p&gt;

&lt;h2&gt;
  
  
  Window two: the grandchildren
&lt;/h2&gt;

&lt;p&gt;A few weeks later they were back, from jobs that were already launching through the hidden wrapper.&lt;/p&gt;

&lt;p&gt;These jobs start other processes. One monitor, when it raises an alert, starts a background worker that launches a headless coding-agent run to investigate. Two others start headless agent runs of their own. A notification job running under &lt;code&gt;pythonw&lt;/code&gt; calls &lt;code&gt;git&lt;/code&gt;, &lt;code&gt;ssh&lt;/code&gt; and &lt;code&gt;powershell&lt;/code&gt;. The parent was hidden. Its children were not.&lt;/p&gt;

&lt;p&gt;The worker launch looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CREATE_NO_WINDOW&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DETACHED_PROCESS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="nf"&gt;popen&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;worker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;creationflags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both flags sound like "no window". Together they gave me windows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does that combination open windows?
&lt;/h3&gt;

&lt;p&gt;Microsoft's &lt;a href="https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags" rel="noopener noreferrer"&gt;process creation flags&lt;/a&gt; page answers it in one line. &lt;code&gt;CREATE_NO_WINDOW&lt;/code&gt; "is ignored if the application is not a console application, or if it is used with either &lt;code&gt;CREATE_NEW_CONSOLE&lt;/code&gt; or &lt;code&gt;DETACHED_PROCESS&lt;/code&gt;."&lt;/p&gt;

&lt;p&gt;So the effective flag was &lt;code&gt;DETACHED_PROCESS&lt;/code&gt; alone, and a detached process has no console at all. That's fine for the Python worker, which prints nothing anyone sees. The trouble starts when it launches a console program. Per the &lt;a href="https://learn.microsoft.com/en-us/windows/console/creation-of-a-console" rel="noopener noreferrer"&gt;console creation docs&lt;/a&gt;, a console process normally inherits its parent's console. A parent with no console has nothing to hand down, so Windows creates a new one, with a visible window. The agent CLI is a &lt;code&gt;.cmd&lt;/code&gt; shim, which means &lt;code&gt;cmd.exe&lt;/code&gt;, which means a console, which means a window, once per run.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pythonw&lt;/code&gt; has the same shape. It's a GUI-subsystem build of Python with no console, so every &lt;code&gt;git&lt;/code&gt; or &lt;code&gt;ssh&lt;/code&gt; it starts without flags gets a fresh console window.&lt;/p&gt;

&lt;p&gt;The fix is to drop &lt;code&gt;DETACHED_PROCESS&lt;/code&gt; and keep &lt;code&gt;CREATE_NO_WINDOW&lt;/code&gt; alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# A hidden console, not DETACHED_PROCESS: a detached child has none,
# so the agent CLI it starts opened a terminal.
&lt;/span&gt;&lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CREATE_NO_WINDOW&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CREATE_NO_WINDOW&lt;/code&gt; gives the child a console that exists but has no window. Its own children inherit that hidden console instead of making new ones. The same flag goes on every &lt;code&gt;subprocess.run&lt;/code&gt; and &lt;code&gt;Popen&lt;/code&gt; in any job that runs without a console, including one-off calls to &lt;code&gt;git&lt;/code&gt;. The rule I ended up writing down: any code that starts a process from a scheduled job passes &lt;code&gt;CREATE_NO_WINDOW&lt;/code&gt;, and never &lt;code&gt;DETACHED_PROCESS&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When a window does appear, I check two things in order. First, the non-Microsoft scheduled tasks, for any action that points straight at a console program:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-ScheduledTask&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Where-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;TaskPath&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-notlike&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'\Microsoft\*'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the code those tasks run, searching for &lt;code&gt;DETACHED_PROCESS&lt;/code&gt; and for &lt;code&gt;subprocess&lt;/code&gt; calls with no &lt;code&gt;creationflags&lt;/code&gt; at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The opposite problem: a job that stops without a sound
&lt;/h2&gt;

&lt;p&gt;Hidden jobs have a downside. When one stops, you don't see that either. A desktop goes to sleep, a task gets disabled while testing, a timer on the server disappears after a reinstall. None of those produce an error, because nothing ran.&lt;/p&gt;

&lt;p&gt;The first layer is a heartbeat wrapper around the job command, using &lt;a href="https://healthchecks.io" rel="noopener noreferrer"&gt;healthchecks.io&lt;/a&gt;'s ping API. It pings &lt;code&gt;/start&lt;/code&gt;, runs the job, then pings the exit code. The service raises an alert when a ping is late or reports a non-zero exit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;_send&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;runner&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;ping_key&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;ping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;ping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three choices in there are deliberate. A failed ping (5-second timeout) is logged and swallowed, so a monitoring hiccup can never fail the job. With no key configured, the wrapper runs the job unchanged, so a job can be wired in before the monitoring account exists. And it only ever sends start and exit-code pings, never the job's output, which can contain personal data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it late, or is the PC just off?
&lt;/h2&gt;

&lt;p&gt;The second layer is one table of every scheduled job and always-on service on both machines, refreshed hourly. On the desktop it reads Task Scheduler through its COM interface from PowerShell. On the server it runs &lt;code&gt;systemctl show&lt;/code&gt; over SSH. Each job gets a status: ok, running, failed, stopped (enabled but no next run), late, down (a service that should always run), missing (listed in config, gone from the machine) or off.&lt;/p&gt;

&lt;p&gt;Task Scheduler reports results as codes, and three non-zero ones are not failures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;RUNNING_CODE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x41301&lt;/span&gt;      &lt;span class="c1"&gt;# the task is running now
&lt;/span&gt;&lt;span class="n"&gt;NOT_RUN_CODES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x41303&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;  &lt;span class="c1"&gt;# has not run yet
&lt;/span&gt;&lt;span class="n"&gt;OK_CODES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x41306&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# done; or stopped by the owner
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also reports "never" as a date in 1899 or 1999, so any date before 2001 is treated as no date.&lt;/p&gt;

&lt;p&gt;"Late" was the hard one. A job is late when its last run is older than twice its normal gap plus a grace period. On the server the grace is 30 minutes. On the desktop it's 14 hours, because that machine is only on from about midday to one in the morning. Without that, every daily job would be flagged late every morning, and an alert that fires every morning is one I'd learn to ignore.&lt;/p&gt;

&lt;p&gt;Two smaller details made it trustworthy. A job only counts as missing when the machine it lives on was actually reachable. If SSH to the server fails, that's one error row, not every server job marked missing at once. And a job that's on the machine but not in the config still shows up under its raw name, so a new job can't hide by being forgotten.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alert once, and again only when something changes
&lt;/h2&gt;

&lt;p&gt;Problems go to my phone, and the rule is once per problem. A small state file maps each job to the problem status already sent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;new_problems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alerted&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROBLEMS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;alerted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
                &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A job that's still failing an hour later stays quiet. A job that moves from late to failed alerts again, because that's new information. A job that recovers drops out of the map, so if it breaks again next week, that's a fresh alert. If the phone message itself fails to send, those jobs are left out of the saved state and the next hourly run tries again.&lt;/p&gt;

&lt;p&gt;The collector follows the window rule too: its PowerShell call passes &lt;code&gt;CREATE_NO_WINDOW&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do from day one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Launch every scheduled task through a GUI host (&lt;code&gt;wscript.exe&lt;/code&gt; with a hidden-window script, or &lt;code&gt;pythonw.exe&lt;/code&gt;). Never point a task straight at &lt;code&gt;cmd.exe&lt;/code&gt;, a &lt;code&gt;.bat&lt;/code&gt; or &lt;code&gt;powershell.exe&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pass &lt;code&gt;CREATE_NO_WINDOW&lt;/code&gt; on every child process a background job starts, and never combine it with &lt;code&gt;DETACHED_PROCESS&lt;/code&gt;. Windows ignores it when you do.&lt;/li&gt;
&lt;li&gt;Make the wrapper wait for the job, so the task's state and exit code mean something.&lt;/li&gt;
&lt;li&gt;Watch for silence as well as errors, with a grace period that matches when the machine is actually on.&lt;/li&gt;
&lt;li&gt;Alert on change, not on state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One of the jobs that table watches is the publishing pipeline in &lt;a href="https://logicahq.com/work/automated-seo-system" rel="noopener noreferrer"&gt;this write-up of an automated SEO system&lt;/a&gt;. It runs unattended, so it's exactly the kind of job that could stop for a week with nobody noticing, without the table.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance, working from the project's own scripts, commit history and Microsoft's documentation.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>windows</category>
      <category>python</category>
      <category>devops</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>My website audit crawler named Google as the company that built the site</title>
      <dc:creator>Jonathan Bajrami</dc:creator>
      <pubDate>Mon, 26 Oct 2026 00:33:13 +0000</pubDate>
      <link>https://dev.to/jonathan_bajrami_687037af/my-website-audit-crawler-named-google-as-the-company-that-built-the-site-37jm</link>
      <guid>https://dev.to/jonathan_bajrami_687037af/my-website-audit-crawler-named-google-as-the-company-that-built-the-site-37jm</guid>
      <description>&lt;p&gt;I build a tool that reads a business's website before I talk to the owner. It crawls up to 60 pages, works out what the site runs on (booking system, chat widget, analytics, form handler, cookie banner) and writes one internal markdown brief for whoever makes the call. Nobody outside sees it.&lt;/p&gt;

&lt;p&gt;One field in that brief is "who built this site". When the first build went through review, a page carrying a standard reCAPTCHA badge got this answer: &lt;code&gt;policies.google.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Google does not build small-business websites. This post is about how that happened, the second bug the same review found, and the rule I now apply to anything that reads a website and makes a claim about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a reCAPTCHA badge became a web agency
&lt;/h2&gt;

&lt;p&gt;Plenty of small-business sites carry a credit in the footer from the agency that built them. The giveaway is often not the "Website by X" text. It's the legal links. An agency that hosts its clients' privacy and terms pages on its own domain leaves footer links like &lt;code&gt;agency.example/privacy-policy/&lt;/code&gt; and &lt;code&gt;agency.example/website-terms/&lt;/code&gt; on every page.&lt;/p&gt;

&lt;p&gt;So the first version of the detector did this: for every link on every page, if it points off-site and the path contains &lt;code&gt;privacy&lt;/code&gt;, &lt;code&gt;terms&lt;/code&gt; or &lt;code&gt;cookies&lt;/code&gt;, count that host as a candidate builder. Most frequent host wins.&lt;/p&gt;

&lt;p&gt;Now look at what a standard reCAPTCHA badge puts on a page: &lt;code&gt;policies.google.com/privacy&lt;/code&gt; and &lt;code&gt;policies.google.com/terms&lt;/code&gt;. Off-site, on every page with a form, both paths matching. My heuristic was built to find exactly that shape. The saved site I use as the main test fixture, a real dental practice, loads assets from &lt;code&gt;www.google.com&lt;/code&gt; too, so this was not a contrived case.&lt;/p&gt;

&lt;p&gt;The fix had two parts. First, a short list of known infrastructure hosts that can never be the builder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_AGENCY_INFRASTRUCTURE_HOSTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policies.google.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;www.google.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;google.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;support.google.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myaccount.google.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accounts.google.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, any host that is already a named vendor in one of the other categories (a cookie-consent provider's own policy page, say) is skipped too. When nothing is left, the field says &lt;code&gt;not detected&lt;/code&gt;. A reCAPTCHA-only site now returns &lt;code&gt;not detected&lt;/code&gt;, and a real agency footer still resolves.&lt;/p&gt;

&lt;p&gt;What bothered me was where the wrong answer would end up. The brief is read by a person about to ring a real business, and "your site was built by Google" is a false statement about that business.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that every test agreed with
&lt;/h2&gt;

&lt;p&gt;The same review found a second problem, worse because the test suite was green.&lt;/p&gt;

&lt;p&gt;For each detected vendor the brief reports how many pages it appeared on: &lt;code&gt;len(pages)&lt;/code&gt; against &lt;code&gt;total_pages&lt;/code&gt;. The reviewer tried replacing &lt;code&gt;len(pages)&lt;/code&gt; with &lt;code&gt;total_pages&lt;/code&gt;, which turns "1 of 8" into "8 of 8" for every vendor. The suite stayed at 115 passed.&lt;/p&gt;

&lt;p&gt;The only test checking the page count used a review widget that appeared on 8 of 8 pages. In that case the right number and the wrong number are the same number, so the test could never tell them apart.&lt;/p&gt;

&lt;p&gt;I now think of this as the main way a test suite lies: an oracle where the correct value and a plausible wrong value coincide. The fix was a small corpus where a vendor appears on exactly 1 of 3 pages, and a test asserting &lt;code&gt;pages=1, total_pages=3&lt;/code&gt;. Put the mutation back and it fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: trust where an asset came from, never what the page says
&lt;/h2&gt;

&lt;p&gt;Both bugs led back to a rule that was already in the spec for vendor detection, and that I now apply to everything the tool claims.&lt;/p&gt;

&lt;p&gt;A vendor counts as present only when a host served something to the page. The evidence is the set of external hosts in &lt;code&gt;src&lt;/code&gt; and &lt;code&gt;href&lt;/code&gt; attributes (scripts, iframes, stylesheets, images) and the hosts that forms post to. The detector never reads the rendered body text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_match_vendor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;known_host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vendor&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;known_host&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="c1"&gt;# booking.vendor.example matches vendor.example
&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;vendor&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two cases in the test fixtures show why the body text is off limits.&lt;/p&gt;

&lt;p&gt;Across the dental practice's pages, the word "dentally" appears six times, every one of them in the phrase "dentally phobic patients". Dentally is also the name of a dental booking system. A substring match over the page text reports that the practice books through Dentally. It doesn't.&lt;/p&gt;

&lt;p&gt;On the same site, the word "crisp" appears, but only inside a WordPress CSS custom property, &lt;code&gt;--wp--preset--shadow--crisp&lt;/code&gt;. Crisp is also a live-chat product. A text matcher reports a chat widget that isn't there.&lt;/p&gt;

&lt;p&gt;Both are kept as negative tests. Plant a body-text matcher and they go red.&lt;/p&gt;

&lt;p&gt;The other half of the rule is the vocabulary. When no host evidence turns up, the brief says &lt;code&gt;not detected&lt;/code&gt;, never &lt;code&gt;absent&lt;/code&gt;. A booking system can sit behind a button the crawler never clicked, or be wired up server-side. "We didn't see one" is a fact. "They don't have one" is a guess, and it becomes a question for the call instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the model away from the page text
&lt;/h2&gt;

&lt;p&gt;The brief also has a judgement layer. Three model-based reviewers each look at one area: whether the site gets found and turns visits into enquiries, how enquiries are followed up, and what software the business runs on. They write prose; code checks it.&lt;/p&gt;

&lt;p&gt;The obvious design gives each reviewer the crawl, which would undo the rule above. A model handed the dental site's HTML sees "dentally" six times and is likely to conclude the practice uses Dentally. Origin-based detection only helps if nothing downstream re-reads the text.&lt;/p&gt;

&lt;p&gt;So the reviewers never see the crawl. The engine dumps its evidence once, then cuts a projection per reviewer: worked-out findings (vendors with their hosts, structured-data findings, which sector workflows had evidence) plus page metadata such as titles, headings and schema types. No page body, not even truncated. In the sample dump the raw pages were 127KB of a 174KB file, all of it material the rule forbids.&lt;/p&gt;

&lt;p&gt;Page metadata is still text the business wrote, and "dentally phobic" can turn up in a meta description. So each projection also carries a contract block saying vendor claims come from the stack findings only. The projection removes most of the risk. The contract covers the rest.&lt;/p&gt;

&lt;p&gt;Two more limits are computed in code and travel inside the projection, so a reviewer can't widen them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The follow-up reviewer is forbidden a verdict. A website can show there are three ways to get in touch and no booking widget. It cannot show who answers the phone at 6pm.&lt;/li&gt;
&lt;li&gt;The website reviewer loses its verdict when the crawl fetched less than 60% of the URLs it discovered. Judging a whole site from a fifth of it is the same mistake as judging something invisible.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The gate writes the file, not the person running it
&lt;/h2&gt;

&lt;p&gt;The last guard is a gate script that runs over the composed brief before it is saved. One check is a deliberately blunt regular expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;G7_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/10\b|/100\b|[0-9]/5\b|losing £|lost revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exists because this tool replaced an older audit that produced scores. That version scored eleven areas, but six couldn't be measured from free public data, so its headline number was an average over only the areas it could score. The new brief has no score, no grade and no money estimate, and this pattern keeps them out. It over-catches on purpose (a URL path like &lt;code&gt;/100-club/&lt;/code&gt; trips it), because clearing a false positive takes a minute and a scored brief that ships costs the call.&lt;/p&gt;

&lt;p&gt;The other change is who writes the file. Originally the gate printed pass or fail and the person running it copied the draft across, so a failing draft could land because nobody checked an exit code. Now the gate takes &lt;code&gt;--write-to&lt;/code&gt; and does the write itself. On failure it writes nothing and leaves the previous brief untouched.&lt;/p&gt;

&lt;h2&gt;
  
  
  A smaller one: the crawler refuses to audit itself
&lt;/h2&gt;

&lt;p&gt;The older audit once reported that a live business had no HTTPS. It had been pointed at a local copy of the site.&lt;/p&gt;

&lt;p&gt;The new runner refuses loopback addresses and exits with code 2. The first version compared hostnames as strings, which misses most of the ways to write "this machine". The current check normalises first, then parses with both &lt;code&gt;ipaddress&lt;/code&gt; and &lt;code&gt;socket.inet_aton&lt;/code&gt;, which accepts the short, decimal and hex IPv4 forms a browser accepts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# 127.1, 2130706433 and 0x7f000001 all parse here
&lt;/span&gt;        &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inet_ntoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inet_aton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IPv6Address&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ipv4_mapped&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ipv4_mapped&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_loopback&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_unspecified&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;[::1]&lt;/code&gt;, &lt;code&gt;[::ffff:127.0.0.1]&lt;/code&gt;, &lt;code&gt;0.0.0.0&lt;/code&gt; and a trailing-dot &lt;code&gt;127.0.0.1.&lt;/code&gt; are refused; a real domain like &lt;code&gt;localhost-dental.co.uk&lt;/code&gt; is not. There's no DNS lookup, on purpose: the offline modes must make no network calls, and a lookup during argument parsing would be one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone writing a site analyser
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Decide what counts as evidence before you write a detector. "A host served an asset" is evidence. "The page mentions a word" is not.&lt;/li&gt;
&lt;li&gt;Write the negative fixtures early, from real pages that contain the vendor's name without using the vendor.&lt;/li&gt;
&lt;li&gt;Plant the wrong answer and watch for red. If the suite stays green, the test is checking a case where right and wrong coincide.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The brief is an internal document that prepares a first conversation with a business, the kind of discovery work described on our &lt;a href="https://logicahq.com/ai-consultancy" rel="noopener noreferrer"&gt;AI consultancy&lt;/a&gt; page, and its unanswered questions are asked there rather than guessed at. Every one of the fixes above came from asking what that brief might say about a real business that wasn't true.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance, working from the project's own code, commit history and test fixtures.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>testing</category>
      <category>webscraping</category>
      <category>ai</category>
    </item>
    <item>
      <title>I banned arithmetic in my invoicing app and enforced it with an AST test</title>
      <dc:creator>Jonathan Bajrami</dc:creator>
      <pubDate>Mon, 26 Oct 2026 00:26:46 +0000</pubDate>
      <link>https://dev.to/jonathan_bajrami_687037af/i-banned-arithmetic-in-my-invoicing-app-and-enforced-it-with-an-ast-test-5974</link>
      <guid>https://dev.to/jonathan_bajrami_687037af/i-banned-arithmetic-in-my-invoicing-app-and-enforced-it-with-an-ast-test-5974</guid>
      <description>&lt;p&gt;The quoting and invoicing app I built has a rule that looks odd at first: the app package is not allowed to do arithmetic. No &lt;code&gt;+&lt;/code&gt;, no &lt;code&gt;-&lt;/code&gt;, no &lt;code&gt;*&lt;/code&gt;, no &lt;code&gt;/&lt;/code&gt;, no &lt;code&gt;float&lt;/code&gt;, and no &lt;code&gt;Decimal(...)&lt;/code&gt; outside a short list of named files.&lt;/p&gt;

&lt;p&gt;Every money figure comes from a separate engine, &lt;code&gt;quote_to_cash&lt;/code&gt;: one function prices a set of lines (&lt;code&gt;compute_totals&lt;/code&gt;), a few more work out what's paid and outstanding. The app above it, a Next.js front end over a thin FastAPI layer with a SQLite file per tenant, can store figures, show them and send them on. It can't make new ones.&lt;/p&gt;

&lt;p&gt;The reason is practical. A quote total that the API worked out one way and the PDF another is a bug a customer finds before I do. If only one module can produce a figure, the two can't disagree. And a rule like that doesn't survive long in a README, so it lives in a test.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you test that code doesn't do maths?
&lt;/h2&gt;

&lt;p&gt;You read the code as data. Python ships its own parser, so the test walks the syntax tree of every &lt;code&gt;.py&lt;/code&gt; file under the package and fails on anything that looks like arithmetic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_ARITH_OPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Div&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FloorDiv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BinOp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AugAssign&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_ARITH_OPS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;exempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lineno&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: arithmetic operator &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                              &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; not allowed here&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On top of operators it reports any &lt;code&gt;float&lt;/code&gt; literal, any &lt;code&gt;float(...)&lt;/code&gt; call, any &lt;code&gt;.quantize(...)&lt;/code&gt; call, any &lt;code&gt;sum(...)&lt;/code&gt; outside the one aggregates module, and any &lt;code&gt;Decimal(...)&lt;/code&gt; outside &lt;code&gt;money_io.py&lt;/code&gt;, the boundary where a request string becomes money and money becomes a response string.&lt;/p&gt;

&lt;p&gt;It also refuses to pass if it scanned fewer than ten files, because a gate pointed at an empty directory passes forever.&lt;/p&gt;

&lt;p&gt;One consequence is easy to miss. &lt;code&gt;pathlib&lt;/code&gt; overloads &lt;code&gt;/&lt;/code&gt; for joining paths, and in the syntax tree &lt;code&gt;base / "data"&lt;/code&gt; is a &lt;code&gt;BinOp&lt;/code&gt; with &lt;code&gt;ast.Div&lt;/code&gt;, the same node as &lt;code&gt;a / b&lt;/code&gt;. The checker doesn't try to tell them apart. Instead the spec says paths are joined with &lt;code&gt;Path.joinpath()&lt;/code&gt;, never &lt;code&gt;/&lt;/code&gt;. It reads oddly, and it's the cheapest fix available.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exceptions are by path, and narrow
&lt;/h2&gt;

&lt;p&gt;Some files genuinely need an operator: date ageing subtracts dates, invoice numbering increments a sequence, the demo data offsets dates from today. Each gets exactly one operator, in exactly one file. Later work added a few more, and the interesting ones are narrower than "this file may use &lt;code&gt;+&lt;/code&gt;":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The files that keep integer counters (push attempts, failed sign-ins) may use &lt;code&gt;+&lt;/code&gt; only in the shape &lt;code&gt;&amp;lt;expr&amp;gt; + 1&lt;/code&gt;. The check looks at the right operand and requires the literal integer &lt;code&gt;1&lt;/code&gt;. It also rejects &lt;code&gt;True&lt;/code&gt;, because &lt;code&gt;bool&lt;/code&gt; is a subclass of &lt;code&gt;int&lt;/code&gt; in Python and &lt;code&gt;n + True&lt;/code&gt; would otherwise pass.&lt;/li&gt;
&lt;li&gt;The approvals store may use &lt;code&gt;+&lt;/code&gt; only as &lt;code&gt;&amp;lt;anything&amp;gt; + timedelta(...)&lt;/code&gt;, for a 30-minute expiry. A static check can't know a name holds a &lt;code&gt;datetime&lt;/code&gt;, so it pins the half it can see: the right operand must be a literal &lt;code&gt;timedelta(...)&lt;/code&gt; call. &lt;code&gt;now + LIFETIME&lt;/code&gt; is refused even though it's correct, so the code says &lt;code&gt;now + timedelta(minutes=LIFETIME_MINUTES)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each narrowing has a planted-violation test. &lt;code&gt;n + 2&lt;/code&gt;, &lt;code&gt;1 + n&lt;/code&gt; and &lt;code&gt;a + b&lt;/code&gt; in a counter file must all be reported, and &lt;code&gt;n + 1&lt;/code&gt; in a file with no exemption must be reported too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the first version let through
&lt;/h2&gt;

&lt;p&gt;The first version of the gate went through a cold-read review, which found three holes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spellings.&lt;/strong&gt; Version one looked for a bare name &lt;code&gt;Decimal&lt;/code&gt; being called. These all construct a &lt;code&gt;Decimal&lt;/code&gt; and all passed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;D&lt;/span&gt;

&lt;span class="n"&gt;decimal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;D&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builtins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix collects every alias bound by &lt;code&gt;from decimal import Decimal as X&lt;/code&gt; in the file, and treats any call ending in &lt;code&gt;.Decimal&lt;/code&gt;, &lt;code&gt;.from_float&lt;/code&gt; or &lt;code&gt;.float&lt;/code&gt; as a construction, whatever comes before the dot. A test plants all four and asserts exactly four violations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Basenames.&lt;/strong&gt; Exemptions were matched on the file name alone. &lt;code&gt;money_io.py&lt;/code&gt; may construct &lt;code&gt;Decimal&lt;/code&gt;, so a future &lt;code&gt;api/money_io.py&lt;/code&gt; would have inherited that right just by sharing a name. Root-level exemptions now require &lt;code&gt;path.parent == package_root&lt;/code&gt;, and nested ones are keyed by the path relative to the package root. The test builds a fake package with the same file at both levels and checks only one is exempt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unchecked calls inside the exempt file.&lt;/strong&gt; The aggregates module is allowed &lt;code&gt;sum&lt;/code&gt;, but only as &lt;code&gt;sum(values, Decimal("0.00"))&lt;/code&gt;. Version one never looked at the arguments, so &lt;code&gt;sum([a, b])&lt;/code&gt; with an integer start passed. It now requires exactly two positional arguments, the second being that literal.&lt;/p&gt;

&lt;p&gt;The common thread: the gate was checking one spelling of the rule rather than the rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one file that's allowed to divide
&lt;/h2&gt;

&lt;p&gt;Then I added staged invoicing: bill 60% of a quote now and the rest on completion. That needs a percentage of a figure, which is multiplication and division. It went into one new file, &lt;code&gt;staged/split.py&lt;/code&gt;, with its own short list of operators and three allowed literals: &lt;code&gt;Decimal("0.01")&lt;/code&gt;, &lt;code&gt;Decimal("0.00")&lt;/code&gt; and &lt;code&gt;Decimal("100")&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The rule in that file is that the stage that closes a quote is a &lt;strong&gt;remainder&lt;/strong&gt;, never a fresh percentage. Here's the demo quote from the spec:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Labour&lt;/th&gt;
&lt;th&gt;Materials&lt;/th&gt;
&lt;th&gt;Net&lt;/th&gt;
&lt;th&gt;VAT 20%&lt;/th&gt;
&lt;th&gt;Gross&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Whole quote&lt;/td&gt;
&lt;td&gt;338.00&lt;/td&gt;
&lt;td&gt;272.00&lt;/td&gt;
&lt;td&gt;610.00&lt;/td&gt;
&lt;td&gt;122.00&lt;/td&gt;
&lt;td&gt;732.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60% stage&lt;/td&gt;
&lt;td&gt;202.80&lt;/td&gt;
&lt;td&gt;163.20&lt;/td&gt;
&lt;td&gt;366.00&lt;/td&gt;
&lt;td&gt;73.20&lt;/td&gt;
&lt;td&gt;439.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Closing stage (remainder)&lt;/td&gt;
&lt;td&gt;135.20&lt;/td&gt;
&lt;td&gt;108.80&lt;/td&gt;
&lt;td&gt;244.00&lt;/td&gt;
&lt;td&gt;48.80&lt;/td&gt;
&lt;td&gt;292.80&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;338.00 - 202.80 = 135.20&lt;/code&gt;. Taking &lt;code&gt;338.00 × 40 / 100&lt;/code&gt; gives the same 135.20 here, which is exactly the problem. The fixed oracles in the spec couldn't distinguish the two rules, because for these figures no whole-number percentage does.&lt;/p&gt;

&lt;p&gt;Try a split that doesn't divide cleanly: 33.33%, 33.33%, 33.34%. Price the third stage as a fresh 33.34% and the three nets come to 610.01. A penny more than the quote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the remainder still came out a penny short
&lt;/h2&gt;

&lt;p&gt;The first implementation took the remainder on the line amounts only, then priced each stage as an ordinary invoice. The nets summed to 610.00. VAT didn't.&lt;/p&gt;

&lt;p&gt;The engine rounds VAT once per invoice: it rounds the net, then rounds &lt;code&gt;net × 20 / 100&lt;/code&gt; half-up. Each of the first two stages has a net of 203.32 and VAT of 40.664, rounded to 40.66. The closing stage's lines come to 203.36, VAT 40.672, rounded to 40.67. Total VAT: 121.99 against the quote's 122.00. Gross: 731.99 against 732.00.&lt;/p&gt;

&lt;p&gt;With two stages this never shows up. Whatever the first stage's rounding drops, the second stage's rounding picks back up. Every oracle in the spec had two stages. The review that caught it tried three.&lt;/p&gt;

&lt;p&gt;The fix takes the remainder of every figure on the closing stage, not just the lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_closing_totals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;whole&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Totals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;earlier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Totals&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Totals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Totals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;net_gbp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remainder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;whole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;net_gbp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;net_gbp&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;earlier&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="n"&gt;vat_gbp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remainder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;whole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vat_gbp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vat_gbp&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;earlier&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="n"&gt;gross_gbp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remainder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;whole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gross_gbp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gross_gbp&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;earlier&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="c1"&gt;# ...CIS deduction, retention and payable the same way
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The closing stage now carries VAT of 40.68 (&lt;code&gt;122.00 - 40.66 - 40.66&lt;/code&gt;), a penny off what &lt;code&gt;quantize(203.36 × 0.20)&lt;/code&gt; gives on its own. That penny is the one the earlier stages rounded away, and the last invoice is where it belongs, as a contractor's final invoice balances the account. Gross is still net plus VAT, because a difference of differences is the difference of the sums. And the subtraction goes through &lt;code&gt;split.remainder&lt;/code&gt;, so the gate needed no new exemption.&lt;/p&gt;

&lt;p&gt;The proof is a test that runs 7 percentage sets across 3 quotes, including a reverse-charge quote with a CIS deduction, checks all six figures against the quote's own, and goes red against a planted mutation that re-prices the closing stage from its lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same review found a race
&lt;/h2&gt;

&lt;p&gt;One more defect from that review, the kind a demo never shows. Two stage requests arriving together were both admitted. Both read "60% remaining", both wrote, one record overwrote the other, and 120% of the quote was pushed to the accounting system while 40% was still marked as invoiceable.&lt;/p&gt;

&lt;p&gt;The fix is a per-quote lock held across check, write and push, plus a create-only write that raises if the record already exists. The test races two threads through two API clients with a gate clock that holds both inside the check-then-write window.&lt;/p&gt;

&lt;p&gt;That lock is in-process only, and the commit says so. The storage layer has no transactions or compare-and-set, so two API processes against one tenant file could still race. The app runs a single local API process, so the lock covers what exists today; anything more belongs in the storage layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I do it again?
&lt;/h2&gt;

&lt;p&gt;Yes. The gate is about 580 lines including its own tests. What it buys is less "no arithmetic" than "every exception is a reviewed line in one file, with a test proving it can't spread". When staged invoicing needed to divide, the change was one new path and three literals, not a search for stray multiplications.&lt;/p&gt;

&lt;p&gt;If you want the same thing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Put every money calculation behind one module, and make the rest of the code unable to do maths by test, not by convention.&lt;/li&gt;
&lt;li&gt;Make the checker catch constructions, not names: &lt;code&gt;decimal.Decimal&lt;/code&gt;, aliases, &lt;code&gt;from_float&lt;/code&gt;, &lt;code&gt;builtins.float&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Match exemptions by full path.&lt;/li&gt;
&lt;li&gt;For splits, close with a remainder of every figure, and test with at least three parts. Two parts hide the rounding.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The user's side of this app, rather than its internals, is described on the &lt;a href="https://logicahq.com/solutions/quoting" rel="noopener noreferrer"&gt;quoting automation&lt;/a&gt; page: quotes priced on the business's own rate card and checked by a person before they go out.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance, working from the project's own code, specs, tests and commit history.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
