---
name: alert-bot-fleet-6
description: Continuation of the alert bot fleet change log from 12 Sep 2026, after alert-bot-fleet-5.md hit its size cap. Carries the LINKSCAN "send more, I review" recall bias. Read all six whenever a bot, alert, tracker or watcher comes up.
sources: [cowork]
aliases: [alert bots part 6, linkscan v4, send-more rule]
---
Continues [[alert-bot-fleet-5]], [[alert-bot-fleet-4]], [[alert-bot-fleet-3]], [[alert-bot-fleet-2]] and [[alert-bot-fleet]]. Same droplet `159.223.88.168`, same rules: everything runs on the droplet, nothing on the Mac.

## 12 Sep 2026: LINKSCAN v4, Ben's PAIREX win sets the standing recall bias. SEND MORE.
- **THE CALL PAID.** [stated] Ben on the 13:01 LINKSCAN alert for PAIREX `0xB8Cf4ad387cfd607C66a207CFFFC46498aACd9d6` (cap $133,714, pool $29,716, traded $86,660, domain 0.3d old, X 3d old with 75 followers): *"This was a great find today. I made good money with this. I got a great entry. So I want stuff like this."*
- **THE STANDING INSTRUCTION, verbatim, and it governs every gate in this bot:** *"you need to be careful. Before I said that about the ticker, maybe if there's two or multiple tokens competing for the same ticker and whatnot, it's better to send me stuff and I review it than not sending it and I get it too late. The main thing is, is the tech, do they have something that's working? Do they have some product that you test that is working? And it's not like a blatant copy of something else. That's the main test."*
- **So the loss function is asymmetric and now explicit: a false positive costs Ben 30 seconds of reading, a false negative costs him the trade. RECALL BEATS PRECISION on this bot.** Only two things may silence an alert: no working product, or a blatant copy. Everything else is a warning line on the card and HIS call.
- **This REVERSES the same-day v3 decision that CONTESTED never alerts** (see [[alert-bot-fleet-5]]). That rule came from Ben's own earlier nuance about squatters getting there first, and it was over-applied: it turned an ambiguity into a veto. The ambiguous window (team announced, squatters camped on the socials, nobody endorsed yet) is precisely where the early entry lives.

### What shipped
| change | was | now |
|---|---|---|
| `CONTESTED` verdict | never alerted | **alerts**, with a loud `⚠️ TICKER CONTESTED` line inserted at the top of the card |
| `look_score` threshold | hardcoded 4 | **config `look_score`, set to 2** |
| `min_mcap` | 20,000 | **8,000** |
| `backfill_max_alerts` | 6 | 10 |
| new config keys | | `look_score`, `alert_contested` |
- **`IMPOSTOR` deliberately stays silent, and this is the line between the two.** CONTESTED = nobody endorsed, undecided, Ben decides. IMPOSTOR = the project's own site or X names a DIFFERENT contract as theirs, so it is decided and the token is disowned by the thing it claims to be. **Ambiguity gets sent; a settled negative does not.**
- **Why threshold 2 is the right number and not a guess:** Group A (hard proof something RUNS) is already a required gate returning `SITE ONLY` before scoring, and COPY / FAKE / OFF PAD return earlier still. One Group A hit scores 2, so 2 means exactly "any working product that is not a copy". The remaining points (words>250, domain older than 14d, X over 1,000 followers) are **polish, not product**, and a 0.3-day-old domain with 75 followers is the PAIREX shape Ben wants. **A freshness penalty on an early-entry scanner is the bot working against its own purpose.**
- **Measured impact on the 1,039-row bench: ZERO retroactive change.** Counts were NO LINKS 933, OFF PAD 40, LOOK 19, SITE ONLY 18, DEAD 18, COPY 9, FAKE 2. **There were no THIN rows and no CONTESTED rows at all**, so neither gate had ever yet fired. The change is future-proofing against a false negative, not a volume increase. `min_mcap` 20,000 to 8,000 is the only change that adds throughput.
- **`scan.py` had NO config access, so every threshold in `verdict()` was a magic number.** Added a module-level `CFG` load at import. Worth checking in every other bot: a file that only receives `r` cannot be tuned without a code edit.
- **`backfill.py` was dropping `social` from the alert payload** (it built the dict from an explicit key list: sym/mc/liq/vol/words/flags/notes), so the new CONTESTED warning could never have reached a card on the discovery path. Added `social`, `score`, `product_why`. **Whenever a renderer gains a new input, check every path that constructs its payload from an explicit key list.**
- Verified live: imports clean, `linkscan.service` restarted 16:2x UTC and active, and a forced re-scan of PAIREX returns **LOOK, score 9, social SOLE** with the full card rendering (`/portfolio`, `/docs`, 5 own contracts in use, wallet connect, real app bundle, working controls).
- Backups: `scan.py.benbak-*`, `linkscan.py.benbak-*`, `backfill.py.benbak-*`, `config.json.benbak-*`.
- **Deploy method that beat base64-through-the-Mac: `Desktop_Commander.write_file` the patch script to the Mac, `scp` it to the droplet, md5 on both ends before running** (`3a5f868d85783dd1be2e48b9f4cffde9` matched). No shell quoting, no transcription risk, one round trip.
- **GENERALISABLE TO EVERY SCREENING TOOL BUILT FOR BEN: ask which error is expensive before setting any threshold.** On a discovery scanner he reads himself, missing the trade is the expensive error, so gates may encode only what is DECIDED (no product, proven copy, disowned by its own project) and never what is merely UNCERTAIN.

## 12 Sep 2026: LINKSCAN v5, the cap band and the link-less watch list. "Don't over-engineer it."
- [stated] Ben, declining a proposed second bot that would have watched for "team still shipping while the crowd calls it a scam": **"No, don't over-engineer it. The main thing is that the tech is working and that it's early."**
- [stated] **His cap band, verbatim: "early ideally means under 500K market cap. But I would much prefer something 100K, 200, 300K market cap."**
- [stated] **And the trigger he actually wants: "it needs to look at things as they start graduating, or once they appear on the chain and links are there. As soon as links pop up, website or Twitter or whatever, then follow and see whatever is available, and then check it immediately and do a lot of these scans to be early."**

### THE BUG THIS EXPOSED, and it was silently costing him every late-linking team
- **`backfill.py` called `done.add(ca)` for EVERY enriched token, including the `NO LINKS` ones, before the `continue`.** So a token that launched with no website and no X was retired permanently on first sight. When the team added its links on day two or three, **LINKSCAN would never look at it again.** 955 of 1,062 bench rows were sitting in that state.
- **Fix: link-less tokens are parked in `nolinks.json` {ca: first_seen_epoch} instead of being retired, and re-enriched on EVERY run until `nolinks_watch_days` (5) expires.** The moment links appear the token is scanned in that same run.
- **Seeded from the existing bench, filtered to `mc >= min_mcap`** (597 of 955; below the floor it could never alert anyway, so re-checking it is pure waste). **Cost measured: 19 to 20 batched DexScreener `tokens/v1` calls, 16 to 18 seconds per run.** Verified on the 16:45 cron run: `universe 2127 (quick), 860 left to enrich | enriched 578`.
- **The second silent killer on the same path: the alert required `age_h <= backfill_max_age_hours` (24), measured from POOL creation.** A team that shipped its site on day three was 72h old and could never alert however good the product. **Now `fresh = just_linked or pool age <= 24h`.** The card says `LINKS JUST APPEARED on a token that had none` so the reason is visible.
- **THE PRINCIPLE, and it generalises to every scanner in the fleet: "already checked" is not the same as "checked under the conditions that matter". A token is not done, a token-at-this-state is done.** Retire on a state change or a time window, never on one look.

### The cap band
| config | was | now |
|---|---|---|
| `min_mcap` | 8,000 | 8,000 |
| **`max_mcap`** | none | **600,000** |
| `nolinks_watch_days` | none | **5** |
- **600,000 rather than his stated 500,000 on purpose: one scan interval of headroom** so a token moving fast between the 5-minute passes is not lost at the boundary. Told to him plainly rather than silently rounded.
- Backups: `backfill.py.linkbak-*`, `config.json.linkbak-*`, `backfill_done.json.linkbak-*`. Seeder left at `/opt/linkscan/_benseed.py`.

### OPERATIONAL TRAP THAT COST A RUN, and it is the sharper version of the note in [[alert-bot-fleet-5]]
- The first seed attempt was **clobbered by the in-flight `*/5` cron run**, which finished after the seed and wrote its own stale `nolinks.json` (464 bytes) and `backfill_done.json` back over it. The follow-up manual run then crashed with `FileNotFoundError: library.json.tmp -> library.json`, because `save()` does `open(tmp,"w")` then `os.replace` and **two processes racing on the same tmp path means the second replace finds nothing there**.
- **So: any manual state edit AND the run that consumes it must sit inside ONE `flock /tmp/lsbf.lock` invocation**, not two. `flock ... bash -c 'python3 seed.py; python3 backfill.py'`. Seeding under the lock and then running under the lock separately still loses, because cron slots in between.
- **And `save()` across the whole fleet uses a fixed `<path>.tmp`, which is not multi-process safe.** It has not bitten in normal operation because cron is flocked, but any manual run outside the lock can corrupt or crash a write. Worth moving to `tempfile.mkstemp` in the same directory if it recurs.
- Mac bridge dropped several times during this build; long-running droplet work was moved to `nohup ... &` plus a log file and polled, rather than held open on a 60s bridge call.

## 12 Sep 2026: LINKSCAN v6, the DIP alert. A ceiling that only ever looks up is a trap.
- [stated] Ben, immediately after the cap band shipped: **"also something could go above 500 thousand market cap and then it could dump back below, so you need to factor those in. If something looks good and then it dumps under 500 then I need an alert."**
- **He is right and the v5 ceiling was half a feature.** `max_mcap` silently discarded a token that passed the product test but happened to be expensive on the day it was scanned, and `done.add(ca)` then retired it forever. The good ones are exactly the ones that run past the ceiling, so the ceiling was systematically throwing away the best candidates.
- **Fix, same pattern as the link-less watch list: `pending.json` {ca: {t, sym, peak}}.** A token whose verdict is in `alert_on` but whose cap is above `max_mcap` is parked there, kept OUT of `done`, and its cap re-read on every 5-minute pass for `pending_watch_days` (30). When it comes back inside the band it is **re-scanned fresh** (so a product that died in the meantime is caught) and alerted with `⬇️ DIPPED BACK INTO RANGE, peaked at $N cap`.
- `peak` is carried and printed, so the card shows the round trip rather than just the current number.
- **A dip counts as `fresh`**, alongside `just_linked` and pool-age-under-24h, otherwise the age gate would have killed it too.
- Seeded from the bench: **3 tokens** above the ceiling, all re-verified live 16:55 UTC. CME `0xe2324ff2a59f8ecba8c321c6466e59121c00e795` $4,636,730 · PAIREX `0xB8Cf4ad387cfd607C66a207CFFFC46498aACd9d6` $2,060,434 · MONEY `0x0a8b4763c71ac39101b3b8a97e62da0b81549a4f` $1,990,015.

### THE DUP-GUARD BUG, and it would have silently killed BOTH new alert paths
- **`seen[ca]["alerted"]` was hardcoded to `1` for every token the scanner ever LOOKED AT, not every token it ever SENT.** The alert path then checks `if (disk.get(ca) or {}).get("alerted")` and skips. So **every token ever scanned was permanently un-alertable**, which would have made the dip alert and the links-just-appeared alert both dead on arrival while looking perfectly healthy in the logs.
- Fixed to `"alerted": 1 if src_tag == "backfill-alert" else 0`.
- **Repaired the historical state surgically rather than wholesale:** `src` counts were `None` 471, `backfill` 400, `backfill-alert` 3. Only `src == "backfill"` is provably scanned-and-not-sent, so **393 false guards were cleared** and the 471 `src: None` rows (older live-path entries with unknown send history) were LEFT ALONE rather than risk re-sending something Ben has already seen. 30 remain guarded. Backup `seen.json.dupbak-*`.
- **GENERALISABLE, and this is the third variant of the same mistake today: a flag must record what HAPPENED, not what was ATTEMPTED.** "scanned" is not "sent", "enriched" is not "checked", "seen" is not "done". Every dedupe key in the fleet is worth re-reading against that sentence.

### Config after v6
`min_mcap` 8,000 · `max_mcap` 600,000 · `look_score` 2 · `alert_contested` true · `nolinks_watch_days` 5 · **`pending_watch_days` 30** · `backfill_max_alerts` 10 · `alert_on` ["LOOK"] · `pads_allow` ["long","pons","o1"].
State files: `backfill_done.json` (retired) · `nolinks.json` (596 waiting for links) · `pending.json` (3 waiting for a dip) · `seen.json` (dedupe) · `bench.json` (1,094 rows) · `bf_misses.json`.
Backups: `backfill.py.dipbak-*`, `config.json.dipbak-*`, `backfill_done.json.dipbak-*`, `seen.json.dupbak-*`. Helper scripts left on the droplet: `_benpatch.py` `_benpatch2.py` `_benpatch3.py` `_benpatch4.py` `_benseed.py` `_benseed2.py` `_benfix.py`.
- **THE SHAPE THIS BOT HAS CONVERGED ON, worth reusing: three lists, not one.** `done` = decided and closed · `nolinks` = not yet judgeable, re-check until it is · `pending` = judged good, waiting for the PRICE to be right. Any scanner with both a quality test and a price test needs the third list, or the price test quietly deletes the quality hits.

## 12 Sep 2026: LINKSCAN v7, every pad, real Telegram buttons, and the pair header
- [stated] Ben: **"recheck everything again to make sure it's properly set up. And I want this to run on the entire Robinhood launch pads. And I want there to be links that go directly to FOMO and directly to GMGN, make buttons for those. And I want there to be a note at the top that says which launch pad it was launched on. And also what it is trading against. So if it's trading against AI, then write the token ticker slash AI."**

### All launchpads
- `pads_require` is now **false** and the `PADS_REQUIRE` defaults in `scan.py`, `linkscan.py` AND `backfill.py` were all flipped to False (backfill's used `_c.get(...)` not `cfg.get(...)` and was missed by the first pass: **grep the variable name, not the call shape**). The pad is still READ and printed, it just no longer gates the scan. 50 tokens previously retired as `OFF PAD` were requeued, since that check returns before any HTTP and they had never actually been examined.

### THE PAD CENSUS, measured over the whole 2,072-token universe. Keep this.
Batched `eth_getCode`, 20 per HTTP call (**the first attempt did them one at a time and had not finished 200 tokens in five minutes: the public RH RPC 429s hard, and `rpc_batch` is the standing fix from [[alert-bot-fleet-5]]**), then each unknown cluster labelled from GeckoTerminal's own `dex` id:

| cluster | count | what it is |
|---|---|---|
| **pons** (3248 bytes) | **1,670** | Pons |
| **long** (44-byte EIP-1167 proxy to `0x3be8b97fd0e713b5abe0649fa830223b6b4bc599`) | **174** | long.xyz |
| len:7154 | 50 | `uniswap-pools-trade` |
| len:1770 | 26 | `uniswap-v4-robinhood` |
| len:1764 | 16 | `uniswap-v3-robinhood` |
| len:45 | 14 | `uniswap-v3-robinhood` |
| len:4655 | 10 | `uniswap-v4-robinhood` (NOT o1: o1 is 4657/4724, do not merge them) |
| len:3553 | 5 | `uniswap-v2-robinhood` |
| len:4322 | 5 | `uniswap-v4-robinhood` |
| len:5274 / 5047 / 3572 | 15 / 8 / 5 | no pools indexed |

- **THE CONCLUSION, and it settles the question: pons + long already are 89% of the chain, and every remaining cluster labels as a plain Uniswap pool, not a launchpad.** So "pad is None" does not mean the detector failed, it means **the token was deployed straight to a DEX with no pad behind it.** The card now says `no launchpad, direct deploy` instead of `unknown pad`. o1 did not appear at all in this registry slice.

### The card, rebuilt
- Header is now **`TICKER/QUOTE`** (`PAIREX/USDG`, `MONEY/SPY`, `CME/ETH`), with a second line **`launched on <pad>   trades against <quote>`**.
- `enrich()` now captures `quoteToken.symbol` from the **deepest** pool (it already picked the highest-liquidity pair), carried through `r["quote"]` into the card and into `bench`.
- **Real Telegram inline-keyboard buttons** via `reply_markup`, not text links: row 1 **FOMO / GMGN / DexScreener**, row 2 **site / X** when present. `send(cfg, text, r=None)` builds them from `r`; `flush()` passes no `r` and is unaffected.
- **URL forms verified rather than assumed** (Ben's standing rule): `https://fomo.family/tokens/robinhood/{ca}` returns 200 from the container. **GMGN 403s the container from every path, so it was opened in Chrome instead: `https://gmgn.ai/robinhood/token/{ca}` loads the token page.** A 403 from the container is Cloudflare, never evidence a URL is wrong.

### THE SECOND DUP-GUARD BUG, and it killed the dip alert on its most important case
- The dip alert was being eaten by the SAME guard fixed earlier. The common shape is **alerted cheap, runs, dumps back into range** (exactly PAIREX), but `alerted` was already 1 from the discovery alert, so the dip was silently skipped. Caught because **PAIREX left `pending.json` with no message**: it had genuinely dipped to $554,604 and the guard swallowed it.
- **A dip is a DIFFERENT EVENT from a discovery, so it gets its own guard: `dip_alerted`.** Fires at most once per token; `alerted` is now also carried forward rather than recomputed, so an earlier alert is never forgotten by a later scan.
- **THE RULE, third time today: one flag per event type. A single "we told him" boolean cannot serve two different questions.**

### VERIFICATION METHOD WORTH REUSING: the sandbox dip test
- Waiting for a real dip proves nothing today, and faking market data proves nothing ever. So: **copy the whole `/opt/linkscan` tree to a tmpdir, raise `max_mcap` to $3M so MONEY's live $1.4M reads as "back in range", stub `send()` to capture, and run one REAL `backfill.main()` against live chain and DexScreener data.** Nothing was mocked except the transport.
- Result: `⬇️ DIPPED BACK INTO RANGE, peaked at $4,000,000 cap, pool 23.4h old` on a full `MONEY/SPY` card with both button rows. Sandbox deleted afterwards.

### State after the recheck, 17:20 UTC
`linkscan.service` active · cron `*/5` · bench 1,114 rows (LOOK 22, SITE ONLY 25, DEAD 33, COPY 14, FAKE 5, NO LINKS 977, plus 38 stale OFF PAD rows that clear as the requeue works through) · `nolinks.json` 611 · `pending.json` 2 (MONEY peak $1,990,015 `0x0a8b4763c71ac39101b3b8a97e62da0b81549a4f`, CME peak $4,636,730 `0xe2324ff2a59f8ecba8c321c6466e59121c00e795`) · droplet 171 MB free, disk 22%.
Five real alerts fired today through the new pipeline: RONO, FLYR, SMA, Nomo, RIME `0x3f3caef6f6e8cb98f56d5425b68da32529f8b1ce`.
Backups: `*.uibak-*`, `*.padbak-*`, `*.pdbak-*`, `*.dip2bak-*`. Helper scripts on the droplet: `_benpatch.py` through `_benpatch8.py`, `_benseed.py`, `_benseed2.py`, `_benfix.py`, `_benverify.py` (full config/state/card/button dump, `--send` posts a live test card), `_benpadmap2.py` (the pad census).

## 13 Sep 2026: NOSTRADAMUS ALERT cut to one wallet, after the attribution collapsed
- [stated] Ben, after the verification and co-buy passes in [[neetolympics-wallet]] proved only one of the three candidate wallets is really @NeetOlympics: **"ok watch the first one only"**, meaning the proven one.
- **`/opt/neetwatch/config.json` now has exactly ONE wallet: `0xd048558edbf485f56e2410ba9e20b7d458c9b1fb`, relabelled from "NOSTRADAMUS DAILY" to plain "NOSTRADAMUS".** Its note carries the proof (12 of the 15 coins he named publicly, all five of his 2 Sep list, most bought before he posted them) so a future session cannot quietly re-add the other.
- **`0xfd6e86cf9aeca787cffb198bca859d459a44a847` REMOVED.** It had been in the config since 10 Sep labelled "NOSTRADAMUS BIG" with a note calling it his conviction wallet. That note was wrong: the wallet scores 5 of 15, misses SPACEHOOD, JOHNDOG, BOXY, SWOLE, UNIPEG, COCO and SBS, and shows zero co-buys with the real wallet inside a 3-second window on any of twelve coins. **Every alert it ever sent was some stranger's trade**, including the two in the log an hour before the change ("sold $16,594 INU", "bought $9,545 ICOIN").
- Config backed up to `config.json.bak-20260913-*`; service restarted 02:02 UTC, active; zero NOSTRADAMUS BIG lines in the log since.
- **THE FLEET-WIDE LESSON, and it is worth a sweep: a wallet-watch bot inherits the confidence of the hunt that found the wallet, and then LAUNDERS it.** Every alert after that reads as evidence about the person, because the label says their name. The nine "NOSTRADAMUS BIG" alerts already sent are exactly that failure. **So the note field in every wallet config should state the EVIDENCE, not the conclusion, and any wallet whose attribution rests on an intersection or a vibe rather than on positions the person declared himself should be labelled UNPROVEN in the alert card until it is tested.** Worth re-reading the other KOL bots (grailwatch, alfiewatch, dougwatch, ghostwatch, chjokkawatch, ubermwatch) on that standard.

## 13 Sep 2026: THE X BLACKOUT. The whole fleet went blind for 32 hours and said nothing.
- [stated] Ben, pasting a PAIREX MONITOR screenshot full of "indexed coins 277 to 278" and "currencies 157 to 164": **"the monitor missed key stuff like new tweets where they announced that they now have metals and chemicals and everything. What is this monitor good for if it misses everything that's important?"** He was right on both counts, and the two complaints have the same root.
- **ROOT CAUSE: the twitterapi.io credits ran out at 12 Sep 18:04:34 and the balance is `-1013`.** `/oapi/my/info` still answers 200 and returns `recharge_credits` even while every other endpoint 402s, which is what makes a balance watcher possible at all.
- **BLAST RADIUS, one shared key: xdeck 592 failed fetches (first 12 Sep 08:13), pairexmon 127 (first 18:04), strongtrader 1, linkscan's endorsement check silently returning empty.** `xalert` and `followwatch` are inactive so they were spared. **Every one of those services stayed `active` in systemd the entire time.**
- **WHAT IT COST, and this is the part that matters: PAIREX announced on X that you can now launch against metals, energy, agriculture, livestock, utilities, trading cards, CS2 skins and pharmaceuticals, AND that "Holder rewards and buyback & burn are now launch options too."** That second clause is precisely the fee-switch-class event this bot was built to catch, because [[pairex-token]] records that PAIREX has no contractual value accrual and the whole design premise was "the only things that can change the thesis are things the devs DO". It also missed "Creators on pairex.market have now earned a total of over $23,000", an overnight outage, and a "what pairs do you want next" poll.
- Recovered the missed posts by reading x.com in Ben's own Chrome, since the API was dead. **A paid API being down does not mean the data is unreachable; the browser is always the fallback.**

### THE REAL DEFECT: liveness is not sight
- `watch_x` did `log("x fetch failed:", e); return`. It failed 127 times into a log nobody reads while the on-chain watchers kept firing, **so the bot looked healthier than ever precisely because it was broken.** The counter spam was camouflage.
- **THE PRINCIPLE, and it should be applied to every bot in the fleet: a process being UP is not the same as a process being able to SEE. `systemctl is-active` cannot tell the difference, and the watchdog only ever asked systemd.** A watcher that loses one of its senses must say so loudly and keep saying it.

### What shipped, all live and verified firing
1. **`self.note(name, ok, detail)` health tracking in pairexmon.** Three consecutive failures on any watcher fires a `crit` card: *"BLIND: the X feed is down, failing for N hours... This monitor is NOT watching X right now. Treat its silence on that source as no information."* Re-fires every 6h until fixed, and fires a recovery card naming how long the gap was so Ben knows which window was never checked.
2. **`watch_credits` in pairexmon** (every 60 cycles) reading `recharge_credits` and alerting below `credits_floor` 20,000, at most twice a day.
3. **The same check in `/opt/watchdog/watchdog.py`, which is the right home for it** because the key is shared by four services. It names all four in the alert. **Fired on the first cycle at 02:23:45 with `balance -1013`.**
4. **Counter throttle, `self.counter(key, val, label, why, step)`.** Coin and currency counters no longer fire per increment: they alert on a real step (`step_coins` 25, `step_currencies` 25) or as a 12-hourly digest, and the card now prints **`+N in M hours`**, which is the rate and therefore the actual information. The existing zero-guard (a rendered 0 means the site failed, not the platform emptied) is preserved.
- **Generalisable, and it is the same lesson as the drainer campaign: a signal that fires on every event carries no information.** One more coin launching on a launchpad is not news. The launch RATE is.
- Backups: `pairexmon.py.blindbak-*`, `pairexmon.py.wirebak-*`, `watchdog.py.credbak-*`. Counter baselines were reset so the first post-patch run could not dump a giant catch-up delta.
- **WHAT NEEDS BEN'S HANDS AND ONLY HIS: top up twitterapi.io.** Nothing on the droplet can restore X until the balance is positive. Everything else is fixed, and from now on he finds out in minutes rather than after 32 hours and a missed announcement.

## 13 Sep 2026: DELTA ALERT reports the CYCLE, not its two legs
- [stated] Ben pasted the pair of cards ("TREASURY IS ACCUMULATING DELTA" at 08:14, "DELTA IS BEING BURNED" at 08:16 Bangkok) and asked **"is this new? tokens didn't get burned before? treasury didn't accumulate delta before?"** Then "yes" to the offer to merge them.
- **THE DEFECT IS A FRAMING DEFECT, not a data defect. Both cards were correct and both described ONE event 25 seconds apart**: the treasury bought 984,359 DELTA at 01:14:09 UTC and burned it at 01:14:34. Two loud cards with no counter and no history read as two separate first-ever events, which is exactly how Ben read them. It was the **third** buyback and burn (7, 10, 13 Sep, full table in [[delta-liquidity-2]]).
- **THE FIX: the buy is recorded SILENTLY into `state["pending_buy"]`, and the BURN emits one card.** New card shape: `BUYBACK AND BURN #4 / bought 984,359 DELTA for 4.14 ETH, burned it 25 seconds later / total burned now 5,313,740 = 0.531% of supply / cadence: 3.0 days since the last burn`.
- **The suppressed case is itself an alert, and that is what makes the suppression safe.** If a buy sits unburned for `hold_alert_minutes` (20) while the treasury still holds more than the burn floor, it fires **"TREASURY BOUGHT DELTA AND HAS NOT BURNED IT ... Every cycle so far burned inside a minute. This one broke the pattern."** Silence is only acceptable when the ABSENCE of the follow-up has its own alarm.
- Also added `treasury_eth` to `fee_switch_snapshot` (an `eth_getBalance` on the treasury) so the card can price the buy in ETH from the balance drop across the cycle. New config: `hold_alert_minutes` 20, `cycle_window_minutes` 30, `total_supply` 1000000000.
- **`state["burn_log"]` seeded with the three real historical burns** (timestamps and amounts from the chain scan), so the counter starts at #4 and the first cadence line is correct rather than blank.
- **Threshold sanity, per the standing "never test a token balance against zero" rule:** DELTA fee revenue arrives at the treasury in legs of **18 to 14,000**; buybacks arrive in legs of **900,000 to 2,340,000**. The existing floors (`burn_alert_tokens` 100,000, `treasury_buy_alert_tokens` 500,000) sit in a wide empty gap, so no tuning was needed. Verified against the full inflow history, not assumed.
- **VERIFIED BY REPLAY, not by inspection** (`/tmp/t_delta.py` on the droplet): the live module imported with `tg` stubbed and `fee_switch_snapshot` fed the real 13 Sep numbers. Three assertions, all pass: the buy leg alone sends **0** messages; the burn leg sends **exactly 1** containing `#4`, `4.14 ETH` and `cadence`, and NOT `ACCUMULATING`; an aged unburned buy sends exactly 1 `HAS NOT BURNED IT`. `state.json` snapshotted and restored by the test itself.
- Backups `deltaalert.py.bak-20260913-024202`, `config.json.bak-*`, `state.json.bak-*`. Service restarted 02:44 UTC, active, burn_log intact at 3 entries.
- **DEPLOY METHOD, and it finally beats the transcription problem: split the base64 at ~3.5KB, `Desktop_Commander.write_file` each part to the Mac, `cat part_a part_b | tr -d '\n' | ssh ... base64 -d`, then md5 BOTH ends before executing.** Matched byte-for-byte first try on both the patch and the test (`04171daf645d51f21af32ec7a60211cd`, `0cb628be8b5e3ba83485bb7666e22271`). A single blob over ~6KB is where transcription has failed before.
- **GENERALISABLE TO THE WHOLE FLEET, and it is the counterpart to the "send more" rule: an alert must carry its own HISTORY or it cannot be read.** Every recurring event card should say which number it is, how long since the last one, and the running total. Without that, the reader cannot tell a first from a third, and the honest question "is this new?" has to come back to me instead of being answered on the card. Candidates to audit on this standard: pairexmon burns, prmwatch auction bids, the HKRS and PRIV buyback cards, coinbench.

## 13 Sep 2026: THE SAMPLED-MAX BUG. "Peaked at $2,060,434" when it peaked at $7M.
- [stated] Ben, on the LINKSCAN dip card for PAIREX: **"Make sure that your numbers on the spot are correct. This thing peaked at around 7 million, not 2 million. You need to make sure that however this mistake happened, it doesn't happen again. Also check all the other processes to make sure that that alert is correct."**
- **HE IS RIGHT. TRUE PEAK, from full GeckoTerminal OHLCV on the deepest pool `0xf28a04eb457c1118e448d3ac7c4cb25768307db91920ba5d087c3db0da1720d4`: price $0.00718522 at 12 Sep 09:00 to 09:55 UTC.** On total supply that is **$7,185,224**; on the circulating basis DexScreener uses (1,000,000,000 minus the ~29.6M burned to dEaD) it is **$6,971,729**. Corroborated on two other pools ($6,890,523 and $6,897,481). The card said **$2,060,434**.
- **ROOT CAUSE, and it is a whole class, not a typo: `peak` was `max()` over LINKSCAN'S OWN SCAN SAMPLES.** The bot first saw PAIREX at 13:01 on 12 Sep, **four hours after the top**. A sampled maximum cannot see anything before first contact and can miss anything between two 5-minute samples. **It is not a peak, it is an artefact of when the scanner happened to look**, and printing it as "peaked at" states an artefact as a fact.
- **THE RULE: a peak, ATH, high or low reported to Ben must come from a source holding the FULL history. Never from the tool's own observation log. If the real figure cannot be fetched, print nothing.**

### The fix, mechanical in three parts
1. **`linkscan.token_ath(chain, ca, mc, price)`**: lists the token's pools on GeckoTerminal, pulls `ohlcv/hour?limit=1000` on the two deepest, takes `max(high)`, multiplies by supply derived as `mc / price` (the same basis DexScreener shows Ben), caches 15 min in `ath.json`, backs off on 429. Returns **None** on failure and the card then says `DIPPED BACK INTO RANGE (ATH could not be verified)`.
2. **`peak_` is no longer read from `pending.json` for display.** The stored `peak` remains only as a rough internal tracker and never reaches a card.
3. **A PRE-SEND GATE in `send()`**, because a prose rule has now failed three times in two days. `gate(text)` refuses to deliver a message that contains `None / nan / undefined / null` as a standalone token, or the words `peaked at`. Verified: both bad shapes BLOCK, both good shapes PASS.
- **Verified by sandbox replay on the exact token that failed:** PAIREX re-run through a copied tree with `max_mcap` raised to $3M, real chain and real API data, stubbed transport only. Card now reads **`⬇️ DIPPED BACK INTO RANGE, real ATH $6,971,729 cap, pool 21.4h old`**, and the string `2,060,434` is absent.

### AUDIT OF EVERY OTHER NUMBER ON THAT CARD, since Ben asked
| field | verdict |
|---|---|
| `cap $554,604` | **CORRECT.** The 12 Sep 17:00 hourly candle ran $457,410 to $1,296,200. |
| `pool 11.3h old` | **CORRECT.** Pool created 12 Sep 05:55:30 UTC; 05:55 + 11.3h = 17:13 UTC = 00:13 Bangkok, which is the timestamp Telegram showed. **The card was NOT stale: Telegram displays local time and I nearly misread that as queue lag.** |
| `pool $61,393`, `traded $4,874,570` | correct values, but `traded ... today` was mislabelled: DexScreener `h24` is a ROLLING 24 hours. Relabelled **`vol 24h`**. |
| `X account 4 days old` vs `X: 1,527 followers, 3 days old` | **BOTH FROM THE SAME NUMBER (3.5), printed with `%.0f` in scan.py and `int()` in linkscan.py.** Two renderings of one value contradicting each other on one card. Both now `round()`. |
| `domain is 0.7 days old` | whois-granularity drift only (0.3d at 13:01, 0.7d at 17:13, 1.1d at 03:19). Not an error. |
- **Fleet sweep for the same pattern, done rather than assumed:** grepped every bot for `peak|ath|max(...cap/mcap/price)`. `coinbench/board.py`, `fillwatch.py` and `pairexmon.py:671` all use `max()` **across a cohort in one read**, which is correct. **LINKSCAN's dip alert was the only temporal sampled max in the fleet.**

### A SECOND BUG THE REPLAY EXPOSED, and it is the third "missing is not zero" today
- The re-run card printed **`X has None followers`** on every token. `if (xi.get("followers") or 0) < 100` turns a FAILED lookup into a real-looking low-follower flag, and with twitterapi.io out of credits every lookup fails. Now: `None` prints **`X data unavailable (lookup failed)`**, a real number prints normally.
- **Same class as the pairexmon zero-read (`indexed coins 274 to 0` from a failed page) and the same class as the sampled max. ABSENT DATA MUST BE REPORTED AS ABSENT.** That is exactly what the new pre-send gate now enforces at the last possible moment.
- Backups: `linkscan.py.athbak-*`, `linkscan.py.gatebak-*`, `scan.py.athbak-*`, `scan.py.nonebak-*`, `backfill.py.athbak-*`. `re` added to linkscan's imports for the gate.

## 13 Sep 2026: `/opt/common/evhist.py`, the shared event-history line. First code in a common module.
- [stated] Ben, after the DELTA merge landed: **"Yes, patch them also."**
- **CORRECTION I OWE THE RECORD: in offering this I named "the HKRS and PRIV buyback cards" as candidates. THERE ARE NO HKRS OR PRIV BOTS.** Checked `/opt/*/`: nothing matches. HKRS and PRIV are tokens I have screened, and PRIV posts burn updates on X, and I turned that into a bot that does not exist. **Never name a bot without listing the directory first.** The real candidates were prmwatch and pairexmon, and that is what shipped.
- **`/opt/common/evhist.py` (md5 `4ed79bce43ef9ad7e214b5c621e41cda`), the first shared library in the fleet.** Three functions, stdlib only:
  - `record(state, key, amount, t=None)` appends one event, returns `(n, gap_seconds_or_None, running_total)`.
  - `histline(state, key, amount, unit, verb, supply)` records AND returns the card line: `#3 · 3.0 days since the last · 4,320,291 DELTA burned in total (0.432% of supply)`.
  - `summary(state, key, unit, window_h)` the digest: `3 in the last 168h, 4,320,238 DELTA (one every 72.0 h) · 3 all time`.
  - History lives under `state["_evhist"][key]`, capped at 80 events, so it drops into any dict a bot already persists without colliding. `since()` scales its own precision: seconds under 2 min, minutes under 2 h, hours under 48 h, days beyond.
- **prmwatch** (`prmwatch.py.bak-20260913-041811`): every fleet card now carries `_hist(kind, amount)`. **Each kind is its own counter** (`prm_auction_bid`, `prm_burn`, `prm_sell`), because one counter across event types is the same mistake as one "alerted" flag across event types, which has now bitten three times. History is kept in **`/opt/prmwatch/evhist.json`, deliberately NOT in `state.json`**, so an alert write can never race the main loop's own state write. `_hist` swallows every exception and returns `""`: a missing history line must never cost Ben the alert itself.
  - Also fixed a real gap from the earlier auction patch: `AUCTION_BID`, `AUCTION_REFUND` and `BURN` existed in `classify()` but had **no icon and no title**, so those cards would have rendered with the bare enum as the headline. Now 🏷 PRM FLEET BID IN A DESK AUCTION, ↩ PRM FLEET WAS OUTBID PRM RETURNED, 🔥 PRM FLEET BURNED. **When you add a new event kind, grep for every place the old kinds are enumerated.**
- **pairexmon** (`pairexmon.py.bak-20260913-041950`): PAIREX burns run several an hour, so a card per burn would be spam and they **stay silent on purpose**. They are now recorded, and the two cards that DO fire carry the rate: the existing "the buyback has gone quiet" card gains a 48h digest, and a **new "the buyback is back" card** fires on the first burn after a quiet alert with the 24h digest. **An alarm now has a matching all-clear**, which is the same shape as the X-blackout BLIND/recovery pair.
- **VERIFIED BY REPLAY** (`/tmp/t2.py` on the droplet, live modules, `send` stubbed): bid #1 renders "first one on record"; bid #2 two seconds later renders "#2 · 2 seconds since the last · 1,450,000 PRM in total"; a BURN card correctly starts its own series at #1. `evhist.json` snapshotted and restored by the test. prmwatch, pairexmon, deltaalert and watchdog-bots all active afterwards, prmwatch tracking the chain tip.
- **DEPLOY NOTE worth keeping: the marker `"TRANSFER = "` does not exist in pairexmon, which writes `TRANSFER= "0x..."` with no space, so the combined patch died mid-run.** prmwatch was already written, pairexmon was not, and nothing was left half-edited **because every string replacement happens in memory and compiles before anything touches disk.** That ordering is the reason a failed patch is safe; keep it in every patch script. pairexmon then shipped separately against an `import urllib.request` anchor.
- **THE STANDARD THIS SETS, and it is now enforceable in one import: every recurring event card in the fleet says which number it is, how long since the last one, and the running total.** Bots still un-audited against it: coinbench, fillwatch, rugscan, unistalk, the wallet-watch family.

## 13 Sep 2026: LINKSCAN v8, THE MARKET EXISTS GATE. "One buy and zero volume. Why is this showing up?"
- [stated] Ben, on the `WORTH A LOOK HSTRAT/ETH` card (`0x1f83023d4239F21e2F20168C179903A598F83939`, cap $9,047, pool $3, vol 24h $0, pair 6 minutes old, domain 0.3d, X 0 days old with 11 followers): **"This thing only has one buy and zero volume. Why is this showing up as a worthwhile thing to look at?"**
- **HE IS RIGHT AND THE GATE DID NOT EXIST.** `verdict()` tested the PRODUCT thoroughly (Group A, COPY, FAKE, IMPOSTOR, OFF PAD, SITE ONLY) and tested the MARKET not at all. The only market-side condition in the whole pipeline was `mc >= min_mcap` (8,000).
- **ROOT CAUSE, first principles: MARKET CAP ON A BONDING-CURVE PAD IS SET BY THE PAD, NOT BY THE MARKET.** Pons opens a launch around $5,000 to $9,000 and CME opens every market at exactly $5,000. So `mc >= 8,000` passes on essentially every launch before one human has arrived. **It had never filtered anything and could not.** The only numbers a market actually sets are **liquidity actually in the pool, dollars actually traded, and people who actually bought.** None of the three was being read: `market()` did not even collect `txns`.
- **A "worth a look" card asserts TWO independent things: (1) a real product exists, (2) a real MARKET exists. Only (1) was ever tested.** That is the generalisable shape, not a LINKSCAN quirk.

### `scan.market_gate(r, cfg)` — EXISTENCE, never size
- **This is explicitly NOT an exit-depth test and it does not touch Ben's standing 2 Sep rule that ticket size is never a disqualifier.** A $3 pool with one buy is not a small opportunity, it is **a token nobody has traded**. That distinction is the whole justification for the gate and it is written into the docstring on the droplet so a future session cannot mistake one for the other.
- Floors, all config-tunable, no code edit: **`gate_min_liq` 250 · `gate_min_buys` 3 · `gate_min_vol` 100**.
- **Pools younger than 1h have no 24h history, so the gate reads the 1h window instead** (`vol_h1`, `buys_h1`). Without that the gate would have killed the early-entry purpose of the whole bot, which is the PAIREX shape Ben wants.
- Fails produce a new silent verdict **`NO MARKET`** with the reason on the record (`NO MARKET: pool $3, 1 buy(s), vol $0`), not a LOOK.
- `market()` now also collects `buys`, `sells`, `buys_h1`, `vol_h1` and the EARLIEST `pairCreatedAt` across pools, and the card gained a **`buys N   sells N`** line so a thin-but-real market is visible rather than inferred.

### BACKTEST AGAINST ALL 60 TOKENS EVER ALERTED, the number that settles it
- **42 would still alert · 18 would have been blocked (30%).**
- Blocked and correctly so: **HSTRAT** (pool $3, 1 buy, $0) · **COMP** (pool $3, 2 buys, $0) · **PONSR** (pool $7, 0 buys, $0) · **STOCKPARCEL** (pool $9, 1 buy, $0) · **OPEC** (pool $0, 0 buys, $0) · UP, TRACE, BRAINRAT, Pawcket, ANY, XPONS, THWAY, ORVIA, plus 5 whose pools no longer exist at all (ZenoCoin, MARIO, Orus, BRODIEWIF, FLY).
- **NOTHING GOOD DIED. Every real hit survives**: Nomo ($709,599 v24), GOMO ($1,077,119), CC ($936,048), SWOLE ($926,151), HOODIE ($843,770), RWI, SILK, MONEY, PACKD, SANTACOIN, RIME, SMA. **The smallest survivors are BSTR (pool $4,996, vol $2,719, 6 buys) and H01 (pool $5,096, vol $272, 17 buys)**, which is the proof the gate is not a size filter: both are tiny and both pass because people are actually trading them.
- Verified on the live case first (`_verify_gate.py` on the droplet re-fetches HSTRAT and prints `GATE -> BLOCKED (correct)`), then on the full alert history. Service restarted 06:2x UTC, active.
- Backups `scan.py.mktbak-*`, `linkscan.py.mktbak-*`, `config.json.mktbak-*`. Scripts left at `/opt/linkscan/_marketgate.py` and `/opt/linkscan/_verify_gate.py`.

### GENERALISABLE, and it belongs in the standing method
- **Before trusting ANY threshold, ask who sets the number being tested.** A launchpad sets the opening market cap, so gating on it is gating on the pad's own constant. The same question kills a lot of bad filters: a project sets its own TVL claim, a promoter sets his own "mcap", a scanner sets its own sampled peak (the 13 Sep ATH bug above is the same disease). **Only gate on numbers a counterparty had to pay for.**
- **Recall-beats-precision (the 12 Sep "send more, I review" rule) is about UNCERTAINTY, not about EMPTINESS.** Sending an ambiguous ticker is cheap and might be the trade. Sending a token with no market is not an ambiguity, it is noise, and it is the thing that trains Ben to stop reading the cards. The two rules do not conflict.
- Worth auditing on the same standard, since they all carry a cap or price floor: coinbench, fillwatch, backfill's `min_mcap`/`max_mcap` band, rugscan's watchlist.
