Prototype for TTB label compliance review: upload a label image plus the application's form fields (single or CSV batch) and get a per-field PASS / FAIL / NEEDS_REVIEW verdict (brand name, ABV, net contents, class/type, government warning).
Live demo: https://proofread-3hn.pages.dev — this *.pages.dev URL is
the canonical production address; no custom domain is attached, deliberately
(the POC keeps the Cloudflare-issued origin, see
docs/api-edge-hardening.md).
Where things are documented:
| Document | What it covers |
|---|---|
| INSTRUCTIONS.md | The original brief |
| docs/latency-gate.md | OCR engine decision record + model A/B evidence |
| docs/api-edge-hardening.md | Why/how the API is reachable only through Cloudflare |
| Productionize.md | POC shortcut → production path, item by item |
| iac/README.md | Terraform bootstrap, state layout, OIDC, cost posture |
The only prerequisite is mise — every other tool (python, node, pnpm, uv, pitchfork, lefthook, git-lfs, terraform) is installed and version-pinned by the mise configs. No Docker in the local loop.
brew install mise # non-Homebrew installers: https://mise.jdx.dev/getting-started.html
mise install --monorepo # installs every package toolchain in one shot
git lfs pull # first clone only: materialize LFS image assets
cp .env.example .env # then fill in your Upstash REDIS_URL
mise run dev # api + worker + frontend under pitchfork
First run only: mise asks you to trust the repo's config files (root and one
per package) — accept the prompts, or run mise trust in each directory.
(Image assets are stored in git LFS. git-lfs is installed by mise and its
filters are enabled automatically on entering the directory; git lfs pull
is only needed if the repo was cloned before mise was set up.)
Frontend: http://localhost:5173 — API: http://localhost:8000/healthz. The
local Vite server proxies /api/* to the local FastAPI process, mirroring
the deployed edge proxy (VITE_API_URL remains only as an escape hatch to
bypass the proxy).
Other tasks: mise run test (pytest + vitest), mise run lint (ruff + eslint).
Git hooks install automatically when you enter the directory. Stack debugging:
see .claude/skills/dev-stack/SKILL.md.
Two pytest suites are deliberately excluded from the default run and every-push
CI (they load real OCR models): pytest -m ocr_model (engine contract smoke)
and pytest -m acceptance (default engine over all 53 fixtures vs
samples/index.json — also runnable in CI via the dispatch-only acceptance
workflow). Both need uv sync --group ocr first.
Opening a PR against main auto-deploys the backend to the shared dev
Azure environment, and — when the PR touches the frontend — publishes a
Cloudflare Pages preview; a bot comment on the PR carries both URLs.
Previews are per-PR and immutable: each PR gets its own
https://<hash>.proofread-3hn.pages.dev deployment serving exactly that
PR's frontend build (they don't expire, they just go stale). The backend
behind every preview's /api proxy is a different story — dev is one
long-lived shared environment, updated in place by whichever PR deployed
most recently (a deliberate free-tier choice: per-PR backend stacks would
need per-PR Redis databases and state, and the free tier allows exactly
one). So with several PRs open, a preview's frontend is always its own, but
the dev API/worker it talks to are last-writer-wins. Merging to main
deploys prod and updates the live site (docs-only changes deploy nothing;
pipeline details and rollback: .claude/skills/deploy-rollback/SKILL.md).
Both environments scale the backend to zero when idle, so the first
interaction after a quiet spell is slower than the rest: the page's warm-up
ping absorbs the API cold start (~9 s), and the first check pays the OCR
worker start (~30–50 s — scaler poll + container boot + model load). After
that, checks run at the warm numbers under § Deployed checks, and the worker
stays warm for 15 minutes after the queue empties. The full scaling model
and its knobs: iac/README.md § Scaling model;
queue mechanics (key layout, blocking pops, job TTL) are documented in the
code that implements them —
src/backend/app/queue/redis.py.
A React + Vite SPA (Cloudflare Pages) calls the API same-origin at
/api/*; a Pages worker (src/frontend/public/_worker.js) proxies those
calls to a Python/FastAPI control plane on Azure Container Apps, whose
ingress allowlists Cloudflare's edge IPs — direct requests to the ACA FQDN
get 403 by design. The API validates uploads and enqueues job IDs into
Upstash Redis; OCR workers (RapidOCR behind an OcrEngine interface; Surya
as the optional US-provenance engine) consume the queue, run the pure
verification library, and write per-label results back to Redis with a 24 h
TTL. API and worker are one Python package and one container image with
multiple entrypoints, deployed as two Container Apps; locally the same
entrypoints run as native processes under pitchfork — no Docker in the local
loop.
browser ──── static assets ────► Cloudflare Pages (proofread-3hn.pages.dev)
│ │
└──── /api/* (same origin) ────► _worker.js proxy (Pages worker at the edge)
│ fetch(https://<aca-fqdn>/…)
▼
ACA api app — FastAPI, 0–1 replicas, scale-to-zero
(ingress allowlists Cloudflare edge IPs; direct curl → 403)
│ job IDs + job state (images staged in state)
▼
Upstash Redis — every key prefixed local:|ci:|dev:|prod:
▲ BLPOP job IDs (≥30 s blocking pops)
ACA worker app — RapidOCR + verification core,
KEDA-scaled 0→N on queue depth (listLength=5), 900 s cooldown
Dev and prod are the same templatized Terraform stack in separate Azure
subscriptions: a PR applies dev and comments a Pages preview URL; a merge to
main applies prod and updates the production site (bootstrap and state
layout in iac/README.md; rollback in
.claude/skills/deploy-rollback/SKILL.md).
Measured through the real user path (Pages origin → edge proxy → ACA → queue
→ worker) with .claude/skills/smoke-deployed/smoke.py:
- Warm single check: 4.83–5.41 s wall-clock (median 5.35 s over six back-to-back runs) — straddling the 5 s budget, recorded honestly as a finding rather than rounded down. The breakdown clears the queue: submit ≈ 0.6 s, worker pickup < 1 s of submit, the rest is OCR + matching on the worker's 1-vCPU replica. The latency gate's 1.6 s median was measured on 4-vCPU x86, so the gap is inference compute on the small replica, not queue/polling overhead — the lever is worker CPU sizing (Productionize.md § 4), not architecture.
- Cold start: 37.5 s for the first check after the worker scales to
zero (30 s KEDA poll + container boot + model load), reported separately —
it is not part of the 5 s budget. The frontend's
/healthzping on page load hides the API's own cold start (~9 s observed). - Load / KEDA scale-out: 48 labels (12 jobs × 4, from
samples/) drained in 99.8 s; worker scaled 1 → 2 replicas within one 30 s KEDA poll of submission and back to 0 after the 900 s cooldown. Kept deliberately small — free-tier credit matters more than a big number. - Correctness under load: 0 false verdicts. 28/48 labels landed an acceptable strict verdict (including one degraded fixture where FAIL is the documented alternate outcome), 20 punted to NEEDS_REVIEW — the same profile as the recorded acceptance-gate verdict (32/53 strict, 0 false verdicts; see the verdict-semantics bullet under § Trade-offs).
Single check — open https://proofread-3hn.pages.dev, upload
samples/labels/generated/gen-spirit-clean.png, and enter the matching
application fields: brand OLD TOM DISTILLERY, class/type
Kentucky Straight Bourbon Whiskey, ABV 45% Alc./Vol. (90 Proof), net
contents 750 mL — all five fields verify PASS.
Batch check — the browser sends bare filenames, so strip the directory
prefix from the fixture manifest for the generated set, then drop the ten
samples/labels/generated/*.png images plus that CSV onto the batch screen:
(head -1 samples/batch-manifest.csv; grep '^generated/' samples/batch-manifest.csv | sed 's|^generated/||') > demo-manifest.csv
Or from a terminal, run the deployed smoke check
(.claude/skills/smoke-deployed/SKILL.md):
python3 .claude/skills/smoke-deployed/smoke.py # prod; --base for a PR preview
Do not curl the *.azurecontainerapps.io FQDNs directly — the 403 you'll
get is the edge lock working as designed.
.mise.toml Shared tools (git-lfs, lefthook, pitchfork) + root tasks (monorepo root)
pitchfork.toml Local process supervision: api, worker, frontend
lefthook.yml Git hooks (pre-commit: staged lint; pre-push: full lint + typecheck)
.env.example REDIS_URL, QUEUE_ENV, worker/OCR knobs — copy to .env
Productionize.md What changes when real money/users arrive (per-item production paths)
.claude/skills/ Dev runbooks: dev-stack, upgrade-deps, gen-test-labels, add-ocr-engine,
smoke-deployed (deployed smoke + latency check)
.github/workflows/ Path-filtered CI (ci-backend, ci-frontend), image build (Buildah→GHCR),
deploy-dev (PR) / deploy-prod (merge), dispatch-only benchmarks
(ocr-bench: engine latency/recall on x86; acceptance: full fixture gate)
docs/ latency-gate.md (OCR decision record), api-edge-hardening.md (edge lock)
src/backend/ One Python package, entrypoints: api (FastAPI), worker (queue consumer)
├── app/core/ Pure verification library: ocr/ (engine seam), matching/, verify.py
├── app/queue/ JobQueue seam: Redis (QUEUE_ENV-prefixed) + in-memory adapters
└── .mise.toml Backend toolchain: uv; python via .python-version
src/frontend/ React + Vite + TypeScript SPA (vitest, eslint)
├── public/_worker.js Pages worker: same-origin /api/* proxy to the env's backend
└── .mise.toml Frontend toolchain: node via package.json devEngines + pnpm
iac/ Terraform: Upstash + Azure Container Apps + KEDA scaler (envs/ tfvars)
└── .mise.toml IaC toolchain: terraform via .terraform-version; tflint (latest)
samples/ 53 fixture labels (35 real TTB COLA panels — 4 with genuine warning
defects — plus generated + degraded variants), ground truth in
index.json, batch-manifest.csv; fetch/generate tools in tools/
mise configs are per package and merge with the root: working inside
src/frontend (or running its tasks from the root, e.g.
mise run '//src/frontend:test') resolves that package's toolchain.
POST /jobs— single (image + application form fields) or batch (images + CSV manifest, format =samples/batch-manifest.csv); whole batch rejected with every validation problem listed, nothing partially enqueuedGET /jobs/{id}— status + progress;GET /jobs/{id}/results— per-label results streaming in as workers finish;GET /healthz— warm-up probe- Queue carries job IDs only; job state lives in Redis hashes under a
QUEUE_ENVkey prefix with a 24 h TTL (app/queue/redis.pydocuments the key layout)
No package version younger than 7 days is adopted (supply-chain cooldown),
enforced in the resolvers themselves: uv's exclude-newer (backend) and
pnpm's minimumReleaseAge (frontend). Upgrades only via
.claude/skills/upgrade-deps/SKILL.md.
Each POC shortcut and its exit is written up per item in Productionize.md; the highlights:
- No result retention —
persist_results()is a deliberate no-op seam; nothing sensitive is stored. Retention slots in behind that one function. - One shared Upstash database for local/dev/prod, walled off by
QUEUE_ENVkey prefixes (free tier allows exactly one database). Production: one Azure Managed Redis per env — a connection-string swap for the adapter; Azure Service Bus documented as the idiomatic queue adapter (Productionize.md § 1). - OCR engine provenance: the default is RapidOCR — Baidu-trained PP-OCR
weights on Microsoft's onnxruntime — because it is the only self-hosted
engine that met the 5 s CPU budget (evidence +
RAPIDOCR_*re-bench knobs in docs/latency-gate.md); bigger/English model tiers were A/B'd and all increased false verdicts. Surya (US-developed) stays behind the sameOcrEngineseam as the provenance swap (needs GPU or budget relief); Azure AI Vision Read is the documented cloud fallback (Productionize.md § 5) — relevant because the customer's firewall posture blocks external ML endpoints, so self-hosted is the default. - Verdict semantics: false verdicts are the trust-destroying failure mode — the acceptance gate hard-fails on any false PASS/FAIL and tolerates NEEDS_REVIEW punts (an unreadable label goes to a human — the existing agent workflow). Gate: 32/53 strict, 21 punts, 0 false verdicts; the deployed load check reproduced the same profile (above).
- Edge lock, not network isolation: the API is "only reachable through the frontend" via the Cloudflare allowlist — a free-tier stand-in for the real design (frontend in ACA, backend internal-only ingress), which is Productionize.md § 2.
- Iron Bank base images required before production (registry1.dso.mil equivalents) — appropriate for a federal (TTB) deployment posture; public images are a POC cost/simplicity choice (Productionize.md § 6).
- Known limitations: bold detection on the government warning is not
attempted (documented matcher limitation); worker cold start is ~35 s
(mitigated by the 900 s warm cooldown and the
/healthzwarm-up ping); COLA integration is out of scope — the manifest loader is the documented seam.
Complete. The build ran as six phases (scaffolding/CI → verification core →
control plane/queue/worker → frontend → IaC + deploy pipelines →
hardening/docs/deployed checks); the retired phase plan and its per-phase
verdicts live in git history (PLAN.md, removed once everything in it
shipped) and the merged PR record. The one unbuilt stretch item — an MCP
server over the same pure core — is documented as a production add-on in
Productionize.md § 7.