<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Agents on Flavio Silva</title><link>https://fsilva.me/tags/agents/</link><description>Recent content in Agents on Flavio Silva</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><managingEditor>flavio1110@gmail.com (Flavio Silva)</managingEditor><webMaster>flavio1110@gmail.com (Flavio Silva)</webMaster><lastBuildDate>Tue, 14 Jul 2026 14:00:00 +0100</lastBuildDate><atom:link href="https://fsilva.me/tags/agents/feed.xml" rel="self" type="application/rss+xml"/><item><title>🤖 Building a propose-then-approve bot engine</title><link>https://fsilva.me/posts/the-autonomous-bot-engine/</link><pubDate>Tue, 14 Jul 2026 14:00:00 +0100</pubDate><author>flavio1110@gmail.com (Flavio Silva)</author><guid>https://fsilva.me/posts/the-autonomous-bot-engine/</guid><description>&lt;img src="https://fsilva.me/posts/the-autonomous-bot-engine/autonomous-bot-engine.png" alt="Featured image of post 🤖 Building a propose-then-approve bot engine" /&gt;&lt;p&gt;After every race weekend on &lt;a class="link" href="https://apexclub.live" target="_blank" rel="noopener"
 &gt;apexclub.live&lt;/a&gt; I used to do the same three things by hand. Enter the starting grid, enter the final classifications, trigger the scoring calculation. Every step was a few clicks and a few minutes. The annoying part wasn&amp;rsquo;t the time. It was the &lt;em&gt;kind&lt;/em&gt; of work. It&amp;rsquo;s detectable, it&amp;rsquo;s repetitive, and getting it wrong silently corrupts a season&amp;rsquo;s standings. So I wanted it gone, but only on my terms.&lt;/p&gt;
&lt;p&gt;I wanted something autonomous enough that I didn&amp;rsquo;t have to remember. Deterministic enough that I could trust it. Small enough that I could still explain every line.&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;TL;DR: A recurring worker tick runs a cheap &lt;code&gt;Detect&lt;/code&gt; query for each registered use case. If there&amp;rsquo;s nothing to do, no LLM runs. If there&amp;rsquo;s a candidate, the engine builds a proposal, persists it, sends me a Telegram message with Approve / Reject buttons, and only on my tap calls a typed executor to apply the change. The LLM proposes. The database and the human approve. The executor writes.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;h2 id="the-shape-of-the-problem"&gt;The shape of the problem
&lt;/h2&gt;&lt;p&gt;The original setup wasn&amp;rsquo;t bad. Race results came from analytics on Monday. The application had a Telegram bot I already trusted for one-off commands. I had a database I could read and write to. The only thing missing was glue.&lt;/p&gt;
&lt;p&gt;A naive first attempt was to let the existing chat bot propose mutations during a conversation. Press a button, the model picks a tool, the tool runs. I tried it and closed it. The problem wasn&amp;rsquo;t Telegram. It was mixing recurring operations with a conversational tool loop. Most ticks have nothing to do: the race hasn&amp;rsquo;t finished, analytics hasn&amp;rsquo;t synced, results already exist, scores are already published. Paying for an LLM call to discover that &amp;ldquo;nothing is ready&amp;rdquo; is wasteful, and worse, &amp;ldquo;the model decided to call this tool&amp;rdquo; is not an audit record I want to publish against a league&amp;rsquo;s standings.&lt;/p&gt;
&lt;p&gt;I wanted a concrete payload, a visible proposal, a named human decision, and a deterministic function that could be retried safely. So I split the work in five pieces:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;detect&lt;/strong&gt; step that is just SQL, cheap and easy to reason about;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;propose&lt;/strong&gt; step that builds a structured payload and a human-readable summary;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;persist&lt;/strong&gt; step that stores the proposal before anything is sent anywhere;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;human decision&lt;/strong&gt; delivered through a channel I already trust (Telegram, in my case);&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;typed executor&lt;/strong&gt; that applies an approved proposal and is required to be idempotent.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each piece is small. The interesting bit is the contract between them.&lt;/p&gt;
&lt;h2 id="the-contract"&gt;The contract
&lt;/h2&gt;&lt;p&gt;A use case is anything that can detect a candidate, propose a payload, and apply it. The shape, in Go, is roughly:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;UseCase&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Schedule&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Detect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="nx"&gt;Candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Propose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;Proposal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The four methods divide responsibility cleanly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Detect&lt;/code&gt; is a cheap precondition, usually a single &lt;code&gt;SELECT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Propose&lt;/code&gt; turns one candidate into a structured payload and an admin-facing summary.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Execute&lt;/code&gt; applies an approved action. Idempotency is required.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Kind&lt;/code&gt; is the dispatch and dedup key.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Schedule&lt;/code&gt; lives on the use case, not in central wiring.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The engine has three pieces that move a use case through that contract:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;runner&lt;/strong&gt; that ticks on schedule, calls &lt;code&gt;Detect&lt;/code&gt;, and for each candidate checks whether an action already exists for the same &lt;code&gt;kind&lt;/code&gt; + subject reference. If not, it asks &lt;code&gt;Propose&lt;/code&gt; to build a proposal and hands it to the coordinator.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;coordinator&lt;/strong&gt; that owns persistence and decisions. It stores the proposal, sends the notification, atomically claims an approval, dispatches the executor, and records the outcome.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;registry&lt;/strong&gt; that maps a kind such as &lt;code&gt;insert_race_results&lt;/code&gt; to its executor. Registering the same kind twice panics at startup, which is the right moment to discover a wiring mistake.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The lifecycle of a persisted action is small enough to draw in text:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pending -&amp;gt; executing -&amp;gt; executed
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; failed
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pending -&amp;gt; rejected
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;A partial unique index allows only one open action per &lt;code&gt;(kind, subject_ref)&lt;/code&gt;. That single index is what makes the runner safe to call every 30 seconds across multiple application instances, and what makes a second admin press of &amp;ldquo;Approve&amp;rdquo; a no-op instead of a duplicate write.&lt;/p&gt;
&lt;p&gt;The approval transport can be anything with two buttons. Telegram was the obvious choice because I was already living there for one-off commands. A web page could work just as well. It just has to call back into the same coordinator.&lt;/p&gt;
&lt;h2 id="the-flow-end-to-end"&gt;The flow, end to end
&lt;/h2&gt;&lt;p&gt;Here is the whole path of one piece of work, written the way the code actually runs it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;worker tick (every 30s)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; for each registered use case:
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Detect() with SQL
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; zero candidates: stop
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; candidate: is there already an action for (kind, subject)?
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; yes: stop
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; no: Propose(candidate) -&amp;gt; Proposal
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Coordinator.Submit(proposal)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; persist action with status=pending
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; send approval message with action UID
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Telegram button press
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Coordinator.Approve(uid) (atomic: pending -&amp;gt; executing)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Registry.Dispatch(kind).Execute(action)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; mark action executed or failed
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The decision to put deterministic work first is the entire insight. The LLM only runs after SQL has already said &amp;ldquo;there is something to do here&amp;rdquo;, and only to convert that &amp;ldquo;something&amp;rdquo; into a payload a human can review.&lt;/p&gt;
&lt;h2 id="why-this-set-of-qualities"&gt;Why this set of qualities
&lt;/h2&gt;&lt;p&gt;A few properties fall out of the shape, not from extra effort.&lt;/p&gt;
&lt;p&gt;When nothing needs doing, the worker runs only &lt;code&gt;SELECT&lt;/code&gt; queries. No model call, no proposal, no notification. I called that &amp;ldquo;cheap idle ticks&amp;rdquo; above, and I underestimated how much it mattered before I built it. Most ticks &lt;em&gt;are&lt;/em&gt; idle. Saving a model call on every idle tick is the difference between a system I forget exists and a system I worry about.&lt;/p&gt;
&lt;p&gt;Auditability is mostly free. Every action is a row: &lt;code&gt;kind&lt;/code&gt;, subject reference, JSONB payload, the human-readable summary, the Telegram message identifiers, who decided it, when, and the final outcome. That&amp;rsquo;s a complete answer to &amp;ldquo;why did the standings change on Sunday at 23:14?&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;The executor contract is &lt;code&gt;Execute(ctx, *Action)&lt;/code&gt;. Rerunning it with the same action must converge to the same outcome. That requirement is what makes a button-mash, a network retry, or a panic mid-flight all safe. The row in &lt;code&gt;pending&lt;/code&gt; / &lt;code&gt;executing&lt;/code&gt; is the source of truth, and the executor decides whether to insert, update, or do nothing.&lt;/p&gt;
&lt;p&gt;There is no global &amp;ldquo;AI: on&amp;rdquo; switch. The system is on whenever the worker is running with a configured approval channel. The absence of an admin to approve is the absence of writes.&lt;/p&gt;
&lt;p&gt;Failure modes are independent. Each use case has its own schedule and its own advisory lock. A broken results matcher doesn&amp;rsquo;t prevent scores from being checked.&lt;/p&gt;
&lt;h2 id="two-use-cases-end-to-end"&gt;Two use cases, end to end
&lt;/h2&gt;&lt;p&gt;The same engine runs two unrelated capabilities on apexclub.live. They&amp;rsquo;re not hardcoded into the engine. They&amp;rsquo;re ordinary use cases that happen to be the first ones I needed.&lt;/p&gt;
&lt;h3 id="insert-race-results"&gt;Insert race results
&lt;/h3&gt;&lt;p&gt;The use case owns kind &lt;code&gt;insert_race_results&lt;/code&gt; and schedule &lt;code&gt;*/30 * * * *&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Its &lt;code&gt;Detect&lt;/code&gt; query is &amp;ldquo;active-season races in the past with no row in &lt;code&gt;results&lt;/code&gt; yet&amp;rdquo;. When that returns nothing, no LLM runs.&lt;/p&gt;
&lt;p&gt;When it returns a candidate, the use case:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;matches the main race to a row in an analytics calendar (the LLM only picks from offered candidates, it can&amp;rsquo;t author the mapping);&lt;/li&gt;
&lt;li&gt;pulls qualifying pole and classified finishers from the analytics tables;&lt;/li&gt;
&lt;li&gt;resolves driver names to the application&amp;rsquo;s own grid entries (again, the LLM only picks from offered IDs);&lt;/li&gt;
&lt;li&gt;assembles a structured result plus the P1–P22 finishing order;&lt;/li&gt;
&lt;li&gt;refuses incomplete mappings and surfaces them as a manual-entry notice instead of a proposal;&lt;/li&gt;
&lt;li&gt;persists the payload for approval.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;On approval, &lt;code&gt;Execute&lt;/code&gt; checks for an existing result before inserting and only writes final positions when none are already recorded. A retry after a partial failure converges without overwriting manual data.&lt;/p&gt;
&lt;h3 id="publish-scores"&gt;Publish scores
&lt;/h3&gt;&lt;p&gt;The second use case owns kind &lt;code&gt;publish_scores&lt;/code&gt;, also scheduled every 30 minutes. Its &lt;code&gt;Detect&lt;/code&gt; query is &amp;ldquo;an active-season race with a results row, not yet finalized, no scores row, and at least one real starting position recorded&amp;rdquo;. Same shape, a &lt;code&gt;SELECT&lt;/code&gt; that asks whether the next step&amp;rsquo;s preconditions are met.&lt;/p&gt;
&lt;p&gt;The proposal payload is just a race identifier. On approval, the executor runs the existing scoring code and flips a finalized flag. Re-running the executor upserts and sets the same boolean, which converges safely.&lt;/p&gt;
&lt;p&gt;There is no direct call between these two use cases. The database state is the hand-off: the results executor creates the data that makes scoring eligible on a later tick. Independent schedules, independent advisory locks, independent failure handling.&lt;/p&gt;
&lt;h2 id="the-traps-in-order"&gt;The traps, in order
&lt;/h2&gt;&lt;p&gt;Building the engine was less work than fixing the things I got wrong the first time. A few worth naming.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Approval state can stick.&lt;/strong&gt; The first version moved an action from &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;executing&lt;/code&gt; before calling the executor, which prevented two button presses from racing. But if the executor panicked, the action stayed in &lt;code&gt;executing&lt;/code&gt;, and because &lt;code&gt;executing&lt;/code&gt; counted as open, the runner could never propose it again without a manual database update. The fix was to move the lifecycle into the coordinator: a panic is converted into an error, and &lt;code&gt;Approve&lt;/code&gt; is the only transition that marks an action &lt;code&gt;failed&lt;/code&gt;. Atomic claiming was only half the story. Every transition needs a recovery path.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Source identities are fuzzy.&lt;/strong&gt; The first attempt matched races by season and round number. Real data proved that wrong. For one British Grand Prix, the main calendar said round 11 while analytics said round 9, and analytics round 11 was a different race with no results yet. The LLM&amp;rsquo;s job in this system is to resolve exactly the kind of ambiguity that round numbers and slightly different race names cause. The authority stays narrow: the model picks from offered source rows, never authors them. Deterministic code then checks whether the chosen mapping is safe enough to propose.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The approval message is part of the safety boundary.&lt;/strong&gt; A Telegram message that shows only the top three drivers is not a complete proposal if the scoring system needs the full finishing order. Anything required for approval belongs in deterministic presentation, not in prose the model is free to shorten. The pattern that worked: build the visible fields from the assembled payload in code, and append the model-generated summary as commentary. The fields are guaranteed. The commentary is colour.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Preconditions encode data completeness.&lt;/strong&gt; Scoring requires both a result and a starting grid. A result without a grid produces a plausible-looking but silently-wrong score. The &lt;code&gt;Detect&lt;/code&gt; query for scoring was the right place to encode that requirement. The engine should refuse a valid-looking operation when its inputs would produce an incorrect result.&lt;/p&gt;
&lt;p&gt;These weren&amp;rsquo;t architectural mistakes. They were the price of admitting the second iteration. The contract didn&amp;rsquo;t have to change. The implementations got better at honouring it.&lt;/p&gt;
&lt;h2 id="tradeoffs-and-limits"&gt;Tradeoffs and limits
&lt;/h2&gt;&lt;p&gt;A few honest costs.&lt;/p&gt;
&lt;p&gt;Latency is human-shaped. A use case that detects at 30 seconds and a human who approves five minutes later means a five-minute gap between readiness and execution. For race weekend admin that&amp;rsquo;s fine. For anything time-sensitive, propose-then-approve is the wrong shape.&lt;/p&gt;
&lt;p&gt;Approval delivery is one channel. Today the only way to approve is Telegram. If the notification fails to send, the action is persisted in &lt;code&gt;pending&lt;/code&gt; and stays there until a human notices or a redelivery job is added. Persistence beats loss, but it&amp;rsquo;s not the same as a guaranteed delivery.&lt;/p&gt;
&lt;p&gt;One primary approver. Notifications go to the first ID in the allowlist. Every allowlisted user can press the buttons, but only one gets the message. A real multi-admin workflow needs a delivery and decision policy, not just an allowlist.&lt;/p&gt;
&lt;p&gt;Rejected actions don&amp;rsquo;t auto-retry. The repository layer suppresses future proposals for a rejected &lt;code&gt;(kind, subject_ref)&lt;/code&gt; to avoid nagging. There is no &amp;ldquo;retry this one&amp;rdquo; operation that doesn&amp;rsquo;t also require a policy or data change. For one-shot work that&amp;rsquo;s the right default. For evolving inputs it can feel stubborn.&lt;/p&gt;
&lt;p&gt;Fuzzy matching is bounded. The model can only pick from offered candidates. That&amp;rsquo;s a strong guardrail and also a real ceiling. When the input source gets a new spelling or a new driver, the matcher will refuse rather than guess. Human review remains mandatory.&lt;/p&gt;
&lt;h2 id="where-id-take-it-next"&gt;Where I&amp;rsquo;d take it next
&lt;/h2&gt;&lt;p&gt;The next changes I&amp;rsquo;d make are unglamorous:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A redelivery job for &lt;code&gt;pending&lt;/code&gt; actions whose Telegram message failed.&lt;/li&gt;
&lt;li&gt;A web approval page that calls the same coordinator. It doesn&amp;rsquo;t need to replace Telegram. It just needs to be a second transport.&lt;/li&gt;
&lt;li&gt;An explicit retry / reopen transition for rejected subjects, gated by policy rather than by data manipulation.&lt;/li&gt;
&lt;li&gt;More use cases. The shape proved out on results and scoring. The next two I&amp;rsquo;m eyeing are joker-round announcements and end-of-season standings exports. Both are detectable, both are one-shot, both have a payload I want to review before they go out.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The thing I would &lt;em&gt;not&lt;/em&gt; change is the order. Cheap detect, deterministic propose, human approve, typed execute. Each piece is replaceable. The order is the engine.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;The engine is small because it refuses to solve every agent problem. It works for recurring, detectable, one-shot changes with a payload that can be reviewed before it&amp;rsquo;s applied. &amp;ldquo;Autonomous&amp;rdquo; here means the system notices and prepares work by itself. Authority to change league results still belongs to a person.&lt;/p&gt;
&lt;p&gt;What kind of recurring work would you trust it to propose, but not to approve?&lt;/p&gt;</description></item></channel></rss>