Your banner pipeline fails on bad data, not bad design: ArtCraft's feed gate
A 266-line gate that rejects 5 of 7 rows before the engine sees them — a reason per row, a hash per artifact. A product feed with a few thousand SKUs always contains a handful of rows that cannot become a banner: a tit
A 266-line gate that rejects 5 of 7 rows before the engine sees them — a reason per row, a hash per artifact.
A product feed with a few thousand SKUs always contains a handful of rows that cannot become a banner: a title that arrives as whitespace, a currency written usd, an image path with .. in it, a sale price above the price. None of these are design problems, and none of them fail where they happen — they fail twenty minutes later, as a command_batch error with no row number and a half-written PNG.
The fix is not better error handling inside the render job. It is a gate that runs before the engine is involved at all, decides row by row, and writes down why. A row either has a reason to be rejected or it does not: that is the entire contract, and it makes the pipeline boring in the way a pipeline should be.
This post is that gate — ten rejection rules, the ordering that matters, and the audit report that makes a rejected row a fact instead of a support ticket.
Repo: github.com/edgarmilvus/artcraft-book · GitHub: @edgarmilvus · Ebook: ArtCraft: The Agentic Creative Studio in TypeScript
Reject with a reason, not a stack trace
The type is the design:
export type RejectionReason =
| "missing-sku" | "missing-title" | "title-too-long" | "invalid-price"
| "invalid-sale-price" | "invalid-currency" | "missing-hero" | "path-escape"
| "control-chars" | "mojibake-title";
Ten values, no unknown, no other. A union that a caller can switch over is the difference between "the feed is broken" and "row 3 of the Czech feed has currency usd, fix it upstream and re-run". The detail string carries the evidence — the offending value, the limit it broke, the byte count — so nobody has to reproduce the failure to understand it.
Order is part of the contract
Control characters are checked first, before SKU, title, price or anything else:
for (const [name, value] of Object.entries(row)) {
if (typeof value === "string" && CONTROL_CHAR_RE.test(value)) {
return { reason: "control-chars", detail: `field "${name}" contains a C0/C1 control character` };
}
}
A \u0000 in a title makes every downstream check behave strangely: regexes anchor oddly, logs break, the engine may truncate the string at the NUL and render a title that is half the text you validated. Cheap check, first position — the rest of the function can then assume clean strings.
The ten rules
| reason | triggered by |
|---|---|
control-chars |
any C0/C1 character in any string field |
missing-sku |
absent, empty, or containing whitespace |
missing-title |
absent, or whitespace only |
mojibake-title |
contains U+FFFD, i.e. an upstream decode already lost bytes |
title-too-long |
more than 120 UTF-8 bytes — not characters |
invalid-price |
not a finite number, or negative |
invalid-sale-price |
not strictly below price
|
invalid-currency |
not an uppercase three-letter ISO 4217 code |
missing-hero |
no image path |
path-escape |
absolute, a Windows drive, or containing ..
|
Two of those deserve a sentence.
Bytes, not characters. title-too-long measures Buffer.byteLength(title, "utf8") because the limit protects a fixed-width text box, and a 60-character Japanese title can be 180 bytes. Counting characters would pass a title that does not fit.
path-escape is the security rule. The gate normalises backslashes and rejects anything that starts with /, matches [A-Za-z]:[\\/], or has a .. segment:
const hero = row.heroImage.replace(/\\/g, "/");
if (hero.startsWith("/") || WINDOWS_DRIVE_RE.test(hero) || hero.split("/").includes("..")) {
return { reason: "path-escape", detail: `heroImage "${row.heroImage}" escapes the read root` };
}
The engine is started with --automation-read-root, so a path that escapes it is not a formatting problem — it is a feed trying to read a file the job was never granted.
Rejected, deferred, and the difference
The audit report counts three outcomes, and the third one is the subtle one:
accepted: number;
deferred: number;
rejected: Array<{ rowIndex: number; sku: string | null; reason: RejectionReason; detail: string }>;
A row that fails validation is rejected: it was never going to work, and no amount of retrying helps. A row that validates but whose batch times out or throws is deferred: the data was fine, the render was not. Mixing the two into one failed bucket is how a nightly job ends up re-rendering the same 40 broken rows forever while the two genuinely slow ones never get retried.
The gate also hashes what it produced:
const { bytes, sha256 } = await sha256File(absOut);
report.artifacts.push({ rowIndex: i, sku: row.sku!, path: `${row.sku}.png`, bytes, sha256 });
and writes the whole report atomically — feed-audit.json.tmp, then rename — so a crash mid-write leaves the previous audit intact instead of a truncated JSON file that the next stage cannot parse. The report is serialised with sorted keys and no whitespace (canonicalJson), which means two runs of the same feed produce byte-identical audits: you can diff last night against tonight and see only real changes.
The gate
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { promises as fs } from "node:fs";
import * as path from "node:path";
import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url";
export interface ProductFeedRow {
sku?: string; title?: string; brand?: string;
price?: number; salePrice?: number;
currency?: string; heroImage?: string; altImages?: string[];
locale?: string; category?: string[];
}
export type RejectionReason =
| "missing-sku" | "missing-title" | "title-too-long" | "invalid-price"
| "invalid-sale-price" | "invalid-currency" | "missing-hero" | "path-escape"
| "control-chars" | "mojibake-title";
export interface FeedAuditReport {
generatedBy: "feed-hygiene-gate/v1";
readRoot: string; writeRoot: string;
accepted: number; deferred: number;
rejected: Array<{ rowIndex: number; sku: string | null; reason: RejectionReason; detail: string }>;
artifacts: Array<{ rowIndex: number; sku: string; path: string; bytes: number; sha256: string }>;
}
const CONTROL_CHAR_RE = /[\u0000-\u001F\u007F-\u009F]/;
const ISO_4217_RE = /^[A-Z]{3}$/;
const WINDOWS_DRIVE_RE = /^[A-Za-z]:[\\/]/;
const SKU_RE = /^\S+$/;
export function rejection(row: ProductFeedRow): { reason: RejectionReason; detail: string } | null {
// control-chars first: they poison any downstream check
for (const [name, value] of Object.entries(row)) {
if (typeof value === "string" && CONTROL_CHAR_RE.test(value)) {
return { reason: "control-chars", detail: `field "${name}" contains a C0/C1 control character` };
}
}
// SKU
if (typeof row.sku !== "string" || row.sku.length === 0 || !SKU_RE.test(row.sku)) {
return { reason: "missing-sku", detail: "sku missing, empty, or contains whitespace" };
}
// title
if (typeof row.title !== "string" || row.title.trim().length === 0) {
return { reason: "missing-title", detail: "title missing or whitespace-only" };
}
if (row.title.includes("\uFFFD")) {
return { reason: "mojibake-title", detail: "title contains U+FFFD (upstream decode error)" };
}
const titleBytes = Buffer.byteLength(row.title, "utf8");
if (titleBytes > 120) {
return { reason: "title-too-long", detail: `title is ${titleBytes} UTF-8 bytes (limit 120)` };
}
// price
if (typeof row.price !== "number" || !Number.isFinite(row.price) || row.price < 0) {
return { reason: "invalid-price", detail: `price must be a non-negative finite number (got ${String(row.price)})` };
}
if (row.salePrice !== undefined) {
if (typeof row.salePrice !== "number" || !Number.isFinite(row.salePrice) || row.salePrice >= row.price) {
return {
reason: "invalid-sale-price",
detail: `salePrice ${String(row.salePrice)} must be a finite number strictly below price ${row.price}`,
};
}
}
// currency
if (typeof row.currency !== "string" || !ISO_4217_RE.test(row.currency)) {
return { reason: "invalid-currency", detail: `currency must be an uppercase 3-letter ISO 4217 code (got ${JSON.stringify(row.currency)})` };
}
// hero
if (typeof row.heroImage !== "string" || row.heroImage.trim().length === 0) {
return { reason: "missing-hero", detail: "heroImage missing or empty" };
}
const hero = row.heroImage.replace(/\\/g, "/");
if (hero.startsWith("/") || WINDOWS_DRIVE_RE.test(hero) || hero.split("/").includes("..")) {
return { reason: "path-escape", detail: `heroImage "${row.heroImage}" escapes the read root` };
}
return null;
}
/** Canonical JSON: sorted keys, no whitespace, floats with fixed precision. */
export function canonicalJson(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return "[" + value.map(canonicalJson).join(",") + "]";
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a < b ? -1 : 1);
return "{" + entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",") + "}";
}
class PhotoCraftMcp {
private client!: Client;
private transport!: StdioClientTransport;
async start(readRoot: string, writeRoot: string): Promise<void> {
this.transport = new StdioClientTransport({
command: "photocraft-cli",
args: ["mcp", "--automation-read-root", readRoot, "--automation-write-root", writeRoot],
});
this.client = new Client({ name: "feed-hygiene-gate", version: "1.0.0" }, { capabilities: {} });
await this.client.connect(this.transport);
}
async close(): Promise<void> { await this.client.close(); }
async callTool(name: string, args: Record<string, unknown>, timeoutMs: number): Promise<unknown> {
return await withTimeout(this.client.callTool({ name, arguments: args }), timeoutMs, name);
}
async runBatch(steps: Array<{ id: string; params?: Record<string, unknown> }>, timeoutMs: number): Promise<unknown> {
return this.callTool("command_batch", { steps, stop_on_error: true }, timeoutMs);
}
}
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`batch-timeout:${label}`)), ms);
p.then((v) => { clearTimeout(t); resolve(v); })
.catch((e) => { clearTimeout(t); reject(e); });
});
}
export function formatPrice(value: number, locale: string, currency: string): string {
try {
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(value);
} catch {
// bad locale or currency – fall back to a plain format; validation already
// passed the currency, so this is only a locale-shape rescue.
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(value);
}
}
export function buildCommandsForRow(row: ProductFeedRow, rowIndex: number) {
const sku = row.sku!;
const locale = row.locale ?? "en-US";
const heroPath = row.heroImage!.replace(/\\/g, "/");
const priceText = formatPrice(row.salePrice ?? row.price!, locale, row.currency!);
return [
// 1. Dedicated hero layer named after the SKU (doc_place embeds into it)
{ id: "layer.new.layer", params: { name: `hero-${sku}` } },
// 2. Type: title
{
id: "type.create",
params: {
text: row.title!.replace(/\n/g, " "),
x: 96, y: 900,
font: "Inter-SemiBold", size: 56, color: "#111111",
box: [96, 840, 1728, 120],
},
},
// 5. Type: price
{
id: "type.create",
params: { text: priceText, x: 96, y: 780, font: "Inter-Bold", size: 96, color: "#d97706" },
},
// 5. Brand S-curve (deterministic: no noise, no seeded filters)
{
id: "layer.newAdjustmentLayer.curves",
params: { points: [[0, 0], [64, 54], [192, 205], [255, 255]] },
},
];
}
async function sha256File(absPath: string): Promise<{ bytes: number; sha256: string }> {
const buf = await fs.readFile(absPath);
return { bytes: buf.byteLength, sha256: createHash("sha256").update(buf).digest("hex") };
}
interface RunOptions { feedPath: string; readRoot: string; writeRoot: string; }
export async function runGate(opts: RunOptions): Promise<FeedAuditReport> {
const feedRaw = await fs.readFile(opts.feedPath, "utf8");
const rows: ProductFeedRow[] = JSON.parse(feedRaw);
const report: FeedAuditReport = {
generatedBy: "feed-hygiene-gate/v1",
readRoot: opts.readRoot,
writeRoot: opts.writeRoot,
accepted: 0,
deferred: 0,
rejected: [],
artifacts: [],
};
const photocraft = new PhotoCraftMcp();
await photocraft.start(opts.readRoot, opts.writeRoot);
try {
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const reject = rejection(row);
if (reject) {
report.rejected.push({ rowIndex: i, sku: row.sku ?? null, reason: reject.reason, detail: reject.detail });
continue; // nothing touched on disk
}
const steps = buildCommandsForRow(row, i);
try {
// Metadata tools, not registry commands: create the document, add the
// hero layer, embed the image, then run the text/curve commands.
await photocraft.callTool("doc_new", {
name: `banner-${row.sku}`,
width: 1920, height: 1080, mode: "rgb", depth: 8, background: "#ffffff",
}, 20_000);
await photocraft.callTool("doc_place", {
path: row.heroImage!.replace(/\\/g, "/"),
fit: true,
center: [960, 540],
}, 20_000);
await photocraft.runBatch(steps, 20_000);
await photocraft.callTool("doc_export", {
path: `${row.sku}.png`, format: "png", quality: 90,
}, 20_000);
} catch (err) {
// Could be a timeout or a genuine batch failure. Record as deferred.
const detail = err instanceof Error ? err.message : String(err);
(report as any).deferredDetails = [
...(((report as any).deferredDetails as any[] | undefined) ?? []),
{ rowIndex: i, sku: row.sku!, detail: `BATCH_${detail}` },
];
report.deferred += 1;
continue;
}
// Verify the artifact actually landed and hash it.
const absOut = path.join(opts.writeRoot, `${row.sku}.png`);
try {
const { bytes, sha256 } = await sha256File(absOut);
report.artifacts.push({ rowIndex: i, sku: row.sku!, path: `${row.sku}.png`, bytes, sha256 });
report.accepted += 1;
} catch {
report.rejected.push({
rowIndex: i,
sku: row.sku!,
reason: "missing-hero",
detail: "export did not produce the expected artifact",
});
}
}
} finally {
await photocraft.close();
}
// Atomic write of the audit report beneath the write root.
const reportPath = path.join(opts.writeRoot, "feed-audit.json");
const tmpPath = `${reportPath}.tmp`;
await fs.writeFile(tmpPath, canonicalJson(report));
await fs.rename(tmpPath, reportPath);
return report;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
runGate({
feedPath: path.join(process.argv[2] ?? ".", "feed.json"),
readRoot: path.resolve(process.argv[3] ?? "./feeds/nightly"),
writeRoot: path.resolve(process.argv[4] ?? "./out/nightly"),
}).then((r) => console.log(JSON.stringify({ accepted: r.accepted, deferred: r.deferred, rejected: r.rejected.length })))
.catch((e) => { console.error(e); process.exit(1); });
}
What it actually does, on seven rows
The harness ships a fixture where five rows are wrong in five different ways, and runs the gate against the real engine:
PASS rule: control characters win first
PASS rule: whitespace sku
PASS rule: 121 UTF-8 bytes is too long
PASS rule: sale price must be below price
PASS rule: path escape
PASS gate: two rows accepted
PASS gate: five rows rejected
...
35 assertions passed
The audit it wrote (trimmed to the fields that matter):
{
"accepted": 2,
"deferred": 0,
"rejected": [
{ "rowIndex": 2, "sku": "NO-TITLE", "reason": "missing-title", "detail": "title missing or whitespace-only" },
{ "rowIndex": 3, "sku": "BAD-CURRENCY", "reason": "invalid-currency", "detail": "currency must be an uppercase 3-letter ISO 4217 code (got \"usd\")" },
{ "rowIndex": 4, "sku": "ESCAPE", "reason": "path-escape", "detail": "heroImage \"../secret.png\" escapes the read root" },
{ "rowIndex": 5, "sku": "BAD-SALE", "reason": "invalid-sale-price", "detail": "salePrice 12 must be a finite number strictly below price 10" },
{ "rowIndex": 6, "sku": "MOJIBAKE", "reason": "mojibake-title", "detail": "title contains U+FFFD (upstream decode error)" }
],
"artifacts": [
{ "sku": "SKU-1", "path": "SKU-1.png", "bytes": 699747, "sha256": "dda526c72508…" },
{ "sku": "SKU-2", "path": "SKU-2.png", "bytes": 701959, "sha256": "eb55e5b13ebb…" }
]
}
Two banners rendered at 1920×1080, five rows stopped before they touched the engine, and every one of the five has a sentence a human can act on.
Run it
The gate drives photocraft-cli, so this one needs the engine binaries — the honest cost is about 4 GB, installed once:
git clone https://github.com/edgarmilvus/artcraft-book
cd artcraft-book
npm ci
./tools/install_craft_clis.sh
cd chapter10/solutions_and_explanations/exercise_1_feed-hygiene-gate-validating-and-normalizing-dynamic-product-feeds-before-banner/files
export PATH="$PWD/../../../../tools/craft-cli/bin:$PATH"
npx tsx run_ex1.ts
It takes under three seconds once the engines are installed.
The rule
Put the gate before the expensive step, give every rejection a named reason and the evidence that produced it, and keep rejected separate from deferred. It costs one pass over the feed and it buys you a nightly job whose failures are rows, not mysteries — which is the difference between a pipeline you run and a pipeline you babysit.
Repo: github.com/edgarmilvus/artcraft-book — MIT, twenty chapters, every harness executed against the real binaries
GitHub: @edgarmilvus — the other books and repositories
Ebook: ArtCraft: The Agentic Creative Studio in TypeScript — the prose around the code
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.