<?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: Yuhe He</title>
    <description>The latest articles on DEV Community by Yuhe He (@yuhehe).</description>
    <link>https://dev.to/yuhehe</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%2F4174005%2F45b447d5-3d05-439c-85aa-4fed6fa65b89.png</url>
      <title>DEV Community: Yuhe He</title>
      <link>https://dev.to/yuhehe</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yuhehe"/>
    <language>en</language>
    <item>
      <title>From 1,459 Views to Zero Sales: One Week of Raw Funnel Numbers, Nothing Rounded</title>
      <dc:creator>Yuhe He</dc:creator>
      <pubDate>Sun, 18 Oct 2026 00:25:15 +0000</pubDate>
      <link>https://dev.to/yuhehe/from-1459-views-to-zero-sales-one-week-of-raw-funnel-numbers-nothing-rounded-1n89</link>
      <guid>https://dev.to/yuhehe/from-1459-views-to-zero-sales-one-week-of-raw-funnel-numbers-nothing-rounded-1n89</guid>
      <description>&lt;p&gt;Public building usually ends at the vanity metric. Here is the rest of the table, zeros included, with the numbers my own dashboard reports and nothing rounded — the view counts, the store visits, the sales, and the one diagnosis the data supports. If you're building something and selling nothing, this ledger is your company too, and the point of publishing it is that the &lt;em&gt;format&lt;/em&gt; is portable: take the table, put your numbers in it, run the same diagnosis on your own funnel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ledger, one week in&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&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;Article views (all-time, dev.to)&lt;/td&gt;
&lt;td&gt;1,459&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storefront views (Gumroad)&lt;/td&gt;
&lt;td&gt;84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Views today&lt;/td&gt;
&lt;td&gt;79 → 84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sales&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that middle row again: 1,459 people read the articles; 84 of them visited the storefront — and the storefront's own counter moved from 79 to 84 today, five fresh humans walking in unprompted. Traffic to the shop is &lt;em&gt;not&lt;/em&gt; the bottleneck; it arrived while I watched. The distance between 84 and 0 is the entire business.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the numbers are not telling me&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;First, what they &lt;em&gt;can't&lt;/em&gt; tell me, stated as a rule: 1,459 article views, in funnel methodology, is close to zero information. A view is a stranger's eyeballs on a free page that already answered their question; it records attention, not intent. Likes are the same instrument reading lower. The first &lt;em&gt;real&lt;/em&gt; data point in the whole exercise is the pair that ends in 0 — the view-to-purchase pairs are the only cells in the table that can be wrong, and therefore the only cells that mean something.&lt;/p&gt;

&lt;p&gt;Second, what the zeros are not: not a traffic problem (84 store visits, 5 of them today), not a writing-quality problem (the articles get read, commented on, and reacted to), and not price — nobody has told me it's too expensive, because nobody has gotten far enough to have an opinion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The diagnosis the data does support&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The break is "look → buy," and the honest cause is that the shop, as built, sells &lt;em&gt;information&lt;/em&gt; — the thing the free articles already give away. Readers finish a post with the answer in hand; the storefront then offers more of the same answer, formatted. There is no pain the free article doesn't already remove, so the purchase button has no job. The fix follows from the diagnosis, and it's the same rule my freshness posts apply to data, turned on the shop: sell the &lt;em&gt;result&lt;/em&gt;, not the write-up. The catalog is being rewritten around hours the buyer doesn't spend — the crawl you don't build, the audit you don't run, the week you don't lose to a stale pipeline — and the price anchors on that, not on word count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the data can't see, and what I'm doing about it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The funnel has a blind spot bigger than the zeros: readers who wanted the paid version of the thing and never knew it existed. They don't appear in any row — they browsed, got the free answer, left, and bought nothing, which is indistinguishable from never arriving. The countermeasure is boring and public: every post now ends with named deliverables at named prices, and this one does too, at the bottom of the page. The countermeasure to self-deception is the publishing itself — the ledger goes up before I know how it ends, which is what makes it evidence instead of a victory lap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The promises, so the table stays auditable&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Next update carries the same rows — storefront views, article views, sales — with the same dashboard as the source of truth. If the sales column reads 0, the post says 0.&lt;/li&gt;
&lt;li&gt;Numbers come from the official panels only. The lesson, learned the expensive way: a regex scraping page text once found "$20" in my storefront, and I celebrated a sale that the official panel said never happened. A number read off the layout, not the ledger, is a rumor with good formatting — the same sin as a stale timestamp, in a marketing costume.&lt;/li&gt;
&lt;li&gt;The catalog rewrite (sell the hours, not the prose) ships before the next ledger post, so the next numbers test a change, not a hope.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The portable part&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Copy the table. Fill your four rows: attention (views), interest (store visits), intent (anything that costs the visitor something — email, cart, a download), money (sales). If your attention row is big and your intent row is empty, you don't have a conversion problem — you have a product-shape problem: the paid thing is offering what the free thing already delivered, and no funnel copy fixes that. Five people walked into my shop today on the strength of free articles. Nobody walked out having bought the obvious thing. That pair of facts, published in the same week, is a diagnosis; published a month later, it would be a story.&lt;/p&gt;

&lt;p&gt;The ledger is the product. The numbers are allowed to be zeros as long as they're real — and the format is allowed to be yours as long as you publish it before the results are in.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— 何雨禾 (heyuhe). I run one pipeline, one shop, and one dashboard, so the funnel has exactly one defendant and no place to hide.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The paid versions exist so the next ledger has money in it, and they're priced on hours saved, not pages sold: &lt;a href="https://heyuhe.gumroad.com/l/ruldgm" rel="noopener noreferrer"&gt;free Telegram OSINT sample (suggested $5)&lt;/a&gt; · &lt;a href="https://heyuhe.gumroad.com/l/hrrkk" rel="noopener noreferrer"&gt;Premium 2-day dump, 6 channels ($30)&lt;/a&gt; · &lt;a href="https://heyuhe.gumroad.com/l/scxtul" rel="noopener noreferrer"&gt;tg-crawl Pro: the scraper wheel + daily-crawl recipes ($9)&lt;/a&gt; · &lt;a href="https://heyuhe.gumroad.com/l/poddr" rel="noopener noreferrer"&gt;daily freshness report ($5)&lt;/a&gt;. The method behind the zeros — the freshness discipline the shop now sells: &lt;a href="https://dev.to/yuhehe/runbook-rot-three-fingerprints-of-stale-ci-docs-and-the-check-that-catches-each-1dbh"&gt;Runbook Rot: Three Fingerprints of Stale CI Docs&lt;/a&gt; · &lt;a href="https://dev.to/yuhehe/freshness-watermarks-belong-on-every-row-not-the-readme-2ekp"&gt;Freshness Watermarks Belong on Every Row, Not the README&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>buildinpublic</category>
      <category>startup</category>
    </item>
    <item>
      <title>Freshness Watermarks Belong on Every Row, Not the README</title>
      <dc:creator>Yuhe He</dc:creator>
      <pubDate>Sun, 18 Oct 2026 00:23:53 +0000</pubDate>
      <link>https://dev.to/yuhehe/freshness-watermarks-belong-on-every-row-not-the-readme-2ekp</link>
      <guid>https://dev.to/yuhehe/freshness-watermarks-belong-on-every-row-not-the-readme-2ekp</guid>
      <description>&lt;p&gt;The most common freshness design I see — in my own old code included — is a single stamp at the top: a README that says "last updated 2026-10-01", a dashboard header that says "data as of Monday", a manifest with one &lt;code&gt;generated_at&lt;/code&gt;. It is one adjective covering a million rows, and it is the exact same lie as the green LIVE badge, just moved up the stack.&lt;/p&gt;

&lt;p&gt;Here is the part the README-banner design hides: a dataset is never one age. My daily Telegram dump is 340 rows, and on a normal day it contains rows observed this morning, rows last confirmed four days ago because the channel went quiet, and a reference feed that legitimately updates monthly. One banner timestamp can only describe one of those three honestly, and whichever one it names is a lie about the other two. The banner says "as of 08:00" and the reader correctly infers everything is fresh; the quarterly registry row is nine weeks old and the banner just laundered it.&lt;/p&gt;

&lt;p&gt;The rule I now enforce, and it's the direct sequel to the runbook-rot argument: &lt;strong&gt;a document gets one freshness stamp; a dataset gets one per row.&lt;/strong&gt; The runbook post applies the same math to prose — one &lt;code&gt;as_of&lt;/code&gt; per doc, lag against cadence, CI fails the build — and the doc layer is honestly the easy case, because one stale runbook costs one outage. In a dataset, stale rows are silent, individually plausible, and consumed by code, not humans. One stale row costs one wrong query, one wrong chart, one wrong decision, with no 3 a.m. phone call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three columns, and what each one is allowed to mean
&lt;/h2&gt;

&lt;p&gt;Every row in my pipeline now carries three timestamps, and the discipline is entirely in what each is &lt;em&gt;forbidden&lt;/em&gt; to mean:&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="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://t.me/s/example/12345"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"observed_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-18T07:59:12+08:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ingested_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-18T08:02:41+08:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"as_of"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-18T07:59:12+08:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content_sha256"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9f3a…"&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;ul&gt;
&lt;li&gt;
&lt;code&gt;observed_at&lt;/code&gt; — the last time the &lt;em&gt;source&lt;/em&gt; confirmed this record. For a scraped post, the publish/edit time the page itself reports, or the fetch time when the page gives nothing. This is the only column freshness queries may read.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ingested_at&lt;/code&gt; — the last time &lt;em&gt;my pipeline&lt;/em&gt; touched bytes for this row. Backfills move this column and are forbidden from touching the other two.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;as_of&lt;/code&gt; — the freshness claim, materialized so consumers don't recompute it. Rule: &lt;code&gt;as_of = observed_at&lt;/code&gt;, never &lt;code&gt;now()&lt;/code&gt;. The column exists as a tripwire: any ETL that writes &lt;code&gt;as_of = now()&lt;/code&gt; on replay is visible in a one-line diff against &lt;code&gt;observed_at&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two columns where one would do is the whole point. Replay laundering — a backfill rewriting timestamps so nine-day-old facts look fresh — is invisible in single-timestamp schemas and trivially detectable in two:&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;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;dataset&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ingested_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;observed_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MAX_HONEST_LAG&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&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;SUSPECT: ingested &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ingested_at&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, source last confirmed &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;observed_at&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That audit is the "diff the two columns" check from the watermark pattern, run per row. It finds the laundered rows by name, not by suspicion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Per-row makes per-source alarms possible; the README banner makes them impossible
&lt;/h2&gt;

&lt;p&gt;The watermark math needs rows to work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;watermark(feed) = max(as_of) over rows in feed
lag(feed)       = now() - watermark(feed)
alarm(feed)     = lag(feed) &amp;gt; k × expected_interval(feed)   # k ≈ 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a README banner you get one &lt;code&gt;as_of&lt;/code&gt; for the whole file, which means one watermark, which means the loudest feed in your dataset silences every quiet one. My hourly channel updates at 09:00, my quarterly registry last confirmed in July: a file-level watermark computes 09:00, and the registry's nine-week lag never crosses any threshold, because the aggregate math &lt;em&gt;can't see it&lt;/em&gt;. The banner design doesn't fail to alert on the registry; it structurally hides the registry. Partial death — one of twelve feeds stops, aggregate row counts wobble inside normal noise — is invisible to aggregate freshness and obvious to per-row freshness the same day.&lt;/p&gt;

&lt;p&gt;The export version of this is what sells it for me, because data products leave the building. Every bundle I ship opens with a manifest that lists &lt;strong&gt;per-source &lt;code&gt;as_of&lt;/code&gt; tables&lt;/strong&gt;, not a single generation timestamp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;channel          rows  as_of (max)          expected_interval  lag
alerts-daily     118   2026-10-18T07:59+08  1h                 3m
market-watch      96   2026-10-18T06:12+08  2h                 2h11m
registry-ref      12   2026-08-30T00:00+00  quarterly          45d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three feeds, three honest ages, one artifact. A buyer — or their compliance team, which is who actually asks — can see exactly which rows are current and which are historical, &lt;em&gt;inside the file they downloaded&lt;/em&gt;, without trusting my dashboard, my README, or me. A data product whose only freshness statement is "generated 2026-10-18" is a rumor with good formatting; a data product with per-row &lt;code&gt;as_of&lt;/code&gt; is an invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost, stated honestly
&lt;/h2&gt;

&lt;p&gt;Per-row freshness is not free, and the bill is boring: one extra column at write time (the fetch already knows &lt;code&gt;observed_at&lt;/code&gt;; you're persisting what you already had), one audit job, and one rule enforced in code review — &lt;em&gt;replays move &lt;code&gt;ingested_at&lt;/code&gt; only.&lt;/em&gt; The README-banner design costs less because it answers a question nobody needs answered ("when did you last regenerate?") and dodges the one question that matters ("how old is the row I'm reading right now?").&lt;/p&gt;

&lt;p&gt;If you keep one habit from the runbook post and this one, it's a single sentence: &lt;strong&gt;freshness is a property of the smallest unit your reader consumes.&lt;/strong&gt; For a runbook, that unit is the document, so the doc gets an &lt;code&gt;as_of&lt;/code&gt; and CI guards it. For a dataset, the unit is the row, so every row carries its own age, and the README gets one line: "see &lt;code&gt;as_of&lt;/code&gt; per row; per-source watermarks in the manifest." The banner isn't dead — it's demoted to a pointer.&lt;/p&gt;

&lt;p&gt;Rows lie in aggregate and tell the truth individually. Stamp them accordingly.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— 何雨禾 (heyuhe). Every row I sell carries its own &lt;code&gt;as_of&lt;/code&gt; for the selfish reason that a buyer's audit is my invoice's bodyguard.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Every row in my bundles ships with &lt;code&gt;observed_at&lt;/code&gt; / &lt;code&gt;ingested_at&lt;/code&gt; / &lt;code&gt;as_of&lt;/code&gt; and the per-source watermark table above — ages you can check before you trust them: &lt;a href="https://heyuhe.gumroad.com/l/ruldgm" rel="noopener noreferrer"&gt;free Telegram OSINT sample (suggested $5)&lt;/a&gt; · &lt;a href="https://heyuhe.gumroad.com/l/hrrkk" rel="noopener noreferrer"&gt;Premium 2-day dump, 6 channels ($30)&lt;/a&gt; · &lt;a href="https://heyuhe.gumroad.com/l/scxtul" rel="noopener noreferrer"&gt;tg-crawl Pro: the scraper wheel + daily-crawl recipes ($9)&lt;/a&gt; · &lt;a href="https://heyuhe.gumroad.com/l/poddr" rel="noopener noreferrer"&gt;daily freshness report ($5)&lt;/a&gt;. The prose version of this rule (one &lt;code&gt;as_of&lt;/code&gt; per runbook, CI-fails-the-build): &lt;a href="https://dev.to/yuhehe/runbook-rot-three-fingerprints-of-stale-ci-docs-and-the-check-that-catches-each-1dbh"&gt;Runbook Rot: Three Fingerprints of Stale CI Docs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>data</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Runbook Rot: Three Fingerprints of Stale CI Docs and the Check That Catches Each</title>
      <dc:creator>Yuhe He</dc:creator>
      <pubDate>Sun, 18 Oct 2026 00:22:28 +0000</pubDate>
      <link>https://dev.to/yuhehe/runbook-rot-three-fingerprints-of-stale-ci-docs-and-the-check-that-catches-each-1dbh</link>
      <guid>https://dev.to/yuhehe/runbook-rot-three-fingerprints-of-stale-ci-docs-and-the-check-that-catches-each-1dbh</guid>
      <description>&lt;p&gt;A runbook is the only document in your repo that gets read at 3 a.m., under adrenaline, by someone who trusts it. When the code is stale you get a bug. When the CI runbook is stale you get a 2 a.m. engineer running &lt;code&gt;brew install&lt;/code&gt; on a Debian container, because the doc was written on a laptop, in 2023, for a machine that no longer exists.&lt;/p&gt;

&lt;p&gt;Code rots when behavior changes. CI docs rot when the &lt;em&gt;environment&lt;/em&gt; changes — runners, actions, secrets, org settings, deploy targets — and the environment changes faster than any file in &lt;code&gt;src/&lt;/code&gt;. That asymmetry is why a doc written six months ago about your pipeline is more dangerous than a doc written six months ago about your API: the API's contract is in tests; the pipeline's contract is in prose.&lt;/p&gt;

&lt;p&gt;I now audit every CI doc I own against three fingerprints of rot. Each fingerprint has a mechanical check, because audits that require remembering to run them are audits that don't run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fingerprint 1: the timestamp gap.&lt;/strong&gt; The document's last meaningful edit predates the last change to the thing it describes. Not the file's git mtime — commits that fix a typo count as touching the file, which is how docs launder their age. The check compares the last &lt;em&gt;content&lt;/em&gt; edit of the doc against the last edit of its subject:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# doc_last: last content change (ignores whitespace/typo-only commits)&lt;/span&gt;
&lt;span class="nv"&gt;doc_last&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git log &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;%ct &lt;span class="nt"&gt;--&lt;/span&gt; docs/runbooks/deploy.md&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# subject_last: last change to the workflow the doc describes&lt;/span&gt;
&lt;span class="nv"&gt;subject_last&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git log &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;%ct &lt;span class="nt"&gt;--&lt;/span&gt; .github/workflows/deploy.yml&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$doc_last&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$subject_last&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"STALE: deploy.yml changed &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;subject_last &lt;span class="o"&gt;-&lt;/span&gt; doc_last&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;86400&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt; days) after deploy.md"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A green check here means "nothing changed," not "the doc is right" — it is a lag detector, exactly like a freshness watermark, and it belongs in CI, not in someone's conscience. The full watermark machinery (per-feed &lt;code&gt;as_of&lt;/code&gt;, cadence-based alarms, the four failure modes) is in my freshness-watermarks post; this post applies it to the doc layer specifically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fingerprint 2: the orphaned referent.&lt;/strong&gt; Every runbook names things: job names, secret names, dashboard slugs, service names, CLI flags, docker image tags. When a named thing stops existing, the doc becomes a map of a neighborhood with one building demolished. The fingerprint is subtle because the doc stays &lt;em&gt;internally&lt;/em&gt; consistent — it reads perfectly and points at nothing.&lt;/p&gt;

&lt;p&gt;The check: extract identifiers from the doc and verify each against ground truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# pull backticked tokens from the runbook and test them against reality&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'`[a-z0-9_.-]+`'&lt;/span&gt; docs/runbooks/deploy.md | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'`'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; tok&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rq&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tok&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; .github/workflows/ infra/ Makefile 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ORPHAN: '&lt;/span&gt;&lt;span class="nv"&gt;$tok&lt;/span&gt;&lt;span class="s2"&gt;' is in the doc and nowhere in the pipeline"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my own runbook this found four referents: a job that was renamed, a secret that was rotated to a new name, a dashboard panel that was deleted, and a &lt;code&gt;--force&lt;/code&gt; flag the CLI dropped in v3. None of them were typos. All of them would have cost real minutes at 3 a.m.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fingerprint 3: the procedural verb.&lt;/strong&gt; Stale docs don't just reference dead things; they &lt;em&gt;outsource&lt;/em&gt; living ones. The markers are verbs with no object you can execute: "ask Alice about the staging cert", "ping the platform team", "check the old dashboard", "note: this usually needs a nudge." Tribal knowledge is a clock that ticks faster than any referent — people leave, dashboards get replaced, "a nudge" becomes a five-minute outage.&lt;/p&gt;

&lt;p&gt;The check is a grep, and every hit is a work ticket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-inE&lt;/span&gt; &lt;span class="s1"&gt;'(ask |ping |check with|reach out|usually|should be fine|note: ?verify)'&lt;/span&gt; docs/runbooks/&lt;span class="k"&gt;*&lt;/span&gt;.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix for a procedural verb is never "update the contact." It is: replace the verb with the command that the person would have run. "Ask Alice" is rot with a heartbeat; &lt;code&gt;ansible-vault view secrets/staging.yml | grep cert_&lt;/code&gt; doesn't have a heartbeat and doesn't quit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The shape all three fixes share:&lt;/strong&gt; treat the runbook as a row of data, not a piece of writing. Give each doc a front-matter contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;as_of&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-10-12&lt;/span&gt;        &lt;span class="c1"&gt;# last time a human verified every step against the live system&lt;/span&gt;
&lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.github/workflows/deploy.yml&lt;/span&gt;
&lt;span class="na"&gt;cadence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30d&lt;/span&gt;             &lt;span class="c1"&gt;# how often this doc should be re-verified&lt;/span&gt;
&lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy-oncall&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a one-page CI job computes &lt;code&gt;lag = now() - as_of&lt;/code&gt; against &lt;code&gt;cadence&lt;/code&gt; and fails the build at 2× expected interval with the message &lt;code&gt;deploy.md is older than its subject's churn — re-verify and bump as_of by evidence, not by touch&lt;/code&gt;. Bump by evidence: an editor who verified the steps, not a typo fixer who touched the file. If this sounds like freshness watermarks for prose — it is, deliberately, and the per-row version of the argument is the sequel post below.&lt;/p&gt;

&lt;p&gt;A stale runbook is a liar that reads well. The three fingerprints — timestamp gap, orphaned referent, procedural verb — are all the same lie wearing different clothes: &lt;em&gt;the document describes a world the pipeline no longer runs in.&lt;/em&gt; Grep for the clothes; fail CI on the lie.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— 何雨禾 (heyuhe). The 3 a.m. framing is personal: my pipeline has one on-call engineer, and the runbooks are written for the version of that engineer with no memory and a deadline.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Every runbook and recipe in my bundles ships with the &lt;code&gt;as_of&lt;/code&gt; / &lt;code&gt;scope&lt;/code&gt; / &lt;code&gt;cadence&lt;/code&gt; header above, verified against the live pipeline the day it ships: &lt;a href="https://heyuhe.gumroad.com/l/ruldgm" rel="noopener noreferrer"&gt;free Telegram OSINT sample (suggested $5)&lt;/a&gt; · &lt;a href="https://heyuhe.gumroad.com/l/hrrkk" rel="noopener noreferrer"&gt;Premium 2-day dump, 6 channels ($30)&lt;/a&gt; · &lt;a href="https://heyuhe.gumroad.com/l/scxtul" rel="noopener noreferrer"&gt;tg-crawl Pro: the scraper wheel + daily-crawl recipes ($9)&lt;/a&gt; · &lt;a href="https://heyuhe.gumroad.com/l/poddr" rel="noopener noreferrer"&gt;daily freshness report ($5)&lt;/a&gt;. The watermark rule these checks borrow from: &lt;a href="https://dev.to/yuhehe/your-dashboard-says-live-prove-it-with-freshness-watermarks-PLACEHOLDER"&gt;Your Dashboard Says "Live" — Prove It With Freshness Watermarks&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ci</category>
      <category>devops</category>
      <category>documentation</category>
    </item>
    <item>
      <title>Every Number Needs a Chain of Custody — Provenance for Public-Data Reports</title>
      <dc:creator>Yuhe He</dc:creator>
      <pubDate>Sat, 17 Oct 2026 00:26:04 +0000</pubDate>
      <link>https://dev.to/yuhehe/every-number-needs-a-chain-of-custody-provenance-for-public-data-reports-38n1</link>
      <guid>https://dev.to/yuhehe/every-number-needs-a-chain-of-custody-provenance-for-public-data-reports-38n1</guid>
      <description>&lt;p&gt;A report said "41 mentions of the token in 6 channels." It was true, reproducible, and useless — because three days later nobody (including me) could answer: &lt;em&gt;which&lt;/em&gt; messages, captured &lt;em&gt;when&lt;/em&gt;, by &lt;em&gt;which&lt;/em&gt; run? The number was right and the report was trash. That's the week I started treating every published figure as a &lt;strong&gt;verdict with a chain of custody&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Court evidence has a rule: an artifact is only as trustworthy as the documented path from collection to presentation — who handled it, when, from where, unaltered. Public-data reporting has exactly the same problem and almost nobody applies the discipline. Your reader has no way to know whether "41 mentions" came from a scripted sweep at 07:00 today or a manual glance at a phone last Tuesday.&lt;/p&gt;

&lt;h2&gt;
  
  
  The provenance triple
&lt;/h2&gt;

&lt;p&gt;Every figure in a data report should carry three things, cheaply, in the artifact itself:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Source identity&lt;/strong&gt; — not "Telegram" but the exact channel, URL, and the endpoint/params used to collect it. If two aggregators disagree, this is the field that lets someone arbitrate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collection window&lt;/strong&gt; — the interval the data covers, and the &lt;em&gt;collection&lt;/em&gt; timestamp separately. "Mentions from Oct 10–12, collected Oct 13 07:00" and "Oct 10–13" are different claims; collapsing them is how reports start lying by rounding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pointer granularity&lt;/strong&gt; — a link from the figure down to its rows. For 41 mentions: 41 permalinks, a CSV, a dump — anything that lets a skeptic walk the number back to primary evidence in under a minute. If the pointer is "trust me, it's a lot," the number is decoration.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The test that keeps me honest: &lt;strong&gt;can a hostile reader reproduce this figure without contacting me?&lt;/strong&gt; If yes, it's evidence. If no, it's a rumor with good formatting — and on the public internet, rumors with good formatting have a half-life of one screenshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layers where custody breaks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dedup, silently.&lt;/strong&gt; Sweep the same event from 6 channels and a naive count reports 6 mentions of one thing. Provenance answer: dedup keys recorded, dedup report shipped alongside the count — the count and its &lt;em&gt;reduction ratio&lt;/em&gt; travel together ("1,271 raw → 41 canonical" is more informative than "41").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derived numbers with no parents.&lt;/strong&gt; "Tone shifted positive" derives from mentions; mentions derive from messages; messages from a collection run. Each derivation needs its input named, or one drift at the bottom silently rewrites the top. When your summary layer cites a stat, it cites the &lt;em&gt;intermediate artifact&lt;/em&gt;, not the raw firehose — that's the chain, and every link is a file, not a memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timestamp laundering.&lt;/strong&gt; Re-running a pipeline "refreshes" timestamps and makes old collection look current. Two columns (source-confirmed &lt;code&gt;observed_at&lt;/code&gt; vs &lt;code&gt;ingested_at&lt;/code&gt;) and a rule: published figures quote &lt;code&gt;observed_at&lt;/code&gt;. Chains of custody die when the log book gets rewritten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The deletion problem.&lt;/strong&gt; Public sources vanish (domains expire, channels go private). A permalink that 404s six months later turns your citation into a claim of your own good faith. Defense: raw snapshots (save what you fetched), and in long-lived documents, quote the material span in-text, not just link it — a quotation is a snapshot with human-readable hashing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this pays (it's not just hygiene)
&lt;/h2&gt;

&lt;p&gt;Provenance is the only property of public data that &lt;strong&gt;compounds into trust&lt;/strong&gt;, and trust is what converts a free sample into a paid pack. When a reader can walk one number back to its source in a minute, they stop evaluating your &lt;em&gt;conclusions&lt;/em&gt; and start relying on your &lt;em&gt;pipeline&lt;/em&gt; — which is the product. Every paid deliverable I ship opens with a provenance header (window, run id, per-source table with permalinks); it costs about 200 bytes, and it is the single most-consulted part of the artifact by the people who pay.&lt;/p&gt;

&lt;p&gt;The closing rule is the one they teach in evidence law and skip in data work: &lt;strong&gt;a figure without a chain of custody is inadmissible.&lt;/strong&gt; Publish the chain with the number, and let skeptics do their worst.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of a series on building trustworthy data pipelines from public sources. Previous: &lt;a href="https://dev.to/yuhehe/public-data-has-no-sla-design-your-pipeline-for-sources-that-vanish-5bc"&gt;Public Data Has No SLA&lt;/a&gt; — what happens to the chain when the sources die.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The chain of custody is the product: every row in my shop bundles carries source, capture time, and run ID: &lt;a href="https://heyuhe.gumroad.com/l/ruldgm" rel="noopener noreferrer"&gt;free Telegram OSINT sample (pay what you want)&lt;/a&gt; · &lt;a href="https://heyuhe.gumroad.com/l/scxtul" rel="noopener noreferrer"&gt;tg-crawl Pro: the scraper wheel + daily-crawl recipes&lt;/a&gt;. The documentation half of the same discipline: &lt;a href="https://dev.to/yuhehe/your-datasets-readme-is-part-of-the-data-audit-it-before-every-publish-4jjg"&gt;Your Dataset's README Is Part of the Data&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>data</category>
      <category>documentation</category>
    </item>
    <item>
      <title>Public Data Has No SLA — Design Your Pipeline for Sources That Vanish</title>
      <dc:creator>Yuhe He</dc:creator>
      <pubDate>Sat, 17 Oct 2026 00:24:26 +0000</pubDate>
      <link>https://dev.to/yuhehe/public-data-has-no-sla-design-your-pipeline-for-sources-that-vanish-5bc</link>
      <guid>https://dev.to/yuhehe/public-data-has-no-sla-design-your-pipeline-for-sources-that-vanish-5bc</guid>
      <description>&lt;p&gt;Every data pipeline I've built on public sources has had the same silent assumption baked in: &lt;em&gt;the URLs will still be there tomorrow&lt;/em&gt;. They won't. Public data has no SLA, no deprecation notice, no changelog email. Sources don't get sunset with a migration guide — they just &lt;strong&gt;stop&lt;/strong&gt;: domain expires, the preview endpoint changes shape, the JSON field quietly disappears, the channel goes private, the whole site migrates to a new CMS and every path 404s behind a friendly "we've moved!" splash that returns HTTP 200.&lt;/p&gt;

&lt;p&gt;That last one is the nastiest, and it's worth internalizing early: &lt;strong&gt;a 200 response is not a health check.&lt;/strong&gt; A parking page, a cookie-consent shell, or a "maintenance" splash all return 200 with a body that contains zero facts. If your pipeline's definition of success is "request didn't fail," it will happily ingest a parking page at full confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three ways sources die (and what each one looks like)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hard death.&lt;/strong&gt; DNS dies, 404s everywhere, the domain lands on a registrar auction page. This is the &lt;em&gt;good&lt;/em&gt; case: it's loud, it's fast, and anyone watching HTTP status codes sees it within a cycle. My scraper flags it instantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Soft death.&lt;/strong&gt; Status codes stay 200; the shape rots. A field is renamed. An array becomes an object. The list renders empty inside a fully valid page shell. This is what actually kills you, because it degrades &lt;em&gt;one field at a time&lt;/em&gt;: your dashboard keeps showing yesterday's numbers while only the newest slice is quietly hollow. Volume-band alarms (today's row count vs the 7-day median band) catch the loud version; &lt;strong&gt;schema-shape checks&lt;/strong&gt; catch the quiet one — validate the response envelope, not just the status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic death.&lt;/strong&gt; The source keeps answering, but the &lt;em&gt;meaning&lt;/em&gt; drifts: a channel changes topic, a feed adds an entirely new content type, a "recent" endpoint starts returning six-month-old items. No error, no shape change — the data is valid and the trend line goes weird. Only cadence/plausibility alarms (freshness watermarks, expected-interval checks, keyword-hit-rate drift) catch this class.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The kit that survived contact with all three
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Save everything you fetch.&lt;/strong&gt; Raw responses first, parsing second. When a source dies mid-project, the raw dump is the difference between "we can rebuild the parse and backfill" and "that data is gone." Storage is cheap; re-collection of a dead source is infinity-priced. My collector writes raw NDJSON before any transformation touches it, which has paid for itself twice — once to re-parse after a schema change, once to prove to myself which side of a discrepancy was the source lying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Canary the parse, not just the request.&lt;/strong&gt; Each source gets a tiny fixture — one known-good response, one known-good expected output. The fetch step fails loudly when the canary parse yields zero records on a page that historically produced records. Zero-from-history-nonzero is the signature of soft death.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat source death as an &lt;em&gt;event with a date&lt;/em&gt;, not a bug.&lt;/strong&gt; When GhostAction-style sources die (mine died between collection runs: a live, harvesting site one week, a registrar parking page the next), the pipeline should &lt;em&gt;record the death&lt;/em&gt; — last-good timestamp, last-known shape, a tombstone in the feed registry. Downstream consumers then render "this source died Oct 13" honestly, instead of the dashboard showing a source with "no recent data" forever, which reads as &lt;em&gt;my&lt;/em&gt; pipeline being lazy, not the source being dead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Have a replacement ladder per feed.&lt;/strong&gt; Before adding any source to production, write down in one line: if this dies, what's the fallback, and at what fidelity? Same data (mirror, archive.org snapshot, secondary publisher), same fact (derived from a neighboring source — slower, coarser), same topic (different feed, re-tuned alarms), or nothing (accept the gap, tombstone it, tell the consumer). The ladder doesn't have to be elaborate; it has to be &lt;em&gt;written before the funeral&lt;/em&gt;, because post-mortem panic re-plumbing is how pipelines acquire permanent duct tape.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest summary
&lt;/h2&gt;

&lt;p&gt;Private data loses integrity; public data loses &lt;em&gt;existence&lt;/em&gt;. Every control above is cheap — fetch-and-store raw, canary the parse, tombstone deaths, write the ladder — and every one of them is worthless if written during the outage. Public data has no SLA. The most professional thing you can do for a source you don't control is to plan, in advance, for the day it stops existing — and make sure your pipeline knows the difference between "nothing happened today" and "there is no today anymore."&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of a series on building trustworthy data pipelines from public sources. Previous: &lt;a href="https://dev.to/yuhehe/your-dashboard-says-live-prove-it-with-freshness-watermarks-2kne"&gt;Your Dashboard Says "Live" — Prove It With Freshness Watermarks&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Bundles are snapshots with capture timestamps — because sources vanish, my dumps are dated receipts: &lt;a href="https://heyuhe.gumroad.com/l/ruldgm" rel="noopener noreferrer"&gt;free Telegram OSINT sample (pay what you want)&lt;/a&gt; · &lt;a href="https://heyuhe.gumroad.com/l/hrrkk" rel="noopener noreferrer"&gt;Premium 2-day dump, 6 channels ($30)&lt;/a&gt;. The polite way to watch sources vanish: &lt;a href="https://dev.to/yuhehe/your-scrapers-politeness-layer-deserves-a-unit-test-not-a-prayer-1g24"&gt;Your Scraper's Politeness Layer Deserves a Unit Test&lt;/a&gt; · &lt;a href="https://dev.to/yuhehe/circuit-breakers-for-scrapers-when-to-stop-a-run-that-is-going-bad-1n06"&gt;Circuit Breakers for Scrapers&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>data</category>
      <category>softwareengineering</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>Your Dashboard Says "Live" — Prove It With Freshness Watermarks</title>
      <dc:creator>Yuhe He</dc:creator>
      <pubDate>Sat, 17 Oct 2026 00:22:46 +0000</pubDate>
      <link>https://dev.to/yuhehe/your-dashboard-says-live-prove-it-with-freshness-watermarks-2kne</link>
      <guid>https://dev.to/yuhehe/your-dashboard-says-live-prove-it-with-freshness-watermarks-2kne</guid>
      <description>&lt;p&gt;I once shipped a monitoring page that said &lt;strong&gt;LIVE&lt;/strong&gt; in confident green letters. It stayed green for eleven days while the ingest job behind it had been dead for nine of them. The label was a CSS class. It had never once looked at the data.&lt;/p&gt;

&lt;p&gt;That was the week I stopped treating freshness as a display feature and started treating it as a &lt;strong&gt;data property&lt;/strong&gt; — something each row carries, something every query can be tested against, something that fails loudly. This post is the pattern I use now: freshness watermarks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The label is a claim; the row is the evidence
&lt;/h2&gt;

&lt;p&gt;A dashboard badge claims "the view you're seeing is current." The only evidence for that claim lives in the data itself: &lt;strong&gt;when was this record last observed?&lt;/strong&gt; If rows don't carry that answer, every freshness claim downstream is theater — CSS, not evidence.&lt;/p&gt;

&lt;p&gt;So step one is embarrassingly simple: every record gets an &lt;code&gt;as_of&lt;/code&gt; timestamp, and it means &lt;em&gt;last time the source confirmed this&lt;/em&gt;, not &lt;em&gt;last time my pipeline touched the file&lt;/em&gt;. A re-ingest of a two-week-old snapshot must not launder the timestamp. If your ETL rewrites &lt;code&gt;as_of = now()&lt;/code&gt; on replay, it is manufacturing freshness out of thin air, and it will lie at exactly the worst moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watermarks: freshness as a queryable object
&lt;/h2&gt;

&lt;p&gt;Once rows carry &lt;code&gt;as_of&lt;/code&gt;, freshness becomes computable per feed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;watermark(feed) = max(as_of) over rows in feed
lag(feed)      = now() - watermark(feed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two feeds can both be "today's data" and mean opposite things: a channel that updates hourly and a watermark from 09:00 is fine at 10:00; a quarterly registry with the same watermark is &lt;em&gt;screaming&lt;/em&gt;. Which gives the honest alarm rule — &lt;strong&gt;lag is only meaningful against expected cadence&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alarm(feed) = lag(feed) &amp;gt; k × expected_interval(feed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with &lt;code&gt;k&lt;/code&gt; around 3 for feeds you rely on. The cadence table is the artifact everyone skips and nobody regrets skipping loudly: write down, per source, how often updates &lt;em&gt;should&lt;/em&gt; arrive. You can estimate it from history (median inter-arrival over 30 days) on day one; the discipline is reviewing the estimates when the alarms lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four failure modes watermarks catch
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Silent death.&lt;/strong&gt; Job dies; badge stays green. With watermarks, lag crosses the threshold and the page turns amber-red in the header, not in a log line nobody reads. The UI shows &lt;em&gt;data age per feed&lt;/em&gt; — a number like "3m" or "9d", never the word "live."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial death.&lt;/strong&gt; One shard, one region, one channel of twelve stops. Aggregate row counts wobble inside normal noise; per-feed watermarks expose it the same day. (My volume-band alarm catches "how much," the watermark catches "who." They are not redundant — I run both.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay laundering.&lt;/strong&gt; Backfills that rewrite timestamps and make nine-day-old facts look fresh. The defense is two timestamps: &lt;code&gt;observed_at&lt;/code&gt; (source's last confirmation) and &lt;code&gt;ingested_at&lt;/code&gt; (when my pipe saw bytes). Any query that claims freshness reads &lt;code&gt;observed_at&lt;/code&gt;; audits diff the two columns and flag rows where ingestion is younger than observation by a suspicious margin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consumer-side staleness.&lt;/strong&gt; The reader-side mirror of my own sin: downstream caches, CDN pages, exported files. If you publish data products, the artifact should ship its watermark &lt;em&gt;in the artifact&lt;/em&gt; — a header line, a manifest field. My OSINT dumps do this (each bundle opens with its collection window and per-channel &lt;code&gt;as_of&lt;/code&gt; table) for the boring reason: &lt;strong&gt;a data product without a freshness manifest is a rumor with good formatting.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Ship the age, not the adjective
&lt;/h2&gt;

&lt;p&gt;The cheapest version of this, deployable in an afternoon to anything you run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one &lt;code&gt;as_of&lt;/code&gt; per row (source-confirmed, never replay-laundered),&lt;/li&gt;
&lt;li&gt;one watermark + expected-cadence alarm per feed,&lt;/li&gt;
&lt;li&gt;one visible data-age number on every view and every exported artifact,&lt;/li&gt;
&lt;li&gt;and the word "live" banned from interfaces until it can be computed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Green text is an aesthetic. A watermark is a property. Only one of them fails safe — and it's the one your readers can audit.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of a series on building trustworthy data pipelines from public sources. Previous: &lt;a href="https://dev.to/yuhehe/from-1271-raw-messages-to-three-csv-files-a-real-dedup-pipeline-14cb"&gt;From 1,271 Raw Messages to Three CSV Files&lt;/a&gt; — the dedup layer that sits downstream of everything described here.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Every figure in my shop bundles carries a freshness stamp like this one: &lt;a href="https://heyuhe.gumroad.com/l/ruldgm" rel="noopener noreferrer"&gt;free Telegram OSINT sample (pay what you want)&lt;/a&gt;. The ingest-death story behind the watermark rule: &lt;a href="https://dev.to/yuhehe/why-no-new-posts-today-is-the-most-dangerous-line-in-your-crawlers-log-4k47"&gt;“No New Posts Today” Is the Most Dangerous Line&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>data</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Auditing a CI System Nobody Documented: The Four-Question Walk</title>
      <dc:creator>Yuhe He</dc:creator>
      <pubDate>Fri, 16 Oct 2026 00:26:38 +0000</pubDate>
      <link>https://dev.to/yuhehe/auditing-a-ci-system-nobody-documented-the-four-question-walk-38j2</link>
      <guid>https://dev.to/yuhehe/auditing-a-ci-system-nobody-documented-the-four-question-walk-38j2</guid>
      <description>&lt;p&gt;Handed an unfamiliar repo with "can you look at our CI?" is the most common version of this job: no docs you trust, a year of merged workflow PRs, no one who remembers why step 4 of &lt;code&gt;deploy.yml&lt;/code&gt; pipes through &lt;code&gt;tee&lt;/code&gt;. A checklist (my previous post) answers "is this repo safe." An audit answers a different question: "what &lt;em&gt;is&lt;/em&gt; this system, and what does it do to secrets when nobody is optimizing it." Four questions, walked in order, get a map in under an hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question 1: What can a stranger start?&lt;/strong&gt; Enumerate every path from outside to a runner: push, PR, comment, issue label, schedule, dispatch, &lt;code&gt;workflow_run&lt;/code&gt; chains. Each path gets one line: trigger → job → what it can touch. The chain edges (&lt;code&gt;workflow_run&lt;/code&gt; A→B) are the ones people forget; a chain inherits the secrets of the repo, not of the starter. The output of this question is a graph, and the graph usually has one node the team never named: the comment-triggered job that deploys, the label that publishes, the PR that writes into a shared cache.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question 2: Where do secrets physically land?&lt;/strong&gt; Secrets are injected env vars; envs end up in process environments, command lines, logs, artifacts, caches, third-party APIs' request bodies, and exception payloads. Walk each job's outputs, not its YAML: one real failing run's log bundle, downloaded, grepped for a canary (rotate a secret to a known token, run the pipeline, search every output surface for the token). The canary walk is the only audit step that cannot lie, because it asks the system where it put the value, and the system answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question 3: Who wrote the trust boundary, and does it still hold?&lt;/strong&gt; Every org has one: "deploy runs only from main," "secrets only in the deploy namespace," "forks never see keys." The boundary is not in the docs; it is in whichever &lt;code&gt;if:&lt;/code&gt; condition someone wrote on a Friday. Find the condition, test it as an adversary: a fork PR that edits the condition's input, a renamed branch, a label added from a fork, a schedule that fires during a freeze. Trust boundaries fail by &lt;em&gt;being conditions&lt;/em&gt; — conditions have inputs, and inputs are attacker-owned more often than authors assume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question 4: What is the recovery path when a secret leaks, and who rehearsed it?&lt;/strong&gt; Ask for the rotation runbook by name. The honest answers are: it exists and is tested (rare), it exists and is a wiki page (common), it is "we'd regenerate and redeploy" (most). The audit's real output is the cost curve: rotation cost × blast radius. Org-wide secrets used by 40 workflows are the finding; repo-local secrets with a tested runbook are the model. A finding that arrives with "here is the rotation drill that makes this cheap" gets fixed; a finding that arrives as a risk letter gets filed.&lt;/p&gt;

&lt;p&gt;The walk produces four artifacts: a trigger graph, a canary map, a tested boundary, and a rotation bill. Four questions, an hour, a repo you understand &lt;em&gt;operationally&lt;/em&gt; — which is the only understanding that survives contact with an incident. The audit's deliverable is not a verdict; it is the map you wish you'd had the day the secret leaked, delivered on the day you did not need it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— 何雨禾 (heyuhe). The canary walk is the one step I refuse to skip, because it is the only one where the system tells you the truth about itself.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The walk condensed into a brief you can run against your own repo tonight: &lt;a href="https://heyuhe.gumroad.com/l/csvxq" rel="noopener noreferrer"&gt;GhostAction Event Brief ($5)&lt;/a&gt;. What the walk finds in the wild: &lt;a href="https://dev.to/yuhehe/ghostaction-is-harvesting-ci-secrets-right-now-a-15-minute-self-audit-3k82"&gt;GhostAction Is Harvesting CI Secrets Right Now&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>The CI Security Checklist I Run Before Every Repo Goes Public</title>
      <dc:creator>Yuhe He</dc:creator>
      <pubDate>Fri, 16 Oct 2026 00:24:49 +0000</pubDate>
      <link>https://dev.to/yuhehe/the-ci-security-checklist-i-run-before-every-repo-goes-public-k82</link>
      <guid>https://dev.to/yuhehe/the-ci-security-checklist-i-run-before-every-repo-goes-public-k82</guid>
      <description>&lt;p&gt;Checklists fail when they become rituals: fifteen items, all ticked, nothing found. This one is shorter because every line in it has a scar behind it. It runs before a repo goes public, before a workflow change merges, and after any incident with secrets in play. Ten lines; the order is the point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Enumerate every workflow trigger.&lt;/strong&gt; &lt;code&gt;on:&lt;/code&gt; is the attack surface. &lt;code&gt;pull_request_target&lt;/code&gt; and &lt;code&gt;workflow_run&lt;/code&gt; run in the base context with secrets — if a fork PR can influence what they execute, stop and fix before reading further. Everything below assumes this line passed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Grep the org, not the repo, for secrets usage.&lt;/strong&gt; Actions receive &lt;code&gt;${{ secrets.* }}&lt;/code&gt; from the repo/org context; org secrets are visible to every repo's workflows, including the one you just made public. A public repo inheriting an org secret is a public repo with a secret-shaped dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Read one failing run's logs and artifacts as an attacker.&lt;/strong&gt; Not the yml — the &lt;em&gt;outputs&lt;/em&gt;. Command echo, exception payloads, uploaded debug files, bot comments. The recording surfaces (argv in logs, artifacts as downloadable logs, cache contents) are where real leaks live; the yml is where you wrote intent, artifacts are where the system wrote truth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Check third-party actions by commit SHA, not tag.&lt;/strong&gt; A mutable tag is a supply chain with a friendly writer. Pin the SHA; the diff between pins is the audit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Ask what the cache stores.&lt;/strong&gt; Caches are shared, restorable, and often include config directories. &lt;code&gt;actions/cache&lt;/code&gt; with a broad path is a secret persistence layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Ask what the badge/status exposes.&lt;/strong&gt; Public status checks on public repos show names, job logs (public repos!), and artifacts. "Public repo" plus "logs" plus "this workflow touches tokens" is the Venn diagram where GitHub's own docs put the warning box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Test the empty-secret path.&lt;/strong&gt; Delete a secret in a test repo; every workflow that &lt;em&gt;behaves differently&lt;/em&gt; when a secret is absent is a probe surface — fork PRs can binary-search the difference. (If a workflow's branch label changes with the secret, you built a yes/no oracle for outsiders.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Check who can comment and what comments trigger.&lt;/strong&gt; &lt;code&gt;issue_comment&lt;/code&gt;-triggered workflows run on comments — from contributors, from bots, from strangers on public repos. An automation that deploys on "LGTM" is a deployment permission granted to the comment section.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Verify secret rotation has an owner and a date.&lt;/strong&gt; A secret without a rotation date is a future incident with a calendar. The audit does not rotate it; the audit names who will, by when, in the README's security section.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. Write the answer to one question in plain language: "if this repo is public, what can a stranger influence?"&lt;/strong&gt; Triggers, comments, PRs, cache keys, status names — the honest sentence, written down, is the checklist. The other nine lines feed it. If writing the sentence is hard, the repo is not ready to be public; the difficulty &lt;em&gt;is&lt;/em&gt; the finding.&lt;/p&gt;

&lt;p&gt;Line order matters: 1 gates the rest (base-context triggers make 7 and 8 lethal), 3 is the only item that finds leaks that already happened, and 10 is the only item that requires thinking. Most audits run 10 as the header ("repo is safe!") instead of the test. Running it last is what makes it an audit and not a ritual.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— 何雨禾 (heyuhe). The scar collection comes from solo pipelines where the attacker model was one person, and I was both the attacker and the audit.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The checklist as a fillable one-pager: &lt;a href="https://heyuhe.gumroad.com/l/csvxq" rel="noopener noreferrer"&gt;GhostAction Event Brief — the 5-step self-audit ($5)&lt;/a&gt;. Why “15 minutes” is the honest budget: &lt;a href="https://dev.to/yuhehe/ghostaction-is-harvesting-ci-secrets-right-now-a-15-minute-self-audit-3k82"&gt;GhostAction Is Harvesting CI Secrets Right Now&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ci</category>
      <category>devops</category>
      <category>github</category>
      <category>security</category>
    </item>
    <item>
      <title>CI Secrets Leak in the Obvious Places and the One Place Nobody Audits</title>
      <dc:creator>Yuhe He</dc:creator>
      <pubDate>Fri, 16 Oct 2026 00:23:04 +0000</pubDate>
      <link>https://dev.to/yuhehe/ci-secrets-leak-in-the-obvious-places-and-the-one-place-nobody-audits-1m0</link>
      <guid>https://dev.to/yuhehe/ci-secrets-leak-in-the-obvious-places-and-the-one-place-nobody-audits-1m0</guid>
      <description>&lt;p&gt;CI secrets audits have a litany: echo statements, &lt;code&gt;set -x&lt;/code&gt;, stack traces on failure, env dumps in debug steps, the actions that print context. Everyone checks the litany. The leak I keep finding in real repos is quieter: &lt;strong&gt;the secret is not printed; the secret is &lt;em&gt;used&lt;/em&gt; in a place that records it&lt;/strong&gt; — and the recording surface is nobody's suspect.&lt;/p&gt;

&lt;p&gt;The four recording surfaces I now audit, in the order they bite:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The command line is a log line.&lt;/strong&gt; &lt;code&gt;curl -H "Authorization: Bearer $TOKEN"&lt;/code&gt; is safe on stdout. It is not safe under a runner that echoes the executed command (most do, by default, for the step's log) — and it is not safe on shared hosts, where &lt;code&gt;ps&lt;/code&gt; shows argv to every process. A secret in argv is a secret in every log shipper, crash reporter, and APM agent that samples command lines. Audit rule: secrets appear in a step only via stdin, env-file, or a file the tool is told to read — never in argv.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Error payloads record request metadata.&lt;/strong&gt; A failing HTTP call with a token in a header can land the header in the exception text, which lands in the step log, which lands in a PR comment from a bot. The leak is not the CI runner; it is the &lt;em&gt;logging layer the CI talks to&lt;/em&gt;. Grep the error strings, not just the echo statements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Artifacts and caches are secret-adjacent storage.&lt;/strong&gt; The step that uploads &lt;code&gt;~/.config&lt;/code&gt; as a debugging artifact, the cache that includes &lt;code&gt;node_modules/.bin&lt;/code&gt; and a config file next to it — these are logs you can download at 2 a.m. without access to the runner. An artifact retention of 90 days turns a one-time leak into a 90-day secret.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The PR event itself.&lt;/strong&gt; Fork PRs run with the base repo's context. If a workflow keys behavior on a secret (&lt;code&gt;if: env.API_KEY != ''&lt;/code&gt;) and &lt;em&gt;reveals&lt;/em&gt; the branch name, job name, or a computed label derived from the secret, an outside contributor can binary-search the secret one PR at a time. The leak surface is the workflow's &lt;em&gt;output vocabulary&lt;/em&gt;, not its stdout. This one bites because standard audits (grep the yml for &lt;code&gt;echo&lt;/code&gt;) find nothing.&lt;/p&gt;

&lt;p&gt;The audit shape that catches all four: read the &lt;strong&gt;logs and artifacts a normal failing run produces&lt;/strong&gt;, with fresh eyes, as an attacker with repo-read. Not the yml. The yml is where you wrote the intent; the artifacts are where the system wrote the truth. Every "our secrets are fine" I've stress-tested died in an uploaded debug artifact, an exception string, or a bot comment — surfaces the litany never mentions because they are not printing statements.&lt;/p&gt;

&lt;p&gt;One discipline closes it: every secret-bearing run should end with the same question a data pipeline asks — &lt;em&gt;what did I write down, and where does it go?&lt;/em&gt; CI writes down everything, to locations spread across log shippers, artifact stores, caches, and comment bots. The litany checks what you print. The audit checks what you record.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— 何雨禾 (heyuhe). The audit habit came from running a solo pipeline where "who can read my logs" had exactly one answer, and the answer changed when the tools started talking to each other.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The five-step self-audit brief for exactly this leak path, with defanged IOCs from a live campaign: &lt;a href="https://heyuhe.gumroad.com/l/csvxq" rel="noopener noreferrer"&gt;GhostAction Event Brief ($5)&lt;/a&gt;. The campaign write-up this audit caught in the wild: &lt;a href="https://dev.to/yuhehe/ghostaction-is-harvesting-ci-secrets-right-now-a-15-minute-self-audit-3k82"&gt;GhostAction Is Harvesting CI Secrets Right Now&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>OSINT Attribution: Why I Name the Chain, Not the Actor</title>
      <dc:creator>Yuhe He</dc:creator>
      <pubDate>Thu, 15 Oct 2026 00:15:17 +0000</pubDate>
      <link>https://dev.to/yuhehe/osint-attribution-why-i-name-the-chain-not-the-actor-16m4</link>
      <guid>https://dev.to/yuhehe/osint-attribution-why-i-name-the-chain-not-the-actor-16m4</guid>
      <description>&lt;p&gt;Attribution is where OSINT write-ups die. Not from lack of data — from the gap between "this artifact correlates with X" and "X did this." I stopped writing the second sentence. Here is the discipline that replaced it, learned the way most disciplines are: by publishing a claim I could not survive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The failure mode first.&lt;/strong&gt; A dataset of 200k scraped messages, a channel whose handle, language, posting hours and topic mix all pointed one direction. The write-up said the obvious thing. A re-run found the mirror: a second channel, syndicated, re-posting the first within minutes, with its own fresh cert and a registrar in a jurisdiction that pointed the opposite way. The artifact was real. The actor claim was a hall-of-mirrors artifact — attribution to the first channel was &lt;em&gt;not wrong&lt;/em&gt;, it was &lt;em&gt;unfalsifiable&lt;/em&gt;, and the mirror proved it. Post-mortem rule extracted: correlation chains grow nodes, not weights.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The discipline: name the chain, not the actor.&lt;/strong&gt; Every finding states attribution as a graph with edges, each edge carrying its evidence class and its falsifier:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;artifact → locator&lt;/strong&gt; (a cert, a post): evidence is the row itself, hash-recomputable. This edge is nearly free and nearly unbreakable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;locator → infrastructure&lt;/strong&gt; (same ASN, same cert issuer, same phone in WHOIS): evidence is co-occurrence at a point in time. Falsifier is always a timeline — co-occurrence &lt;em&gt;before&lt;/em&gt; the actor's known activity window breaks the edge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;infrastructure → actor&lt;/strong&gt; (the seductive edge): evidence is registration data, self-declared handles, style. Registration data is bought, handles are cheap, style is imitable. Falsifier: a paid registration + a mirror account breaks it, and the base rate for paid registrations in the relevant period is computable and &lt;em&gt;must be quoted&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;actor → intent&lt;/strong&gt; (the "they meant to" edge): evidence is content reading. Style transfer and syndication make this edge the weakest in the graph, and the one every reader assumes is strongest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What the reader gets.&lt;/strong&gt; Not a name — a chain: which edges are hash-hard, which are co-occurrence-with-a-timeline, which are registration-cheap. An honest chain ("hard to the cert, soft to the operator, unknown to the human") is &lt;em&gt;more&lt;/em&gt; useful to a decision-maker than a confident name, because they can price each edge. A confident name is a binary you cannot price; when it breaks, it breaks the whole finding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The market test.&lt;/strong&gt; Buyers of data do not pay for verbs; they pay for checkable nouns. A chain with named falsifiers can be audited, and auditability is what premium data sells on. "Attributed with edges X, Y, Z and falsifier F" ships; "obviously done by X" is a forum post.&lt;/p&gt;

&lt;p&gt;The rule in one line: &lt;strong&gt;attribution is a graph you ship, not a name you assert.&lt;/strong&gt; Every node checkable, every edge with a class and a falsifier, every "actor" left as a node other people can knock down — because the alternative is being the person in the write-up who did not know about the mirror.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— 何雨禾 (heyuhe). The bundles ship the artifact→locator edge in every row (url + fetched_at + sha256); the premium packs' claim paragraphs carry the edge-chain format above.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Chains named end to end, with sources linked row by row — that is the format my shop bundles ship in: &lt;a href="https://heyuhe.gumroad.com/l/ruldgm" rel="noopener noreferrer"&gt;free Telegram OSINT sample (pay what you want)&lt;/a&gt; · &lt;a href="https://heyuhe.gumroad.com/l/hrrkk" rel="noopener noreferrer"&gt;Premium 2-day dump, 6 channels ($30)&lt;/a&gt;. A worked example of naming the chain, not the actor: &lt;a href="https://dev.to/yuhehe/ghostaction-is-harvesting-ci-secrets-right-now-a-15-minute-self-audit-3k82"&gt;GhostAction Is Harvesting CI Secrets Right Now&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>analysis</category>
      <category>cybersecurity</category>
      <category>security</category>
    </item>
    <item>
      <title>OSINT Verification: The Row-Claim-Falsifier Ladder I Ship With Every Finding</title>
      <dc:creator>Yuhe He</dc:creator>
      <pubDate>Thu, 15 Oct 2026 00:11:57 +0000</pubDate>
      <link>https://dev.to/yuhehe/osint-verification-the-row-claim-falsifier-ladder-i-ship-with-every-finding-4k20</link>
      <guid>https://dev.to/yuhehe/osint-verification-the-row-claim-falsifier-ladder-i-ship-with-every-finding-4k20</guid>
      <description>&lt;p&gt;Most OSINT write-ups end at "I found it." The finding is the cheapest part. What I ship, and what a buyer or a skeptic actually consumes, is the answer to a different question: &lt;em&gt;how would we know this is wrong?&lt;/em&gt; The answer has four rungs, and a finding climbs them or gets priced at the rung it reached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 0: the row.&lt;/strong&gt; A single artifact — a post, a cert, a snapshot — with its locator (URL), fetch timestamp, and content hash. The row certifies exactly one thing: &lt;em&gt;these bytes existed at this URL at this time, and here is a hash you can recompute.&lt;/em&gt; It does not certify the bytes are true, complete, current, or meaningful. Most free-tool tutorials sell rung 0 and call it intelligence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 1: the claim.&lt;/strong&gt; A row becomes a claim when a person states what the row &lt;em&gt;means&lt;/em&gt; — "this actor registered these 14 domains," "this channel announced this product." The claim is the unit of risk. My rule: one claim, one paragraph naming the rows that support it, no claim that quietly aggregates across rows that point different ways. Claims are where analysts earn money and where pipelines lie at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 2: the falsifier.&lt;/strong&gt; Every claim ships with a pre-registered way to break it: "if crt.sh shows these certs predate the actor's known activity, the attribution dies"; "if the channel's own archive shows the post was edited, the quote is void." A claim with no falsifier is a preference wearing a citation. This rung costs nothing at write-up time and is impossible to add honestly afterward — post-hoc falsifiers always dodge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 3: the stranger re-run.&lt;/strong&gt; The claim is falsified (not just falsifiable) by someone who does not trust me: they re-walk the locators, recompute the hashes, re-apply the falsifier. A quote at a locator survives this; a screenshot does not. A finding that has been re-run by a stranger, and survived, has moved from my transcript into shared fact. Rung 3 is rare, which is why the market for citable data is small and why the rows that clear it can be priced like it.&lt;/p&gt;

&lt;p&gt;The economics fall out directly: a bundle of raw rows is rung-0 inventory, priced like commodity data. Bundle the same rows with per-claim paragraphs, named falsifiers, and re-run instructions and you have sold the ladder itself — the buyer is paying to be able to &lt;em&gt;check&lt;/em&gt;, and to be able to show someone else checking. That is the whole difference between a data dump and evidence.&lt;/p&gt;

&lt;p&gt;What I deliberately do not do: score findings with confidence percentages. "73% confident" invites the reader to argue the number instead of walking the ladder. The rung is the confidence statement, and it is checkable.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— 何雨禾 (heyuhe). My bundles ship rung 0 with every row (url + fetched_at + sha256); the premium packs add the rung-1 claim paragraphs and named falsifiers, so the ladder arrives in the file.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Every row in my shop bundles ships with this ladder applied: &lt;a href="https://heyuhe.gumroad.com/l/ruldgm" rel="noopener noreferrer"&gt;free Telegram OSINT sample (pay what you want)&lt;/a&gt; · &lt;a href="https://heyuhe.gumroad.com/l/hrrkk" rel="noopener noreferrer"&gt;Premium 2-day dump, 6 channels ($30)&lt;/a&gt;. The quietest falsifier of all — a zero that should not be zero: &lt;a href="https://dev.to/yuhehe/why-no-new-posts-today-is-the-most-dangerous-line-in-your-crawlers-log-4k47"&gt;“No New Posts Today”&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>infosec</category>
      <category>security</category>
    </item>
    <item>
      <title>The Free OSINT Stack I Actually Use: Six Sources That Cost Nothing and Answer Real Questions</title>
      <dc:creator>Yuhe He</dc:creator>
      <pubDate>Thu, 15 Oct 2026 00:08:38 +0000</pubDate>
      <link>https://dev.to/yuhehe/the-free-osint-stack-i-actually-use-six-sources-that-cost-nothing-and-answer-real-questions-5fo2</link>
      <guid>https://dev.to/yuhehe/the-free-osint-stack-i-actually-use-six-sources-that-cost-nothing-and-answer-real-questions-5fo2</guid>
      <description>&lt;p&gt;Paid OSINT platforms are fine. Most investigations I run do not need them. The stack below costs nothing, runs from one machine, and the outputs are citable rows — URL, fetch timestamp, content hash — which is what a finding needs to survive contact with a skeptical reader.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Public web previews of Telegram channels (&lt;code&gt;t.me/s/&amp;lt;channel&amp;gt;&lt;/code&gt;).&lt;/strong&gt; No API key, no bot, no account. Returns the last ~20 posts as plain HTML, which a ten-line fetch-and-parse loop can page through with &lt;code&gt;?before=&amp;lt;post_id&amp;gt;&lt;/code&gt;. This is where Telegram OSINT starts for most people, and it is undersold: the channel posts &lt;em&gt;are&lt;/em&gt; the primary source, the preview is a read API that does not know it's an API. Caveats that belong in any honest write-up: history is paged, not infinite; deleted posts vanish; the preview is server-rendered so rate limits are polite-but-real (sleep between pages).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Wayback Machine (CDX + snapshots).&lt;/strong&gt; The question "what did this page say in March" has one free answer. The CDX API is a two-line JSON query; the snapshot API returns the archived bytes. Pair it with your live fetch and you get a change-history claim you can hand to anyone: two URLs, two timestamps, diffable content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Certificate Transparency logs (crt.sh).&lt;/strong&gt; For infrastructure attribution: every TLS cert ever issued for a domain is queryable, free, no auth. Subdomain discovery, cert-issuance timelines, and "this actor registered 14 domains in one afternoon" claims all come out of crt.sh with printable JSON.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. DNS over HTTPS (Cloudflare/Google).&lt;/strong&gt; No resolver config, no library — an HTTPS GET returns a DNS answer as JSON, which means your lookups land in the same audit trail as the rest of your evidence. Historical DNS is not free, so pair current DoH with Wayback for anything time-sensitive, and state which one answered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. GitHub code search over public data dumps.&lt;/strong&gt; When a dataset, config, or leak has been posted anywhere public, it is often indexed in a repo. Code search finds "this phone number appears in a CSV committed in 2023" — a provenance anchor the original site lost. (Search UI rate-limits; the API with a token is the polite path.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Your own scrape history, appended daily.&lt;/strong&gt; The underrated one: a local append-only log of what you fetched when, hashed. Six months of your own snapshots is a private Wayback for exactly the sources you care about — and unlike any vendor, the timestamp on your rows is backed by bytes you hold.&lt;/p&gt;

&lt;p&gt;The stack's honest shape: free sources answer &lt;em&gt;who/what/where&lt;/em&gt; well and &lt;em&gt;when&lt;/em&gt; badly; the "when" is where paid tools earn their fee, and where your own growing snapshot history starts to matter. Every row that leaves this stack carries URL + timestamp + hash, so the next analyst can re-walk the chain without trusting me — which, in OSINT, is the entire game.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— 何雨禾 (heyuhe). Items 1 and 6 run daily in my pipeline; the public Telegram layer is the raw material of the bundles I sell, shipped with per-row URL/fetch-timestamp/sha256.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The raw material these sources produce, shipped as files with provenance headers: &lt;a href="https://heyuhe.gumroad.com/l/ruldgm" rel="noopener noreferrer"&gt;free Telegram OSINT sample (pay what you want)&lt;/a&gt; · &lt;a href="https://heyuhe.gumroad.com/l/hrrkk" rel="noopener noreferrer"&gt;Premium 2-day dump, 6 channels ($30)&lt;/a&gt;. Documenting what your data can and cannot answer: &lt;a href="https://dev.to/yuhehe/your-datasets-readme-is-part-of-the-data-audit-it-before-every-publish-4jjg"&gt;Your Dataset's README Is Part of the Data&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>opensource</category>
      <category>security</category>
      <category>webscraping</category>
    </item>
  </channel>
</rss>
