Decision summary
One workflow owner — DBOS Transact
DBOS Transact owns the workflow: Lane A → B → C sequencing, parallel fan-out via durable queues, step-level retries, cancellation, scheduled re-audits (@DBOS.scheduled), and the five human-gate pause/resume points (G1–G5) via durable send/recv events. Every workflow and step checkpoints to the same RDS Postgres instance in § Data & storage before doing anything else — a crash mid-run or a 4-hour Gate 1 wait both cost nothing, since there's no separate state-machine service holding a slot.
LangGraph is a library, not the orchestrator
LangGraph runs inside a single DBOS step, and only for the three agents that must reason across multiple tool results: A1 (entity resolution), B5 (competitor shortlist) and C5 (evidence audit). It never owns cross-step workflow state — that boundary belongs to DBOS alone.
DSPy compiles what LangGraph runs. Every prompt inside A1/B5/C5's tool-calling nodes — and the structured-call agents in Lane A/B/C — is a DSPy Signature compiled against a gold set, not a hand-tuned string. LangGraph still owns control flow (which tool to call next); DSPy only owns the prompt text at each node. Recompiling a signature produces a new prompt version, recorded in prompt_versions exactly like a hand-written prompt would be.
Everything else needs no framework
A2–A3, B1, B2–B4/B6/B7, C3 and C4 are a single schema-constrained model call. (B1 Keyword, Catchment & Market is sequential in Lane A, not parallel in Lane B — every Lane B agent consumes its output before running.) C1/C2 are plain Python — scoring and opportunity math are deterministic by design. The renderer is a template plus headless Chromium, with no model at all.
MCP is deferred; fixed integrations use typed Python adapters. DSPy is adopted for prompt compilation once a gold set exists for that agent — never for orchestration (that's DBOS alone), never to tune the score formula (that stays a fixed Python function).
Full stack, top to bottom
Ten layers, all AWS-managed. Adopted ships from day one · Reserved is designed for but not built until a measured need proves it.
The AWS-managed execute-api HTTPS endpoint routes every browser and service request to Lambda. No custom domain, external DNS, CDN or WAF is a day-one dependency; add ACM + DNS only if a public domain is introduced later.
Audit intake, live progress, report and evidence viewer, and the review UI for G1–G5. Shows domain milestones only — never an agent's raw reasoning trace.
React · Vite · AWS LambdaApproved internal users sign in with Google Login (Sign in with Google, OAuth 2.0). The control plane validates every session server-side; all signed-in users can work through the review gates, with each decision attributed in the audit log. Detail in § Auth.
Google Login (OAuth 2.0 / OIDC) · Amazon Cognito · API Gateway authorizerValidates commands, registers idempotent audit runs, returns safe status, and routes every gate decision back to the workflow. It never crawls, scores, calls a model, or owns a long-running audit.
FastAPI · AWS LambdaThe single owner of workflow state, as a durable-execution library rather than a separate managed state machine. Durable queues fan out Lane B; step-level retry policy enforces provider failure handling; durable send/recv events hold G1–G5 waits without consuming compute; @DBOS.scheduled triggers re-audits. Every checkpoint lands in the same RDS Postgres instance as the rest of the data plane — no separate state-machine service to run or pay for. Fargate remains the fallback only if a single step's runtime outgrows a Lambda invocation's execution limit.
Each DBOS workflow step invokes one bounded Lambda. Only A1, B5 and C5 run a LangGraph tool loop inside that step; every other agent is a structured call or plain Python. Wherever a gold set exists for that agent, its prompt is a DSPy-compiled signature rather than a hand-written string — LangGraph still decides control flow, DSPy only owns the prompt text. Detail in § Agent framework tiering.
LangGraph · DSPy · Python · AWS LambdaClaude Haiku 4.5 / Sonnet 5 / Opus 5, routed per agent by task shape, called through the Anthropic-Messages-API proxy. Detail in § Model runtime.
Amazon Bedrock · ClaudeEvery provider is a typed Python client imported by the Lambda that needs it: Places, DataForSEO, Google Business Profile, SE Ranking AI Search, Local Falcon, Practo/Justdial, Instagram, YouTube. Each declares timeout, retry class, quota, cache TTL and fallback, and has a fixture twin for replayable tests. MCP is deferred — introduce it only if model-driven tool discovery across runtimes becomes a real need, and never for scoring, persistence or rendering.
Typed Python adapters · Secrets ManagerRDS is the single source of truth for run state and decisions; S3 holds immutable raw evidence and rendered PDFs. RDS Proxy protects connections from Lambda concurrency. Detail in § Data & storage.
Amazon RDS · RDS Proxy · Amazon S3 · KMSTraces every workflow step, agent, tool call and Bedrock call: latency, cost, and the pipeline's quality metrics — fact precision, competitor F1, GEO F1, unsupported-claim rate, and reviewer edit distance from Gate 4. Those metrics are the gold sets DSPy compiles each agent's prompt against — a metric moving the wrong way is the trigger to recompile, not to hand-edit the prompt. Every Lambda invocation's structured logs land in CloudWatch Logs, correlated by audit_run_id, for stepping through a single failed run outside the trace view; CloudWatch Alarms watch error rate and spend against budget.
Agent framework tiering
Same evaluate(input) → result contract for every agent; the implementation underneath is chosen per agent, not applied uniformly. Three patterns cover all 15.
| Agent | Pattern | Why this pattern |
|---|---|---|
| A1 Entity Resolution | LangGraph tool loop | Disambiguation needs reasoning across several tool results (Places, Business Data, Maps SERP), not one completion. |
| B5 Competitor Shortlist | LangGraph tool loop | Same tool-loop shape as A1, reused rather than re-derived. |
| C5 Evidence Auditor | LangGraph tool loop | Adversarial audit against a fixed evidence table, in a fresh context with no visibility into C3's reasoning trace — so it can't rubber-stamp the composer's blind spots. |
| A2, A3, B1 | Structured single call | One schema-constrained completion each in Lane A sequence. A2 profiles the entity; A3 estimates economics; B1 resolves the keyword universe and catchment — all before Lane B fans out (since every B2–B7 agent consumes B1's output). |
| B2–B4, B6, B7, C4 | Structured single call | One schema-constrained completion each — classification, extraction, or a sentence around a number Python already computed. No loop, no framework. |
| C3 Narrative Composer | Structured single call, one pass | No tool calls — pure generation over the payload it's handed; every figure is passed in, none recalled from memory. |
| C1, C2 | Plain Python | Pure functions with fixtures. Scoring and opportunity math must be reproducible; a model here would be a liability. |
| R Renderer | Template + headless Chromium | No model at all — if it isn't in the Gate-4-approved payload, it can't appear on the page. |
One execution owner per node
A node is either a LangGraph tool loop, a structured model call, or deterministic code — never two at once. Retries, budget and termination for a node belong to exactly one layer, so a failure has exactly one place to look.
Model runtime — Amazon Bedrock
Bedrock hosts every Claude call. How the pipeline talks to it matters more than the model choice.
Proxy shape, not native Converse
Call Bedrock through the Anthropic-Messages-API-compatible proxy — x-api-key + anthropic-version headers, standard Messages request/response shape — not the native …/model/{id}/converse endpoint. A bedrock-api-key-… bearer token authenticates against the proxy and is rejected by native Converse with a 403. Verify the exact endpoint and model-id string against a working implementation before assuming AWS's general Converse docs apply.
| Tier | Model | Used by | Why |
|---|---|---|---|
| Bulk | claude-haiku-4-5 | A2, A3, B1, B2, B3, B4, B7, C4 | Classification and schema-constrained extraction at volume — hundreds of reviews or keywords per run must stay cheap enough to process per-item. |
| Tool-calling | claude-sonnet-5 | A1, B5, C5 | Multi-step reasoning across tool results. A lighter tier than C3 keeps the C5 audit cheap without weakening it — it's a targeted check against known facts. |
| Narrative | claude-opus-5 | C3 | One pass, no tools, highest-stakes prose in the report. Runs once per audit, not per evidence row, so quality outweighs cost. |
Cost controls
Cache the system prompt and large static context (specialty taxonomy, benchmark tables) per agent. Pin one region for predictable latency and cost. Every node declares a maximum tool-call, token, time and spend ceiling — enforced by DBOS, never by the prompt.
Data & storage
| Store | Holds | Notes |
|---|---|---|
| Amazon RDS PostgreSQL | audit_runs, seed_inputs, evidence_index (pointers, not payloads), review_decisions, prompt_versions, report_revisions | Single source of truth for run state and every gate decision. Private subnets, reached through RDS Proxy. A re-audit writes a new revision — it never overwrites one. |
| Amazon S3 | Immutable raw provider responses (Source Artifacts), screenshots, rendered PDFs | Written before any extraction touches the data — a provenance rule, not an optimization. Versioning on; Object Lock where retention demands immutability. |
| Secrets Manager + KMS | Provider API keys, the Bedrock proxy token, database credentials | Each Lambda execution role reads only the secrets it needs. Nothing lives in the repo. |
| pgvector · Redis · R2 Reserved | Semantic evidence search · caching and rate-limit coordination · cheaper object egress | All three are designed for but unbuilt. Add one only when a concrete measured need appears — not preemptively. |
Auth
Two separate domains — internal people signing off on gates, and services calling each other. They use different credentials and should not be conflated.
| Who | Access | Audit record |
|---|---|---|
| Approved internal user | Shared access to the internal webapp, audit runs, evidence, reports and gates G1–G5. | Every submission, approval, rejection and feedback action records the authenticated user and timestamp. |
Chosen approach — Google Login
Google is the identity provider via Sign in with Google (OAuth 2.0 / OIDC) — no Google Workspace admin-console federation required. Amazon Cognito federates through OIDC and issues the application JWT; API Gateway validates that JWT before FastAPI receives the request. No gate-specific roles are required at this stage.
Service auth
Three separate, non-interchangeable credentials: Lambda execution roles for AWS resources, scoped provider keys from Secrets Manager for external APIs, and the Bedrock proxy token for model calls. Gate decisions derive the user's identity from the authenticated session — never from the client — and every decision writes to the audit log.
Webapp & backend layout
One monorepo. Apps deploy as Lambda functions; packages are shared libraries imported by them.
doctor-seo/ ├── apps/ │ ├── web/ # React — intake, progress, report/evidence viewer, gate UI │ ├── control-plane/ # FastAPI — auth, commands, /reviews, /publish │ └── workflow/ # DBOS Transact durable workflows + Lambda step handlers ├── packages/ │ ├── agents/ # LangGraph loops (A1, B5, C5) + structured-call agents │ ├── adapters/ # typed provider clients: places, dataforseo, gbp, │ │ # ai-search, aggregators, social + fixture twins │ ├── prompts/ # DSPy signatures + compiled programs (a1.py … c5.py), │ │ # versioned and PR-reviewed like any other prompt asset │ ├── scoring/ # C1/C2 deterministic Python, no framework dependency │ └── schemas/ # Pydantic contracts shared across all three apps └── infra/ # Terraform or CDK — API Gateway, Lambda, # RDS, S3, KMS, Secrets Manager, CloudWatch Logs + alarms
Prompt discipline
System prompts are compiled artifacts, not hand-typed strings — a DSPy signature compiled against a gold set, checked into git like code, changed through a recompile + PR review, recorded per run in prompt_versions so scoring drift traces back to a specific compile. User/task prompts are data — assembled per invocation from typed state, never a template with user text spliced into the system role. The seed's free-text remark is the one open field in the pipeline; it steers tone and hero-procedure only, and is never a measured input.
End-to-end request path
One audit run, walked through every layer, from a doctor's name to a signed-off PDF. See End-to-End-Request-Path-AWS.html for the same path broken into 12 steps with per-step requirements.
An approved internal user enters name, locality, specialty and remark in the React webapp, reached over HTTPS through API Gateway. Session auth has already been validated through Google Login.
The FastAPI control plane verifies the caller, checks an idempotency key, and writes an audit_runs row to RDS Postgres before queuing any work — so a refresh can't create a duplicate audit.
DBOS persists the run state to Postgres, then invokes A1 as a LangGraph tool loop — its prompt a DSPy-compiled signature — calling the Places adapter against Bedrock (Sonnet 5) to resolve the canonical clinic. Then A2 enriches the profile, A3 estimates economics, and B1 resolves the keyword universe and catchment — all sequential.
Low match confidence pauses the run; an approved internal user approves or rejects from the webapp. After enrichment and the economic estimate, another review decision ratifies the ranges at Gate 2. Both waits are held by a durable DBOS event — nothing consumes compute while the workflow sleeps, and it resumes exactly where it left off on approval.
Six agents run in parallel as DBOS queued steps, each writing raw responses to S3 before extraction and indexed evidence rows to RDS. All consume B1's keyword and catchment outputs. A provider that can't answer marks the run partial — a model is never asked to fill the gap.
An approved internal user spot-checks the evidence table and its linked raw artifacts, then approves, refetches, or marks a row unavailable.
C1/C2 run as plain Python over the approved evidence; C3 (Opus 5) drafts the narrative from that payload alone; C5 (Sonnet 5, fresh context) audits every claim against the evidence and blocks anything unsupported.
An approved internal user signs off — this gate always fires. Only then does a Playwright/Chromium Lambda render the approved payload to PDF, publish an immutable Report Revision to S3, and serve it through a short-lived pre-signed URL.