DEV Community

Rulestack
Rulestack

Posted on

Fresh at the label, stale at the post: our 3-day news check was reading the wrong clock

On 2026-09-05 our agent swapped three queued Bluesky posts for posts about AI news from the previous two days, each carrying the source URL. Every swap passed a freshness check: the source had to be at most 3 days old at the moment the post would go out. The next morning we measured when those posts would actually go out, and the answer was four to six days after their sources were published. The check had been reading the label on the slot, not the queue in front of it.

This is a build report on three CLIs that let the agent put a same-week news link into a schedule that is otherwise written a week ahead, and on the one design mistake that made the first version of the freshness check quietly wrong.

Why a queue written a week ahead needs a side door

Our Bluesky schedule is a JSONL file of stock rows, five posts a day at fixed JST slots (23:00, 01:00, 03:00, 06:00, 08:00), refilled on Mondays to reach the following Tuesday. That is the right shape for evergreen content. It is the wrong shape for "Anthropic reset weekly usage limits last night", which is worth posting on Saturday and worth nothing on Wednesday.

The owner's instruction on 2026-09-05 was specific: timely posts are welcome, they should link a piece of AI news from the past 3 days, and the agent may post one immediately per session if it is urgent. Hand-editing the stock file to do this is possible, but every edit has to remember the same list: the source has to be fresh, the URL has to be in the body in a form Bluesky will make clickable, the text has to fit in 300 graphemes after our link rewriter appends ?ref=bsky, there has to be a Japanese translation for the owner, the body must not trip the boilerplate detector, and the post must not push the week over its declared hashtag cap. Forget one and the failure surfaces later, in the test suite or on the runner.

So the list became code, in three commands.

Finding news you can date

pnpm find-ai-news queries the Hacker News search API (Algolia, no key needed) with six default terms, Claude, Anthropic, Claude Code, AI agents, LLM, AI, filters to stories with at least 5 points that have a URL, and returns up to 30 items shaped so they can be pasted straight into the next command's source field: url, title, publishedAt, and evidence: "hn-algolia".

The reason it is Hacker News and not a news site is the date. The API returns created_at for every story, which is a machine-checkable timestamp, and we pass a numericFilters lower bound on created_at_i so the API does the windowing too. The limitation is stated in the command's own header: that timestamp is when the story was submitted, not when the article was written. Old articles get resubmitted. The command's own header tells the operator to open the article with a text extractor and confirm the piece itself is new before using the date. The run log for 2026-09-05 shows two such pulls, 30 candidates each.

The evidence field exists for the same reason. A source can declare its date came from hn-algolia, from page-meta, from fxtwitter (for posts on X), or manual. The manual kind requires an evidenceNote describing how the date was checked, so "it looked recent" cannot be recorded as a date.

Swapping a slot

pnpm swap-timely-stock takes a list of replacements, each naming an existing slot by its plannedFor label, a new body with the URL, the Japanese text, and the source. It runs seven checks per replacement, in this order:

  1. The slot exists in stock and its label is in the future.
  2. The source is at most 3 days old at the time the post will go out (more on "will go out" below). A source dated more than 30 minutes in the future is rejected as a data error.
  3. The body contains the source URL in a clickable form, meaning with its scheme. Our Bluesky client deliberately drops link facets for bare example.com/... text because they were false-positiving on things like CLAUDE.md, so a scheme-less URL would post fine and be plain text.
  4. The body, after the ?ref=bsky rewrite, fits Bluesky's 300-grapheme limit, and the Japanese text is at least 10 characters.
  5. The body is not boilerplate and not a near-duplicate of anything in the remaining stock, anything posted in the last 60 days, or any other replacement in the same batch.
  6. The body does not breach the week's declared hashtag cap, evaluated with the replaced row removed and the new row added.
  7. No two replacements target the same slot.

All checks run for all replacements before a single byte is written. That is the two-phase shape we use in every writer that touches the stock file: if replacement two fails, replacement one must not already be on disk.

When the write happens, the old body is not lost. It is appended to state/timely-posts.jsonl with mode: "stock-swap", the slot, the new body and source, and a replaced object holding the previous text. Evergreen rows that got bumped for news can be put back later. The stock row itself gets the new text, a timely object with the source, and a note naming the source title. It keeps its plannedFor.

The immediate variant, pnpm post-timely-now, runs the same checks against now instead of a slot, posts, and records the post with origin: "timely-immediate". That origin is how the daily budget code tells it apart: immediate posts do not count toward the five scheduled posts per day (they do not take a slot), but they do count toward the hard cap of 60 actions per day across every kind. The command also refuses a third immediate post in one JST day. The owner's rule was one per session; sessions run about twice a day; two is the machine-enforceable version of that.

The check that was reading the wrong clock

Here is what the first version of check 2 did. It took the replacement's plannedFor, say 2026-09-06T01:00:00+09:00, subtracted the source's publishedAt, and compared to 3 days. For the swap we ran on 2026-09-05 at 14:41 UTC, the source (a post on X from the previous evening) was 0.83 days old relative to that label. Pass.

What plannedFor actually means to the poster is different. The scheduled poster runs at each slot, takes every row whose plannedFor is at or before now, and posts the one with the oldest label. It is a FIFO with plannedFor as a lower bound: "not before this time". If the queue has rows with older labels waiting, a row labelled 01:00 tomorrow is not the post at 01:00 tomorrow. It is the post at whichever slot the queue has drained to it.

And the queue had rows waiting. On 2026-09-06 at 00:41 UTC the head of the stock file was labelled 2026-09-03T06:00:00+09:00, 75.7 hours in the past, with 17 of 28 rows already eligible. How the queue got that far behind, one refill at a time, without any job failing, is its own story and has its own write-up. What matters here is the consequence for the three rows we had just swapped in: by position rather than by label, they were going to go out four to six days after their sources were published. Each of them had passed a check whose entire purpose was to prevent that.

Nothing in that path had a bug in the usual sense. The poster was right to treat labels as lower bounds. The freshness check was checking exactly what it said it checked. The property we cared about, "the link is at most 3 days old when people see it", lived in the gap between two components that had never been told about each other.

plannedFor is a floor, not a slot. poster: oldest label first, 1 per slot / head label was 75.7 h in the past / 17 of 28 rows already eligible / freshness now measured at projected slot / lag check: warn 20 h / alert 48 h

Projecting the real post time

The fix is a function that reproduces the poster's behaviour without posting. projectStockPostTimes sorts the rows by label (ties by file order, which is what the poster does), sets a cursor to now, and for each row in order finds the first slot that is both after the cursor and not before the row's own label. That slot is the row's projected post time, and it becomes the cursor for the next row. The slot table is the same JST list the workflow uses, and a test reads the workflow file's cron lines to make sure the two never drift apart.

The freshness check now finds the replacement's target row in that projection and measures the source's age at projectedAt, not at the label. On success the command reports the label, the projected time, and the age side by side:

{ "plannedFor": "…+09:00", "projectedPostAt": "…Z", "sourceAgeDaysAtPost":  }
Enter fullscreen mode Exit fullscreen mode

and on failure it reports nothing, because it throws before the write phase with the URL, the age in days at the projected time, and the 3-day limit in the message.

The positive-control test constructs a stock with 16 backlog rows labelled from 09-02 and a replacement targeting the 09-06 01:00 slot, which the projection places seventeenth, more than 3 days out, and asserts the error text says the source is too old. The companion test asserts that with no backlog the projection equals the label and projectedPostAt comes back matching it. The first test is the one that matters: it encodes the exact situation we were in, and it fails on the old code.

That closes the write path. A lag check now runs every session (warning at 20 hours, alert at 48), and a realignment command rewrites labels to projected slots, moving timely rows to the front of the queue because freshness is their whole value, and refusing to write at all if any of them would be stale at its new slot. Running it on 2026-09-06 put the three news rows in the first three slots. The projection has one honest limitation: it assumes the workflow fires at its cron time, and GitHub Actions documents that scheduled runs can be delayed under load. A late firing pushes everything behind it later by the same amount, which the projection cannot see in advance and the lag check sees afterwards.

What the ledger is for

The reason the old bodies go into state/timely-posts.jsonl rather than being overwritten is that a news post displaces something, and the something was written on purpose. Every swap row carries the evergreen text it replaced, so a Monday refill can put it back into a later slot instead of writing a new post to fill the hole. The same ledger records the immediate posts with mode: "immediate" and the post URI, so the daily budget code, the duplicate detector, and the weekly report all read one file when they need to know what timely content went out and when.

As of writing that file has six rows: five stock swaps across three slots (two of the slots were swapped twice on 2026-09-05, once with a draft and once with the wording the owner preferred, and the ledger keeps both), and one immediate post on 2026-09-06 at 00:42 UTC, linking an engineering blog post that was 1.04 days old at the moment of posting. That last number is the whole point. It is measured against the clock the reader experiences, and it is written down where the next check can find it.

What we would tell someone building the same thing

If a field is used as a lower bound by one reader and as a schedule by another, one of those readers is wrong, and it is usually the one that never had to execute. Our poster was correct. The freshness check was reasoning about the label as if the queue were empty.

The cheap fix would have been a comment on the field. The fix that stays fixed is a function that models the executor and a check that runs on real data every session. Three days of news is a narrow window, and a window that narrow does not survive being measured against the wrong clock.


The stock file, the three commands, and the lag check are part of the pipeline behind Rulestack, a small shop of rules and skills packs for AI coding agents.

The news posts they produce, and the evergreen ones they displace, go out from @ai-shop.bsky.social.

Top comments (0)