Preserve-Design (in-place flyer tagging)
Tags designed PDFs (flyers, posters, short brochures) in place β marked-content injection + a PDF/UA-1 structure tree via pikepdf/PyMuPDF β instead of re-authoring. Output is verified pixel-identical (β₯99.95%) and veraPDF UA-1 compliant, or it is not delivered. Shipped 2026-08-21/22 across PRs #2117, #2131, #2143, #2145, #2146, #2147.
Authoritative contract: services/flyer-tagger/docs/flyer-tagger-integration.md.
Architecture
upload (apps/web) βββΊ POST /api/v1/flyer/preflight ββ sync Lambda invoke {preflight_only} β route: in-place | reflow (+ eligible, designDense, β signed, formCentric β 'forms' route retired by #2144) βΌ choice card (shown whenever eligible; designDense picks emphasis; preserve on a non-in-place route sends force:true) β user picks preserve βΌ POST /api/v1/flyer/tag ββ entitlement gate (reserveUsage) ββΊ job row ββΊ SQS flyer-tagger-jobs ββΊ settle (deductCredits, charge_units) βΌ Lambda flyer-tagger (us-east-1, arm64 container, 3 GB / 600 s) R2 download β preflight recheck β Bedrock vision analysis (1 call/page) β inject tags β verify gate (pixel, qpdf, veraPDF, unclaimed ops) β R2 upload (quarantine/<key> on gate failure) β job row + cost_ledger βΌ GET /api/v1/flyer/jobs/:id (poll: refund reconciliation, file completion) GET β¦/jobs/:id/download GET β¦/jobs/:id/report (WCAG HTML report) POST β¦/jobs/:id/accept POST β¦/jobs/:id/reflow (comped conversion)- Routes live in
workers/api/src/routes/flyer.ts, registered on BOTH prod compositions (index-aws.tsLambda +server.tsNode) β never the legacy CF worker. - Queue:
flyer-tagger-jobs+ DLQ (maxReceiveCount 3, visibility 660 s), ESM batch size 1 withReportBatchItemFailures. - Model: Bedrock Sonnet via the Lambda execution role (ZDR posture β no Gemini). Compact index-based response schema (PR #2146) β $0.07/page.
FLYER_TAGGER_MODELenv overrides for canary (Haiku β $0.015/page but 70% role agreement + figure undercounting β human-reviewed canary only, see PR #2146 eval).
Billing (the part youβll get support tickets about)
Charged at enqueue (consume_entitlement, product pdf_convert, 1 unit/page); the Lambda never charges.
Pre-flight quote (Polish, apps/pro). Before the user starts, the confirm bar counts each PDFβs pages in the browser (pdf-lib, same counter as /tag) and calls POST /api/v1/flyer/quote { pages } β a read-only checkUsageAffordable (no hold, no writes) returning { pages, mode, affordable, cost, available, unitPriceCents, shortfall, reason, purchaseUrl } (paygo amounts in cents, quota in pages). When short, Start is disabled and the bar shows the shortfall with an Add funds link to https://pdf.gevell.com/settings?tab=billing (TOP_UP_URL, also the /tag 402 purchaseUrl). Every drop β including a single PDF β now waits for an explicit start. /tagβs reserveUsage gate stays authoritative; if it still returns INSUFFICIENT_CREDITS (race, or the quote failed) Polish shows an informational toast with Add funds and does not POST /api/errors, so no admin ticket is filed for an expected balance condition (admin ticket #8). Real failures are still recorded. Resolution of a non-delivered job is exactly-once and mutually exclusive, enforced end-to-end:
| Outcome | Trigger | Column stamped |
|---|---|---|
| Auto-refund | job failed, preflight-skipped, swept, or fidelity gate failure (pixel/qpdf) | refunded_at |
| Accept as-is | user keeps a compliance-only failure (veraPDF/untagged ops; pixel-perfect) | accepted_at |
| Comped reflow | user takes the free standard conversion instead | comp_conversion_id (stores the files.id) |
- DB backstop:
flyer_resolution_exclusiveCHECK (num_nonnulls(...) <= 1), migration 259. - All claims are guarded PostgREST PATCHes (
β¦=is.nullon all three columns); rollbacks are guarded on the requestβs own stamp and checkres.ok(PR #2145 review fixes β a silent rollback failure is a kept charge; grep logs forCLAIM ROLLBACK THREW). - The comped conversionβs billing bypass is
_compedFlyerJobIdin the server-built SQS message only; the client-facing zod schema strips it. Settlement skip lives inchunk-scheduler.ts. - Refund reconciliation is lazy (on poll) plus the sweepβs terminal pass for users who stopped polling.
Stale-job sweep
workers/api/src/services/flyer-stale-sweep.ts, Node (.4) scheduler only, every 10 min:
- Rows stuck
processing>45 min orqueued>60 min β markedfailed(status+cutoff-guarded PATCH; a concurrently-completing Lambda wins) β refunded immediately. - Terminal pass:
failed/fidelity-failed rows withcharge_units > 0and no resolution, older than 30 min β refund retry. Compliance-only failures are excluded (they await the userβs accept/reflow choice).
Deploy runbook
| Piece | How | Gotchas |
|---|---|---|
Lambda flyer-tagger | cd services/flyer-tagger && bash deploy/deploy.sh | Env: script merges, never replaces (#2147 β a replace once wiped the R2/Supabase secrets, 2026-08-22). Secrets are set out-of-band from .4βs .env.node-server; never print values. Buildx: BUILDX_NO_DEFAULT_ATTESTATIONS=1 if Lambda rejects the manifest. |
| workers/api (Lambda) | CI on merge to main (CDK carries FLYER_TAGGER_QUEUE_URL + SQS/invoke grants) | β |
| workers/api (Node .4) | npm run rebuild from repo root | Check large_conversion_jobs, form_conversions, AND flyer_tagging_jobs for in-flight rows first. Sweep + comp settlement only run here. |
| apps/web | CI on merge | β |
| Migrations | 257 (table + cost op type), 258 (refund cols), 259 (accept/comp/file_id + CHECK + files.flyer_job_id), 260 (human_verifications jsonb β per-criterion user attestations) | All applied to prod 2026-08-21/22. Apply before code that writes the columns. |
Smoke test after a Lambda deploy (must return route, no errorMessage):
aws lambda invoke --function-name flyer-tagger --region us-east-1 \ --cli-binary-format raw-in-base64-out \ --payload '{"preflight_only": true, "inputKey": "<any real users/... key>"}' /tmp/out.json \ && python3 -c "import json; d=json.load(open('/tmp/out.json')); print(d.get('route'), d.get('errorMessage'))"Alt-text review/edit (#2164)
GET /api/v1/flyer/jobs/:id/figures and PUT .../figures/:index (owner-only,
delivered jobs) invoke the tagger Lambda synchronously with {"op": "list_figures"|"set_alt", "key": <output_key>} (flyer_tagger/altedit.py).
Figure identity is positional (depth-first structure-tree order β stable across
pikepdf save cycles). set_alt rejects empty alt (PDF/UA-1 7.3), edits only
the structure tree (pixels untouched by construction), qpdf-checks, and only
then overwrites the stored object. Thumbnails are PyMuPDF crops of the
figureβs /A /BBox, returned as data URIs.
OCR-mode figures get their /A /BBox from the vision analysis directly
(#2166): the compact wire shape carries b = the image region on Figure
elements (text lines canβt supply one β OCR figures have none). A missing or
invalid region is a validation problem in the retry loop, but the
final-attempt fallback degrades it to no-thumbnail (region_dropped) rather
than failing the page β regions are cosmetic, never gated. Pictorial pages
use the whole page as the region. Jobs tagged before #2166 have no /BBox
on OCR figures and show the βNo previewβ placeholder.
Verification gate
evaluate() in services/flyer-tagger/flyer_tagger/cli.py fails a run on: untagged text ops, unclaimed text ops (model omitted a matched line β added PR #2146), orphaned MCIDs (a mark in the content stream no structure element references β added with the #2155 multi-page fix; independent of veraPDF), pixel identity <99.95%, qpdf errors, veraPDF unavailable/non-compliant (absence of the validator is a failure, never a pass). Failed output goes to quarantine/<key>, never the delivery key.
Multi-page documents: ONE StructureContext accumulates the tree, ParentTree keys, and heading-clamp state across pages (#2155 β a fresh tree per page previously left every earlier pageβs marks orphaned). Heading levels are clamped document-wide (first heading β H1, no skipped levels; PDF/UA 7.4.2).
Troubleshooting
- Choice card never appears: user must be authenticated; preflight fails open to normal conversion on any error (check
[flyer/preflight]logs in the API composition serving the request). - Job stuck: sweep resolves + refunds within ~10 min of the staleness cutoff. If the sweep itself errors,
[flyer-sweep]in.4api-node logs. - User says they were charged for nothing: check the rowβs resolution columns; a NULL-all-three terminal non-delivered row older than the grace period means the sweep/poll refund is failing β look for
CLAIM ROLLBACK THREW/refund_entitlement failed. - Cost anomalies:
cost_ledgeroperation_type='flyer-tagging';metadata.unpriced_model=trueflags a model id the rate table didnβt recognize. - Debug a specific PDF locally:
services/flyer-tagger/.venv+python -m flyer_tagger.cli <in> <out>; preflight only:run_preflight()(see README). Model A/B:tools/eval_models.py.
Known limits / open work
- #2144 β SHIPPED: AcroForm fields are tagged in place (
<Form>+ OBJR +/TUfrom adjacent labels;/Tabs /S; fields stay fillable).formsretired as a route: XFA is the only form-related hard bar; digital signatures surface assigned(consent warning β tagging invalidates validation); high field density surfaces asformCentric(choice card adds a Gevell Forms offer). Fixture generator:services/flyer-tagger/tools/make_form_fixture.py. - #2148 β shipped: OCR + ActualText mode for flattened/scanned flyers. Preflight classifies flattened design-dense rasters as
mode: 'ocr'(route staysin-place); the vision pass recognizes text with per-line confidence; structure attaches as/ActualTextover the untouched raster. Two new gate failures β OCR coverage and low-confidence OCR β are compliance-only (accept / comped-reflow choice, never auto-refund). Confidence threshold:FLYER_TAGGER_OCR_MIN_CONF(default 0.80) on the Lambda. - #2118 β opt-in minimal visual fixes (small text / contrast are report-only advisories today).
- Haiku canary β parked pending quality; procedure in
services/flyer-tagger/README.mdCost section.