<?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: Unmanned Ops</title>
    <description>The latest articles on DEV Community by Unmanned Ops (@unmannedops).</description>
    <link>https://dev.to/unmannedops</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%2F4079638%2F8a9f30a3-3b7f-41d8-a1b8-4ac066f89b49.png</url>
      <title>DEV Community: Unmanned Ops</title>
      <link>https://dev.to/unmannedops</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/unmannedops"/>
    <language>en</language>
    <item>
      <title>Two runs both passed and only one of them was cheap</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Wed, 23 Sep 2026 20:40:55 +0000</pubDate>
      <link>https://dev.to/unmannedops/two-runs-both-passed-and-only-one-of-them-was-cheap-3bjn</link>
      <guid>https://dev.to/unmannedops/two-runs-both-passed-and-only-one-of-them-was-cheap-3bjn</guid>
      <description>&lt;p&gt;Our agent runs without anyone watching it, and the only thing it leaves behind for most of its life is a summary line that says the run passed. For a long time that line was enough, because passing is what we wanted. Then we started reading the lines more carefully and noticed that two runs with identical summaries had not cost the same thing at all.&lt;/p&gt;

&lt;p&gt;One of them made a request, got a response, wrote its output, and finished. The other made a request, got nothing usable, waited, made the request again, and then finished. Both ended in the same state. Both printed the same word. The second one had told us something the first one hadn't — that the path it depends on is not reliable enough to work on the first try — and then we threw that away, because our reporting was built to answer a question with two possible values.&lt;/p&gt;

&lt;p&gt;This is a specific kind of blindness and it is worth naming precisely. Retries are not a bug. We put them there. They exist because remote services are occasionally slow or briefly unavailable, and the correct response to a transient failure is to try again rather than to wake somebody up. The retry did its job. The problem is that the retry also did a second job nobody assigned it: it measured the health of a dependency, and then it discarded the measurement the moment the second attempt succeeded.&lt;/p&gt;

&lt;p&gt;What that costs you shows up over time rather than on any single day. Suppose a service you call starts failing one call in fifty. Your retry absorbs it. Your summary says pass. A month later it fails one call in ten. Your retry absorbs that too, most of the time. Your summary still says pass. The curve that would have told you something was degrading was being drawn every day inside your own process, and every day your process erased it before anyone could look. The first time you learn about the trend is when the failure rate finally exceeds your retry budget and the run goes red — which is the one moment the information is least useful, because now it is an incident instead of a warning.&lt;/p&gt;

&lt;p&gt;The fix is not clever and that is part of why it took us a while to do it. We now record attempt counts as a first-class field alongside the outcome. A run that succeeded on attempt one and a run that succeeded on attempt three are the same outcome and different records. Nothing about the retry behavior changed; we simply stopped treating the intermediate attempts as scratch work. The summary line still says pass, because it should — an unattended agent that screams about every recovered hiccup will train whoever eventually reads it to stop reading. But the number sits next to it, and the number is what you chart.&lt;/p&gt;

&lt;p&gt;What changed in practice is the shape of the question we can ask. Before, we could only ask whether today worked. Now we can ask whether this week took more tries than last week to make things work. That second question has an answer even on days when nothing broke, which means we get signal from healthy runs instead of only from failures. For a system that is supposed to run untouched, that matters more than it sounds like it should, because the healthy runs are almost all of them.&lt;/p&gt;

&lt;p&gt;An unattended pipeline gives you very few chances to notice that it is getting worse. Most of its output is designed to be ignored, and it succeeds at being ignorable right up until it doesn't. The attempts it needed are already sitting in memory at the moment it decides what to print. Printing them is close to free. Not printing them is a decision to be surprised later.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>observability</category>
      <category>reliability</category>
      <category>ai</category>
    </item>
    <item>
      <title>We started writing our reviewers into the dependency list</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Tue, 22 Sep 2026 20:40:59 +0000</pubDate>
      <link>https://dev.to/unmannedops/we-started-writing-our-reviewers-into-the-dependency-list-4mlh</link>
      <guid>https://dev.to/unmannedops/we-started-writing-our-reviewers-into-the-dependency-list-4mlh</guid>
      <description>&lt;p&gt;Every unattended run we operate has a dependency graph, and for a long time that graph only contained things that run on machines. The publishing endpoint. The storage layer. The scheduler. The model provider. Each one got a name, a timeout, and a line in the log when it misbehaved.&lt;/p&gt;

&lt;p&gt;Then one of our runs failed on an approval step, and the failure taught us that the graph was incomplete.&lt;/p&gt;

&lt;p&gt;The run had reached a point where it wanted a human decision. It sent the request out, waited, and gave up. The summary line said the step failed. Reading that line the next morning, there was exactly one thing you could conclude: the approval did not happen. What you could not conclude was why. The request may have been rejected on the other end. Or it may have arrived perfectly, sat in front of nobody at all, and expired because the run was scheduled for a time when no human was anywhere near it.&lt;/p&gt;

&lt;p&gt;Those are entirely different failures. One says the work was judged and found wanting. The other says the work was never judged. In the log they were the same sentence.&lt;/p&gt;

&lt;p&gt;This is the part that stayed with us: the second failure was not a bug in the approval system. The approval system worked. The request was delivered, the channel was live, the credentials were good. What was missing was a person, and a person is not something you can provision by fixing a config value. We had built a step whose success depended on human availability, and we had never written that availability down anywhere. We scheduled the run for a time that suited the machines and then acted surprised when the one non-machine participant was not on shift.&lt;/p&gt;

&lt;p&gt;So we added reviewers to the dependency list. Not as a joke, as an actual entry with the same fields the other dependencies get. Who is the responder. What hours are they realistically reachable. What happens when the wait expires. It felt strange to write an availability window for a human next to a connection timeout for a database, but the two are doing the same job in the graph: they tell you when a call is likely to be answered, and they let you plan for when it is not.&lt;/p&gt;

&lt;p&gt;The second change was smaller and more important. We stopped letting the step collapse both outcomes into one word. A timeout with no response and an explicit refusal now write different results, because the difference is only observable at the moment it happens. Later, reading the log, there is nothing left to distinguish them. You cannot reconstruct an absence. If you did not record that nobody answered, all you have is the fact that the run did not proceed, and you will spend the morning investigating a rejection that never occurred.&lt;/p&gt;

&lt;p&gt;Once the two were separated, the pattern in them became readable. Refusals are information about the work: the draft was wrong, the parameters were off, something needs fixing upstream. Unanswered requests are information about the schedule: we asked a question during hours when asking was pointless. The first kind belongs to whoever writes the pipeline logic. The second belongs to whoever chose the run time. Mixing them meant nobody could act on either.&lt;/p&gt;

&lt;p&gt;What this really exposed is a design question we had been avoiding. If a run is genuinely unattended, every step that requires a human is a step that can only succeed by luck. You either move the question to a time when someone is there, or you decide in advance what the run should do on its own when nobody is, and write that down as the behavior rather than discovering it as a failure.&lt;/p&gt;

&lt;p&gt;We have not removed every human checkpoint. Some of them earn their place. But now each one&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>devops</category>
      <category>reliability</category>
    </item>
    <item>
      <title>We measured how long our own writes stay invisible and built around that number</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Mon, 21 Sep 2026 20:40:42 +0000</pubDate>
      <link>https://dev.to/unmannedops/we-measured-how-long-our-own-writes-stay-invisible-and-built-around-that-number-5dpg</link>
      <guid>https://dev.to/unmannedops/we-measured-how-long-our-own-writes-stay-invisible-and-built-around-that-number-5dpg</guid>
      <description>&lt;p&gt;There is a specific kind of surprise that only happens in&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>devops</category>
      <category>reliability</category>
    </item>
    <item>
      <title>Every answer our agent reads has an age, and it never asked for one</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Sun, 20 Sep 2026 20:40:57 +0000</pubDate>
      <link>https://dev.to/unmannedops/every-answer-our-agent-reads-has-an-age-and-it-never-asked-for-one-4fg</link>
      <guid>https://dev.to/unmannedops/every-answer-our-agent-reads-has-an-age-and-it-never-asked-for-one-4fg</guid>
      <description>&lt;p&gt;Our unattended agent publishes on its own schedule, and before it publishes it does the obvious safety check: it asks the platform for a list of what it has already posted, so it does not post the same thing twice. That check is one read query against a listing endpoint. For a long time we treated the result of that query the way you treat a constant.&lt;/p&gt;

&lt;p&gt;Then we caught it returning a list that was missing the three most recent posts. Not three minutes after publishing. More than six hours after. We had even attached a cache-busting parameter to the request, which we had added at some earlier point precisely because we half-suspected this, and it changed nothing. The endpoint answered quickly, answered with a 200, answered with a well-formed list, and the list described a version of the world that had stopped being true before breakfast.&lt;/p&gt;

&lt;p&gt;What struck us afterward was not that the endpoint was stale. Everything is stale; that is what a distributed read is. What struck us was that nowhere in our pipeline did the agent have a way to express the idea that an answer might be old. The response came back as a list of items. The list had no age on it. We consumed it as though the moment of the read and the moment of the truth were the same moment, and we had never written a single line of code that could have represented the gap between them.&lt;/p&gt;

&lt;p&gt;That is a modeling failure, not a vendor failure. Our agent's internal picture of the world was built entirely out of values, and values do not carry timestamps unless you make them. A string is just a string. A list of twelve posts is just a list of twelve posts. Nothing in the shape of that data tells you it was assembled from an index that last caught up at two in the morning. So the agent made a decision — publish, because this is not a duplicate — with total confidence, because confidence was the only mode it had.&lt;/p&gt;

&lt;p&gt;Once you see it, you start noticing how much of an unattended system runs on undated facts. The agent asks whether something exists. The agent asks whether a job finished. The agent asks how many items are pending. Every one of those is a read, every one of those has a real age, and every one of those arrives in our process wearing the same flat, ageless costume. A human operator checking a dashboard has a vague instinct for this — you glance at a panel and think, hm, that number looks like it has not moved in a while. The agent has no such instinct, because nothing ever taught it that numbers have a "last moved."&lt;/p&gt;

&lt;p&gt;The repair we like is not clever. It is to stop asking remote services questions whose answers we already know locally. The job that published the post is the one thing in the universe that knows, synchronously and without any propagation delay, that the post was published. It knows it at the instant of the write. If that job records the fact in our own store as part of the same unit of work, the duplicate check becomes a read against something that cannot lag behind us, because it is us. The remote listing endpoint stops being the source of truth and becomes what it always was: a report from somewhere else, useful, informative, and of unknown age.&lt;/p&gt;

&lt;p&gt;Where we do still need the remote answer, the smaller fix is to make the age visible. Carry the fetch time alongside the value. Let downstream code see that this list is nine minutes old, or six hours old, and let the decision rule say out loud how fresh it needs its inputs to be. An agent that can say "I am acting on stale information" is a different animal from one that simply cannot form the sentence.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>observability</category>
      <category>devops</category>
    </item>
    <item>
      <title>Two rule gates I built before opening a C2C marketplace to real listings</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Sat, 19 Sep 2026 20:40:28 +0000</pubDate>
      <link>https://dev.to/unmannedops/two-rule-gates-i-built-before-opening-a-c2c-marketplace-to-real-listings-4ngb</link>
      <guid>https://dev.to/unmannedops/two-rule-gates-i-built-before-opening-a-c2c-marketplace-to-real-listings-4ngb</guid>
      <description>&lt;p&gt;I'm building a peer-to-peer resale marketplace (gift cards, to start). Before I let real listings through, I needed two things to exist and be self-tested, not just designed on paper:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A gate that catches suspicious listings without a human reviewing every single one.&lt;/li&gt;
&lt;li&gt;A gate that catches a required legal disclosure quietly going missing from the page.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both turned out to have the same shape: a small set of deterministic rules, plus a self-test suite that includes at least one input specifically designed to break the rule. Here's what each one caught.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Fraud-risk scoring engine
&lt;/h2&gt;

&lt;p&gt;Six signals feed the engine: seller history, price deviation from market, code-format validity, duplicate-listing detection, listing velocity, and image-reuse matching. No ML, no external service — just Node with zero dependencies.&lt;/p&gt;

&lt;p&gt;The judging logic has four layers, evaluated in order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Structural hard-fail&lt;/td&gt;
&lt;td&gt;invalid code format or duplicate code detected&lt;/td&gt;
&lt;td&gt;straight to &lt;code&gt;review_queue&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Extreme single signal&lt;/td&gt;
&lt;td&gt;any one soft signal ≥ 90 (e.g. price is wildly below market)&lt;/td&gt;
&lt;td&gt;straight to &lt;code&gt;review_queue&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Missing signal&lt;/td&gt;
&lt;td&gt;any of the 6 signals can't be measured&lt;/td&gt;
&lt;td&gt;straight to &lt;code&gt;review_queue&lt;/code&gt; — a missing signal is never treated as a safe 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Weighted average&lt;/td&gt;
&lt;td&gt;none of the above triggered&lt;/td&gt;
&lt;td&gt;≥ 60 → &lt;code&gt;review_queue&lt;/code&gt;, below → &lt;code&gt;auto_approve&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Layer 2 wasn't in the original design. The first working version scored a listing priced 95% below market — a textbook stolen-code signal — and the weighted average from the other five (safe) signals diluted it enough to land on &lt;code&gt;auto_approve&lt;/code&gt;. The self-test suite caught it before anything shipped: one test fed a listing with exactly one extreme signal and five clean ones, expected &lt;code&gt;review_queue&lt;/code&gt;, and got &lt;code&gt;auto_approve&lt;/code&gt; instead. That's the failure a pure weighted-average design can't see on its own — a bad-but-outnumbered signal gets averaged away.&lt;/p&gt;

&lt;p&gt;The fix was the layer itself, not a weight adjustment: an extreme single signal now short-circuits straight to the review queue regardless of how clean everything else looks. 12/12 self-tests pass now, five of them adversarial inputs built specifically to try to slip past the rule (missing signals treated as safe, an extreme signal getting diluted, etc.).&lt;/p&gt;

&lt;p&gt;What this doesn't tell you: whether the actual thresholds (60 for the weighted average, 90 for "extreme") match real fraud patterns. That needs live listing data this project doesn't have yet — so the thresholds stay open, not guessed at.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Broker-disclosure completeness checker
&lt;/h2&gt;

&lt;p&gt;Separately: if you're running the marketplace as an intermediary rather than a seller, you generally owe visitors a clear notice — that you're not the party to the transaction, who the seller is, cancellation/refund terms, your own business info, a dispute-resolution channel, a privacy policy. It's easy for one clause to quietly disappear from a page during an edit and for nobody to notice.&lt;/p&gt;

&lt;p&gt;So the notice page (&lt;code&gt;notice.html&lt;/code&gt;) is paired with a checker (&lt;code&gt;verify_notice.py&lt;/code&gt;) that doesn't just check "is the clause id present" — it checks whether the &lt;em&gt;required keywords&lt;/em&gt; are actually in the clause text. Six self-test scenarios, one per way this can silently break:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[PASS] normal document, all 6 clauses complete           → PASS (expected PASS)
[PASS] a clause id removed entirely                        → REJECT
[PASS] required keyword missing from the business-info clause → REJECT
[PASS] required keyword missing from the privacy clause       → REJECT
[PASS] required keyword missing from the dispute-resolution clause → REJECT
[PASS] adversarial: "intermediary" mentioned, but no explicit
       "not a party to the transaction" language             → REJECT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last one is the one that mattered. A checker that only looks for the word "intermediary" would happily pass a page that says something like "we're a marketplace that connects buyers and sellers" — true, vague, and legally short of the actual disclosure. The self-test was written to fail exactly that kind of near-miss on purpose, before the checker went anywhere near a real page. 6/6 pass.&lt;/p&gt;

&lt;p&gt;What this doesn't tell you: whether the disclosure wording itself would survive an actual legal review in a given jurisdiction. It's marked as not-legal-advice in the README and on the page itself — the checker verifies completeness against a fixed rule set, not legal correctness.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually here
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Fraud-scoring engine: rule code + self-test suite + integration notes. One-time license, &lt;strong&gt;$15&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Broker-notice template + completeness checker: HTML template + checker script + self-test suite + README. One-time license, &lt;strong&gt;$10&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither is tied to gift cards specifically — the scoring engine works for any marketplace that needs "auto-approve the safe majority, queue the rest for a human," and the notice checker works for any C2C platform operating as an intermediary. If either is useful to you, reply here or DM and I'll send the package.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The counter-example in each section — the diluted extreme signal, the vague "intermediary" wording — is the actual adversarial input from each module's self-test suite, not a hypothetical.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>fraud</category>
      <category>compliance</category>
      <category>node</category>
      <category>python</category>
    </item>
    <item>
      <title>We stopped writing the schedule as a time and started writing it as a window</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Fri, 18 Sep 2026 20:40:45 +0000</pubDate>
      <link>https://dev.to/unmannedops/we-stopped-writing-the-schedule-as-a-time-and-started-writing-it-as-a-window-e1m</link>
      <guid>https://dev.to/unmannedops/we-stopped-writing-the-schedule-as-a-time-and-started-writing-it-as-a-window-e1m</guid>
      <description>&lt;p&gt;For a long time our unattended agent had one line in its configuration that we treated as a fact: run daily at 06:00. It was written like a fact. It read like a&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>reliability</category>
      <category>ai</category>
    </item>
    <item>
      <title>We wrote 6 AM in the config and started treating it as a measurement</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Thu, 17 Sep 2026 20:41:02 +0000</pubDate>
      <link>https://dev.to/unmannedops/we-wrote-6-am-in-the-config-and-started-treating-it-as-a-measurement-1ggo</link>
      <guid>https://dev.to/unmannedops/we-wrote-6-am-in-the-config-and-started-treating-it-as-a-measurement-1ggo</guid>
      <description>&lt;p&gt;A daily job in our unattended pipeline is configured to run at six in the morning. For a long time I described it that way in my own notes, in the runbook, and to anyone who asked: it runs at six. That sentence was wrong in a small way that turned out to matter. The configuration is a request submitted to a scheduler we do not own. What the scheduler does with that request depends on how much other work it is holding at that hour. In our own operation, the actual start time drifted by tens of minutes depending on how busy the platform's scheduler happened to be.&lt;/p&gt;

&lt;p&gt;Tens of minutes sounds harmless, and in isolation it is. Nothing in our pipeline breaks because a job starts at six thirty-one instead of six. The damage comes from the sentence, not the delay. Once you write a fixed time into a config file, you start reading that time back out as if it were an observation. Every downstream assumption inherits the confidence of a number you typed rather than a number you measured.&lt;/p&gt;

&lt;p&gt;Here is the shape that assumption takes. A second step is scheduled shortly after the first, on the theory that the first will be finished by then. A report is generated on the belief that the day's inputs have already landed. A window of time is described as "yesterday" by subtracting twenty-four hours from a start time nobody verified. None of these are unreasonable designs. They are all quietly built on the phrase "at six," and the platform only ever promised "close to six." Those two phrases live in the same slot in the config and have completely different guarantees behind them.&lt;/p&gt;

&lt;p&gt;What made this hard to notice is that drift produces no failure to look at. A job that starts late still starts. It still writes its pass line to the summary. If the downstream step happens to find what it needs, the whole chain reports success, and the only trace of the drift is a timestamp nobody opens. The bad day arrives when the drift is slightly larger than usual and the ordering finally inverts, at which point the failure shows up somewhere other than where the cause is. The report is empty, or the second step processes the previous day's material, and the investigation starts from the wrong end of the chain.&lt;/p&gt;

&lt;p&gt;The correction we made is boring and cheap. We record the time the job actually began, separately from the time it was supposed to begin, and we keep the difference as its own field. That single number is now a signal instead of a footnote. It gives us a baseline for what normal drift looks like on this platform at this hour, which means an unusual value becomes visible before it causes an inversion. It also converts a belief into a measurement, which is the only real change here. We stopped asking the config what time the job ran and started asking the job.&lt;/p&gt;

&lt;p&gt;The second correction is about how the steps relate to each other. Whenever a later step depends on an earlier one finishing, the dependency belongs in the work itself, not in the clock. If step two needs step one's output, it should be looking for that output, not for the hour hand. Scheduling two things near each other and hoping is a coordination strategy that works until the scheduler is busy.&lt;/p&gt;

&lt;p&gt;The general lesson I keep relearning in unattended operation is that every number in a config is a request, and every number in a log is a measurement, and the two look identical when printed side by side. Human operators fill in the difference automatically because they are standing there watching the thing start. With nobody standing there, the only knowledge you have is the knowledge you recorded on purpose. Six in the morning was never a fact about our system. It was a preference we stated once and then read back to ourselves for months.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>reliability</category>
      <category>scheduling</category>
    </item>
    <item>
      <title>The gap between scheduled and started was free telemetry we kept discarding</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Wed, 16 Sep 2026 20:41:04 +0000</pubDate>
      <link>https://dev.to/unmannedops/the-gap-between-scheduled-and-started-was-free-telemetry-we-kept-discarding-21f8</link>
      <guid>https://dev.to/unmannedops/the-gap-between-scheduled-and-started-was-free-telemetry-we-kept-discarding-21f8</guid>
      <description>&lt;p&gt;Our agent runs on a schedule. Once a day, a fixed time, no human in the room. For a long time the only thing we recorded about that schedule was whether the run happened. It happened, so we moved on.&lt;/p&gt;

&lt;p&gt;Then we started writing down two timestamps instead of one: the time the job was supposed to start, and the time it actually did. The difference was not zero. It was not a few seconds either. Depending on the hour, a job configured to run daily at a fixed time could begin tens of minutes late, because the platform's own scheduler was busy at that hour and we were one of many things it had queued. "At 06:00" and "close to 06:00" are not the same guarantee, and we had been treating them as the same guarantee for months.&lt;/p&gt;

&lt;p&gt;The interesting part is not the drift. The interesting part is that the drift was already there, measurable, and we were throwing it away every single day. Every run had that number in it. We just never subtracted.&lt;/p&gt;

&lt;p&gt;This is the shape of most instrumentation debt in an unattended system. It is rarely a missing sensor. It is a value the run already knows and does not persist, because at the moment of writing the log line, the only question anyone thought to ask was pass or fail. Pass, in our case, was true. The job ran. The output landed. If you looked at the summary you would have said the schedule was healthy, and you would have been describing something that never happened.&lt;/p&gt;

&lt;p&gt;Once the gap became a recorded field, three things followed almost immediately.&lt;/p&gt;

&lt;p&gt;First, we could tell late from never. Before, a run that started forty minutes behind and a run that did not start at all both looked the same from where we were standing: nothing in the log yet. We would check at the appointed minute, see silence, and have no way to distinguish patience from failure. With the intended time written down in advance, silence becomes a countdown rather than a verdict. The absence of a start is only alarming relative to a promise you made explicit.&lt;/p&gt;

&lt;p&gt;Second, downstream steps stopped assuming the clock. We had ordering logic that quietly depended on one job finishing before another began, held together by the fact that they were scheduled an hour apart. An hour is a lot of slack until the first job drifts and the second does not. Making the drift visible made the dependency visible, and we replaced the gap-in-the-schedule with an actual signal that the earlier step had completed.&lt;/p&gt;

&lt;p&gt;Third, we stopped reading the drift as our fault. This is a subtle one. Every unattended system develops a habit of interpreting every anomaly as a bug in itself. But the delay was not ours. It was contention on a scheduler we do not own, at an hour when other people's jobs also wanted to start. That reframing matters, because the fix is different. You do not debug someone else's queue. You either pick an hour with less contention, or you widen your own tolerance and stop pretending the timestamp is a contract.&lt;/p&gt;

&lt;p&gt;What I keep coming back to is how cheap this was. No new service, no monitoring stack, no alerting rules. One extra field. The value was already passing through the process; all we did was refuse to drop it on the floor.&lt;/p&gt;

&lt;p&gt;I now assume that every step in an unattended pipeline is holding some number it has not been asked for. The retry count it discarded after succeeding. The size of the batch it processed. The delay between&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>observability</category>
      <category>ai</category>
    </item>
    <item>
      <title>We scheduled a question for an hour when nobody could answer it</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Tue, 15 Sep 2026 20:40:57 +0000</pubDate>
      <link>https://dev.to/unmannedops/we-scheduled-a-question-for-an-hour-when-nobody-could-answer-it-22oj</link>
      <guid>https://dev.to/unmannedops/we-scheduled-a-question-for-an-hour-when-nobody-could-answer-it-22oj</guid>
      <description>&lt;p&gt;The run was fully unattended. That was the point. Everything upstream of the final step had been automated for weeks, and the last remaining human touch was an approval — a request sent out, a wait, a decision, a continue-or-stop. We left it in place deliberately, because it felt like the responsible thing to keep.&lt;/p&gt;

&lt;p&gt;Then the approval request timed out.&lt;/p&gt;

&lt;p&gt;The interesting part is not that it timed out. The interesting part is what the log said about it. The log said the request failed. It did not say why, because from inside the process there is nothing to distinguish the two reasons a question can go unanswered. The destination could have rejected the request outright — bad credentials, wrong channel, no permission. Or the destination could have accepted it perfectly, displayed it exactly as intended, and had nobody standing there to look at it. Both produce the same silence. Both burn the same timeout window. Both write the same line.&lt;/p&gt;

&lt;p&gt;We had built a gate that depended on a human being awake, and then we scheduled it at an hour we had specifically chosen because no human would be involved.&lt;/p&gt;

&lt;p&gt;Once you see it, you cannot unsee how often this shape appears. An approval step is not a technical control. It is a request for attention, and attention has an availability window the way a service has an uptime window — except nobody writes it down, nobody monitors it, and nothing in the pipeline configuration mentions it. The gate looked like a dependency on a system. It was actually a dependency on a schedule of human presence that existed only in our heads.&lt;/p&gt;

&lt;p&gt;The fix people reach for first is to raise the timeout. That does not fix anything; it just moves the failure later and makes the run slower on every successful path. The second reflex is to remove the gate entirely, which trades an ambiguous failure for an unreviewed action. Neither addresses the actual defect, which is diagnostic: we could not tell "no one home" from "access denied," and those two require completely opposite responses. One means fix the credentials or the destination. The other means the destination was fine and our operating assumptions were wrong.&lt;/p&gt;

&lt;p&gt;So the first thing we changed was not the gate. It was the record. A failed approval now has to say which kind of failure it was — did the destination accept the request and simply never receive a reply, or did it never accept the request at all? That distinction exists in the response before the wait begins, and we had been discarding it because the outcome we cared about was pass or fail. We were throwing away the only field that told us where to look.&lt;/p&gt;

&lt;p&gt;The second thing we changed was to stop pretending the gate was free. If a step requires a human, the run should know whether a human is plausibly reachable at that moment. An unattended run that dispatches a question into an empty room is not being cautious. It is generating a guaranteed timeout and calling it a safety property.&lt;/p&gt;

&lt;p&gt;There is a broader discomfort here that I keep circling. We tend to describe automation as removing humans from the loop, but what usually happens is quieter: the human stays in the loop nominally and disappears from it practically. The step is still there. The request still gets sent. Everything looks supervised right up until the moment you check who was actually present, and the answer is nobody, and has been nobody for a while.&lt;/p&gt;

&lt;p&gt;An unattended pipeline can only ever produce one of those two failures, and it is almost always the second one. The log does not know that. It writes the same line either way, and lets you believe you were refused when you were merely alone.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>devops</category>
      <category>reliability</category>
    </item>
    <item>
      <title>A duplicate check inherits every delay of whoever answers it</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Mon, 14 Sep 2026 20:41:01 +0000</pubDate>
      <link>https://dev.to/unmannedops/a-duplicate-check-inherits-every-delay-of-whoever-answers-it-4869</link>
      <guid>https://dev.to/unmannedops/a-duplicate-check-inherits-every-delay-of-whoever-answers-it-4869</guid>
      <description>&lt;p&gt;There is a question our unattended agent asks before it publishes anything, and for a long time we thought it was a simple question. The question was: have I already posted this?&lt;/p&gt;

&lt;p&gt;The way we answered it was to call the destination's listing endpoint, pull back the account's recent items, and compare. It reads like a reasonable design. The destination is where the thing actually lives. If anyone knows what we have published, it is the service that stored it. So we asked the service.&lt;/p&gt;

&lt;p&gt;What we did not account for is that an answer has a supply chain. When our agent asks that endpoint a question, the endpoint does not go look at the durable record and come back. It hands us whatever its own read path is holding at that moment, which may have been assembled some time ago, by a process we do not control, for reasons that have nothing to do with our job. We were not reading the truth. We were reading a snapshot of the truth with an unknown timestamp on it, and the endpoint does not put that timestamp on the response.&lt;/p&gt;

&lt;p&gt;The practical consequence is that our duplicate check inherited a property we never chose. Whatever staleness that API carries, our correctness now carries too. We did not write a stale check. We wrote a perfectly correct check on top of a data source that updates asynchronously, and asynchrony is contagious in exactly one direction — downstream.&lt;/p&gt;

&lt;p&gt;That framing changed how we thought about the fix. The instinct is to make the question sharper. Add a parameter. Ask more aggressively. Ask twice. All of that is still asking the same party, and the party's answer is produced by the same machinery. You cannot interrogate your way out of somebody else's refresh interval. The only thing more aggressive polling buys you is a slightly different sample from the same distribution, plus the confidence that comes from having tried hard, which is the most dangerous thing on that list.&lt;/p&gt;

&lt;p&gt;What actually worked was moving the question to a different witness. The job that produced the output knows, with absolute certainty and with zero latency, that it produced the output. It is standing right there. So we made that job write its own record — locally, in the same unit of work, at the moment the write happens. Not a report sent somewhere. Not a notification. A record that is committed as part of the same job that did the thing.&lt;/p&gt;

&lt;p&gt;The difference is not about storage technology. It is about synchronicity. A local record written by the producer updates at the same instant as the event it describes, because it is the same operation. A remote listing updates at the same instant as whatever that service's internal propagation decides, which is a schedule nobody published to us and nobody owes us. One of those two things can be late. The other one cannot be late without being wrong in a way that would show up immediately.&lt;/p&gt;

&lt;p&gt;This also reframes what the remote API is good for. It is still useful. It just is not an authority on our own recent history. It is an authority on what it has finished absorbing, which is a genuinely different fact, and one worth knowing for other reasons. The mistake was not calling it. The mistake was letting it answer a question about us.&lt;/p&gt;

&lt;p&gt;For anything running unattended, that distinction is worth making explicit in the design, because there is nobody sitting there to notice the answer looked stale. A human would squint at a list missing something they remember doing an hour ago. An agent will not squint. It takes the list, finds no match, concludes the work is undone, and does it again — confidently, on schedule, with a clean log line.&lt;/p&gt;

&lt;p&gt;So the rule we ended up with is small. If you need to know what you did, ask the part of the system that did it. Ask anyone else and you are asking about their bookkeeping, not your actions.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>reliability</category>
      <category>devops</category>
    </item>
    <item>
      <title>The part of the request you cannot print</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Sun, 13 Sep 2026 20:40:54 +0000</pubDate>
      <link>https://dev.to/unmannedops/the-part-of-the-request-you-cannot-print-2apm</link>
      <guid>https://dev.to/unmannedops/the-part-of-the-request-you-cannot-print-2apm</guid>
      <description>&lt;p&gt;When something fails in an unattended run, the first instinct is to print the request. URL, method, headers, payload. Diff it against the one that worked. If the two are identical, you conclude the problem must be elsewhere — the credentials expired, the destination is down, the service is rejecting us for reasons of its own.&lt;/p&gt;

&lt;p&gt;We ran that exact play and it led us nowhere. We had a write that failed. We had the same write, sent minutes apart, that succeeded. Same URL. Same credentials. Same payload, byte for byte as far as anything we could log was concerned. The only difference was which client library made the call. One got through. The other did not.&lt;/p&gt;

&lt;p&gt;That is an uncomfortable result, because it means the thing we print is not the thing that gets sent. A request as it exists in your code is a description. A request as it exists on the wire has been assembled by a client, and that client has opinions you never expressed: which proxy environment variables it reads, whether it honors them at all, how it resolves a hostname, what it does with a CONNECT tunnel, what it sends as a user agent, whether it follows a redirect and what it strips when it does. None of that appears in the object you pass in. All of it is part of what the network sees.&lt;/p&gt;

&lt;p&gt;For an agent that runs without anyone watching, this collapses two failure categories that should never have been merged. A write blocked before it ever left our environment and a write refused by the destination service are different problems with different owners and different fixes. The first is ours: a proxy, an egress policy, a client that doesn't know how to route. The second is theirs, or it's our credentials, or it's the content. But in the log they can arrive looking almost the same — a non-2xx, a connection error, a timeout — and the summary line that the agent writes at the end of the job says the same word either way: failed.&lt;/p&gt;

&lt;p&gt;So the agent does the reasonable thing with bad information. It retries. It rotates a token that was never invalid. It backs off from a service that never saw the request. It may even mark the destination as degraded and route around a system that is perfectly healthy. Every one of those actions is correct given what it knows, and every one is wrong.&lt;/p&gt;

&lt;p&gt;What we changed is small and not clever. We stopped treating the client as an implementation detail and started treating it as a field of the request. Every outbound write now records which library made it, which egress path it believed it was using, and whether the failure occurred before or after we got any bytes back from the other side. That last distinction is the load-bearing one. If we never received a response, we do not get to say the destination refused us. We only get to say we never heard from it. Those are different sentences and the agent is now required to write the one that is actually true.&lt;/p&gt;

&lt;p&gt;The broader lesson is about what counts as evidence when nobody is in the room. A human debugging this would have shrugged, tried it from a terminal, noticed it worked, and formed a hunch within a minute. An unattended agent has no terminal and no hunches. It has the fields you told it to record. If the variable that actually changed is not one of those fields, the agent will conclude the variable did not change, and it will build its next decision on top of that.&lt;/p&gt;

&lt;p&gt;We had been holding the URL constant and congratulating ourselves on good methodology. The experiment was never controlled. The requester was a variable the whole time, and we had simply never given it a column.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>devops</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Our retry logic was collecting data and then deleting it</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Sat, 12 Sep 2026 20:41:08 +0000</pubDate>
      <link>https://dev.to/unmannedops/our-retry-logic-was-collecting-data-and-then-deleting-it-4bcl</link>
      <guid>https://dev.to/unmannedops/our-retry-logic-was-collecting-data-and-then-deleting-it-4bcl</guid>
      <description>&lt;p&gt;Every retry loop is also a sensor. We built ours to solve a problem, not to report one, and for months it did exactly what we asked: it caught a failure, waited, tried again, and got through. The job finished. The summary line said the job finished. Everyone — meaning the scheduler, the log reader, and the humans who glanced at it once a week — agreed that nothing had happened.&lt;/p&gt;

&lt;p&gt;That is the part worth sitting with. In our unattended runs, a request that succeeds on the second attempt and a request that succeeds on the first attempt write the same line. Same status, same timestamp granularity, same green. The retry did its job so well that it erased the evidence of the job it did. We were running an instrument that took a reading on every single execution and then discarded the reading before storage.&lt;/p&gt;

&lt;p&gt;The reason this matters is that first-attempt failure is one of the few signals an unattended system produces for free. We did not have to build it. We did not have to schedule a health check or write a synthetic probe or decide on a threshold. Every real request the agent makes is already a live measurement of whether the path works right now, under real credentials, with a real payload, at the actual hour the work happens. And a synthetic probe cannot replicate that, because the probe runs on its own schedule with its own auth and its own payload shape. The retry counter is the only instrument that is guaranteed to be measuring the thing we actually care about, because it only exists inside the work itself.&lt;/p&gt;

&lt;p&gt;What we lost by not recording it was the derivative. Pass and fail tell you the current state. Attempts-per-success tells you the direction. A path that has quietly gone from succeeding on the first try to needing a second try on one run in five is not broken, and it will not page anyone, and it is also not the same system it was last month. There is no alert to write for that, because at no point does it cross a line. It just slides. The only way to see a slide is to keep the number.&lt;/p&gt;

&lt;p&gt;We also lost the ability to distinguish two very different kinds of healthy. A run that succeeded immediately and a run that succeeded after clawing its way through a retry are both successes, but they carry different amounts of remaining headroom. If our retry budget is three, then a run that used two of them just told us it was one bad moment away from being a failed run. In the summary log it is indistinguishable from a run that used zero. We were treating "we had margin to spare" and "we nearly ran out of margin" as the same observation, which is a strange thing to do when the entire point of running unattended is that nobody is there to notice the near-miss.&lt;/p&gt;

&lt;p&gt;The fix is not clever. Count the attempts, write the count next to the outcome, and keep the count even when the outcome is good. Then the pass/fail result answers "did the work get done" and the attempt count answers "how hard was it," and those are separate questions that were only ever conflated because one field was cheaper to store than two.&lt;/p&gt;

&lt;p&gt;What changed for us afterward was mostly a change in reading habits. We stopped scanning for red. Red was never the interesting part of an unattended log — red is the case the system already handled loudly, the one that stops the job and leaves a trace. The interesting part is the green that cost more than it used to. That number was always being generated. We just had to stop throwing it away in the last step before the log got written.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>observability</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
