Photo to Listing: Barcode Decoding and LLM Re-rank A developer built intent-longbox, a photo-to-listing pipeline for comic shops that combines barcode decoding with LLM re-ranking to identify exact comic issues and variants. The system uses an append-only database enforced by triggers, and the pilot runs at Gotham City Limit. The developer found that LLM vision alone is not viable for issue-exact identification, so the pipeline relies on deterministic barcode parsing first, with LLM re-ranking as a secondary step. A comic shop employee photographs a back issue on a phone. The system has to come back with the right title, the right issue number, and the right variant, because a first-print variant and a common reprint of the same book are the same picture with different economics. That is the identification problem in intent-longbox , a photo to listing pipeline that ended 2026-09-01 at v0.2.1 after eleven commits. The research that preceded the build settled the architecture in one finding: LLM vision alone is not viable for issue-exact and variant-exact identification. Every incumbent that actually works in this space runs image-similarity retrieval against a reference cover corpus. The model is a ranker, never the source of truth. So the pipeline puts the deterministic parts first https://startaitools.com/posts/llm-legible-deterministic-architecture/ . barcode decode → candidate retrieval → LLM re-rank → human confirm → condition + price → Shopify draft Nothing publishes without a person. The Shopify product lands as a DRAFT for owner review. The pilot shop is Gotham City Limit, running free. The deterministic front of that chain is a barcode parser with no model in it at all. Post-1990 comics carry a 12-digit UPC-A that identifies the series, plus a 5-digit UPC supplement encoding issue, cover variant, and printing: js const supp = digits.slice 12 ; return { ok: true, upc, supplement: { raw: supp, issue: Number supp.slice 0, 3 , cover: Number supp 3 , printing: Number supp 4 , }, }; When that supplement is readable, the variant question is already answered by arithmetic. The model never gets asked. The honest version of that diagram is that the retrieval leg is not built yet. v0 ships barcode plus vision plus human pick, with no similarity index behind the candidate step. So a barcode miss pre-1990 stock, a damaged code, a variant with no UPC falls to vision plus the gate below plus a mandatory human confirmation, which is the weakest path in the system and the one the pilot is meant to measure. That gap is tracked as the project's top open decision, not as a solved problem. Every event in a scan session is a separate immutable row, which makes the table an audit trail rather than a current-state cache. scan session is the identity. candidate set holds the deterministic result barcode decode plus similarity k-NN, FK'd to a corpus version . llm rerank holds the probabilistic value: provider, model, prompt hash, the verbatim response, confidence, band, contradiction flag, tokens, cost. It annotates the candidate set. It cannot edit it. Then human confirmation , condition assessment , pricing snapshot , shopify draft , cost log . Every shop-scoped table carries shop id . The only UPDATE anywhere in the codebase is on scan session.status . The obvious way to enforce append-only is code discipline. Write no UPDATE statements, review for them, move on. I rejected that, because it holds exactly as long as every future query is well behaved. A migration script, a hotfix, or one psql session at two in the morning ends the guarantee quietly, and the tell is that the audit trail looks fine afterward. So the rule lives in the database: bash CREATE OR REPLACE FUNCTION forbid mutation RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'table % is append-only Hickey model : % not allowed', TG TABLE NAME, TG OP; END; $$ LANGUAGE plpgsql; DO $$ DECLARE t text; BEGIN FOREACH t IN ARRAY ARRAY 'corpus version','scan photo','candidate set','llm rerank','human confirmation', 'condition assessment','pricing snapshot','shopify draft','cost log' LOOP EXECUTE format 'CREATE TRIGGER %I append only BEFORE UPDATE OR DELETE ON %I FOR EACH ROW EXECUTE FUNCTION forbid mutation ', t, t ; END LOOP; END $$; Verified against a real Postgres, not asserted in a comment. An UPDATE on cost log raises table cost log is append-only Hickey model . One more schema decision belongs here. Condition is a grade range label, and no numeric grade type exists anywhere in the schema, the API, the prompts, or the UI copy: grade range low text NOT NULL CHECK grade range low IN 'PR','FR','GD','VG','FN','VF','NM' , grade range high text NOT NULL CHECK grade range high IN 'PR','FR','GD','VG','FN','VF','NM' , A 9.4 is a claim a phone photo does not entitle anyone to make. If the type does not exist, nobody adds it later under deadline pressure. Confidence scores from a model are self-reported. Asking for one and thresholding on it is the cheap version of a quality gate, and it fails in the direction you care about, because a model is most fluent when it is wrong about a plausible thing. The VisionProvider interface therefore requires structured evidence alongside the answer: / REQUIRED structured evidence. The contradiction gate's raw material R7 . / export interface Evidence { issue number read: string | null; price box text: string | null; logo era guess: string | null; } The prompt states the same requirement in the model's own terms: - evidence fields are REQUIRED: report exactly what you can read on the cover issue number printed, cover price box text, publisher logo era guess . Use null only when genuinely unreadable. Now there is something to check. src/services/rerank.ts cross-validates each field against the top candidate's metadata. Issue number read against the candidate's issue. Cover price against a coarse US newsstand era table. Logo era decade against the candidate year: js if evidence.price box text == null && top.year == undefined { const priceMatch = evidence.price box text.match / \d+ \s ¢c |\$\s \d+ ?:\.\d{1,2} ? / ; if priceMatch { const cents = priceMatch 1 == undefined ? Number priceMatch 1 : Math.round Number priceMatch 2 100 ; const era = priceEraBounds cents ; if era && top.year < era.min || top.year era.max { reasons.push price box text "${evidence.price box text}" implies ~${era.min}-${era.max}, contradicts candidate year ${top.year} ; } } } The era bounds are deliberately loose. The gate catches decade-scale misses and does not quibble about a two-year overlap. A contradiction then costs the model its fast path: export function applyContradiction band: Band, contradiction: boolean : Band { if contradiction return band; return band === "high" ? "medium" : band; } Three bands drive the phone UI. High is a one-tap confirm. Medium is a candidate grid with a forced pick. Low is manual search. Downgrading high to medium means a confident but self-contradicting answer costs the employee one extra tap instead of putting a wrong book into inventory. The transferable piece has nothing to do with comics. Do not ask a model how sure it is. Make it report the specific things it read, then check those against something deterministic you already trust. Seeded cases in the test suite all flag and downgrade: a wrong issue number read, a twelve cent price box on a 1988 book, a 1960s logo on a modern year. The provider seam behind that gate is a bring your own key provider architecture https://startaitools.com/posts/the-moat-is-the-trust-layer-nexus-byok-rag/ per shop, with an Anthropic adapter Messages API, image blocks, claude-sonnet-5 as default and reference model and an OpenAI-compatible adapter chat completions, image url data URIs . Resolution order is a gateway override first, then a shop credential row whose key ref names an env var, then a global env fallback. Raw keys never enter the database, only refs to env var names. Two pieces of reliability lore carried over from the estate's existing provider registry shape in @intentsolutions/refiner and the Transport seam in @intentsolutions/jrig-cli : a 2048 max-output-token floor, and a parser that strips