Part 7 of 7 · Contract summarizer series ~8 min read

Engineering reference: the contract summarizer architecture

Same system, drawn for engineers. Region, service names, resource identifiers, Bedrock model IDs, Lambda inventory, IAM scopes, the SES inbound rule set, the S3 Vectors index, the DynamoDB schemas, and the approval flow. Read alongside the previous six posts; this one’s the build sheet.

Region and account shape

Default region: ap-southeast-1 (Singapore). SES inbound, Textract, Bedrock cross-Region inference, and S3 Vectors are all available there. A second region for resilience isn’t worth the setup at SMB volume — the failure mode is a contract that has to be re-forwarded, not a regional outage. One AWS account dedicated to the summarizer (separate from other workloads) keeps the IAM blast radius small and lets a single AWS Budgets alarm cover the whole system.

Topology

AWS topology of the contract summarizer A topology diagram with three regions stacked vertically inside one AWS account boundary. Top region: ingress. Three boxes show the three intake lanes — a Drive folder sync via the drive-watch Lambda triggered every 5 minutes by EventBridge Scheduler that copies new files to s3://cs-contracts/, an SES inbound rule set with action S3 PUT to s3://cs-raw-mime/ plus a Lambda intake-ses that extracts the PDF and copies it to the contracts bucket, and an esign-webhook Lambda on a Function URL that the e-sign tool calls on signing, which fetches the signed PDF and copies it in too. Middle region: per-contract processing. An S3 PUT on cs-contracts triggers the reader Lambda; it runs Textract on the PDF, splits the text into numbered clauses, calls Bedrock Haiku 4.5 to fill the fixed term shape, embeds each clause with Titan Text Embeddings V2 into the S3 Vectors index cs-clauses, searches the always-flag topics, and calls Bedrock Sonnet 4.6 on the matched clauses to write the risk flags, then writes the summary draft to s3://cs-summaries/ and a job row to DynamoDB cs-jobs. Bottom region: review and delivery. A summarize-done event posts the draft to the configured surface — Drive, email via SES outbound, or Slack — each with Approve, Send-to-a-human, and Hold buttons. Button clicks land on a Function URL Lambda approve-handler that updates cs-jobs and cs-audit and, on approve, files and delivers the summary, or on send-to-human forwards it to the configured lawyer. CloudWatch Logs collects from every Lambda at 7-day retention. Across the right edge: a small box labelled AWS Budgets alarm at $15 monthly threshold, posting to SNS topic cs-cost-alarm. A note at the bottom: every point quotes its clause — and every action is logged to cs-audit. Ingress Lambda · drive-watch every 5 min Drive API → s3://cs-contracts/ one object per file SES inbound rule set cs-inbound-rules action: S3 PUT s3://cs-raw-mime/ trigger: intake-ses Lambda · esign-webhook Function URL e-sign tool calls on signing → fetch PDF → cs-contracts S3 cs-contracts bucket one object per contract · versioned Per-contract processing Textract + clause split S3 PUT triggers reader pages → text split into numbered clauses Lambda · reader Haiku 4.5 term pull Titan embeds clauses Sonnet 4.6 reads matched clauses S3 Vectors + draft cs-clauses index flag search → cs-summaries/ + cs-jobs row Review & delivery Summary delivered posts the draft to Drive, Slack, or SES outbound email with the banner Review buttons [Approve] [Send to a human] [Hold] → Function URL Lambda · approve-handler writes cs-jobs, cs-audit, and on approve files + delivers the summary Every point quotes its clause — and every action is logged to cs-audit.
Fig 7. AWS topology, in three regions of the diagram: ingress (three lanes into the contracts bucket), per-contract processing (read, term pull, flag search, risk read, draft), review and delivery (the summary ships and the owner’s decision is recorded). Every Lambda is event-driven; nothing is synchronous-chained beyond a single contract’s read.

Lambda functions

All Lambdas use the arm64 architecture (Graviton), the smallest memory size that meets latency targets, Python 3.14 runtime, and CloudWatch Logs at 7-day retention. Each function has its own least-privilege IAM role. None run inside a VPC.

  • drive-watch — EventBridge Scheduler target, fires every 5 minutes. Uses the Google Drive API (service-account credentials in Secrets Manager under cs/drive/sa) to list new files in the watched folder and copy each to s3://cs-contracts/<uuid>. Records the Drive file id in cs-jobs to avoid re-importing. Memory: 256 MB. Timeout: 30 s.
  • intake-ses — S3 PUT trigger on s3://cs-raw-mime/. Walks the MIME tree, extracts the contract attachment (PDF/PNG/JPEG/TIFF go straight to Textract; DOCX falls back to python-docx, XLSX to openpyxl), and copies it to s3://cs-contracts/. Both fallback packages are stable and widely used in 2026, though maintenance velocity is light — acceptable for a path that runs a few dozen times a month; python-docx-oss is a drop-in if extraction precision becomes a concern. Memory: 512 MB. Timeout: 60 s.
  • esign-webhook — Lambda Function URL, AuthType: NONE; verifies an HMAC signature from the e-sign provider (DocuSign Connect, PandaDoc webhooks, etc.) using the secret in cs/esign/hmac. On a completed-envelope event, fetches the signed PDF via the provider API and copies it to s3://cs-contracts/. Memory: 256 MB. Timeout: 30 s.
  • reader — S3 PUT trigger on s3://cs-contracts/. The core function. Runs Textract StartDocumentTextDetection + StartDocumentAnalysis asynchronously for multi-page contracts; on completion (SNS notification) reads the structured text and splits it into numbered clauses with a deterministic Python pass. Calls Bedrock Haiku 4.5 (anthropic.claude-haiku-4-5-20251001-v1:0 via global.anthropic.claude-haiku-4-5-20251001-v1:0) to fill the fixed term shape, validating that each field cites an existing clause. Embeds each clause with Titan Text Embeddings V2 (amazon.titan-embed-text-v2:0, 1024-dim) and upserts to the cs-clauses S3 Vectors index; runs the always-flag topic search; calls Bedrock Sonnet 4.6 (anthropic.claude-sonnet-4-6-20250930-v1:0 via global.anthropic.claude-sonnet-4-6-20250930-v1:0) on matched clauses for the risk flags; validates every flag quotes real clause text; writes the summary draft to s3://cs-summaries/ and a row to cs-jobs. Memory: 1024 MB. Timeout: 120 s.
  • deliver — EventBridge rule on the cs.summarize_done event. Posts the draft to the configured surface: a Drive folder (Drive API), a Slack channel (chat.postMessage with Block Kit buttons), or an email (SES SendRawEmail), each with the not-legal-advice banner and the Approve / Send-to-a-human / Hold actions. Memory: 256 MB. Timeout: 30 s.
  • approve-handler — Lambda Function URL, AuthType: NONE; verifies a Slack signature (or a signed token for email-link clicks). Triggered by the three review buttons. Writes to cs-jobs and cs-audit; on approve, files the summary against the contract version and delivers it; on send-to-a-human, forwards the draft and quoted clauses to the configured lawyer/manager via SES and marks the job awaiting_human; on hold, parks the draft. Memory: 256 MB. Timeout: 15 s.
  • summary-monthly — EventBridge Scheduler target, first Monday 9am. Reads the past month’s cs-jobs and cs-audit; calls Bedrock Haiku 4.5 to write a one-paragraph rollup (how many contracts read, how many high-stakes flags, how many sent to a human); emails it via SES to the configured stakeholders. Memory: 512 MB.

Storage

  • DynamoDB · cs-jobs — one row per contract read. PK contract_id; sort key summary_version; attributes: source_lane (drive/ses/esign), status (reading/drafted/approved/awaiting_human/held), page_count, flag_count, high_stakes (bool), created_at. On-demand.
  • DynamoDB · cs-audit — one row per review action. PK (contract_id, ts); attributes: action (approve/send_to_human/hold), by_user, summary_version, notes. On-demand. No TTL — long-term audit trail.
  • S3 · cs-contracts — raw contracts, one object per upload. Versioning enabled. Lifecycle to Glacier at 90 days; expiry at 7 years.
  • S3 · cs-summaries — the cleaned clause text and the one-page summary drafts/finals, versioned, so any summary can be pulled up exactly as it read when approved.
  • S3 · cs-raw-mime — raw inbound MIME from the forwarding lane. Lifecycle to Glacier at 30 days; expiry at 7 years.
  • S3 Vectors · cs-clauses — clause embeddings (1024-dim Titan V2), keyed by (contract_id, clause_no) with the clause text as metadata. Used by the always-flag topic search; entries for a superseded contract version are pruned on re-upload.

Bedrock

  • Foundation models. anthropic.claude-haiku-4-5-20251001-v1:0 for the term pull and the monthly rollup, and anthropic.claude-sonnet-4-6-20250930-v1:0 for the risk read — both via their Global cross-Region inference profiles (global.anthropic.*). Haiku is the cheap path; Sonnet is reserved for the few matched clauses where reasoning earns its cost.
  • Embeddings. amazon.titan-embed-text-v2:0 (1024-dim) for clause embeddings, stored in the cs-clauses S3 Vectors index. This is the one system in the philosophy that genuinely needs retrieval — the flag search has to find topically-matching clauses regardless of the contract’s wording.
  • Quotas. Default account quotas are more than enough at SMB volume. Both models fire at most twice per contract; at a couple hundred contracts a month that’s well within limits.

Grounding and guardrails

  • Citation enforcement. The reader validates, in plain Python, that every term field cites an existing clause number and every risk flag quotes text that literally appears in its clause. Unsupported fields are blanked; unsupported flags are dropped. No model output reaches the draft un-checked.
  • Not-legal-advice banner. Prepended to every summary by the summary writer; no config flag disables it. The Sonnet prompt is instructed to describe and explain, never to recommend; a lint pass rewrites or drops any sentence that drifts into prescriptive “you should” phrasing.
  • Human-in-the-loop. A summary is never approved by the system. High-stakes flags (rules-doc thresholds in /cs/config/flag-rules) set high_stakes=true and route the draft to awaiting_human until a person acts. The owner can also send any draft to a human on demand.

EventBridge Scheduler and SES

  • cs-drive-watchrate(5 minutes). Target: drive-watch Lambda.
  • cs-monthly-summarycron(0 9 ? * 2#1 *) (first Monday 9am) in TZ_NAME. Target: summary-monthly Lambda.
  • Set the MX record on a dedicated subdomain (e.g. review.your-company.com) to inbound-smtp.ap-southeast-1.amazonaws.com.
  • SES inbound rule set cs-inbound-rules: one rule with recipient review@your-company.com → spam scan → S3 PUT to s3://cs-raw-mime/<message-id> → stop. The S3 PUT triggers intake-ses.
  • SES outbound for delivered summaries and lawyer hand-offs: verify a sender identity at summaries@your-company.com with DKIM and SPF. Out of sandbox by request.

IAM (least privilege per Lambda)

Each Lambda has its own role with policies scoped to exact ARNs. Sketch:

  • reader role: s3:GetObject on cs-contracts; s3:PutObject on cs-summaries; textract:StartDocumentTextDetection + StartDocumentAnalysis + GetDocument*; bedrock:InvokeModel on the Haiku, Sonnet, and Titan ARNs; s3vectors:PutVectors + QueryVectors on cs-clauses; dynamodb:PutItem on cs-jobs; events:PutEvents for the done event.
  • deliver role: s3:GetObject on cs-summaries; secretsmanager:GetSecretValue on the Slack and Drive secrets; ses:SendRawEmail; outbound network to slack.com and www.googleapis.com.
  • approve-handler role: dynamodb:PutItem on cs-jobs and cs-audit; s3:GetObject/PutObject on cs-summaries; ses:SendRawEmail for the human hand-off; secretsmanager:GetSecretValue on the Slack signing secret.
  • intake-ses role: s3:GetObject on cs-raw-mime; s3:PutObject on cs-contracts.
  • drive-watch and esign-webhook roles: secretsmanager:GetSecretValue on the relevant secret; s3:PutObject on cs-contracts; dynamodb:PutItem on cs-jobs; outbound network to the Google or e-sign API host.

Review and approval flow

Summaries delivered to Slack use chat.postMessage with Block Kit blocks carrying the three action buttons; clicks hit the configured Interactivity request URL, which is the approve-handler Function URL. The handler verifies the Slack signing secret, parses the action_id (approve, send_to_human, ack_hold), and processes it. Email-delivered summaries carry signed links to the same handler. The Slack app needs chat:write and the Interactivity URL configured; the bot token lives in Secrets Manager under cs/slack/bot-token, the signing secret under cs/slack/signing-secret.

Observability and cost gates

  • CloudWatch Logs: all Lambdas, 7-day retention, structured JSON. Subscription filter on "error" + "throttle" + "timeout" to a metric for alerting.
  • Alarms: reader failures > 0 in an hour (the read is the one piece that has to work); Textract job failures > 1%; approve-handler signature-verification failures > 5/hour (a rotated Slack secret).
  • X-Ray: off by default. Not worth the cost at SMB volume.
  • AWS Budgets: $15/month threshold, alarm at 80% and 100%, posts to SNS topic cs-cost-alarm subscribed to the on-call admin’s email and Slack.

Config and secrets

Drive service-account credentials live in Secrets Manager under cs/drive/sa; the e-sign HMAC secret under cs/esign/hmac; Slack bot token and signing secret under cs/slack/*. The always-flag list, the escalate-to-a-lawyer thresholds, the summary layout, the banner wording, the delivery surface, and the lawyer/manager hand-off address all live in Parameter Store under /cs/config/ (mirrored from the house-style Drive docs by a small sync, so a non-engineer can edit a flag rule without a deploy). Lambdas fetch config on cold start and cache for the execution environment’s lifetime.

Deploy

GitHub Actions with OIDC into a deploy role (no long-lived keys), building and shipping with AWS SAM. The opinionated bits: deploy the SES rule set as a separate stack (rule-set changes affect mail flow), turn on S3 versioning for cs-contracts and cs-summaries so any version of a contract or summary can be pulled up later, and keep the two Function URLs (esign-webhook, approve-handler) signature-verified rather than public-open. Total deployable surface: around seven Lambdas, two DynamoDB tables, three S3 buckets, one S3 Vectors index, one EventBridge rule on the default bus (plus the Scheduler rules), one SES rule set, and one Budgets alarm.

That’s the full system. Six narrative posts and this engineering reference. If you want to talk about adapting it for your business, see Work with me.

All posts