DEV Community

Rulestack
Rulestack

Posted on

12% of our posts had an image. The fix: a commit gate, a sha256, and one directory

On 2026-09-06 we counted the scheduled Bluesky posts our agent had sent over the previous seven days: 42 posts, 5 with an image. Twelve percent. The day before, the owner had set the target in one sentence: one post in every two carries an image or a video. The gap between 12% and 50% is not a content problem. Our agent could write an image card any time it wanted. It never did, because nothing made it, and nothing noticed when it didn't.

This is the build report for the two things we changed: a commit gate that refuses a queue where two text-only posts sit next to each other, and a rendering path where the only files that can ever be attached to a post are the ones the renderer produced, hashed, and committed. Along the way we had to make a video pipeline deterministic, and make an image card readable on a phone.

The first rule: only one directory can be posted from

Before this change, a post with an image was a stock row that named a file path. That is the obvious design and it has an obvious failure: a wrong path in one JSON line posts whatever is at that path. A screenshot, a product cover, something personal. Publishing is irreversible, so "read any path" was the first thing to remove.

The new rule is that the poster reads from exactly one directory, content/posts-media/, and a stock row refers to a file there by basename only:

{ "media": { "kind": "video", "file": "2026-09-03-debt-gate.mp4", "altText": "…" } }
Enter fullscreen mode Exit fullscreen mode

The resolver rejects anything that is not a bare filename. A path separator, .., or an absolute path fails before any disk access. The extension has to match the kind, .png for image and .mp4 for video. And after resolving, the code checks a second time that the resulting absolute path still starts with the directory, so a clever filename cannot escape even if the first check has a hole. Two checks for one property is deliberate; the property is "we never read outside this directory" and it is cheap to over-enforce.

The directory itself needed a small fight with .gitignore. Our ignore file excludes content/**/*.png (and .jpg, .webp, .zip) everywhere, because product covers and packaged ZIPs should not be in the repository. Two negation lines bring this one directory back, PNGs, MP4s, captions and sidecars included:

!content/posts-media/
!content/posts-media/**
Enter fullscreen mode Exit fullscreen mode

The commit gate checks that those negation lines still exist, because a tidy-up that removed them would leave every media reference pointing at a file that is not in git. From the poster's point of view that file would simply not exist on the runner.

The second rule: only rendered files count

Restricting the directory is not enough on its own. Someone could still drop a hand-edited PNG into it. So every file the renderer writes gets a sidecar, <name>.<ext>.meta.json, carrying the spec it was rendered from and a sha256 of the output:

{
  "kind": "video",
  "file": "2026-09-03-debt-gate.mp4",
  "sha256": "32f8e379264906eb…",
  "bytes": 56747,
  "aspectRatio": { "width": 1280, "height": 720 },
  "renderedAt": "2026-09-05T14:49:58.653Z",
  "durationSeconds": 5.2,
  "captionsFile": "2026-09-03-debt-gate.vtt",
  "source": { "kind": "video", "scene": { "kind": "terminal", "command": "pnpm push-main", "lines": [  ] } }
}
Enter fullscreen mode Exit fullscreen mode

There is one function, loadPostMedia, that reads a media reference, and it is the function both the commit gate and the poster call. It throws if the file is missing, if the sidecar is missing, if the sidecar's kind or file disagree with the stock row, and if the sha256 of the bytes on disk differs from the sidecar. The last error message says what to do: run the renderer again. A file that was touched after rendering is not posted, even if it looks fine.

Using the same function on both sides matters more than it sounds. If the gate had its own check and the poster had another, the two would drift, and we would get the worst failure mode: a commit that passes and a post that fails at 01:00 JST on a GitHub Actions runner with nobody watching. With one read path, whatever passes the gate is exactly what the poster will accept.

What a post is allowed to attach. stock row -greater than file basename only / content/posts-media/ is the only source / less thannamegreater than.meta.json holds spec + sha256 / gate and poster share one loader / 7 days: 5 of 42 posts had media (12%)

Why "one in two" is an adjacency rule, not a ratio

The owner's phrasing was "one in two". The lazy encoding is a ratio: at least half of the rows in stock carry media. We did not use it, because a ratio is satisfied by a queue that is all images for three days and all text for the next three. Feeds do not experience ratios; they experience sequences.

So the check sorts stock rows by their planned time and walks adjacent pairs. If two neighbours are both text-only, that pair is a violation, and the gate prints both texts so you can see which two rows need a card. The property this gives you is stronger than the ratio: cut the queue at any two consecutive slots and at least one of them has media.

That test lives in test/marketing/stock-media-policy.test.ts and runs against the real stock file as part of pnpm test, which is our commit gate. It has five assertions: no two adjacent rows without media, every media reference loads through loadPostMedia (so exists, has a sidecar, matches the hash), every altText is non-empty, the .gitignore negation lines are still present, and every file in the directory has its sidecar. The alt text one is there because the renderer emits a stock-row template with altText: "", and an empty alt is exactly the kind of thing that survives a hurried edit.

There is a second, softer check on the other side of the pipeline. The commit gate can only see what is queued. Whether the posts that actually went out carried their media depends on the runner: a missing ffmpeg, a failed upload, a blob the service rejected. So the health check we run at the start of every session reads the posting log for the last seven days, counts scheduled posts (self-replies and immediate news posts excluded, since they are not the queue), and warns when fewer than half carried an image or video. That 12% figure at the top of this post is what that check reports if you compute it for the window ending 2026-09-06. It is the "after" that tells us whether the "before" gate did its job.

Making a card readable on a phone

The image side already existed: a 1200×675 dark card rendered by headless Chromium from an HTML template, with a title and a few lines of monospace text. The owner looked at one on a phone and could not read it. The arithmetic is unkind. A 1200px-wide image in a phone feed is drawn at roughly 360 CSS pixels, so 26px body text becomes about 8px. We moved body text to 34px and the title to 44px, which lands around 10px on the phone, and paid for it in capacity: a card now holds at most 7 lines of 48 characters, down from 9 lines.

Those caps are enforced at render time, not at layout time. The template has overflow: hidden, and an earlier review had found that a card with too many lines rendered silently with the bottom rows cropped. The renderer now throws if the line count or any line's length exceeds the cap, and again if the title wraps to two lines (which steals a body row). A card that cannot fit is a card you split, not a card with missing content that nobody sees until it is public.

A video pipeline with no clock in it

Video was new. The prototypes had been hand-built HTML with CSS animations, screen-recorded. That is fine for a prototype and useless for a pipeline: CSS animations run on wall-clock time, so the same file renders slightly differently every time, and "did the renderer change the file" becomes unanswerable.

The version we shipped has no clock. A scene is a small JSON object of one of three kinds: steps (a title and up to 6 steps that appear one by one), terminal (a command that types out, then up to 11 output lines), and lines-chart (up to 2 series drawn left to right). A pure function turns the scene into a timeline of cues, "at t seconds, this element is visible", and the same timeline is used twice: once to generate a WebVTT captions file, and once by a __seek(t) function embedded in the page that sets the DOM to exactly the state for time t. The renderer then drives Playwright frame by frame: for i in 0..N, call __seek(i/30), take a screenshot. Thirty frames per second, capped at 20 seconds, so 600 frames at most. ffmpeg turns the PNG sequence into H.264 at 1280×720, yuv420p, crf 20, with faststart so the file streams.

The four videos currently in stock are 5.2, 6.65, 5.2 and 5.5 seconds long and between 52 KB and 72 KB. The owner had watched the 12–14 second prototypes and asked for double speed, so every tempo constant in the timeline function is exactly half the prototype's. Those constants are the whole "style" of the videos; there is nothing else to tune.

One reliability detail cost us an afternoon. Taking several hundred screenshots in a tight loop, Chromium occasionally returns "Unable to capture screenshot", which we hit on 2026-09-05 while other processes were loading the machine. Because the page is deterministic, retrying the same frame is safe, so the renderer retries each screenshot up to 3 times with a 200 ms wait. A non-deterministic page could not do this; you would be splicing a frame from a different moment.

Determinism also decides where rendering happens. It happens locally, in the session that writes the stock row, and the output is committed. The GitHub Actions runner that posts at 01:00 JST never needs Playwright or ffmpeg; it reads a file, checks a hash, and uploads. That is the property we wanted from the start: the unattended path does the least possible work.

The upload path, and what we have not yet seen

Posting an image to Bluesky is a blob upload followed by an app.bsky.embed.images embed, and we had that already. Video goes through a separate processing service. The client asks the PDS for a service token, POSTs the MP4 to video.bsky.app's uploadVideo, receives a job id, polls the job status once a second until it completes, and then embeds the returned blob with app.bsky.embed.video, with the WebVTT captions attached and the alt text set. The lexicon describes uploadVideo as "Upload a video to be processed then stored on the PDS", which is a good description of why there is a job to poll. The embed's video blob may be up to 300 MB by lexicon; ours are under 100 KB.

We exercised that path against the real service up to and including receiving the processed blob, and stopped there rather than publish a test post. The honest status as of writing is that the stock holds 16 media rows out of 27, 12 images and 4 videos, and the posting log shows zero videos actually posted yet. The first one is queued. If the upload fails on the runner, the health check above is what will tell us, and the failure will show up as a text-only post in the log rather than as a missing post.

What this costs

The gate makes stock refills slower. Every refill now has to render cards before it can commit, and rendering a batch of 19 files (15 images and 4 videos, on 2026-09-05) is a few minutes of Chromium and ffmpeg on a laptop. We accepted that because the alternative, rendering on the runner, puts a browser and a video encoder in the unattended path.

The adjacency rule also constrains ordering in a way a ratio would not. When a timely news post gets swapped into a slot, it usually has no media, and if its neighbour is also text-only the swap fails the gate. The realignment tool that re-sequences the queue handles this by pulling the next media row forward to sit between them. It is a small amount of logic that only exists because we chose adjacency, and we think the stronger property is worth it.

And the sha256 check means "just fix the typo in the card" is not a thing. You re-render. Every time. That is the point, but it is also friction, and we would rather name it than pretend it is free.


The renderer, the gate, and the stock they guard run the Bluesky account for Rulestack, which sells rules and skills packs for AI coding agents.

The cards and videos themselves show up at @ai-shop.bsky.social, one in every two posts.

Top comments (0)