<?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: Edy Cu</title>
    <description>The latest articles on DEV Community by Edy Cu (@edycutjong).</description>
    <link>https://dev.to/edycutjong</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%2F3911089%2Fb24966e4-839d-4c19-8e2d-16c6a5c8838c.jpeg</url>
      <title>DEV Community: Edy Cu</title>
      <link>https://dev.to/edycutjong</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/edycutjong"/>
    <language>en</language>
    <item>
      <title>When a model writes the maths, make it sit the booklet's own exam first</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sun, 01 Nov 2026 02:59:39 +0000</pubDate>
      <link>https://dev.to/edycutjong/when-a-model-writes-the-maths-make-it-sit-the-booklets-own-exam-first-28h9</link>
      <guid>https://dev.to/edycutjong/when-a-model-writes-the-maths-make-it-sit-the-booklets-own-exam-first-28h9</guid>
      <description>&lt;p&gt;A draft surveyor weighs a bulk cargo by reading the painted draft marks on a hull, and the sea never holds still across them. I built DraftMark for the Meta AI Developer Hackathon to do two things: time-average that moving waterline from a short video, and let a model write the per-ship survey program. This post is about the second part, because the interesting problem is not "can a model write the program" but "why would you believe it".&lt;/p&gt;

&lt;p&gt;Live: &lt;a href="https://draftmark.edycu.dev" rel="noopener noreferrer"&gt;https://draftmark.edycu.dev&lt;/a&gt; · Repo: &lt;a href="https://github.com/edycutjong/draftmark" rel="noopener noreferrer"&gt;https://github.com/edycutjong/draftmark&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with a per-ship program
&lt;/h2&gt;

&lt;p&gt;After the draft is read, a survey converts it to displacement through that ship's hydrostatic booklet: interpolate a table, apply trim corrections, correct for water density, subtract ballast and fuel. Every booklet states its own conventions. The usual answer is a spreadsheet per vessel, built by hand.&lt;/p&gt;

&lt;p&gt;I gave Muse Wonder 1.0 a public trim and stability booklet (a Ro-Ro/passenger ship, not a bulk carrier) and asked for the survey program. A program written by a model is a claim. I wanted an exam it could not talk its way through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exam is the booklet's own printed examples
&lt;/h2&gt;

&lt;p&gt;The booklet prints 22 worked loading conditions with the displacement it expects. So the acceptance rule is: a program passes a row if it lands within 0.1 % of the printed number, and it is accepted only if at least 20 of 22 rows pass, both out-of-range probes raise, and 200 randomized cases agree with a reference program within 0.05 %.&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;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PASS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;probes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;probes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
                     &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;fails&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;dmax&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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;dmax&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FAIL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The program runs in a sandbox: an AST gate, a &lt;code&gt;python -I -S&lt;/code&gt; subprocess, resource limits, an audit hook, no network. The model's code never runs in the server process.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Program&lt;/th&gt;
&lt;th&gt;Printed conditions reproduced&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Generic textbook template&lt;/td&gt;
&lt;td&gt;0 of 22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The booklet's section 3.1 procedure implemented verbatim&lt;/td&gt;
&lt;td&gt;14 of 22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Muse Wonder 1.0, 3rd attempt (2 regenerations)&lt;/td&gt;
&lt;td&gt;22 of 22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Our own reference program&lt;/td&gt;
&lt;td&gt;22 of 22&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The attempt log is more instructive than the result. Attempt 1 was rejected by the static gate (it used &lt;code&gt;globals&lt;/code&gt;). Attempt 2 ran and passed 14 of 22, failing at the same intermediate step on eight rows (conditions 2.2, 3.1, 3.2, 6.2, 7.2, 8.2, 9.1, 9.2: a second trim correction). Those failing ids were fed back to Wonder, and attempt 3 passed 22 of 22. The three calls cost about $0.48 at list price.&lt;/p&gt;

&lt;p&gt;The verbatim procedure scoring 14 of 22 is the point. Section 3.1 omits the second trim correction, and without it the high-trim rows miss by up to 0.38 %. The mark formulas divide by 140 although Lpp is 150, because the marks sit 140 m apart. The MTcm column is per degree by its heading although the legend says per cm. Following the text exactly is not enough (our verbatim implementation scores 14 of 22); graded against the printed numbers, Wonder found the missing convention on its third attempt.&lt;/p&gt;

&lt;p&gt;On condition 4.1 the finished survey gives 17825.8 t against the printed 17827.7 t, a difference of -0.0106 %. That row is one of the 22 used to accept the program, so it is an in-sample check, not a held-out one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the pipeline, briefly
&lt;/h2&gt;

&lt;p&gt;SAM 3.1 tracks the painted numerals ("white number") and the sea ("water") on a 2x crop of the mark column; the crop and scale are needed because 18 px numerals on a full frame returned nothing. On a real third-party clip it found the marks in 60 of 60 frames, giving a draft of 12.761 m with a repeatability uncertainty of 0.086 m. Muse Spark 1.3 reads which box is which label, and five code checks (monotone, pitch, fit residual, two-frame agreement, a wide-glyph anchor) must pass before any label is used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it refuses
&lt;/h2&gt;

&lt;p&gt;Both real-ship clips certify as &lt;code&gt;REFUSE OUT_OF_TABLE&lt;/code&gt; (a third clip, eeLL, was refused earlier by the label checks). The only booklet I have covers drafts of 0.5-9.3 m, and those ships sit at 12.8 m and 20.8 m. The program raises rather than extrapolate, and the certificate says so. An edited measurement is refused as unsigned. I think a tool that refuses to guess is the better default for a document that decides what a buyer pays.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The weight check is on a rendered synthetic barge. It validates the arithmetic and the error model, not the models, and SAM does not detect rendered water.&lt;/li&gt;
&lt;li&gt;The real clips are third-party test footage with no ground truth, so they show repeatability and refusal, not accuracy.&lt;/li&gt;
&lt;li&gt;The test booklet is not a bulk carrier.&lt;/li&gt;
&lt;li&gt;Uncertainty is wave-averaging repeatability only. One clip reads one mark; the other positions are entered by hand.&lt;/li&gt;
&lt;li&gt;It is a prototype, not a surveyor's document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every figure above is in &lt;code&gt;evidence/&lt;/code&gt; or &lt;code&gt;docs/DX-REPORT.md&lt;/code&gt; in the repo, and 124 tests pass. If you want to see it, the self-test re-runs live on the site with no login.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>python</category>
      <category>hackathon</category>
    </item>
    <item>
      <title>Airlock: a veto window for your POT, built on Portaldot's Proxy pallet</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sat, 31 Oct 2026 13:53:05 +0000</pubDate>
      <link>https://dev.to/edycutjong/airlock-a-veto-window-for-your-pot-built-on-portaldots-proxy-pallet-390k</link>
      <guid>https://dev.to/edycutjong/airlock-a-veto-window-for-your-pot-built-on-portaldots-proxy-pallet-390k</guid>
      <description>&lt;p&gt;&lt;strong&gt;A leaked hot key should not empty a wallet in one block.&lt;/strong&gt; A multisig fixes that, but it needs a second signer for every payment, which is heavy for one person.&lt;/p&gt;

&lt;p&gt;The Portaldot runtime already has a better primitive. The Proxy pallet can make a delegate &lt;em&gt;announce&lt;/em&gt; a call, wait a set number of blocks, and only then execute it. Another delegate can &lt;em&gt;reject&lt;/em&gt; the announcement in between. In my search (not a market survey) I did not find a product built around it, so I built one for the Portaldot Hacker House 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;airlock create&lt;/code&gt; makes a keyless vault (&lt;code&gt;proxy.create_pure&lt;/code&gt;) controlled by two keys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hot key&lt;/strong&gt;, your daily key, registered with a delay (10 blocks = 60 s in the demo). It cannot move funds directly: the chain answers &lt;code&gt;Proxy.Unannounced&lt;/code&gt;. It can only announce a call hash and wait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold key&lt;/strong&gt;, instant. It can veto an announcement (&lt;code&gt;reject_announcement&lt;/code&gt;, dispatched as the vault) or retire the hot key (&lt;code&gt;airlock panic&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;strong&gt;watcher&lt;/strong&gt; reads every &lt;code&gt;Proxy.Announced&lt;/code&gt; event for the vault and matches the hash against the owner's intent registry. A spend you registered is YOURS. Anything else is &lt;strong&gt;NOT YOURS&lt;/strong&gt;, printed with the amount at risk, the veto deadline and a paste-ready &lt;code&gt;airlock veto …&lt;/code&gt; line.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb0bkbtboq3907y0ioffl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb0bkbtboq3907y0ioffl.png" alt="Airlock architecture" width="800" height="593"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Six Proxy-pallet calls are the whole product: &lt;code&gt;create_pure&lt;/code&gt;, &lt;code&gt;add_proxy&lt;/code&gt;, &lt;code&gt;announce&lt;/code&gt;, &lt;code&gt;proxy_announced&lt;/code&gt;, &lt;code&gt;reject_announcement&lt;/code&gt; and &lt;code&gt;remove_proxy&lt;/code&gt;. There is no contract and no runtime change; the security is Portaldot's consensus, not my server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened on the V3 testnet
&lt;/h2&gt;

&lt;p&gt;On 2026-10-24, the run in the demo video (&lt;code&gt;evidence/final-video-run-v3-2026-10-24.md&lt;/code&gt;):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A thief holding the hot key announced a sweep of the whole vault (&lt;code&gt;166811-1&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;The watcher printed NOT YOURS: 11.4200 tPOT at risk, "Veto must land by #166820".&lt;/li&gt;
&lt;li&gt;The owner pasted the veto line. It landed in block #166816, four blocks early (&lt;code&gt;166816-1&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;After the delay the thief executed (&lt;code&gt;166825-1&lt;/code&gt;) and then called the proxy directly (&lt;code&gt;166831-1&lt;/code&gt;). Both failed &lt;code&gt;Proxy.Unannounced&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;airlock status&lt;/code&gt; before and after: the same 11.4200 tPOT.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Earlier, on 2026-10-19, a 12.92 tPOT sweep was vetoed the same way, and &lt;code&gt;scripts/verify_onchain.py&lt;/code&gt; re-fetched all 44 V3 proof rows by id: 44/44 matched.&lt;/p&gt;

&lt;p&gt;On two local interim chains (not V3), a benchmark ran 20 thief drains per chain: 20/20 alerted and vetoed, 0 planck moved. A control vault with no delay lost 100 UNIT in one extrinsic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inclusion is not finality.&lt;/strong&gt; V3 has two validators and can fork, so off the interim chains every send waits for finality before Airlock reports it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the chain.&lt;/strong&gt; Chain profiles are pinned by genesis and runtime code hash; an unknown chain fails loudly instead of signing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An announcement exposes only a hash.&lt;/strong&gt; That is why the intent registry exists: anything not registered is treated as NOT YOURS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;If the owner ignores the alert for the whole delay, the spend executes: the delay must be longer than your response time. The benchmark has not run on V3. There is no hosted UI; the CLI and watcher are the owner interface. Testnet only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Code (MIT): &lt;a href="https://github.com/edycutjong/portaldot-hackathon-2026-airlock-edycu" rel="noopener noreferrer"&gt;https://github.com/edycutjong/portaldot-hackathon-2026-airlock-edycu&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Demo video (3 min): &lt;a href="https://youtu.be/Es1387HDSTw" rel="noopener noreferrer"&gt;https://youtu.be/Es1387HDSTw&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Site: &lt;a href="https://airlock.edycu.dev" rel="noopener noreferrer"&gt;https://airlock.edycu.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Proof: &lt;code&gt;docs/PROOF.md&lt;/code&gt; in the repo&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;350 offline tests, plus 4 live-chain integration tests. Built for the Portaldot Hacker House 2026 (Ecosystem DApps). Feedback welcome, especially from validators and anyone holding POT on a hot key.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>blockchain</category>
      <category>security</category>
      <category>python</category>
    </item>
    <item>
      <title>Ballast: freezing a test dollar on Portaldot when its reserve runs short</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sat, 31 Oct 2026 13:52:56 +0000</pubDate>
      <link>https://dev.to/edycutjong/ballast-freezing-a-test-dollar-on-portaldot-when-its-reserve-runs-short-2moo</link>
      <guid>https://dev.to/edycutjong/ballast-freezing-a-test-dollar-on-portaldot-when-its-reserve-runs-short-2moo</guid>
      <description>&lt;p&gt;&lt;strong&gt;A custodial wrapped token fails silently:&lt;/strong&gt; reserves leave, the token keeps moving, and holders learn last.&lt;/p&gt;

&lt;p&gt;Ballast is a safety layer for the first dollar on Portaldot. It is built around a clearly labelled test asset, &lt;strong&gt;pUSDC-test&lt;/strong&gt;, whose roles are enforced by the Portaldot V3 runtime itself (the native Assets pallet plus Multisig), with the reserve held in a &lt;code&gt;ReserveVault&lt;/code&gt; contract on Ethereum Sepolia.&lt;/p&gt;

&lt;h2&gt;
  
  
  One flow, four guarantees
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mint&lt;/strong&gt; needs a 2-of-2 multisig whose mandatory co-signer, the reserve bot, signs only if &lt;em&gt;finalized&lt;/em&gt; reserves cover supply plus the amount.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reserves are attested on chain&lt;/strong&gt;: &lt;code&gt;BALLAST/1&lt;/code&gt; remarks, mirrored into a &lt;code&gt;ReserveOracle&lt;/code&gt; contract on Revive. &lt;code&gt;ballast verify&lt;/code&gt; re-checks both chains and rejects a forged remark.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;watcher set&lt;/strong&gt; (a 1-of-2 multisig) can only &lt;strong&gt;freeze&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Only the &lt;strong&gt;2-of-3 admin multisig&lt;/strong&gt; can &lt;strong&gt;thaw&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because these are runtime roles, not app logic, a watcher that tries to thaw is refused by the chain itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened on the V3 testnet
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Role drill, 2026-10-24.&lt;/strong&gt; The custodian drained 31.20 of 104.00 from the vault (local anvil reserve). Watcher W2 froze asset 4401 in extrinsic &lt;code&gt;170112-1&lt;/code&gt;, 12.22 s after the drain. The next transfer failed &lt;code&gt;Assets.AssetNotLive&lt;/code&gt; (&lt;code&gt;170114-1&lt;/code&gt;), a watcher's attempt to thaw failed &lt;code&gt;Assets.NoPermission&lt;/code&gt; (&lt;code&gt;170115-1&lt;/code&gt;), and the bot co-signed the thaw only after finality passed the drain (&lt;code&gt;170133-1&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real Sepolia drill, 2026-10-30.&lt;/strong&gt; &lt;code&gt;ReserveVault&lt;/code&gt; deployed on Ethereum Sepolia (&lt;code&gt;0x7ceB7c3EBDBCC75e585599e5dd944286F009357b&lt;/code&gt;, 100.00 Circle test USDC). The drill passed 8/8 steps and 18/18 checks: Sepolia &lt;code&gt;release&lt;/code&gt; → freeze &lt;code&gt;258434-1&lt;/code&gt; on V3 10.26 s later → transfer refused → refill → the bot refused to thaw until both providers' finalized head passed the drain (1,026 s of real waiting) → co-signed thaw &lt;code&gt;258627-1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Benchmarks, 35 freeze drills&lt;/strong&gt; (&lt;code&gt;docs/BENCHMARKS.md&lt;/code&gt;): drain → freeze included at p50 / p95 = 12.31 / 16.71 s with the local anvil reserve (n = 26) and 10.61 / 11.25 s on real Sepolia (n = 6). The fee is about 0.03 tPOT per freeze.&lt;/p&gt;

&lt;p&gt;All &lt;strong&gt;261/261&lt;/strong&gt; V3 extrinsics from 23 runs were re-queried and final at head #261558; failed and errored runs stay listed in &lt;code&gt;docs/PROOF.md&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wait for finality on both chains.&lt;/strong&gt; The thaw path deliberately waits until both providers' finalized head is past the drain; on Sepolia that was 1,026 s of real waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A watcher on GitHub Actions is not real time.&lt;/strong&gt; Run &lt;code&gt;41150496088&lt;/code&gt; froze the asset with the primary watcher off, but the &lt;code&gt;*/5&lt;/code&gt; cron is best-effort, so it is a backup, not the main line.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;pUSDC-test is a labelled test asset, not a stablecoin. The reserve gates are bot code; the runtime enforces the roles and multisig thresholds, but this is not a trustless bridge. One person holds every key in this build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Code: &lt;a href="https://github.com/edycutjong/portaldot-hackathon-2026-ballast-edycu" rel="noopener noreferrer"&gt;https://github.com/edycutjong/portaldot-hackathon-2026-ballast-edycu&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Reserves page: &lt;a href="https://ballast.edycu.dev/reserves/" rel="noopener noreferrer"&gt;https://ballast.edycu.dev/reserves/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Demo video: &lt;a href="https://youtu.be/jV5dCy1o3O8" rel="noopener noreferrer"&gt;https://youtu.be/jV5dCy1o3O8&lt;/a&gt; (recorded 2026-10-24: it shows the V3 role drill, not the later Sepolia drill)&lt;/li&gt;
&lt;li&gt;Proof: &lt;code&gt;docs/PROOF.md&lt;/code&gt; · Benchmarks: &lt;code&gt;docs/BENCHMARKS.md&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;342 tests (301 pytest + 20 Foundry + 21 Hardhat). Built for the Portaldot Hacker House 2026.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>blockchain</category>
      <category>web3</category>
      <category>python</category>
    </item>
    <item>
      <title>Forklift: rehearse a Portaldot transaction on a fork, then send the same bytes</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sat, 31 Oct 2026 13:52:42 +0000</pubDate>
      <link>https://dev.to/edycutjong/forklift-rehearse-a-portaldot-transaction-on-a-fork-then-send-the-same-bytes-1112</link>
      <guid>https://dev.to/edycutjong/forklift-rehearse-a-portaldot-transaction-on-a-fork-then-send-the-same-bytes-1112</guid>
      <description>&lt;p&gt;&lt;strong&gt;Your transaction runs twice. The first time is a rehearsal.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Forklift pins a Portaldot block, lifts the chain's real state at that block, forks it on your machine and checks the fork against the live chain key by key. Then &lt;code&gt;forklift rehearse&lt;/code&gt; signs a transaction once, applies those exact bytes on the fork as the prediction, and with &lt;code&gt;--send&lt;/code&gt; sends the same bytes to the V3 testnet and lists every field that differs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo23oqv684a7g08nk1qro.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo23oqv684a7g08nk1qro.png" alt="Forklift architecture" width="800" height="895"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is measured on the V3 testnet
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lift&lt;/strong&gt; at #118,061: 5,508 keys. &lt;code&gt;verify --sample 200&lt;/code&gt; exits 0: 200/200 draws identical, key set 5,508 = 5,508.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rehearse&lt;/strong&gt;: three real V3 transactions, each predicted on a fork first, then sent: &lt;code&gt;118555-1&lt;/code&gt; System.remark, &lt;code&gt;118621-1&lt;/code&gt; Balances.transfer_keep_alive, &lt;code&gt;118669-1&lt;/code&gt; Utility.batch. Each had the same tx hash, success and event sequence as predicted, and the same recipient delta where there is one.&lt;/li&gt;
&lt;li&gt;What differed: the fee-bearing event amounts, the fee (3,660 to 8,269 planck), the signer delta and the inclusion height. Forklift lists every one and exits 7, never a quiet fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay&lt;/strong&gt; re-runs a V3 extrinsic on a fork of its parent block: 118555-1 10/10, 118621-1 14/14, 118669-1 16/16 fields equal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fork Receipt v2&lt;/strong&gt;, a Revive contract on V3 at &lt;code&gt;0x52f0edd92af9ff410ca63ccc83735552e1b2abc7&lt;/code&gt; (84 bytes): the emitted event and the stored &lt;code&gt;receipt()&lt;/code&gt; equal the #118,061 manifest.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Mainnet, too
&lt;/h2&gt;

&lt;p&gt;Three mainnet extrinsics replayed field-equal on a fork of their parent block (24/24, 25/25, 24/24). &lt;code&gt;runtime-diff&lt;/code&gt; and &lt;code&gt;migrate-report&lt;/code&gt; compare mainnet spec 1002 with V3: of a 16,324-key lift, 10,499 keys carry over, 5,443 re-encode and 382 have no home on V3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A fee difference is not a bug, but it must be said.&lt;/strong&gt; Each transaction landed a few blocks after its prediction, so the fee moved. Listing it, with an exit code, is more useful than hiding it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sampling is honest only if you say it is a sample.&lt;/strong&gt; &lt;code&gt;verify&lt;/code&gt; checks 200 random keys, not every key, and Forklift says so.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;A V3 fork is Chopsticks, not a V3 node (no public V3 node binary exists). Forklift reads mainnet and V3; the only writes to a real chain are &lt;code&gt;rehearse --send&lt;/code&gt; and &lt;code&gt;receipt --target v3&lt;/code&gt;, and only to V3. The PyPI package is not published yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Code: &lt;a href="https://github.com/edycutjong/portaldot-hackathon-2026-forklift-edycu" rel="noopener noreferrer"&gt;https://github.com/edycutjong/portaldot-hackathon-2026-forklift-edycu&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Demo video: &lt;a href="https://youtu.be/iVvEXQvfTYY" rel="noopener noreferrer"&gt;https://youtu.be/iVvEXQvfTYY&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Site: &lt;a href="https://forklift.edycu.dev" rel="noopener noreferrer"&gt;https://forklift.edycu.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Proof: &lt;code&gt;docs/PROOF.md&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;324 offline tests and 10 live tests. Built for the Portaldot Hacker House 2026.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>blockchain</category>
      <category>devtools</category>
      <category>python</category>
    </item>
    <item>
      <title>Asked for real places, an LLM got 7 of 17 wrong. My agent isn't allowed to name one.</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sat, 31 Oct 2026 13:52:14 +0000</pubDate>
      <link>https://dev.to/edycutjong/asked-for-real-places-an-llm-got-7-of-17-wrong-my-agent-isnt-allowed-to-name-one-1k20</link>
      <guid>https://dev.to/edycutjong/asked-for-real-places-an-llm-got-7-of-17-wrong-my-agent-isnt-allowed-to-name-one-1k20</guid>
      <description>&lt;p&gt;A therapist treating depression often knows the next step: schedule a few things the patient enjoys, then have them rate how it went. The catch is that a depressed patient often can't name one thing they would enjoy this week. So I built &lt;a href="https://rekindle.edycu.dev" rel="noopener noreferrer"&gt;Rekindle&lt;/a&gt;, a planning agent for clinicians that starts from what the patient &lt;em&gt;used to&lt;/em&gt; love (an artist, a show, a food) and turns it into a week of real, open, nearby activities, built on Qloo's taste API, for the Qloo Agentic Hackathon on Devpost.&lt;/p&gt;

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

&lt;p&gt;The first design question was the obvious one: why not just ask an LLM for the week? I measured it. Then I made one rule that shaped the rest of the code: &lt;strong&gt;the model chooses where to look; Qloo and deterministic code decide what is true.&lt;/strong&gt; The model never names a venue.&lt;/p&gt;

&lt;p&gt;Live app (no login, fictional cases only): &lt;a href="https://rekindle.edycu.dev" rel="noopener noreferrer"&gt;https://rekindle.edycu.dev&lt;/a&gt; · code (MIT): &lt;a href="https://github.com/edycutjong/rekindle" rel="noopener noreferrer"&gt;https://github.com/edycutjong/rekindle&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The number, measured on October 7
&lt;/h2&gt;

&lt;p&gt;I gave the same model (&lt;code&gt;qwen3.8-flash&lt;/code&gt;) the same 20 fictional patient profiles in two arms. Arm A: asked for named places for the week. Arm B: Rekindle. A random 20 outing venues per arm were then checked one by one against public listings (venue sites, city listings, review and delivery listings) for three things: does it exist at that address, is it open at the planned hour, and, for a patient in recovery, does it serve alcohol.&lt;/p&gt;

&lt;p&gt;Measured on October 7: &lt;strong&gt;the LLM alone got 7 of 17 places wrong (41.2%): 4 not found, 2 closed at the planned hour, 1 serving alcohol to a patient in recovery. Rekindle got 0 of 14.&lt;/strong&gt; The other rows were inconclusive (3 of the LLM's 20, 6 of Rekindle's 20) and are left out of the denominator rather than guessed.&lt;/p&gt;

&lt;p&gt;Before you quote that, here is what it is not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It was not blind.&lt;/strong&gt; The hand check of that run's venues (finished the next day) was done by my build agent, Claude, an AI. It ran the bench, so it could have inferred the arm from the address style. No external human auditor has returned a result yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is small.&lt;/strong&gt; 17 and 14 checked venues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It predates later work.&lt;/strong&gt; The October 7 run came before 30 later changes to the plan path, all listed in the repo's &lt;code&gt;DEMO.md&lt;/code&gt;. A re-run on the final deploy is planned for late October.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is also an automated checker over all 20 profiles. It flags 58 of the LLM's 79 venues, but I don't lead with it: it grades with Qloo, which flatters Rekindle by construction, and it over-flags. In the audit sample, 4 of the 10 venues it couldn't match were real and open (it matches by street address and misses large venues such as parks). So the smaller, hand-checked number goes first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule, in code
&lt;/h2&gt;

&lt;p&gt;The tools the model can call carry no constraints at all. Here is the outing tool's input schema from &lt;code&gt;lib/agent.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;findOutings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Fill a solo slot with a verified place: open at the hour and checked by the server against the case row (the model cannot see or change those checks).&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dayEnum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;narrowMeters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="c1"&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 model can pick a day, an hour and a tighter radius. Distance limit, budget, "in recovery", "quiet" and "wheelchair entrance" are read from the stored case on every request; &lt;code&gt;narrowMeters&lt;/code&gt; can only tighten. Qloo returns candidates, and a pure TypeScript verifier with no I/O (&lt;code&gt;lib/verify.ts&lt;/code&gt;) decides each one: open at the slot, near, within budget, an activity rather than a shop, alcohol-free in recovery, quiet or accessible when asked, and not rated low before. Refused candidates stay on the clinician's sheet, struck through, with the rule that refused them.&lt;/p&gt;

&lt;p&gt;The model's only free text is one coaching line per card, and that goes through a boundary too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;submitted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;byId&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="nx"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;R-ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;coaching line references an ID no tool returned this run&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fault&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lineFault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;names&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fault&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;fault&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entity_id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;continue&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;A line keyed to an ID no tool returned is dropped and logged. So is a line over 25 words, a line that names another place from the run, a line that states a fact the card owns (a distance or a price), and a line that mentions drinking, on every case, because the model never sees the recovery flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wall: "exclude alcohol" is not one filter
&lt;/h2&gt;

&lt;p&gt;The demo moment is a toggle. The clinician clicks &lt;strong&gt;In recovery&lt;/strong&gt;, and places that carry an alcohol tag or keyword are struck in place and their slots refilled live. In a live run on October 8 that was "3 outings struck in place and 3 slots refilled live · 2.2 s". Getting there took three wrong turns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong turn 1: trusting the exclusion filter.&lt;/strong&gt; Qloo's insights endpoint takes &lt;code&gt;filter.exclude.tags&lt;/code&gt;. I sent Maya's Thursday place query (Maya is a fictional case, Williamsburg, Brooklyn) three times in a live probe on October 7 and counted what came back:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;filter.exclude.tags&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Places returned&lt;/th&gt;
&lt;th&gt;Still alcohol-tagged&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 bar tags&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14 alcohol tags&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;2 (plus 2 ambiguous)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Excluding the four obvious bar tags removed 2 of 19. The alcohol vocabulary is spread over at least six tag namespaces (&lt;code&gt;category:place&lt;/code&gt;, &lt;code&gt;amenity:qloo&lt;/code&gt;, &lt;code&gt;beverage_offering:qloo&lt;/code&gt; and others), so excluding a concept means listing every tag that expresses it. Even with 14 tags excluded, a coffee shop tagged &lt;code&gt;beverage_offering:qloo:specialty_alcoholic&lt;/code&gt; came back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong turn 2: a substring screen.&lt;/strong&gt; The fix seemed to be "refuse any tag containing &lt;code&gt;pub&lt;/code&gt;, &lt;code&gt;bar&lt;/code&gt;, &lt;code&gt;wine&lt;/code&gt;…". A probe showed it would refuse &lt;code&gt;espresso_bar&lt;/code&gt; and &lt;code&gt;independent_publishing&lt;/code&gt;. The screen now matches exact deny-listed tag IDs first, then whole tokens of each tag's last segment, with an allow list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isBar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bar&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;BAR_SERVICE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seg&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;_bar&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;BAR_ALLOW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// `bar_&amp;lt;x&amp;gt;` segments describe furniture or ordering (bar_stools, bar_area), and `happy_hour` alone is a price deal:&lt;/span&gt;
&lt;span class="c1"&gt;// a signal for the clinician, not proof of alcohol&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;furniture&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bar_&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;BAR_SERVICE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;happy_hour&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokenHit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ALC_TOKENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;isBar&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;furniture&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The furniture case is why there is a third verdict. "Bar stool seating" doesn't prove alcohol, so it goes to the clinician in a dashed &lt;strong&gt;REVIEW&lt;/strong&gt; box instead of being silently passed or refused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong turn 3: a beer hall got through.&lt;/strong&gt; The bench placed "TeKu Tavern + Café" for a profile &lt;em&gt;not&lt;/em&gt; in recovery, because its &lt;code&gt;cafe&lt;/code&gt; category passed the activity check. Drinking-establishment categories are now refused for every case.&lt;/p&gt;

&lt;p&gt;Does the screen earn its place? The October 7 bench ran an ablation on the 10 in-recovery profiles: with the screen on, 0 of 30 outing cards carried an alcohol tag or keyword. Screen off with the 14-tag exclusion kept: 3 of 28. Screen off with only the 4 bar tags excluded: 21 of 28. It counts tags and keywords, not menus, which is its limit (below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Opening hours are day-level
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;filter.hours=thursday&lt;/code&gt; keeps places open on Thursday, not at 18:00. In the same October 7 probe, a Thursday query returned 25 places, 12 of them closed at 18:00 by their own listed hours, and some places have no hours at all. So the verifier checks the slot itself and returns OPEN, CLOSED or UNKNOWN. UNKNOWN is refused, never assumed open. A card closed at 18:00 but open earlier that day moves to an open hour and says so on the card ("moved from 18:00 (closed then)").&lt;/p&gt;

&lt;h2&gt;
  
  
  Week two learns, and says how sure it is
&lt;/h2&gt;

&lt;p&gt;The patient rates pleasure and mastery 0–10. A rating of 7 or more becomes a Qloo signal for week 2, 3 or less an exclusion, and the top-rated activity comes back in its slot after a live re-check. Qloo's explainability weights say which input led each new card; Rekindle draws an "ember thread" from a rated activity only when it leads the patient's best original love by at least 0.02, and labels narrower leads as ties. It is Qloo's attribution, not causation, and the app says so on the screen. On live re-plans most leads are still ties.&lt;/p&gt;

&lt;p&gt;In the bench (re-plan check, same October 7 run), 16 of 16 eligible profiles passed: week 2 had at least one new card whose top input was a highly rated activity, and none of the low-rated ones came back. Four profiles were excluded because week 1 had too few verified outings to rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Grounding is slower. In the October 7 bench, a Rekindle plan took p50 17.43 s and p95 20.61 s on the hosted app, with about 9.4 Qloo calls per plan. The LLM alone answered in p50 7.2 s. It meets the 30 s budget I set but misses the 15 s I wanted; the coaching step dominates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It is clinician support, not a clinical tool: no diagnosis, no efficacy claims, no patient logins. Every case in the app, Maya included, is fictional, and no clinician has endorsed it.&lt;/li&gt;
&lt;li&gt;The alcohol screen is a deny list. A venue that serves alcohol and carries none of the tags or keywords would pass.&lt;/li&gt;
&lt;li&gt;Opening hours come from Qloo; if they are wrong upstream, the check passes a closed door.&lt;/li&gt;
&lt;li&gt;In thin areas, outing slots stay empty with a reason. It doesn't guess.&lt;/li&gt;
&lt;li&gt;The headline audit was done by an AI build agent, not blind, on 17 and 14 venues.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Try the click path (plan Maya's week, toggle &lt;strong&gt;In recovery&lt;/strong&gt;, approve, rate three cards, re-plan): &lt;a href="https://rekindle.edycu.dev" rel="noopener noreferrer"&gt;https://rekindle.edycu.dev&lt;/a&gt; · every live Qloo request behind the latest plans: &lt;a href="https://rekindle.edycu.dev/integrations/qloo" rel="noopener noreferrer"&gt;https://rekindle.edycu.dev/integrations/qloo&lt;/a&gt; · code and bench files: &lt;a href="https://github.com/edycutjong/rekindle" rel="noopener noreferrer"&gt;https://github.com/edycutjong/rekindle&lt;/a&gt; · Devpost entry: &lt;a href="https://devpost.com/software/rekindle-xw53nk" rel="noopener noreferrer"&gt;https://devpost.com/software/rekindle-xw53nk&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>agents</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Plumbline: Portaldot's docs list Balances.transfer. On V3 it doesn't exist.</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sat, 31 Oct 2026 13:51:54 +0000</pubDate>
      <link>https://dev.to/edycutjong/plumbline-portaldots-docs-list-balancestransfer-on-v3-it-doesnt-exist-3i10</link>
      <guid>https://dev.to/edycutjong/plumbline-portaldots-docs-list-balancestransfer-on-v3-it-doesnt-exist-3i10</guid>
      <description>&lt;p&gt;&lt;strong&gt;Portaldot's mainnet docs list &lt;code&gt;Balances.transfer&lt;/code&gt;. On the V3 testnet that call does not exist.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;V3 is a new chain: the testnet reports &lt;code&gt;portaldot-v3&lt;/code&gt; spec 3002 with 69 pallets and 480 calls, where mainnet spec 1002 has 31 pallets. The written sources disagree with it, too: the V3 guide names the token &lt;code&gt;tPOTv3&lt;/code&gt;, while the chain reports &lt;code&gt;tPOT&lt;/code&gt;. From the docs alone, nobody can say which documented call runs on the chain.&lt;/p&gt;

&lt;p&gt;Plumbline answers with transactions, not opinions: &lt;strong&gt;documented call → verdict → proof.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft74245kiunyk285mptt1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft74245kiunyk285mptt1.png" alt="Plumbline architecture" width="800" height="879"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Four commands
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;plumb&lt;/strong&gt; reads runtime metadata through Portaldot's Python SDK (&lt;code&gt;substrate-interface&lt;/code&gt;) and parses the documented surfaces: the 22 mainnet extrinsic doc pages (196 calls), the V3 guide's 46 sample-code &lt;code&gt;compose_call&lt;/code&gt; sites and its FAQ module table (12 modules). Every entry gets a verdict on V3: PROVEN, UNPROVEN, ABSENT, ARG_DRIFT, DRIFTED or UNDOCUMENTED. That is &lt;strong&gt;549 verdict rows&lt;/strong&gt;, each stamped with the spec and metadata hash, and byte-identical on re-run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;canary&lt;/strong&gt; proves calls. A call is PROVEN only by a real transaction read back from its block. Three were signed on V3 (&lt;code&gt;System.remark_with_event&lt;/code&gt;, &lt;code&gt;Balances.transfer_keep_alive&lt;/code&gt;, &lt;code&gt;Utility.batch_all&lt;/code&gt;, blocks 106424–106426), and &lt;code&gt;plumbline readback&lt;/code&gt; re-reads them from a fresh connection: 3/3 match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;diff&lt;/strong&gt; lists the pallet, call, argument and constant changes from mainnet 1002 to V3: &lt;strong&gt;516 rows&lt;/strong&gt;, with 40 pallets added and 2 removed (&lt;code&gt;Proxy.anonymous&lt;/code&gt; → &lt;code&gt;create_pure&lt;/code&gt; among the renames).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;check&lt;/strong&gt; flags call sites in your own dApp that will not run: the example dApp has 0 blocking findings on V3 and 5 on mainnet spec 1002, with exit code 1 on blocking so it can gate CI.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Findings on V3
&lt;/h2&gt;

&lt;p&gt;The 79 calls whose docs and runtime disagree on mainnet 1002 become 2 on V3: &lt;code&gt;Balances.transfer&lt;/code&gt; is absent, and &lt;code&gt;ElectionProviderMultiPhase.governance_fallback&lt;/code&gt; takes different arguments. Five of the 12 modules the V3 guide's FAQ lists are not on the testnet.&lt;/p&gt;

&lt;p&gt;Deterministic code decides every verdict, a metadata set-difference plus a transaction receipt. There is no model in the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The metadata hash is not a stable identity.&lt;/strong&gt; On V3 it varied by one byte between blocks, so Plumbline records the runtime &lt;code&gt;:code&lt;/code&gt; hash as the identity instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Exists in metadata" is not "works".&lt;/strong&gt; Only a receipt read back from a block upgrades a call to PROVEN; everything else stays UNPROVEN.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;No Plumbline contract is deployed: the on-chain evidence is the 3 canary transactions, and every other documented call that exists on V3 stays UNPROVEN until a transaction is sent for it. Runtime APIs (ReviveApi) are not checked yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Code: &lt;a href="https://github.com/edycutjong/portaldot-hackathon-2026-plumbline-edycu" rel="noopener noreferrer"&gt;https://github.com/edycutjong/portaldot-hackathon-2026-plumbline-edycu&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Demo video: &lt;a href="https://youtu.be/edvFuOdUZNk" rel="noopener noreferrer"&gt;https://youtu.be/edvFuOdUZNk&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Site: &lt;a href="https://plumbline.edycu.dev" rel="noopener noreferrer"&gt;https://plumbline.edycu.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Proof: &lt;code&gt;docs/PROOF.md&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;263 tests pass (live tests are opt-in). Built for the Portaldot Hacker House 2026.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>blockchain</category>
      <category>devtools</category>
      <category>python</category>
    </item>
    <item>
      <title>An API that misses its deadline should pay you back: a bonded pay-per-call escrow on Arc</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sat, 31 Oct 2026 04:48:46 +0000</pubDate>
      <link>https://dev.to/edycutjong/an-api-that-misses-its-deadline-should-pay-you-back-a-bonded-pay-per-call-escrow-on-arc-1mmn</link>
      <guid>https://dev.to/edycutjong/an-api-that-misses-its-deadline-should-pay-you-back-a-bonded-pay-per-call-escrow-on-arc-1mmn</guid>
      <description>&lt;p&gt;Your agent paid an API 0.01 USDC and the API never answered. Unless the provider decides to refund you, that money is gone, and your only recourse is a refund policy, a support ticket or a chargeback. The provider had nothing at stake.&lt;/p&gt;

&lt;p&gt;I wanted the opposite default: &lt;strong&gt;if the API misses its deadline, it pays you back, plus a penalty, without you having to ask.&lt;/strong&gt; ReplyBond is a small Solidity escrow on Arc mainnet that does exactly that, and this post walks through the five design choices that make it work, what the AI coding agent (Arc Studio) built, what I did by hand, and where it stops being honest to call it "guaranteed".&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live: &lt;a href="https://replybond.edycu.dev" rel="noopener noreferrer"&gt;https://replybond.edycu.dev&lt;/a&gt; (Arc mainnet, chain 5042, native USDC)&lt;/li&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/edycutjong/replybond" rel="noopener noreferrer"&gt;https://github.com/edycutjong/replybond&lt;/a&gt; (MIT)&lt;/li&gt;
&lt;li&gt;Contract: &lt;a href="https://explorer.arc.io/address/0x2a0f4aeF8E5379d8E4606F40846cf97716fe749f" rel="noopener noreferrer"&gt;&lt;code&gt;0x2a0f4aeF8E5379d8E4606F40846cf97716fe749f&lt;/code&gt;&lt;/a&gt;, Sourcify exact match&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8fixat74bb41plx034fb.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8fixat74bb41plx034fb.gif" alt="A real Arc mainnet job: the 45 s clock hits zero, timeout pays the buyer 0.015 USDC, and the explorer shows the same transaction." width="600" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Job 24 on mainnet: the provider stays silent, the 45 s clock (sped up x8) hits zero, &lt;code&gt;timeout&lt;/code&gt; pays the buyer 0.010 back + 0.005 from the bond, and the explorer shows the same transaction moving 0.015 USDC.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of a job
&lt;/h2&gt;

&lt;p&gt;A provider posts an offer with a bond. A buyer accepts it by paying the price, and the clock starts on acceptance. Every job ends in exactly one of four terminal states:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ending&lt;/th&gt;
&lt;th&gt;Who triggers it&lt;/th&gt;
&lt;th&gt;Who gets price + bond&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Paid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;buyer calls &lt;code&gt;acknowledge&lt;/code&gt; after an answer&lt;/td&gt;
&lt;td&gt;provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Claimed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;anyone calls &lt;code&gt;claim&lt;/code&gt; after the answer + a grace window&lt;/td&gt;
&lt;td&gt;provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TimedOut&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;anyone calls &lt;code&gt;timeout&lt;/code&gt; after the deadline, no answer&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;buyer&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Withdrawn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;provider withdraws an offer nobody took&lt;/td&gt;
&lt;td&gt;provider (bond only)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There is no owner and no upgrade path. On the demo, the price is 0.010 USDC, the bond 0.005 USDC, the response window 45 s and the grace window 120 s.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The bond goes in before the buyer pays
&lt;/h2&gt;

&lt;p&gt;The order matters. The provider locks the penalty first, as &lt;code&gt;msg.value&lt;/code&gt; on &lt;code&gt;postOffer&lt;/code&gt;; only then can a buyer accept by paying exactly &lt;code&gt;price&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function postOffer(
    bytes32 requestHash,
    address buyer,
    uint256 price,
    uint64 responseWindow,
    uint64 graceWindow
) external payable nonReentrant returns (uint256 id) {
    if (msg.value == 0) revert InvalidBond();
    if (price == 0) revert InvalidPrice();
    if (responseWindow == 0) revert InvalidResponseWindow();
    ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because USDC is Arc's native balance, the bond, the price, the penalty and the gas are all the same asset. There are no ERC-20 approvals in the flow at all: one &lt;code&gt;payable&lt;/code&gt; call locks the bond, another locks the price.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. "Answering" means writing the output on-chain
&lt;/h2&gt;

&lt;p&gt;A deadline is meaningless if "I answered" is just the provider's word. So an answer only counts if the provider calls &lt;code&gt;respond&lt;/code&gt; with the output itself, between 1 and 1,024 bytes, at or before the deadline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function respond(uint256 id, bytes calldata output) external nonReentrant {
    Job storage job = _requireJob(id);
    if (msg.sender != job.provider) revert NotProvider(id);
    _requireStatus(id, job, Status.Accepted);
    if (block.timestamp &amp;gt; uint256(job.deadline)) revert ResponseDeadlinePassed(id);
    if (output.length == 0 || output.length &amp;gt; MAX_OUTPUT) revert InvalidOutputLength(output.length);

    job.responseHash = keccak256(output);
    job.respondedAt = uint64(block.timestamp);
    job.status = Status.Responded;
    emit ResponseCommitted(id, job.responseHash, output, job.respondedAt);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The contract stores only the &lt;code&gt;keccak256&lt;/code&gt; and emits the bytes in the &lt;code&gt;ResponseCommitted&lt;/code&gt; event, so delivery is provable from the chain alone, and &lt;code&gt;MAX_OUTPUT&lt;/code&gt; bounds the gas. The boundary is deliberate: &lt;code&gt;respond&lt;/code&gt; is valid &lt;em&gt;up to and including&lt;/em&gt; the deadline, &lt;code&gt;timeout&lt;/code&gt; only &lt;em&gt;strictly after&lt;/em&gt; it, so there is no second where both are possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Nobody has to ask for the refund
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;timeout&lt;/code&gt; is permissionless. Anyone can call it once the deadline passes, and the money always goes to the buyer, never to the caller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function timeout(uint256 id) external nonReentrant {
    Job storage job = _requireJob(id);
    _requireStatus(id, job, Status.Accepted);
    if (block.timestamp &amp;lt;= uint256(job.deadline)) revert DeadlineNotPassed(id);

    uint256 amount = job.price + job.bond;
    job.status = Status.TimedOut;
    emit JobTimedOut(id, job.buyer, msg.sender, amount);
    _pay(id, job.buyer, amount);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mirror case needed the same treatment. If the provider &lt;em&gt;did&lt;/em&gt; answer and the buyer simply goes quiet, the provider must not be stuck: after &lt;code&gt;respondedAt + graceWindow&lt;/code&gt;, anyone can call &lt;code&gt;claim&lt;/code&gt;, and the provider gets price + bond. A silent buyer cannot take the answer and keep the money.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A payout must never block another job
&lt;/h2&gt;

&lt;p&gt;Pushing native value to an arbitrary address is the classic way to brick an escrow: one payee that reverts (or burns gas, or returns a huge blob) and settlement is stuck. ReplyBond sends with a 100,000-gas cap and a zero-length return area, so it never copies return data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function _pay(uint256 id, address payee, uint256 amount) internal {
    totalLocked -= amount;
    if (!_sendNative(payee, amount)) {
        owed[payee] += amount;
        totalOwed += amount;
        emit PayoutOwed(id, payee, amount);
    }
}

/// @dev Bounded gas and zero-length return area: never copies returndata.
function _sendNative(address payee, uint256 amount) private returns (bool success) {
    assembly ("memory-safe") {
        success := call(PAYOUT_GAS, payee, amount, 0, 0, 0, 0)
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the send fails, the amount is credited to &lt;code&gt;owed[payee]&lt;/code&gt; and the payee pulls it later with &lt;code&gt;withdrawOwed()&lt;/code&gt;. State changes happen before any payout, and every entry point sits behind OpenZeppelin's &lt;code&gt;ReentrancyGuard&lt;/code&gt;. The contract's doc comment writes down the invariants the Foundry suite checks, including conservation (balance &amp;gt;= &lt;code&gt;totalLocked + totalOwed&lt;/code&gt;) and "each job reaches exactly one terminal state, once".&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Read the payout from the chain's own Transfer log
&lt;/h2&gt;

&lt;p&gt;This is the part only Arc gives you, and the one I liked most. On Arc, a native USDC movement out of a contract shows up in the receipt as an ERC-20-style &lt;code&gt;Transfer&lt;/code&gt; log emitted by a system address (EIP-7708), right next to the contract's own events. From a real testnet &lt;code&gt;timeout&lt;/code&gt; receipt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;log#25 emitter=0x1234fb0f...2458 [ReplyBond] JobTimedOut(id=1, amount=15000000000000000)
log#26 emitter=0xfffffffffffffffffffffffffffffffffffffffe [ARC SYSTEM EMITTER]
       Transfer(from=&amp;lt;contract&amp;gt;, to=&amp;lt;buyer&amp;gt;, value=15000000000000000)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the app does not trust its own contract's event to say "paid". &lt;code&gt;classifyReceipt&lt;/code&gt; only returns &lt;code&gt;PAID&lt;/code&gt; when the receipt carries the settle event &lt;em&gt;and&lt;/em&gt; a system-emitter Transfer from the contract to that payee for that exact amount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SYSTEM_EMITTER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TRANSFER_TOPIC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transfer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;SYSTEM_EMITTER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;topics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;topics&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="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;TRANSFER_TOPIC&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nf"&gt;topicAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;topics&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nf"&gt;topicAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;topics&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="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;settle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Hex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;settle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no USDC Transfer log to the payee&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI stamps "INDEMNITY PAID" only on a &lt;code&gt;PAID&lt;/code&gt; result. One bug here is worth naming: an early version stored a rate-limited RPC read as a permanent failure. Unreadable receipts now retry with backoff instead of being recorded as unreadable for good.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the AI agent built, and what I did by hand
&lt;/h2&gt;

&lt;p&gt;The contract, its Foundry tests, the API routes and the React app came from Arc Studio turns, driven with the Studio CLI (&lt;code&gt;arc-studio run&lt;/code&gt; / &lt;code&gt;pull&lt;/code&gt;) and committed in order; each commit subject names its turn. Studio wrote the contract in turn 1, deployed it to Arc testnet and ran the Foundry suite there. Later turns built the app, the demo-provider and demo-buyer routes, the receipt classifier, and fixes such as Vercel's file tracer folding BigInt math on a null binding and breaking the build.&lt;/p&gt;

&lt;p&gt;What Studio did &lt;em&gt;not&lt;/em&gt; do: mainnet. Studio deploys to testnet only, so I deployed the unchanged Studio source with &lt;code&gt;forge create&lt;/code&gt; from my own keystore. &lt;code&gt;contracts/ReplyBond.sol&lt;/code&gt; has not changed since Studio turn 1, and Sourcify reports an exact match, which is the check that the bytecode on mainnet really is that source.&lt;/p&gt;

&lt;p&gt;The numbers: &lt;code&gt;forge test&lt;/code&gt; on the pinned forge 1.7.1 gives 66 passed, 0 failed (63 unit and fuzz tests plus 3 invariants at 256 runs); &lt;code&gt;bun test tests/&lt;/code&gt; gives 116 passed, 0 failed across 16 files. One run through the live app on mainnet covered all three settlement paths, each moving 0.015 USDC: &lt;a href="https://explorer.arc.io/tx/0xce43ecd7b5470259caf9658accb7197ecd1707a9b24584a978f0e8b3477de338" rel="noopener noreferrer"&gt;acknowledge&lt;/a&gt;, &lt;a href="https://explorer.arc.io/tx/0x7312e5db712a360e5b582e0f0afa0a9b96e4ce228d89036d6fcd5aae26783a77" rel="noopener noreferrer"&gt;timeout&lt;/a&gt; and &lt;a href="https://explorer.arc.io/tx/0x2c008eb8e69a417b781d6da6065b2b98d87cb40d2cfd6b950db0fe1a3fe2de6f" rel="noopener noreferrer"&gt;claim&lt;/a&gt;.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Where it stops being a guarantee
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delivery, not correctness.&lt;/strong&gt; The contract proves an output was delivered on-chain on time. It does not prove the output is right. Outputs are public and capped at 1,024 bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The demo provider is an echo service.&lt;/strong&gt; It answers with your input in capitals, so the demo proves timing and payment, not useful answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissionless is not automatic.&lt;/strong&gt; Someone has to send &lt;code&gt;timeout&lt;/code&gt; or &lt;code&gt;claim&lt;/code&gt;. The server does it for demo jobs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No always-on worker.&lt;/strong&gt; The demo provider answers only while some request runs the sweep (a page load, an API call, or the daily cron). A buyer who accepts a job and never calls the API can time it out and collect the bond. That is the timeout path working as designed against a provider that is not always on, and the demo bounds it with 20 new offers per hour, 80 per day and a 0.1 USDC wallet floor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unaudited&lt;/strong&gt;, and the demo fixes price, bond and windows rather than letting the buyer choose them per call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next on the list: an always-on provider worker, per-call deadlines and bonds, and a way to check answers so that correctness, not only delivery, can back a payout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Press "Settle a missed deadline now" on &lt;a href="https://replybond.edycu.dev:" rel="noopener noreferrer"&gt;https://replybond.edycu.dev:&lt;/a&gt; no wallet needed, it sends a real &lt;code&gt;timeout&lt;/code&gt; on Arc mainnet and stamps only once the receipt shows the 0.015 USDC Transfer. Or open &lt;a href="https://replybond.edycu.dev/app" rel="noopener noreferrer"&gt;https://replybond.edycu.dev/app&lt;/a&gt;, connect a wallet on Arc mainnet and "Pay &amp;amp; call" for 0.010 USDC.&lt;/p&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/edycutjong/replybond" rel="noopener noreferrer"&gt;https://github.com/edycutjong/replybond&lt;/a&gt; · BUIDL: &lt;a href="https://dorahacks.io/build/50635" rel="noopener noreferrer"&gt;https://dorahacks.io/build/50635&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>solidity</category>
      <category>web3</category>
      <category>ai</category>
    </item>
    <item>
      <title>My AI agent's trade broke the limit. The refusal told it what would fit.</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sat, 31 Oct 2026 02:20:25 +0000</pubDate>
      <link>https://dev.to/edycutjong/my-ai-agents-trade-broke-the-limit-the-refusal-told-it-what-would-fit-3abm</link>
      <guid>https://dev.to/edycutjong/my-ai-agents-trade-broke-the-limit-the-refusal-told-it-what-would-fit-3abm</guid>
      <description>&lt;p&gt;I gave an AI agent a trading key, a $25 limit, and a Bitcoin short that was too big. The interesting part is not that my guardrail said no. It is what the agent did next.&lt;/p&gt;

&lt;p&gt;Codex CLI asked to short 0.0005 BTC on a perps testnet. That was about $40, over my $25 cap. The refusal it got back did not just say "denied". It said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the position this order leaves would be $40.11 notional, above the $25 cap per market
next safe action: reduce size to at most 0.00031 BTC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six seconds later Codex planned exactly 0.00031 BTC, the plan passed, and it filled on chain: &lt;a href="https://smk-dev-markets-explorer-hideki-euw4-uzavbo36ra-ez.a.run.app/tx/0x385c1486498227de49b628ab9f1e2dc6f0a44819793fbe45806a39058e018080" rel="noopener noreferrer"&gt;&lt;code&gt;0x385c1486…8080&lt;/code&gt;&lt;/a&gt;, block 31576050. Nobody told it that number. Its prompt said "stay under the policy"; the refusal supplied the rest.&lt;/p&gt;

&lt;p&gt;That is &lt;strong&gt;Pilot&lt;/strong&gt;, an MCP server I built for the dreamDEX perps bounty on Somnia testnet. Repo: &lt;a href="https://github.com/edycutjong/dreamdex-pilot" rel="noopener noreferrer"&gt;github.com/edycutjong/dreamdex-pilot&lt;/a&gt; · 72 s demo: &lt;a href="https://youtu.be/WpH8jww1u-g" rel="noopener noreferrer"&gt;youtu.be/WpH8jww1u-g&lt;/a&gt;&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The problem: a key is all or nothing
&lt;/h2&gt;

&lt;p&gt;If you hand an agent an exchange SDK and a private key, it can send any order the exchange accepts. The usual fix is a wrapper that checks each order and throws when one breaks a rule. That stops the bad order, but it leaves the agent stuck: it knows it failed, not what would have worked. So it guesses, retries, or gives up and asks you.&lt;/p&gt;

&lt;p&gt;An agent can only act on what a tool returns. So I treated the refusal as the product, and gave it three jobs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;say which rule broke, in numbers,&lt;/li&gt;
&lt;li&gt;name a next safe action the agent can call as-is,&lt;/li&gt;
&lt;li&gt;say plainly when no order fits, so the agent stops instead of looping.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The rule is a pure function
&lt;/h2&gt;

&lt;p&gt;Every limit check in Pilot goes through one pure function: facts in, violations out. Each violation carries a &lt;code&gt;rule&lt;/code&gt;, a &lt;code&gt;detail&lt;/code&gt; and a &lt;code&gt;next&lt;/code&gt;. From &lt;code&gt;src/policy.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pure: facts in, violations out. Each violation names the rule and the next safe action.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;evaluate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowedMarkets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;market&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allowedMarkets&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;market&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is not on the allowed list (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowedMarkets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`pick one of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowedMarkets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notionalUsd&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxNotionalUsd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;maxNotionalUsd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`the position this order leaves would be $&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notionalUsd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="s2"&gt; notional, above the $&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxNotionalUsd&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; cap per market`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;capNext&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s2"&gt;`reduce size to at most &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxSizeUnderCap&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;the cap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;liquidationDistancePct&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;liquidationDistancePct&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minLiquidationDistancePct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;minLiquidationDistancePct&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`liquidation would sit &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;liquidationDistancePct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="s2"&gt;% from mark, closer than the &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minLiquidationDistancePct&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;% minimum`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use a smaller size (less leverage)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ordersToday&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dailyOrderCap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dailyOrderCap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ordersToday&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; orders already sent today (UTC), cap &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dailyOrderCap&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wait for the next UTC day or raise the cap in policy.json yourself&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;v&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;Because it is pure, the same function runs twice per order: once when the agent plans, and again right before the send, on a fresh read of the policy file. Limits you tighten after a plan still apply.&lt;/p&gt;

&lt;h2&gt;
  
  
  "At most 0.00031" has to be a real number
&lt;/h2&gt;

&lt;p&gt;A next step is only useful if the agent can send it unchanged. Two details made that true.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cap is on the position the order leaves, not the order.&lt;/strong&gt; Otherwise five $10 orders walk straight past a $25 cap. So the room under the cap is computed from the current position, and the suggested size is that room, floored to the market's lot size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cap is priced at the worst fill.&lt;/strong&gt; A buy pays at most its limit price. A sell fills from the top bid down, so valuing a short at its limit would let it pass the cap and then fill above it. From &lt;code&gt;src/trader.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Notional (the cap, notional_usd sizing) is valued at the highest price the order can fill at:&lt;/span&gt;
&lt;span class="c1"&gt;// a buy pays at most its limit, but a sell fills at the bids from the top bid down to its limit,&lt;/span&gt;
&lt;span class="c1"&gt;// so at its limit a short would pass the cap and then fill above it.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;capPx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;isBid&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;touch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bid&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;touch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The re-planned short came back at $24.86 notional, under the cap, with liquidation 189.78% from mark.&lt;/p&gt;

&lt;h2&gt;
  
  
  When nothing fits, say so
&lt;/h2&gt;

&lt;p&gt;The harder case is when the room left is real but useless. In another run, 0.2 SOL was already open and Claude Code was told to plan a 0.1 SOL add, about $10 and under $25 on its own. Pilot refused it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the position this order leaves would be $30.80 notional, above the $25 cap per market
next safe action: no SOL order fits under the cap: the room left is 0.04 SOL, below the SOL minimum 0.1; close_position or a reducing order frees room
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Reduce size to at most 0.04 SOL" would have been a trap: the exchange minimum is 0.1, so the agent would plan it, get refused again, and loop. The code checks for that and switches the message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Room under the cap that is below the market minimum is no next step at all: say no order fits.&lt;/span&gt;
&lt;span class="nx"&gt;capNext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;minQ&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`no &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;mk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;baseSymbol&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; order fits under the cap: the room left is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&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="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;mk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;baseSymbol&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, below the &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;mk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;baseSymbol&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; minimum &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;minQ&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;leaning&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="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`close_position or a reducing order frees room`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`pick another market or raise maxNotionalUsd in policy.json yourself`&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the last branch. When the only fix is a bigger limit, the message says &lt;em&gt;you&lt;/em&gt; raise it in &lt;code&gt;policy.json&lt;/code&gt;. No Pilot tool can write that file, so the agent cannot talk itself into more room. (In that run the prompt also scripted the close, so I don't claim the agent chose it.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Plan, then a one-shot ticket
&lt;/h2&gt;

&lt;p&gt;The refuse → re-plan loop only works if what gets sent is what got checked. So &lt;code&gt;plan_order&lt;/code&gt; never sends anything. An ok plan returns a &lt;code&gt;plan_id&lt;/code&gt; that lives 60 seconds and works once. &lt;code&gt;place_order&lt;/code&gt; takes only that id, and claims it before any &lt;code&gt;await&lt;/code&gt;, so two parallel calls cannot both use it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;refuse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknownPlan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;call plan_order first&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;plan_id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;refuse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;planAlreadyUsed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`plan &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;plan_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; was already used: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;call plan_order for a new plan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;plan_id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// Claimed synchronously, before any await: two parallel calls with one plan_id cannot both pass.&lt;/span&gt;
&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&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;Even these refusals carry a &lt;code&gt;next&lt;/code&gt;. A reused id is told what became of the first use ("it was sent as tx 0x…"), which stops an agent from resending an order that already landed. At send time Pilot re-reads the live book, the position, and the SDK's liquidation and margin previews, and the limit price can only tighten.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;The recorded runs, every transaction re-read from the RPC and listed in &lt;a href="https://github.com/edycutjong/dreamdex-pilot/blob/main/DEMO.md" rel="noopener noreferrer"&gt;&lt;code&gt;DEMO.md&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;9&lt;/strong&gt; orders opened and &lt;strong&gt;9&lt;/strong&gt; closed through the MCP tools, plus 2 brackets and 2 cancels&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;15&lt;/strong&gt; refusals with nothing sent, across &lt;strong&gt;7&lt;/strong&gt; distinct rules&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;215&lt;/strong&gt; tests, plus one opt-in live integration check&lt;/li&gt;
&lt;li&gt;two different agents, Claude Code and Codex CLI, on the same server code&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Testnet only.&lt;/strong&gt; Nothing has run on mainnet. Use a burner key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pilot guards its own tools, not the whole agent.&lt;/strong&gt; Claude Code and Codex both have shell and file tools. An agent that keeps those could edit the policy file or read the key from its environment. For a trading session, turn those tools off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plans live in memory.&lt;/strong&gt; Restarting the server drops them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The next safe action reflects the book at plan time.&lt;/strong&gt; The run above was on an older commit. Pilot now prices shorts at the top bid, so the same order would read about $40.51, and the suggested size would be slightly smaller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A close is a plain IOC order.&lt;/strong&gt; If something else shrinks the position mid-close, it can flip. Pilot reads the position back and reports &lt;code&gt;flipped&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If an agent is going to work within your limits, a refusal is half of the interface. A refusal that names a size the agent can send unchanged turns a dead end into one more tool call. And a refusal that says "nothing fits, a human has to change this" stops the loop before it starts.&lt;/p&gt;

&lt;p&gt;The code is MIT: &lt;a href="https://github.com/edycutjong/dreamdex-pilot" rel="noopener noreferrer"&gt;github.com/edycutjong/dreamdex-pilot&lt;/a&gt;. &lt;code&gt;node bin/pilot.mjs journal --card&lt;/code&gt; prints your own receipts if you try it.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>mcp</category>
      <category>web3</category>
    </item>
    <item>
      <title>Ghosts in my Mac: the apps are gone, but five root helpers stayed</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Fri, 30 Oct 2026 20:51:53 +0000</pubDate>
      <link>https://dev.to/edycutjong/ghosts-in-my-mac-the-apps-are-gone-but-five-root-helpers-stayed-17jn</link>
      <guid>https://dev.to/edycutjong/ghosts-in-my-mac-the-apps-are-gone-but-five-root-helpers-stayed-17jn</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;There are five programs on my Mac that are set up to run as root, for apps I no longer have. The oldest one's plist was last modified more than four years ago. I found them by writing a ghost hunter.&lt;/p&gt;

&lt;p&gt;Here's how that happens. Some apps ask for your admin password once and install a small privileged helper, plus a launchd plist that tells macOS how to start it. Drag the app to the Trash, and the helper and its plist can stay. Nothing on screen tells you they're there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;hantu&lt;/strong&gt; (Indonesian for &lt;em&gt;ghost&lt;/em&gt;) is a read-only command-line scanner for macOS. It reads every launchd plist in the three folders where apps put them, works out which program each one would start, and asks two questions: is that program still on disk, and does any installed app still claim it?&lt;/p&gt;

&lt;p&gt;The theme is Ghost in the Machine, and I wanted ghosts I could count. This is one read-only run on my own Mac, on October 31:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Launch plists scanned&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ghosts&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ghosts that are launch daemons with no &lt;code&gt;UserName&lt;/code&gt;, so launchd starts them as root&lt;/td&gt;
&lt;td&gt;5 of 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oldest ghost&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1549 days&lt;/strong&gt; since its plist was last modified&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plists pointing at a missing program&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Couldn't tell either way&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I expected the opposite: a pile of dead links, plists pointing at programs that no longer exist. Harmless clutter. There were none.&lt;/p&gt;

&lt;p&gt;Every ghost still had its program on disk, in &lt;code&gt;/Library/PrivilegedHelperTools&lt;/code&gt;, with a plist in &lt;code&gt;/Library/LaunchDaemons&lt;/code&gt; that tells launchd how to start it as root. Only the apps were gone. Three of the five come from vendors you'll know: a proxy helper from Charles (&lt;code&gt;com.xk72.charles.ProxyHelper&lt;/code&gt;, 1188 days) and two Adobe helpers, one of them &lt;code&gt;com.adobe.ARMDC.Communicator&lt;/code&gt; (695 days).&lt;/p&gt;

&lt;p&gt;hantu is for anyone who has used a Mac for a few years and tried apps they later removed. That's most of us.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftkw8b56fgjpxpzqd2lj1.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftkw8b56fgjpxpzqd2lj1.gif" alt="hantu scanning my Mac: 5 root helpers that no installed app declares, then Gemma explaining the two the tool may name" width="800" height="650"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Five root helpers that no installed app declares, and Gemma explaining the two the tool may name: a 23-second clip of real runs on my Mac, with the CLI's own &lt;code&gt;--redact&lt;/code&gt; on. The opening and closing cards are printed by the recorder; the opening card's numbers come from a live scan made a moment before. The only cut is 6.9 seconds of waiting for Gemma, labelled on screen. Links: &lt;a href="https://github.com/edycutjong/hantu/blob/main/docs/record_demo.py" rel="noopener noreferrer"&gt;recorder script&lt;/a&gt;, &lt;a href="https://github.com/edycutjong/hantu/blob/main/evidence/demo-run.md" rel="noopener noreferrer"&gt;every line the clip shows&lt;/a&gt;, &lt;a href="https://github.com/edycutjong/hantu/blob/main/docs/demo.mp4" rel="noopener noreferrer"&gt;MP4&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The same scan as text you can copy. A wide terminal prints full paths; the clip's narrow one folds a helper named after its label to &lt;code&gt;(same name)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;git+https://github.com/edycutjong/hantu
hantu scan &lt;span class="nt"&gt;--ghosts&lt;/span&gt; &lt;span class="nt"&gt;--redact&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--redact&lt;/code&gt; names labels only from a short list of well-known vendors, one per vendor, so three are withheld:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HALF-GHOST  1549d  [label withheld]
HALF-GHOST  1188d  com.xk72.charles.ProxyHelper  -&amp;gt;  /Library/PrivilegedHelperTools/com.xk72.charles.ProxyHelper
                  privileged helper still on disk, but no installed app declares it (SMPrivilegedExecutables)
HALF-GHOST  1096d  [label withheld]
HALF-GHOST   695d  com.adobe.ARMDC.Communicator  -&amp;gt;  /Library/PrivilegedHelperTools/com.adobe.ARMDC.Communicator
                  privileged helper still on disk, but no installed app declares it (SMPrivilegedExecutables)
HALF-GHOST   695d  [label withheld]

58 plists: 0 orphaned, 5 half-ghost, 44 alive, 9 unknown. Nothing was changed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;hantu explain&lt;/code&gt; sends each ghost's plist to Gemma 4 31B (with &lt;code&gt;--redact&lt;/code&gt;, only the two it may name) and gets back one sentence and a risk score from 1 to 5. These are from the earlier run I saved. Gemma's wording and scores change between runs: the clip above is a later take that scored both 4/5. And "trying to run" is Gemma's phrasing, not a fact hantu checked: it never asks launchd whether these root daemons are loaded.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Charles&lt;/strong&gt;, risk 3/5: "This belonged to Charles Proxy and is trying to run a privileged network proxy helper."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adobe&lt;/strong&gt;, risk 3/5: "This belonged to Adobe software and is trying to run a privileged communication service."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The run's redacted write-up is in &lt;a href="https://github.com/edycutjong/hantu/blob/main/evidence/gemma-run.md" rel="noopener noreferrer"&gt;&lt;code&gt;evidence/gemma-run.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/edycutjong/hantu" rel="noopener noreferrer"&gt;github.com/edycutjong/hantu&lt;/a&gt;&lt;/strong&gt;: MIT, standard-library Python (tested on 3.11), 86 tests.&lt;/p&gt;

&lt;p&gt;The verdict is plain code. Here's the check for a privileged helper nobody claims, from &lt;code&gt;hantu/scan.py&lt;/code&gt;:&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;elif&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HELPER_DIR&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;os&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="nf"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;helpers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;associated_installed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;ag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;HALF_GHOST&lt;/span&gt;
    &lt;span class="n"&gt;ag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;privileged helper still on disk, but no installed app declares it (SMPrivilegedExecutables)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ag&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;index.helpers&lt;/code&gt; is built from every installed app's &lt;code&gt;Info.plist&lt;/code&gt;: the helpers it declares in &lt;code&gt;SMPrivilegedExecutables&lt;/code&gt; and the ones it ships in &lt;code&gt;Contents/Library/LaunchServices&lt;/code&gt;. So if a helper sits in &lt;code&gt;/Library/PrivilegedHelperTools&lt;/code&gt;, its plist names no owning app, and no installed app declares or ships it, hantu calls it a ghost.&lt;/p&gt;

&lt;p&gt;The 79 pytest tests run against fixture plists, and the suite fails any test that tries to reach the network.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Code decides, the model explains.&lt;/strong&gt; I didn't want a language model deciding whether a root daemon is abandoned. A file check and an &lt;code&gt;Info.plist&lt;/code&gt; lookup can answer that, and they can be tested. What a model is good at is reading a plist full of &lt;code&gt;MachServices&lt;/code&gt;, &lt;code&gt;Sockets&lt;/code&gt; and &lt;code&gt;RunAtLoad&lt;/code&gt; keys and saying in one sentence what the thing was for. So there are four verdicts, all set in code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ORPHANED&lt;/code&gt;: the program launchd would start is missing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;HALF-GHOST&lt;/code&gt;: the program is there, but no installed app claims it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ALIVE&lt;/code&gt;: the program is there and nothing shows its owner is gone.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UNKNOWN&lt;/code&gt;: a malformed plist, a relative path, a disk that may just be unmounted. Never counted as a ghost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Gemma 4 31B, open weights.&lt;/strong&gt; &lt;code&gt;hantu explain&lt;/code&gt; calls &lt;code&gt;google/gemma-4-31b-it&lt;/code&gt; through OpenRouter with a small standard-library HTTP client. The prompt says the classification is already made and not to question it. The reply has to be strict JSON: an id, one sentence, a risk from 1 to 5, a short reason. hantu checks every item (id in range, risk an integer from 1 to 5, text not empty, length capped) and drops what fails. Nothing in the reply can change a verdict.&lt;/p&gt;

&lt;p&gt;What came back was honest and a little flat. Gemma gave one ghost 4 out of 5: the oldest, and the only one set to run at load. Its reason: "Privileged daemon that runs at load and creates a system socket." The other four got 3 out of 5 with the same reason, word for word: "Runs as a privileged daemon with root-level access." That isn't much of a ranking. But the one outlier it found is a real difference.&lt;/p&gt;

&lt;p&gt;The free Gemma endpoints answered HTTP 429 ("temporarily rate-limited upstream") every time I tried them that night. hantu backs off, retries, and falls back from the 31B model to the 26B one, and both stayed busy. So I ran &lt;code&gt;hantu explain --model google/gemma-4-31b-it&lt;/code&gt;, the same weights through OpenRouter's paid listing. It took about 13 seconds and cost $0.00046.&lt;/p&gt;

&lt;p&gt;An early version of the half-ghost rule, before the first commit, flagged macFUSE. It isn't a ghost: macFUSE keeps its app inside a filesystem bundle under &lt;code&gt;/Library/Filesystems&lt;/code&gt;. hantu looks there now. That's why the README calls HALF-GHOST a rule, not a proof, and why cleanup never touches half-ghosts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Safety and privacy.&lt;/strong&gt; A plain &lt;code&gt;hantu scan&lt;/code&gt; never writes and never sends anything anywhere. Nothing changes unless you pass both &lt;code&gt;--clean&lt;/code&gt; and &lt;code&gt;--yes&lt;/code&gt;. Even then, hantu only moves &lt;code&gt;ORPHANED&lt;/code&gt; plists out of your own &lt;code&gt;~/Library/LaunchAgents&lt;/code&gt; into a quarantine folder with a manifest, and runs &lt;code&gt;launchctl bootout&lt;/code&gt; if the agent was loaded. Undo is &lt;code&gt;mv&lt;/code&gt;. That path is tested on fixture plists in temp folders; I haven't run it on a real machine. My five ghosts are root daemons under &lt;code&gt;/Library&lt;/code&gt;, so hantu won't touch them at all. Removing them is a decision for a human with &lt;code&gt;sudo&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;explain&lt;/code&gt; does send data off your Mac: each ghost's plist (capped at 1200 characters), file name, age, verdict and reason go to OpenRouter and the Gemma host. Before sending, your home folder becomes &lt;code&gt;~&lt;/code&gt;, environment variable values are masked, and identity keys such as &lt;code&gt;UserName&lt;/code&gt; are dropped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it doesn't do.&lt;/strong&gt; It doesn't scan Login Items, which needs privileges or a permission prompt. It can't tell whether a ghost daemon is loaded right now; that needs root too. And an app installed somewhere hantu doesn't look will make its helper look like a ghost, so read the line before you delete anything.&lt;/p&gt;

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

&lt;p&gt;A tool that inspects root daemons should be readable. hantu is under a thousand lines of standard-library Python, MIT-licensed, with no dependencies. You can read every line that touches your system before you run it. I didn't want to clean up after closed apps with root helpers by installing another closed app with a root helper.&lt;/p&gt;

&lt;p&gt;Open weights turned the rate limit into a detour. When the free endpoint was busy, I changed one flag and the same Gemma answered from another listing. When I checked that night, OpenRouter listed 13 providers serving those weights. That only works because the weights are public: any host can serve them, so one busy endpoint isn't the end of the road.&lt;/p&gt;

&lt;p&gt;Open weights are also what make the next step possible: running Gemma on the Mac itself, so the plists never leave it. hantu doesn't do that yet; today &lt;code&gt;explain&lt;/code&gt; uses a hosted endpoint, and the README says so. But the model only ever receives text and returns JSON, and the code makes every decision, so any open model can be swapped in and the verdicts stay exactly the same. The whole chain from plist to verdict is code you can read and tests you can run.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;I built hantu with Claude Code as my coding agent, under a few hard rules: only ever read the real machine, never delete or unload anything on it, test the cleanup on fixtures only, and put no number in the README that a committed run didn't produce. This post was drafted the same way, and every number in it was checked against the repo before posting.&lt;/p&gt;

&lt;p&gt;There's no DevRelay session to embed. The &lt;a href="https://github.com/edycutjong/hantu/commits/main" rel="noopener noreferrer"&gt;commit history&lt;/a&gt; shows the order of the work: the read-only scanner, then the Gemma step and the quarantine-only cleanup, then the CLI and report, then the README checked against a real run, and last the real Gemma output. It shows the order, not the timing: the first four commits were made together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spirit of It
&lt;/h2&gt;

&lt;p&gt;Halloween and Día de los Muertos are both about what stays after something is gone. On my Mac, what stayed was five root helpers, each with a plist that still tells launchd how to start it, for apps that are no longer installed.&lt;/p&gt;

&lt;p&gt;The moment it landed was one summary line: &lt;code&gt;0 orphaned, 5 half-ghost&lt;/code&gt;. I had expected broken links. I got real programs, set up to run as root, with nobody left to own them.&lt;/p&gt;

&lt;p&gt;It's Halloween. Run &lt;a href="https://github.com/edycutjong/hantu" rel="noopener noreferrer"&gt;hantu&lt;/a&gt; and see who still lives in your house. A plain scan only reads.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 31B is the model behind &lt;code&gt;hantu explain&lt;/code&gt;. It reads each ghost's plist and returns validated JSON, one plain-English sentence and a coarse risk score, and hantu sorts the list by that score. On my Mac it picked out the one ghost set to run at load from four ties. The code decides what is a ghost; Gemma tells you what it was.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My Mac was 99% full, so I let Gemma judge my junk (on a short leash)</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sun, 25 Oct 2026 23:49:22 +0000</pubDate>
      <link>https://dev.to/edycutjong/my-mac-was-99-full-so-i-let-gemma-judge-my-junk-on-a-short-leash-6ga</link>
      <guid>https://dev.to/edycutjong/my-mac-was-99-full-so-i-let-gemma-judge-my-junk-on-a-short-leash-6ga</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;One evening this week, &lt;code&gt;df -h&lt;/code&gt; said my MacBook had &lt;strong&gt;11Gi available out of 926Gi. 99% full.&lt;/strong&gt; About twenty minutes earlier, a full real run of my new tool had found &lt;strong&gt;118.5 GB&lt;/strong&gt; of caches, build folders and temp files, marked &lt;strong&gt;34.2 GB&lt;/strong&gt; of it safe to delete, and deleted none of it. That last part is on purpose.&lt;/p&gt;

&lt;p&gt;I knew roughly where the space had gone: &lt;code&gt;node_modules&lt;/code&gt; everywhere (the scan counted 63 &lt;code&gt;node_modules&lt;/code&gt; folders and kept 42 of them), a Gradle cache, browser caches, &lt;code&gt;.next&lt;/code&gt; builds, and a pnpm store the size of a small country. Cleaning that by hand is an afternoon of &lt;code&gt;du -sh * | sort -h&lt;/code&gt; and second-guessing every &lt;code&gt;rm -rf&lt;/code&gt;. My inner sloth said no. It also wouldn't install a "cleaner" that guesses: guess wrong and you rebuild real work, which is the opposite of lazy.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;sweep&lt;/strong&gt;, for developers whose laptops quietly fill up with &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;target/&lt;/code&gt;, &lt;code&gt;.venv&lt;/code&gt; and package caches. One command finds the regenerable junk on a Mac, asks an open-weight model (Gemma 4, 31B) which of it is safe to delete, and prints a plan. It is a dry run by default. &lt;code&gt;sweep --apply&lt;/code&gt; deletes only the SAFE items, and only after you type the size back to it, like &lt;code&gt;DELETE 34.2 GB&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That real run on my Mac:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Items found&lt;/td&gt;
&lt;td&gt;167&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Found&lt;/td&gt;
&lt;td&gt;118.5 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;SAFE&lt;/strong&gt; (by &lt;code&gt;du&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;45 items · 34.2 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ASK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20 items · 33.6 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KEEP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;102 items · 50.7 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wall time&lt;/td&gt;
&lt;td&gt;202.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deleted&lt;/td&gt;
&lt;td&gt;nothing (dry run)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fedycutjong%2Fsweep%2Fmain%2Fdocs%2Fdemo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fedycutjong%2Fsweep%2Fmain%2Fdocs%2Fdemo.gif" alt="sweep: a real dry run on a full Mac, from df to the SAFE / ASK / KEEP plan" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A second real dry run on the same Mac, about 50 minutes later: &lt;code&gt;df -h ~&lt;/code&gt;, then &lt;code&gt;sweep --redact --top 5&lt;/code&gt; (&lt;code&gt;--redact&lt;/code&gt; swaps the names under your home for labels like &lt;code&gt;&amp;lt;project-007&amp;gt;&lt;/code&gt;). Most of the 3 min 17 s scan and of the 13 s of Gemma calls are cut, and each cut is labelled. This run found 111.7 GB in 145 items, not 118.5 GB in 167: the two scans saw different sets of items; &lt;a href="https://github.com/edycutjong/sweep/blob/main/evidence/demo-run-2026-10-25.md" rel="noopener noreferrer"&gt;this run's numbers&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The plan groups items under SAFE, ASK and KEEP, each with its size and a one-line reason (the top 15 per group by default), and &lt;code&gt;--json&lt;/code&gt; lists every item with who decided. Some real verdicts from that run (project names are redacted in the published evidence):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Decided by&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10.5 GB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/.gradle/caches&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SAFE&lt;/td&gt;
&lt;td&gt;Gemma&lt;/td&gt;
&lt;td&gt;"Gradle build caches are regenerable."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.0 GB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/Library/Caches/BraveSoftware&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SAFE&lt;/td&gt;
&lt;td&gt;Gemma&lt;/td&gt;
&lt;td&gt;"Very old browser cache."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14.9 GB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/Library/pnpm/store&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ASK&lt;/td&gt;
&lt;td&gt;Gemma&lt;/td&gt;
&lt;td&gt;"Large pnpm store; deleting may require re-downloading many dependencies."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.4 GB&lt;/td&gt;
&lt;td&gt;a project's &lt;code&gt;node_modules&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;ASK&lt;/td&gt;
&lt;td&gt;Gemma&lt;/td&gt;
&lt;td&gt;"Relatively recent activity (32 days ago) and large size; user should confirm if still needed."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14.0 GB&lt;/td&gt;
&lt;td&gt;Docker&lt;/td&gt;
&lt;td&gt;ASK&lt;/td&gt;
&lt;td&gt;rule&lt;/td&gt;
&lt;td&gt;never deleted by sweep; it prints &lt;code&gt;docker system prune&lt;/code&gt; for you to run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.8 GB&lt;/td&gt;
&lt;td&gt;a project's &lt;code&gt;node_modules&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;KEEP&lt;/td&gt;
&lt;td&gt;rule&lt;/td&gt;
&lt;td&gt;its repo has uncommitted changes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;sweep --json&lt;/code&gt; gives the same plan as data. Two real entries from its &lt;code&gt;items&lt;/code&gt; list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&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="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/.gradle/caches"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"build-cache"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bytes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10546253824&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"safe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Gradle build caches are regenerable."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"decided_by"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"model"&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 class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/&amp;lt;dir&amp;gt;/&amp;lt;project-001&amp;gt;/node_modules"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node_modules"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bytes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3778359296&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"keep"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"its repo has uncommitted changes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"decided_by"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rule"&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 class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The full report, with every number: &lt;a href="https://github.com/edycutjong/sweep/blob/main/evidence/dry-run-2026-10-25.md" rel="noopener noreferrer"&gt;evidence/dry-run-2026-10-25.md&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/edycutjong" rel="noopener noreferrer"&gt;
        edycutjong
      &lt;/a&gt; / &lt;a href="https://github.com/edycutjong/sweep" rel="noopener noreferrer"&gt;
        sweep
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A lazy disk-cleanup agent for macOS: an open-weight model (Gemma) judges what is safe; nothing is deleted without your OK.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div&gt;
  &lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;sweep 🧹&lt;/h1&gt;
&lt;/div&gt;


&lt;p&gt;&lt;em&gt;A lazy disk-cleanup agent for macOS. It finds the junk, asks an open-weight model what is safe, and never deletes without your typed OK.&lt;/em&gt;&lt;/p&gt;
&lt;br&gt;
  &lt;br&gt;

&lt;p&gt;&lt;a href="https://dev.to/devteam/join-the-hacktoberfest-open-source-ai-challenge-slow-and-steady-2850-in-prizes-across-21-3iee" rel="nofollow"&gt;&lt;img src="https://camo.githubusercontent.com/41cd115b51fd1a6e8f72186f75078d6db88913f1be907d792f5068e5f2ad38ae/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4445562d4861636b746f626572666573745f41495f4368616c6c656e67652d3862356366363f7374796c653d666f722d7468652d6261646765" alt="Hacktoberfest AI Challenge"&gt;&lt;/a&gt;
&lt;a href="https://github.com/edycutjong/sweep/evidence/dry-run-2026-10-25.md" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/ba56c916386c7bd62fbe6503c3636a43ea4046468bf4fa1e234192d1d1653a2f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f2546302539462539332538415f5265616c5f4472792d52756e2d6635396530623f7374796c653d666f722d7468652d6261646765" alt="Real dry run"&gt;&lt;/a&gt;&lt;/p&gt;
  &lt;br&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/a2bc6b48df40c5af17e165e6b92f2bb7e2a8a0e2f5cf507a933aab255f4596dd/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f507974686f6e5f332e31312b2d3337373641423f7374796c653d666c6174266c6f676f3d707974686f6e266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/a2bc6b48df40c5af17e165e6b92f2bb7e2a8a0e2f5cf507a933aab255f4596dd/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f507974686f6e5f332e31312b2d3337373641423f7374796c653d666c6174266c6f676f3d707974686f6e266c6f676f436f6c6f723d7768697465" alt="Python"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/9dcf299beab964305b934820836775457efd4b7f62713b9414a377e91d01841c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f47656d6d615f345f3331422d3432383546343f7374796c653d666c6174266c6f676f3d676f6f676c65266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/9dcf299beab964305b934820836775457efd4b7f62713b9414a377e91d01841c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f47656d6d615f345f3331422d3432383546343f7374796c653d666c6174266c6f676f3d676f6f676c65266c6f676f436f6c6f723d7768697465" alt="Gemma"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/49ee0462cc79f52c58e16fcde9f90f7006a406c7f83e942bbb17fad83d81d327/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4f70656e526f757465722d3131313131313f7374796c653d666c6174"&gt;&lt;img src="https://camo.githubusercontent.com/49ee0462cc79f52c58e16fcde9f90f7006a406c7f83e942bbb17fad83d81d327/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4f70656e526f757465722d3131313131313f7374796c653d666c6174" alt="OpenRouter"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/babf29c7775821f356f867278a28a943a55531b873e2e1e0c0ed40469d50a527/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6d61634f535f6f6e6c792d3030303030303f7374796c653d666c6174266c6f676f3d6170706c65266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/babf29c7775821f356f867278a28a943a55531b873e2e1e0c0ed40469d50a527/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6d61634f535f6f6e6c792d3030303030303f7374796c653d666c6174266c6f676f3d6170706c65266c6f676f436f6c6f723d7768697465" alt="macOS"&gt;&lt;/a&gt;
&lt;a href="https://github.com/edycutjong/sweep/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/8174925d009b42074d50ab5cc7e29fcb1aa613b0d9cb2e43097697a40cf90fa4/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d79656c6c6f77" alt="License"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;macOS only for now — adding Linux or Windows is a small pull request, PRs welcome.&lt;/strong&gt; Every cache location lives in one file, &lt;a href="https://github.com/edycutjong/sweep/src/sweep/catalogue.py" rel="noopener noreferrer"&gt;&lt;code&gt;src/sweep/catalogue.py&lt;/code&gt;&lt;/a&gt;; a port also touches &lt;code&gt;rules.py&lt;/code&gt; (protected paths) and &lt;code&gt;reminder.py&lt;/code&gt; (launchd).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;📸 See it in Action&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/edycutjong/sweep/docs/demo.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fedycutjong%2Fsweep%2FHEAD%2Fdocs%2Fdemo.gif" alt="sweep: a real dry run on a full Mac, from df to the SAFE / ASK / KEEP plan"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;A second real dry run on the same Mac, about 50 minutes after the evidence run below: &lt;code&gt;df -h ~&lt;/code&gt;, then &lt;code&gt;sweep --redact --top 5&lt;/code&gt; (&lt;code&gt;--redact&lt;/code&gt; swaps the names under your home for labels like &lt;code&gt;&amp;lt;project-007&amp;gt;&lt;/code&gt;). Most of the 3 min 17 s scan and of the 13 s of model calls are cut, and each cut is labelled. This run's numbers: &lt;a href="https://github.com/edycutjong/sweep/evidence/demo-run-2026-10-25.md" rel="noopener noreferrer"&gt;demo-run-2026-10-25.md&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sweep&lt;/code&gt; is a dry run by default. This is the real result on the author's own MacBook…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/edycutjong/sweep" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Repo: &lt;a href="https://github.com/edycutjong/sweep" rel="noopener noreferrer"&gt;github.com/edycutjong/sweep&lt;/a&gt;&lt;/strong&gt; (MIT, Python 3.11+, 64 tests)&lt;/p&gt;

&lt;p&gt;You need &lt;a href="https://docs.astral.sh/uv/" rel="noopener noreferrer"&gt;uv&lt;/a&gt;, macOS, and an OpenRouter key in &lt;code&gt;OPENROUTER_API_KEY&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv tool &lt;span class="nb"&gt;install &lt;/span&gt;git+https://github.com/edycutjong/sweep

sweep            &lt;span class="c"&gt;# dry run: prints the plan, deletes nothing&lt;/span&gt;
sweep &lt;span class="nt"&gt;--json&lt;/span&gt;     &lt;span class="c"&gt;# the same plan as JSON (never deletes)&lt;/span&gt;
sweep &lt;span class="nt"&gt;--apply&lt;/span&gt;    &lt;span class="c"&gt;# delete the SAFE items after a typed confirmation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  The model never gets the last word on danger
&lt;/h3&gt;

&lt;p&gt;A language model next to &lt;code&gt;rm -rf&lt;/code&gt; is a scary combination, so sweep splits the job in two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plain code goes first.&lt;/strong&gt; &lt;code&gt;rules.py&lt;/code&gt; looks at each item and either settles it or sets a ceiling. Some rules are final, and the model never sees those items:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker images, containers and build cache: never deleted by sweep. It prints &lt;code&gt;docker system prune&lt;/code&gt; for you to run.&lt;/li&gt;
&lt;li&gt;A folder macOS won't let it read: ASK, never SAFE.&lt;/li&gt;
&lt;li&gt;Anything outside the allowed locations (Documents, Desktop, &lt;code&gt;~/.ssh&lt;/code&gt;, &lt;code&gt;~/.config&lt;/code&gt;, system folders, and more): KEEP.&lt;/li&gt;
&lt;li&gt;A project whose git repo has uncommitted changes, or a commit in the last 14 days: KEEP.&lt;/li&gt;
&lt;li&gt;A temp file modified in the last 24 hours: KEEP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else gets a cap. The cap is &lt;code&gt;safe&lt;/code&gt; if the item looks regenerable, and &lt;code&gt;ask&lt;/code&gt; if nothing next to it can rebuild it (say, a &lt;code&gt;node_modules&lt;/code&gt; with no &lt;code&gt;package.json&lt;/code&gt; beside it). Trash, Downloads and simulator data are capped at ASK no matter what.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then Gemma judges what's left.&lt;/strong&gt; The final verdict is the more cautious of the rule's cap and Gemma's answer, and a missing or invalid answer counts as ASK:&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;CAUTION&lt;/span&gt; &lt;span class="o"&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;safe&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ask&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keep&lt;/span&gt;&lt;span class="sh"&gt;"&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;more_cautious&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&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;a&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;CAUTION&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="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;CAUTION&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model can pull a SAFE down to ASK or KEEP, but it can never lift a KEEP to SAFE. A timeout, a garbage reply or a skipped item becomes ASK. So a model failure costs me a question, never a folder. A wrong SAFE inside a SAFE cap is still possible, which is why every line shows its reason, and why &lt;code&gt;--apply&lt;/code&gt; lists every target, re-checks each item right before deleting it (project folders get their git state re-read), and logs every path it removes or fails to remove.&lt;/p&gt;

&lt;p&gt;In the real run, a hard rule settled or capped 111 of the 167 items, and Gemma's own verdict stood for the other 56.&lt;/p&gt;

&lt;h3&gt;
  
  
  The contract: bare JSON, or it doesn't count
&lt;/h3&gt;

&lt;p&gt;Gemma sees metadata only: an id, a trimmed path (never the absolute one), the kind, size and age, and for project folders the git state (repo or not, last-commit age, dirty). Never file contents. The system prompt ends with the exact shape it must return:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"items":[{"id":"&amp;lt;input id&amp;gt;","verdict":"safe|ask|keep","reason":"&amp;lt;one short plain-English sentence&amp;gt;"}]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;parse_verdicts&lt;/code&gt; is strict on purpose. A reply wrapped in a code fence doesn't parse, so the whole batch becomes ASK. An entry with an extra key, an id I never sent, a duplicate id, or a reason over 200 characters is dropped, and a dropped entry becomes ASK:&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;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&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;e&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="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&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;verdict&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;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&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;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&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="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&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;good&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&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;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verdict&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;VERDICTS&lt;/span&gt; &lt;span class="ow"&gt;and&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;reason&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="ow"&gt;and&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;good&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verdict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verdict&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;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calls go out in batches of 10, at temperature 0, with &lt;code&gt;response_format: json_object&lt;/code&gt;, through plain &lt;code&gt;urllib&lt;/code&gt;. No SDK.&lt;/p&gt;

&lt;h3&gt;
  
  
  Picking the model: five open models, the same 20 real items
&lt;/h3&gt;

&lt;p&gt;Before writing the scanner I ran a spike: 20 real items from a read-only scan of my Mac (13 cache locations and 7 project folders), the same prompt for every model, 3 runs each. No rules ran here. This was the raw model.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Valid entries per run (of 20)&lt;/th&gt;
&lt;th&gt;p50 per 10-item call&lt;/th&gt;
&lt;th&gt;Unsafe SAFE verdicts*&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;google/gemma-4-31b-it&lt;/code&gt; (pinned)&lt;/td&gt;
&lt;td&gt;20 / 20 / 20&lt;/td&gt;
&lt;td&gt;7.35 s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;google/gemma-3-27b-it&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20 / 20 / 20&lt;/td&gt;
&lt;td&gt;7.67 s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openai/gpt-oss-120b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20 / 20 / 20&lt;/td&gt;
&lt;td&gt;8.01 s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;meta-llama/llama-3.3-70b-instruct&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20 / 20 / 20&lt;/td&gt;
&lt;td&gt;10.19 s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen/qwen3-235b-a22b-2507&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20 / 20 / 20&lt;/td&gt;
&lt;td&gt;14.76 s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;* "Unsafe SAFE" means SAFE on Downloads, simulator data, or a project folder whose repo is dirty or had a commit in the last 14 days. It is not a general safety score.&lt;/p&gt;

&lt;p&gt;All five cleared the JSON bar, so the tie-break was the part a human actually reads: the reasons. Gemma 4 explained regenerability ("Rust target directory is a regenerable build artifact."). Gemma 3's reasons leaned on size ("It's a large npm cache."). Gemma 4 was also the only one that marked my Trash ASK on its own: "Trash contains files the user explicitly deleted but may want to recover." Fair point, Gemma. It also had the lowest p50 of the five.&lt;/p&gt;

&lt;p&gt;The 64 &lt;code&gt;pytest&lt;/code&gt; tests cover the hard rules, the JSON contract and its failure fallbacks, the CLI, and &lt;code&gt;--apply&lt;/code&gt; against fixture folders only. They use a fake model and never call Gemma. The stack is Python 3.11, &lt;code&gt;uv&lt;/code&gt; and &lt;code&gt;rich&lt;/code&gt;. I built it with Claude Code as my pair programmer. The model inside the product is Gemma, and nothing closed sits in that path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Honest limits
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;macOS only for now.&lt;/strong&gt; Every cache location lives in &lt;code&gt;catalogue.py&lt;/code&gt;. A Linux or Windows port is a small pull request that also touches &lt;code&gt;rules.py&lt;/code&gt; and &lt;code&gt;reminder.py&lt;/code&gt;. PRs welcome, it's Hacktoberfest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted, not local.&lt;/strong&gt; Trimmed paths, kinds, sizes, ages and git state go to OpenRouter. File contents never do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not bit-deterministic.&lt;/strong&gt; The model id is pinned and the temperature is 0, but the serving provider isn't, and a re-run of the spike flipped 2 of 20 borderline verdicts (the pnpm store and the cargo registry, SAFE to ASK). Drift can't cross a rule's cap, but inside a SAFE cap two runs can differ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;34.2 GB is a &lt;code&gt;du&lt;/code&gt; number, not freed space.&lt;/strong&gt; pnpm and bun hard-link files, so deleting frees less. &lt;code&gt;--apply&lt;/code&gt; reports the measured change in free space.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's slow.&lt;/strong&gt; About 200 s on my Mac, for one &lt;code&gt;du&lt;/code&gt; per folder, the git reads and the model calls. Slow and steady, as the theme says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;14 macOS-protected folders were unreadable&lt;/strong&gt; without Full Disk Access. They stay ASK, never SAFE.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reasons are Gemma's judgement&lt;/strong&gt;, even when they sound like facts. &lt;code&gt;--json&lt;/code&gt; records &lt;code&gt;decided_by&lt;/code&gt; for every item.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;A tool that deletes things needs a model I can pin, re-run and swap more than it needs a clever one. Open weights gave me that.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I could compare before trusting.&lt;/strong&gt; Five open models from four labs, one prompt, one endpoint, one evening, then a pick from a table instead of vibes. Switching is one constant, &lt;code&gt;MODEL_ID&lt;/code&gt;. I only tested open models, so I can't tell you Gemma beats a closed one on accuracy. Here is what open gave me instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The judge can move onto your machine.&lt;/strong&gt; Today sweep calls Gemma on OpenRouter, because running a 31B model on a 99%-full laptop while &lt;code&gt;du&lt;/code&gt; crawls the disk was not the lazy option. But the &lt;a href="https://huggingface.co/google/gemma-4-31B-it" rel="noopener noreferrer"&gt;weights are on Hugging Face&lt;/a&gt;. Serve them behind a local OpenAI-compatible server, point &lt;code&gt;ENDPOINT&lt;/code&gt; and &lt;code&gt;MODEL_ID&lt;/code&gt; in &lt;code&gt;model.py&lt;/code&gt; at it (the client still expects some value in &lt;code&gt;OPENROUTER_API_KEY&lt;/code&gt;), and not even folder names leave the laptop. A closed model can be retired or quietly updated behind its name. A weights file I keep can't, so the spike above stays re-runnable against the exact same model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The judge can be checked.&lt;/strong&gt; The plan footer prints the exact model id, &lt;code&gt;--json&lt;/code&gt; marks which items it decided, and the weights are public, so anyone can run the same model on the same metadata and look. A closed API shows me a model name. Open weights let me re-run the model itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The safety rules are open too.&lt;/strong&gt; They are plain Python: 130 lines in &lt;code&gt;rules.py&lt;/code&gt;, the merge in &lt;code&gt;plan.py&lt;/code&gt;, the delete-time re-checks in &lt;code&gt;apply.py&lt;/code&gt;. What sweep may delete is one code review away, and changing it is one PR.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Lazy Payoff
&lt;/h2&gt;

&lt;p&gt;My total effort for that run: one command. sweep spent 202.6 s walking the disk, sizing 167 items, reading git state and asking Gemma about the rest, while I sat very still, sloth-style. It reported 34.2 GB (by &lt;code&gt;du&lt;/code&gt;) as SAFE, each item with a reason, 20 items it wanted me to look at, and 102 it refused to touch. One of those was a 3.8 GB &lt;code&gt;node_modules&lt;/code&gt; in a repo with uncommitted changes: exactly the folder a blanket &lt;code&gt;rm -rf&lt;/code&gt; of every &lt;code&gt;node_modules&lt;/code&gt; would have eaten.&lt;/p&gt;

&lt;p&gt;Doing this by hand means &lt;code&gt;du&lt;/code&gt; on every cache, then &lt;code&gt;git status&lt;/code&gt; in every project before touching its build folders. I didn't time myself doing that, because I didn't do it. That's the point.&lt;/p&gt;

&lt;p&gt;For the full sloth, &lt;code&gt;sweep --install-reminder&lt;/code&gt; writes a launchd job for a dry run every Sunday at 10:00, with one notification. It never deletes, and it doesn't switch itself on. It prints the &lt;code&gt;launchctl&lt;/code&gt; line that does.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma&lt;/strong&gt;: &lt;code&gt;google/gemma-4-31b-it&lt;/code&gt;, served through OpenRouter, is the judge for every item the hard rules leave open. It was picked over four other open models on reason quality and latency (table above).&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If your Mac is down to its last few gigabytes too, the dry run deletes nothing, so it's an easy thing to try (it does send trimmed paths and sizes to OpenRouter): &lt;strong&gt;&lt;a href="https://github.com/edycutjong/sweep" rel="noopener noreferrer"&gt;github.com/edycutjong/sweep&lt;/a&gt;&lt;/strong&gt;. On Linux or Windows? Send a PR for the catalogue.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>showdev</category>
      <category>gemma</category>
    </item>
    <item>
      <title>Making PayPal refuse the wrong charge: a scooter deposit as one authorization</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sun, 25 Oct 2026 05:27:33 +0000</pubDate>
      <link>https://dev.to/edycutjong/making-paypal-refuse-the-wrong-charge-a-scooter-deposit-as-one-authorization-5629</link>
      <guid>https://dev.to/edycutjong/making-paypal-refuse-the-wrong-charge-a-scooter-deposit-as-one-authorization-5629</guid>
      <description>&lt;p&gt;In Bali the deposit on a rented scooter is often your passport. At return the shop points at a dent, you say it was already there, and whoever is holding the document wins the argument.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;Jaminan&lt;/strong&gt; for the PayPal AI Hackathon to replace the passport with something both sides can check: a PayPal authorization that can only pay for lines the renter accepted. The part worth writing about is not the AI. It is how little code it takes to make PayPal itself refuse the wrong settlement, and the places the sandbox disagreed with my first design.&lt;/p&gt;

&lt;p&gt;Everything below ran on the PayPal &lt;strong&gt;sandbox&lt;/strong&gt;. Code: &lt;a href="https://github.com/edycutjong/jaminan" rel="noopener noreferrer"&gt;https://github.com/edycutjong/jaminan&lt;/a&gt;. Live: &lt;a href="https://jaminan.edycu.dev/app/judge" rel="noopener noreferrer"&gt;https://jaminan.edycu.dev/app/judge&lt;/a&gt; (one tap, fresh sandbox rental). Demo video (2:56): &lt;a href="https://youtu.be/E8r-QMLocQo" rel="noopener noreferrer"&gt;https://youtu.be/E8r-QMLocQo&lt;/a&gt;. Devpost: &lt;a href="https://devpost.com/software/jaminan" rel="noopener noreferrer"&gt;https://devpost.com/software/jaminan&lt;/a&gt;.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The deposit is an authorization, not a charge
&lt;/h2&gt;

&lt;p&gt;One renter approval creates an Orders v2 order with &lt;code&gt;intent: AUTHORIZE&lt;/code&gt; for fee plus deposit ($115.00 in the demo: $15.00 + $100.00), with &lt;code&gt;store_in_vault&lt;/code&gt; so the wallet can be charged later for fines. The fee is captured right away. The deposit is never captured unless something goes wrong:&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;capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authorization_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;final_capture&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...):&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&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;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;final_capture&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;final_capture&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;127&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;note_to_payer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;255&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&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="s"&gt;/v2/payments/authorizations/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;authorization_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/capture&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;The fee capture goes out with &lt;code&gt;final_capture=false&lt;/code&gt;. PayPal reads the authorization back as &lt;code&gt;PARTIALLY_CAPTURED&lt;/code&gt;, which means the remaining $100.00 is still held.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;final_capture=true&lt;/code&gt; is the whole settlement
&lt;/h2&gt;

&lt;p&gt;When the renter accepts a damage line, the settlement is &lt;strong&gt;one&lt;/strong&gt; capture for exactly that amount with &lt;code&gt;final_capture=true&lt;/code&gt;. That single flag does two jobs: it takes the accepted amount and releases everything else on the authorization. A clean return is a plain &lt;code&gt;void&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In the recorded sandbox run for an accepted $18.00 mirror crack, PayPal reads the authorization back as &lt;code&gt;CAPTURED&lt;/code&gt; and $82.00 of the deposit is released. Then the script tries to capture $1.00 more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v2/payments/authorizations/59Y22025SE889191E/capture   amount 1.00
HTTP 422  AUTHORIZATION_ALREADY_CAPTURED   (debug f5799404eea63)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That refusal is the feature. A shop cannot quietly charge a second "scratch" after the renter has walked away, and I did not write a line of code to stop it; I only had to set one flag. The three recorded settlements (accepted, clean, contested) are in &lt;code&gt;evidence/paypal-sandbox-run.json&lt;/code&gt; with every PayPal debug id.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model never sets a price
&lt;/h2&gt;

&lt;p&gt;The vision model only answers narrow questions about the shared pickup photos: is there a mark here now, is it the kind the shop named, was it visible at pickup, can the pickup photo show that spot. A plain function turns the answers into "cannot be charged" or a tariff line, and a second function builds the settlement:&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;capture&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_accepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deposit_cents&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;fee_captured_cents&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;capture&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;authorized_cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="c1"&gt;# PayPal enforces too; we check first
&lt;/span&gt;    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SettleBlocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;captures would exceed the authorized amount (I2)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;overage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_accepted&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;deposit_cents&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Accepted lines above the deposit leave as an itemized PayPal invoice instead of a bigger capture. Contested lines are never captured at all; they go out as an invoice at the published tariff price, and the hold is voided.&lt;/p&gt;

&lt;p&gt;Because the rule that matters ("a chargeable line is new since pickup and has a tariff price") is a pure function, I could test it exhaustively: 23,040 combinations of the model's yes/no answers, serving model, confidence, a confirmed baseline mark, comparability, parts and damage classes. Every chargeable result satisfies every "new since pickup" condition. A Hypothesis property test generates up to 3,000 settlements and checks the hold never pays more than the accepted lines (plus an invoice for any amount above the deposit) and never captures more than the authorization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the sandbox disagreed with my design
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One vault token per buyer and merchant.&lt;/strong&gt; I planned a pool of judge wallets. PayPal returns the same vault token for the same buyer and merchant, so the pool holds one token until I create more sandbox accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No cards.&lt;/strong&gt; The card-path authorize answered 422 without a stated reason (I believe advanced card processing is not enabled for my Indonesia-region sandbox merchant), so Jaminan is wallet-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reauthorize is date-gated.&lt;/strong&gt; PayPal allows it only from Day 4 of an authorization. I built and unit-tested the path and its refusal fallback, then waited. On day 9 the sandbox reauthorized two holds, one with its fee already captured, and refused a second attempt on the same hold with 422: "only allowed once from Day 4 to Day 29".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A stale read-back.&lt;/strong&gt; My own live check caught the &lt;code&gt;/judge&lt;/code&gt; page showing &lt;code&gt;PARTIALLY_CAPTURED&lt;/code&gt; after a release, because I cached the read-back on the authorization id alone. The cache key now includes the rental state, and there is a regression test. A settle screen that stamps "released" should be stamping what PayPal says now.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it does not do yet
&lt;/h2&gt;

&lt;p&gt;Be careful with the AI claim. No vision model is benchmarked yet, because the staged scooter photo set is still to be shot and scored. Until a model is pinned, a live claim ends "comparison unavailable — nothing charged from the hold", so the live app can release a hold but cannot price a damage line. The priced path is covered by tests and by the recorded run (with scripted claim answers, labelled as scripted).&lt;/p&gt;

&lt;p&gt;I did run two smaller checks, and the model failed both, which is exactly why nothing is pinned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A partial check on public photos.&lt;/strong&gt; 48 Wikimedia Commons photos (26 damaged with 37 labelled marks, 22 clean scooters; CC BY, CC BY-SA and CC0 only), each used as both the pickup and the return photo, so the only right answer was "old" or "not new". The labels were drawn by my coding agent (Claude), not an independent annotator, and committed before any model call. &lt;code&gt;qwen3-vl-plus&lt;/code&gt; with the app's prompts called an old mark new in 71 of 105 answers and a clean spot new in 66 of 66. The bar is 0. It is not the staged-set benchmark, and it cannot test new damage between two photos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pair of AI-generated test images (not photographs).&lt;/strong&gt; A pickup image and an edit with both mirrors removed. The model read both pointed claims wrong, three times each. Had that model been pinned without the bench, the rules would have priced an old scuff at $45.00.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the design working as intended: a model that cannot tell old damage from new never gets to move money.&lt;/p&gt;

&lt;p&gt;Also: sandbox only, one merchant, and non-return (a scooter that never comes back) is out of scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents can book, but not pay
&lt;/h2&gt;

&lt;p&gt;A small MCP server (4 tools) lets a booking agent such as Claude Code or Codex quote and start a rental. Only the renter can approve the hold on PayPal's own page; the agent's token gets 403 on all 8 money and answer routes. Both agents' bookings and the 403s are in &lt;code&gt;evidence/mcp/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Open &lt;a href="https://jaminan.edycu.dev/app/judge" rel="noopener noreferrer"&gt;https://jaminan.edycu.dev/app/judge&lt;/a&gt;, tap Start a judge rental, then Release the hold, and watch &lt;code&gt;/integrations/verify&lt;/code&gt; list every PayPal call with its status. Or run the offline suite (234 tests, no credentials, 100% line and branch coverage) from the repo; the full 243-test run includes 8 against the real sandbox.&lt;/p&gt;

&lt;p&gt;If you work on rental, marketplace or escrow-style flows, the thing to steal is small: model the deposit as an authorization, and let &lt;code&gt;final_capture=true&lt;/code&gt; be the only way money leaves it.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>paypal</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My own gate failed (8 of 20), so I rescoped a scam-ad checker around quotes code can find word for word</title>
      <dc:creator>Edy Cu</dc:creator>
      <pubDate>Sat, 17 Oct 2026 22:55:28 +0000</pubDate>
      <link>https://dev.to/edycutjong/my-own-gate-failed-8-of-20-so-i-rescoped-a-scam-ad-checker-around-quotes-code-can-find-word-for-5alp</link>
      <guid>https://dev.to/edycutjong/my-own-gate-failed-8-of-20-so-i-rescoped-a-scam-ad-checker-around-quotes-code-can-find-word-for-5alp</guid>
      <description>&lt;p&gt;A job offer lands in a family WhatsApp group (a composite scene, built from patterns in published reports): "CS Kamboja, Rp 15 jt/bulan, visa turis OK, tiket ditanggung." Someone has to tell the person who is about to board whether to take it, tonight. The rules that would stop them exist. Cambodia is not an official placement country, a tourist visa is not a work permit, a fee before a contract is a police-checklist warning sign. But those rules sit on different ministry, police and embassy pages, and nobody in the group can read them all in an evening.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://awas.edycu.dev" rel="noopener noreferrer"&gt;Awas&lt;/a&gt; for the Nebius Global AI Hackathon to answer that chat with pages instead of a score. This post is mostly about the part that went wrong, because it changed the product.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live: &lt;a href="https://awas.edycu.dev" rel="noopener noreferrer"&gt;https://awas.edycu.dev&lt;/a&gt; (the &lt;code&gt;/judge&lt;/code&gt; page opens five recorded real runs, no login)&lt;/li&gt;
&lt;li&gt;Code (MIT): &lt;a href="https://github.com/edycutjong/awas" rel="noopener noreferrer"&gt;https://github.com/edycutjong/awas&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Demo video (2:37): &lt;a href="https://youtu.be/to4VOBm_eNM" rel="noopener noreferrer"&gt;https://youtu.be/to4VOBm_eNM&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;You paste the text of an ad. Awas returns an evidence file and one of three outcomes: &lt;strong&gt;STOP&lt;/strong&gt;, &lt;strong&gt;VERIFY FIRST&lt;/strong&gt;, or &lt;strong&gt;NO RED FLAGS FOUND&lt;/strong&gt; (it says "not a guarantee", never "safe"). There is no score.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Nemotron 3 Ultra on Nebius Token Factory copies typed fields (company, destination, job, visa, fees) out of the ad. Code checks each field is literally in the ad.&lt;/li&gt;
&lt;li&gt;Fixed rules, each quoting an official page, produce red flags.&lt;/li&gt;
&lt;li&gt;Tavily searches for published pages about the same company, number or destination.&lt;/li&gt;
&lt;li&gt;Nemotron 3 Super proposes evidence lines: a URL, a quote, a claim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A quote gate in plain code&lt;/strong&gt; keeps a line only if the quote is a verbatim substring of the page text fetched in that run.&lt;/li&gt;
&lt;li&gt;The verdict comes from the rules and the verified lines, not from a model.&lt;/li&gt;
&lt;li&gt;A short Bahasa Indonesia message for the family, built only from verified lines.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The wall: my own gate failed
&lt;/h2&gt;

&lt;p&gt;Before the first benchmark run, I wrote a pass condition into my own plan (it only shows up in the repo's history afterwards): for at least 12 of 20 known scam cases, Tavily had to surface a relevant advisory or news item. That promise ("here are published reports about this company") was the heart of the original pitch.&lt;/p&gt;

&lt;p&gt;It surfaced one for &lt;strong&gt;8 of 20&lt;/strong&gt;. (An agent read the results; a human has not reviewed that reading yet.) A second problem sat behind it: reports almost never name the recruiter. In the benchmark's reported cases, 1 in the development set, 0 in the first tranche and 1 in the second name a company at all. A "company-matched" rate cannot be measured on that.&lt;/p&gt;

&lt;p&gt;I had two options: stay quiet and keep the pitch, or rescope. I rescoped and published the number. The verdict now comes from rules quoted from official pages. Web search became context for the family, never an input to a rule. The README and the demo page both say the coverage gate failed and by how much.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part worth copying: the quote gate
&lt;/h2&gt;

&lt;p&gt;The model proposes, code decides. This is the heart of &lt;code&gt;src/lib/gate.ts&lt;/code&gt;, trimmed to the matching step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;gateLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Proposal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;corpus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Corpus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GateContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;EvidenceLine&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;corpus&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="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;url_not_returned&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;words&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MIN_QUOTE_WORDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;too_short&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normaliseWithMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nq&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ... return a "verified" line with the raw quote and its offset&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;joined&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;spans_chunks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not_in_page&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;purpose&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;A line whose quote is not found word for word in the page returned &lt;em&gt;in that run&lt;/em&gt; is dropped, and the drop is shown on screen. Shown quotes can be re-checked against their pages without Awas code: on the held-out run a script re-found 33 of the 48; the rest need a browser (14 pages refuse scripts or are PDF/JavaScript pages) or had changed since fetch (1).&lt;/p&gt;

&lt;h2&gt;
  
  
  What the held-out run measured
&lt;/h2&gt;

&lt;p&gt;The second benchmark tranche was frozen (and its files hashed) before it ran, then run once on code tagged &lt;code&gt;code-freeze&lt;/code&gt;, as a Nebius Serverless Job against the production app. Every number is in &lt;code&gt;DEMO.md&lt;/code&gt; and &lt;code&gt;bench/&lt;/code&gt; in the repo.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Licensed agencies' vacancies wrongly stopped: 0 of 10.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;25 publicly reported scam-recruitment cases: 9 STOP, 15 VERIFY FIRST, 1 NO RED FLAGS FOUND.&lt;/strong&gt; Eight of the nine STOPs come from cited rules alone. One case is a single case, not a rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;48 of 48 evidence lines shown were gate-verified.&lt;/strong&gt; Six of 54 proposed lines were dropped (11 %).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;p50 13.7 s, p95 31.7 s&lt;/strong&gt; from request to finished file (server-side); median cost $0.0078 in Token Factory tokens plus 1 to 3 Tavily credits.&lt;/li&gt;
&lt;li&gt;The same verdict on 20 of 20 reruns of 10 reported cases, and 19 of 20 on 10 licensed vacancies.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Things the docs did not tell me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The structured-output guide's own &lt;code&gt;json_schema&lt;/code&gt; example is refused with HTTP 422 on Nemotron 3 Super and Lightning; the API reference's &lt;code&gt;name&lt;/code&gt;/&lt;code&gt;schema&lt;/code&gt;/&lt;code&gt;strict&lt;/code&gt; form works.&lt;/li&gt;
&lt;li&gt;Super is a reasoning model, so I give it generous &lt;code&gt;max_tokens&lt;/code&gt;: an earlier project of mine saw it spend them all thinking and return an empty answer (the empty answers recorded in this repo are Lightning's).&lt;/li&gt;
&lt;li&gt;Indonesian is on none of the model cards I could read, so I measured it: field accuracy 84.8 % for Ultra, 70.7 % for Super, 59.6 % for Lightning, against labels an agent drafted and no human has reviewed yet. That caveat is on every page that quotes the figures.&lt;/li&gt;
&lt;li&gt;Object Storage's conditional writes (&lt;code&gt;If-None-Match&lt;/code&gt;, 412 on a repeat) were enough for a rate limit with no database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full list, each with a recorded call, is in &lt;code&gt;FEEDBACK.md&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;The coverage gate failed, so web evidence is context only. The benchmark's reported cases are reporters' descriptions of offers, not the original ads, so real ads may carry more red-flag wording. The phone-number path is unmeasured. NO RED FLAGS FOUND is never a guarantee, and no outside user has used Awas yet. It is a research prototype, not legal advice and not an official service.&lt;/p&gt;

&lt;p&gt;If you have seen an overseas job ad in a group chat, paste the text (with phone numbers and names removed) at &lt;a href="https://awas.edycu.dev" rel="noopener noreferrer"&gt;https://awas.edycu.dev&lt;/a&gt; and tell me what it got wrong.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>webdev</category>
      <category>security</category>
    </item>
  </channel>
</rss>
