---
title: "A Valid Image Was Still the Wrong Image"
description: "A case study in silent failure: an automated CS2 news and social pipeline passed every technical check while attaching a photo of the wrong player. 10 sources, 4,109 events, 230 published posts, and what it takes to validate meaning rather than format."
url: https://www.afonsomartins.com/publishing-pipeline
author: Afonso Martins
published: 2026-07-30
updated: 2026-09-18
---

# A Valid Image Was Still the Wrong Image

How an automated CS2 news and social pipeline caught something no health check can see: an image that passed every technical check and was still about the wrong player. A case study in validating meaning rather than format.

**Status:** Figures come from the audited repository and its newest tracked database snapshot, measured 2026-07-30. Every number below has a command behind it.

## The system

I built an automated publishing pipeline for the SkinBetHub platform: news and match signals in, queued social posts and site content out, running unattended.

The shape is ordinary and the discipline is where the work is. Ingestion normalises **10 external sources** into a single event lifecycle. Filtering and generation select what is worth saying. A media layer finds an image. A scheduler queues the work, and a poster owns the external write. A control dashboard exposes health, queue state and service controls through **12 API handlers**.

The tracked snapshot holds **81,072 rows across 22 tables**: **4,109 events**, **263 posts** of which 230 published, **73,265 engagement observations**, and 1,481 follower snapshots. The source mix is led by HLTV at 1,018 events, then two regional Dust2 feeds at 886 and 759.

The codebase is **71,429 lines** across 192 first-party files, **369 commits** between March and July 2026.

## The constraint

Media had to be timely enough for news, specific enough to support a named player or event, safe enough to avoid arbitrary publisher reuse, and resilient enough that a flaky model never stalled the queue.

A conventional asset pipeline checks the URL, the response code, the content type, the dimensions and the file integrity. Every one of those can pass while the image is still false.

## What the checks could not see

It surfaced as a content complaint, not an exception.

A post about xertioN carried a photo of sh1ro. A post about IEM Atlanta 2026 carried a bracket from IEM Cologne 2022.

The download had succeeded. The technical path reported success at every stage. The invariant that mattered was never checked, because it was semantic: the pixels have to agree with the words.

## Three decisions worth defending

**Verification goes after download and before upload.** Checking search text before download still trusts metadata. Checking after upload is too late, because the side effect has already happened. I built a subject description from the event, teams, players and post text, sent the local image to a vision model, and rejected an explicit negative verdict. That check runs on all three acquisition branches: source article, image search, and open graph.

**The verifier fails open, deliberately.** If the model is disabled, the file is missing, the subject cannot be assembled or the API errors, the existing URL filters decide. Only an explicit rejection blocks the image. This is an availability tradeoff, stated rather than hidden: it stops a model outage from becoming a publishing outage, and it leaves a residual risk I can describe precisely.

**I did not build face recognition.** It was the obvious escalation and the wrong one. There was no consented reference set, no evaluation set, and no operational need strong enough to justify the biometric and maintenance burden. Individual player identification remains a known gap, which is a better place to be than a half-built identity system nobody can audit.

## Two more invariants worth guarding

**A generation client assumed `choices[0]` existed.** An upstream response with an empty list raised and killed the event. Now it retries once on a fallback tier, then returns a typed empty result so the caller can degrade instead of dying.

**A scheduled backup was writing 0-byte files.** After a Postgres major-version upgrade the job still invoked the older system binary. The filename was created before the version mismatch aborted the dump, so the scheduler stayed green and produced an artifact that could not restore anything. File presence was the check; file size was the tell. The fix selects the newest installed major-version binary, and 15 nonzero dumps have been retained since.

The pattern in all three is the same and it is the lesson I took: a path that looks successful needs typed failure states, not optimistic indexing and green checkmarks.

## What I would build next

Semantic provenance as a stored contract. Every candidate image should carry its source, rights class, extraction method, subject verdict, verifier model and decision reason. High-risk news categories should fail closed rather than open. Fixed fixtures for event-year and person mismatches. And one runtime tree instead of two, so a test import path maps to exactly one implementation.

## What this is evidence of

Judgment under silent failure. An invariant that conventional health checks miss, verification moved to the last safe boundary before an irreversible action, an availability tradeoff chosen deliberately and written down, and the remaining gap named precisely enough to close next.

The figures here describe the system I built and the data it holds. Where a number would need production access or an external fetch to confirm, I say which one it is rather than rounding up.

Related: [Catching silent data corruption](https://www.afonsomartins.com/data-integrity) · [Build evidence](https://www.afonsomartins.com/build-evidence)
