Bedrock Adoption for PDF Conversions (ZDR) β with Cost Controls
Why
A customer requires Zero Data Retention (ZDR) on PDF conversions. ZDR means
the LLM provider does not persist prompt/response content after serving a request
and does not train on it. Our only ZDR-capable posture today is Amazon Bedrock
with PutAccountDataRetention mode: none + invocation logging off (already in
place for the VPAT worker β see bedrock-vpat-setup.md).
Bedrock hosts Anthropic Claude and Amazon Nova, but not Google Gemini or OpenAI. So delivering ZDR on the PDF pipeline forces every Gemini/OpenAI call in that pipeline onto a Claude or Nova model on Bedrock.
This plan is the PDF-only, cost-control-focused execution of the broader
bedrock-zdr-rollout-roadmap.md. It is
deliberately built so the provider abstraction and cost-control machinery are
product-agnostic and reused when ZDR expands to other products.
Decisions locked (2026-08-15)
| Decision | Choice | Consequence |
|---|---|---|
| Scope | All PDF conversions route through Bedrock | One auditable ZDR story; Gemini fully removed from the PDF path |
| Cheap-tier model | Amazon Nova (lite/micro) replaces Gemini Flash-tier tasks | ZDR-eligible, cheaper than Gemini Flash on our volumes |
| High-fidelity path | Claude Sonnet on Bedrock (not Opus) | Opus uses the Anthropic Files API, unsupported on Bedrock β tracked in #1978 |
| Budget-breach behavior | Hard kill-switch β fail closed (pause/queue) | Never fall back to a non-ZDR provider; overspend is capped, not silently absorbed |
| Budget ceiling | Propose from current spend β see Proposed budget | Start $500/mo with 50/80/100% alerts; re-index to volume when large customers onboard |
| Gemini fallback on kill | Forbidden | Falling back to Gemini breaks ZDR and the βGemini removedβ guarantee |
Non-negotiable invariant
A PDF conversion must never reach a non-ZDR provider. Every migrated call site fails closed (mirror the vpat-parse #1192 pattern: return an error / queue the job rather than silently using Anthropic-direct, Gemini, or OpenAI). This applies to error fallbacks, circuit-breaker escalation, and budget kill-switch behavior alike.
Current state (what weβre migrating)
Full call-site inventory is in the roadmap doc; the PDF-pipeline-specific picture:
Already ZDR-capable pattern
workers/vpat-parseβ Bedrock Converse viaaws4fetch+ SigV4, fails closed on 409 when ZDR required but unavailable. Reference implementation for βfail closed.β
Claude-direct β Bedrock (easy β same models exist on Bedrock)
Main converters and quality gates: claude-converter, agentic-vision-converter
(ClaudeVisionStrategy), unpdf-claude-converter, alt-text-suggester,
cover-text-describer, image-region-recovery, vision-table-extractor,
struct-table-extractor, form-hybrid-converter, premium-form-converter.
Each constructs new Anthropic({ apiKey }) inline β mechanical swap to the factory.
Opus β Sonnet-on-Bedrock (#1978)
opus-page-converter (Files API β no Bedrock equivalent). ZDR jobs route
high-fidelity requests to Sonnet on Bedrock instead. Tracked in #1978.
Gemini β Bedrock (Nova or Claude) β the provider change
Central factory workers/api/src/utils/gemini-client.ts fronts most of these;
model IDs centralized in workers/api/src/config/models.ts.
| Stage | File | Current Gemini model | β Bedrock target |
|---|---|---|---|
| Cost/complexity prediction | conversion-predictor.ts | gemini-3-flash-preview | Nova-lite |
| Alt-text critique | alt-text-critique.ts | gemini-2.0-flash-lite | Nova-micro |
| Long descriptions (charts) | long-description-generator.ts | gemini-3-flash-preview | Nova-lite (Sonnet if quality regresses) |
| Visual layout scoring | visual-layout-scorer.ts | gemini-3-flash-preview | Nova-lite |
| Vision equation audit | vision-equation-audit.ts | gemini-3-flash-preview | Nova-lite |
| Style mimic (hi-fi) | pdf-style-mimic.ts | gemini-2.5-pro | Claude Sonnet (Bedrock) |
| Image enhance / alt-text | image-enhancer.ts | gemini-3-flash-preview | Nova-lite (config-driven) |
| Image OCR | image-ocr.ts | gemini-3-flash-preview | Nova-lite |
| Segmented (diagram alt) | segmented-converter.ts | gemini-1.5-flash | Nova-lite |
| HTML remediator (alt) | html-remediator.ts | gemini-1.5-flash | Nova-lite |
| WCAG structured fixes | wcag-orchestrator/wcag-impl.ts | gemini-2.0-flash (@ai-sdk/google) | Claude Haiku/Sonnet on Bedrock |
| Smart-cascade Gemini branch | smart-cascade-converter.ts | gemini/gpt tiers | Nova / Sonnet |
Nova is the default substitute for every Gemini Flash-tier task; escalate a specific task to Claude Sonnet only if the eval corpus shows a real quality regression. Re-baseline each task β the prompts were tuned for Gemini.
Architectural facts that help
workers/apiruns on Lambda + EC2 Node, both of which carry an IAM role β Bedrock needs no static keys (use@anthropic-ai/bedrock-sdk, which mirrorsmessages.create()1:1, and the Bedrock Converse API for Nova).- The Gemini factory is a single chokepoint; the cost table
(
workers/api/src/utils/llm-cost.ts) is centralized. - Anthropic has no factory today (25 inline
new Anthropic()calls) β the main mechanical cost.
Provider abstraction (foundation β reuse #1652, do NOT build a competing factory)
Do not create a new
anthropic-client.ts. The existing Bedrock epic #1651 already owns the provider abstraction: #1652 builds the@accessible/aisemantic-tier library β call sites request a capability tier (TEXT_CHEAP,TEXT_QUALITY,VISION_CHEAP,VISION_QUALITY,IMAGE_GEN), and the tierβ(provider, modelId)mapping lives in one env-overridable registry. Provider migration becomes a config flip, not a code change. This plan rides on that library; it must not fork a parallel factory.
What this PDF-ZDR plan requires from / on top of the #1652 library:
- The library exposes a uniform
complete({ tier, messages, schema?, images?, policy? }) β { text|object, usage, modelUsed }, backed by Claude-on-Bedrock (@anthropic-ai/bedrock-sdk) and an Amazon Nova Converse path (SigV4 via IAM role). Directnew Anthropic()remains only as an explicit, non-ZDR break-glass. - ZDR policy β the PDF-specific addition (tracked in #1981): a
requireZdrpolicy on the library that fails closed β never silently downgrades to a retaining provider (Anthropic-direct / Gemini / OpenAI) on error, circuit-breaker escalation, or budget kill. Mirror the vpat-parse #1192 409 pattern. - Backend selection:
LLM_BACKEND(bedrock|anthropic) defaults tobedrockfor the PDF path once migrated; PDFβs capability tiers resolve to Nova (cheap) / Claude Sonnet (quality/hi-fi) on Bedrock. - CI lint (in #1981) forbids new inline
new Anthropic()/new GoogleGenAI()/new OpenAI()outside the library β prevents ZDR regressions.
The library is product-agnostic by design (#1652), so audit/vpat/org-chart/ course-map reuse it; this plan only adds the ZDR policy + PDF tier mappings.
Cost controls (the core of this plan)
Three independent layers, from real-time to authoritative. We do not wait on AWS billing latency for protection β the self-metered ledger is the real-time guardrail; AWS data reconciles it.
Layer 1 β Self-metered, per-call (REAL-TIME, primary)
Every Bedrock response returns usage (input/output/cache tokens). At each call
site we already compute cost via llm-cost.ts and write a cost_ledger row
(product, model, backend, tokens, estimated_cost_usd, operation_type).
This is truly real-time (per request) and independent of AWS billing.
Work:
- Add Bedrock model IDs + Bedrock pricing (Nova-lite/micro, Sonnet/Haiku on
Bedrock, incl. Bedrock prompt-cache rates) to
llm-cost.ts. - Stamp
backend='bedrock'and keepoperation_typeper stage (migration to add any newoperation_typevalues β the DB CHECK is the source of truth; seereference_cost_ledger_guards). - A cheap rollup (materialized view or 1-min cron) maintains
bedrock_spend_rollup(today, month-to-date, by product/model). This is what the budget checks read β no full-table scans on the hot path.
Layer 2 β CloudWatch Bedrock metrics (NEAR-REAL-TIME, ZDR-safe cross-check)
Bedrock emits InputTokenCount / OutputTokenCount / Invocations as
CloudWatch metrics β these are token counts, not prompt content, so they
are emitted even with invocation logging off and do not violate ZDR.
Poll them (1β5 min) to independently verify our self-metered totals and catch any
un-instrumented call site (a call we forgot to ledger still shows up in the metric
counts). Divergence between Layer 1 and Layer 2 β alert.
Layer 3 β AWS Budgets + Cost Explorer / CUR (AUTHORITATIVE, lagged)
- AWS Budgets: a monthly cost budget scoped to the Bedrock service (and, once tagged, to the PDF cost-allocation tag), thresholds at 50/80/100%, SNS β ingest endpoint. Refreshes every ~8β12h β reconciliation, not protection.
- Cost & Usage Report (CUR) or Cost Explorer API pulled daily to true-up the ledger against the actual invoice and correct our pricing table drift.
Budgets, alerts & the hard kill-switch
Mirror the existing GCP budget-killswitch pattern (gcp-budget-killswitch.md,
gcp_budget_events + /admin/spend) on the AWS side:
- New
aws_budget_eventstable + an ingest endpoint for the AWS Budgets SNS notifications; surfaced in the/admin/spendpanel next to GCP/Anthropic. - Alert thresholds via the existing
spend_alert_settings+cost-monitorscron +dispatchAdminAlert(Telegram/email). Escalate at 50% / 80% / 100% of the monthly ceiling, plus a daily guardrail (see below). - Hard kill-switch (fail closed): a
bedrock_pdf_conversions_enabledflag (KV, checked by the conversion dispatcher). When month-to-date self-metered spend crosses 100% (or the daily cap trips):- Flip the flag off.
- New PDF conversions are queued/held with a clear status
(
paused_budget), not dispatched β no Gemini fallback. - Fire a critical alert; require a manual break-glass re-enable (with a reason logged) or an approved ceiling bump.
- In-flight jobs finish; only new dispatch is gated.
- Daily guardrail ($ per day, proposed $50): the real defense against a runaway (e.g., an agentic-vision loop stuck retrying at Sonnet rates, or abuse). Catches within hours what a monthly budget would miss for weeks. Reads Layer 1, so it trips in near-real-time.
Cost attribution & expansion-readiness
Because cost_ledger already carries product, model, backend,
operation_type, the same budget/alert/kill-switch machinery generalizes:
per-product budgets (PDF now; audit/vpat/etc. later) are just filtered rollups.
Build the /admin/spend AWS panel and the kill-switch flag keyed by product
from day one.
Proposed budget
Derived from cost_ledger (product = pdf-converter), last 120 days:
- PDF LLM-only run-rate today: ~$28β35 / month. (The larger PDF line items β Marker ~$36 and Mathpix ~$20 over 120d β are non-LLM services and do not move to Bedrock.)
- Gemini slice being replaced: ~$4.6 / month (gemini-pro-hifi + gemini-3-flash
- legacy gemini-2.5-flash). Nova is cheaper than Gemini Flash, so the flash-tier
tasks get cheaper; the one exception is
pdf-style-mimic(Gemini Pro β Sonnet) which rises but is low-volume (~68 calls/120d).
- legacy gemini-2.5-flash). Nova is cheaper than Gemini Flash, so the flash-tier
tasks get cheaper; the one exception is
- Opus β Sonnet (#1978) should reduce the ~$26/120d Opus line.
- Net effect at current volume: roughly flat, ~$30β40/month LLM on Bedrock.
- Trend: August is already ~2β3Γ spring months β real growth.
Proposed starting ceiling: $500 / month, alerts at $250 / $400 / $500 (50/80/100%), hard kill-switch at 100%, plus a $50 / day runaway guardrail.
Rationale: a kill-switch exists to stop a runaway bug or abuse, not to cap normal growth. $500 is ~10β15Γ todayβs LLM run-rate β deliberate headroom so the switch never trips on organic growth, while still capping a runaway at hundreds (not thousands) of dollars; the $50/day guardrail is the tight, fast trip.
Re-index on volume. This ceiling assumes current volume (~a few thousand pages/mo). A large customer changes everything: the USD proposal alone is 225,000 pages/yr (~18,750/mo) β ~40Γ current volume. Before onboarding any customer >5,000 pages/mo, switch the budget to volume-indexed ($ per 1,000 pages) and raise the ceiling accordingly. Track the $/page unit cost from the ledger so this is a one-number update.
Phased execution (PDF-scoped)
Phase 0 β Foundations (no behavior change)
- Verify the Bedrock ZDR account posture covers the AWS account Lambda/EC2
use (may differ from the vpat-parse account β ZDR is per-account). Re-run the
PutAccountDataRetention mode:none+ logging-off checks. - Confirm each target model is ZDR-eligible under
mode:none(the Fable-5provider_data_sharetrap β pre-Fable Claude + Nova are fine). - Add IAM
bedrock:InvokeModel(+ inference-profile ARNs) to the Lambda execution role and EC2 instance role. No static keys. - Add Bedrock model IDs + pricing to
llm-cost.ts. - Land the #1652
@accessible/ailibrary (or use it if already landed) and add the PDF-ZDRrequireZdrfail-closed policy + CI lint guard on top of it (#1981). Do not build a separateanthropic-client.tsfactory.
Phase 0 verification log (2026-08-15, #1981)
Account-side gate β verified with an operator identity (root/CLI) against the live conversion account. These confirm the account posture; they are not proof that the Lambda execution roles can invoke β those roles remain un-granted until the IAM change below is deployed.
| Check | Result |
|---|---|
| AWS account for the PDF Lambdas | 696081601609 β same account as vpat-parse (ZDR posture applies) |
data-retention mode | none (set 2026-06-18) β
|
| Model invocation logging | OFF (no config) β β prompts never land in CloudWatch/S3 |
Nova Lite / Nova Micro under mode:none | invoke OK β (ZDR-eligible) |
Claude Sonnet 4.6 (us.anthropic.claude-sonnet-4-6) under mode:none | invoke OK β (hi-fi target, #1978) |
Claude Sonnet 5 (us.anthropic.claude-sonnet-5) under mode:none | invoke OK β (not caught in the Fable/Mythos trap) |
| Claude Haiku 4.5 | verified end-to-end by vpat-parse (bedrock-vpat-setup.md Β§Status) β
|
IAM (the one real gap): neither PDF Lambda role
(accessible-pdf-production-api, β¦-api-ai-fix-stream) had bedrock:InvokeModel.
Both roles are CDK-managed, so the grant was added in
infra/cdk/lib/stacks/api-stack.ts (statement InvokeZdrEligibleBedrockModels),
not via a manual put-role-policy that would drift on the next cdk deploy.
Two layers keep this fail-closed:
- Account posture (primary):
data-retention mode: nonemakes any Fable/Mythos-tier model (which requiresprovider_data_share) returnunavailableat invoke time. - IAM scope (secondary): the grant enumerates the ZDR-eligible Claude
families β
claude-opus-*,claude-sonnet-*,claude-haiku-*(all pre-Fable) β plusamazon.nova-*, across the foundation-model ARNs (region-wildcarded so cross-regionus.profiles resolve) and the account-scoped inference-profile ARNs. It deliberately does not listclaude-fable-*/claude-mythos-*. A catch-allclaude-*would have matched Fableβs ID (anthropic.claude-fable-5) and left the account setting as the only guard; enumerating the families denies Fable/Mythos at the IAM layer even if the account posture were ever loosened.
Not yet deployed (cdk deploy AccessiblePdfProd-Api pending) β merging does
not apply the grant.
Still blocked on #1652 (the library doesnβt exist yet): the requireZdr
fail-closed policy wiring, the CI lint guard against new inline
new Anthropic()/new GoogleGenAI()/new OpenAI(), and the /health backend+ZDR
field. These land with / on top of #1652 β doing a half lint-guard now (with a
~25-entry allowlist of existing sites) is churn #1652 would rework.
Not applicable β the on-prem Node box (10.1.1.4) is not EC2 and carries no
IAM role. Its workers/api Bedrock calls will need either static keys (vpat-parse
pattern) or a proxy through the Lambda/roleβd path β decide in #1983 before the
Node runtime flips to Bedrock. Only the Lambda path is IAM-covered by this change.
Phase 1 β Cost-control infrastructure FIRST (before flipping traffic)
Deliberately ahead of the traffic migration so we have guardrails before spend moves:
bedrock_spend_rollup+ Layer-1 real-time budget checks.aws_budget_events+ AWS Budgets SNS ingest +/admin/spendAWS panel (product-keyed).- Kill-switch flag + dispatcher gate (
paused_budgetjob status) + break-glass. - Daily-guardrail + 50/80/100% alerts via
cost-monitors/dispatchAdminAlert. - CloudWatch Bedrock-metrics poller (Layer 2) + divergence alert.
Phase 2 β Migrate Claude call sites (lowest risk)
- Replace inline
new Anthropic()in the PDF converters/quality-gates with the #1652 librarycomplete({ tier })calls; one PR per cluster. - Route high-fidelity to Sonnet-on-Bedrock (#1978); do not call
opus-page-converteron ZDR jobs. - Canary via a Lambda alias; verify output parity, latency (cold-start + inference-profile), and cost (ledger). Canary % β 100%.
Phase 3 β Migrate Gemini/OpenAI call sites (the provider change)
- Port each Gemini/OpenAI PDF call site to Nova (default) or Sonnet (hi-fi) per the mapping table; re-baseline quality against the eval corpus per task.
- Remove the Gemini branch from
smart-cascade-converterfor PDF. - Gate rollout on no quality regression; watch high-parallelism alt-text cost.
Phase 4 β Enforce & verify
- Make
LLM_BACKEND=bedrockthe PDF default; direct Anthropic is break-glass only. /healthreports active backend + ZDR posture; startup assertion fails loudly on misconfig.- Reconcile ledger vs CUR for one full cycle; tune pricing table.
- Only after verification, update the public ZDR claim to cover PDF (today itβs VPAT-only).
Risks & open items
- Per-account ZDR posture β verify the conversion account, donβt assume it matches vpat-parse (Phase 0).
- Quality regression on GeminiβNova/Claude re-prompts β budget eval time; the prompts were tuned for Gemini.
- High-parallelism cost β alt-text can fan out to ~80 calls/page; confirm Nova pricing holds at that fan-out and that the daily guardrail is sized right.
- Latency β Bedrock cold-start + inference-profile routing vs direct API; measure on the canary.
- CUR/Cost Explorer lag β never rely on it for protection; itβs reconciliation only. Protection is Layer 1 + the kill-switch.
- Nova vision parity β confirm Nova-lite/micro handle the vision tasks (layout scoring, image OCR) acceptably; fall back to Sonnet per-task if not.
Issue tracking
Filed under epic #1980 (ZDR for PDF conversions + cost-control guardrails):
- #1981 β ZDR account posture + fail-closed provider policy (Phase 0 ZDR bits + lint)
- #1982 β Cost-control infrastructure (rollup, AWS Budgets ingest, fail-closed kill-switch, daily guardrail, CloudWatch poller) β build before flipping traffic
- #1983 β PDF cutover + verification + public ZDR claim (Phases 2β4)
Reused from the existing Bedrock epic #1651 β do not refile:
- #1652 β
@accessible/aisemantic-tier provider library (the abstraction) - #1655 β migrate
workers/apivision call sites to Bedrock (benchmark-gated) - #1653 β vpat-parse Bedrock default (reference implementation)
- #1978 β Sonnet-on-Bedrock for high fidelity (Opus Files-API gap)