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...
