Engineering reference: the feedback collector architecture
Same system, drawn for engineers. Region, service names, resource identifiers, Bedrock model IDs, Lambda inventory, IAM scopes, the SES inbound rule set, EventBridge Scheduler config, the DynamoDB schemas, and the star-tap and webhook 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, Bedrock Global cross-Region inference, and EventBridge Scheduler are all in good shape there. A second region for multi-region resilience isn’t worth the extra setup work at SMB volume — the failure mode for an SMB is a feedback ask that goes out a few hours late, not a regional outage. One AWS account dedicated to the collector (separate from your other workloads) keeps the IAM blast radius small and lets a single AWS Budgets alarm cover the whole system. Deploy with GitHub Actions using OIDC and AWS SAM, so there are no long-lived AWS keys anywhere — the CI role is assumed per run.
Topology
Lambda functions
All Lambdas use the arm64 (Graviton) architecture, the smallest memory size that meets latency targets (typically 256 MB), 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-sync— EventBridge Scheduler target, fires every 15 minutes. Uses the Google Drive API + Sheets API (service-account credentials in Secrets Manager underfc/drive/sa) to export the customer-list sheet as CSV and write tos3://fc-customers-source/customers.csvonly if the sheet has changed since the last sync. Same pattern syncs the rules and voice docs tos3://fc-rules-source/. Memory: 256 MB. Timeout: 30 s.intake-webhook— Lambda Function URL, public withAuthType: NONE; verifies a shared HMAC secret (fc/pos/webhook-secretin Secrets Manager) on each request. Called by the point-of-sale or e-commerce tool when a sale closes. Validates and normalizes the payload, drops refunds and test transactions, and appends a customer-list row via the Sheets API. Memory: 256 MB. Timeout: 15 s.calendar-sync— EventBridge Scheduler target, hourly. Uses the Google Calendar APIevents.listto scan configured calendars for bookings whose end time has just passed and which are confirmed/done; appends a row per finished booking. For lower-latency setups you can switch toevents.watchand have Calendar push notifications to a Function URL instead of polling, at the cost of refreshing the channel before its TTL expires. Memory: 256 MB. Timeout: 30 s.request-builder— triggered when a new row appears (a short EventBridge Scheduler poll on the mirrored CSV, or invoked directly byintake-webhook/calendar-sync). Readsrules.txtfor the per-visit-type wait, computes the send time, applies quiet hours and the holiday calendar, and creates a one-off EventBridge Scheduler rule (at(...)with--action-after-completion DELETE) that invokes the send. The ask is rendered fromvoice.txtand sent via SES outbound; each star link and the reply-to address carry a signed token (HMAC overcustomer_id|ask_id|exp). Writes a pending row tofc-state. Memory: 256 MB. Timeout: 30 s. No Bedrock calls.reply-handler— two triggers: a Lambda Function URL for star-tap link clicks, and an S3 PUT ons3://fc-raw-mime/when SES inbound writes a written reply. Verifies the signed token, dedupes againstfc-feedback(first reply wins), marks the customer answered, and forwards the payload torouter. Renders a thank-you page for taps; for a happy tap it can show the review link inline. Memory: 256 MB. Timeout: 15 s. No Bedrock calls.router— invoked byreply-handler. A star tap is scored by plain rules fromrules.txt(4–5 happy, 1–2 unhappy, 3 unclear). Free text gets one Bedrock Haiku 4.5 call (anthropic.claude-haiku-4-5-20251001-v1:0viaglobal.anthropic.claude-haiku-4-5-20251001-v1:0) returning{mood, confidence}; confidence below the rules-doc threshold is forced to unclear. Writes the bucket and reason tofc-feedbackand emits the bucket todispatch. Memory: 256 MB. Timeout: 30 s.dispatch— invoked byrouter. Happy: render the review nudge fromvoice.txtand send via SES outbound (or the SMS provider) with the public-review link. Unhappy: send a private owner ping (Slack DM viachat.postMessage, or SES email) with the customer’s words and one-tap call/message links — never the review link. Unclear: enqueue for the daily sweep. Writes a row tofc-auditafter each send. Memory: 256 MB. Timeout: 30 s.sweep-summary— EventBridge Scheduler target. Daily run: lists unclear items for a human, and for asks with no reply past the rules-doc window, sends at most one gentle follow-up viarequest-builder, then marks the row closed. Monthly run (first Monday 9am): reads the past month’sfc-feedbackandfc-audit, calls Bedrock Haiku 4.5 to write a one-paragraph owner summary (asked, happy, unhappy, reviews earned, complaints fixed before they went public), emails it via SES. Memory: 512 MB.
Storage
- DynamoDB ·
fc-feedback— one row per reply. PK(customer_id, ask_id); attributes:received_at,channel(tap/email),raw(star count or text),bucket(happy/unhappy/unclear),reason. On-demand. The dedupe and answered-once guarantees both key off this table. - DynamoDB ·
fc-state— one row per pending ask. PK(customer_id, ask_id); attributes:visit_type,send_at,sent_at,follow_up_sent,status(pending/sent/answered/closed),schedule_arnof the one-off rule. On-demand. - DynamoDB ·
fc-audit— one row per move of any kind. PK(customer_id, ts); attributes:bucket,move(review_nudge/owner_ping/held),sent_via,by_user(for human sweep actions). On-demand. No TTL — this is the long-term audit trail the monthly summary reads. - S3 ·
fc-customers-source— mirrored CSV from the Drive customer list. Versioning enabled. Lifecycle to Glacier at 90 days; expiry at 2 years. - S3 ·
fc-rules-source— mirrored rules and voice docs as plain text. Versioning enabled. - S3 ·
fc-raw-mime— raw inbound MIME from written replies. Lifecycle to Glacier at 30 days; expiry at 2 years.
Bedrock
- Foundation model.
anthropic.claude-haiku-4-5-20251001-v1:0via the Global cross-Region inference profileglobal.anthropic.claude-haiku-4-5-20251001-v1:0. Two callsites:routerfor the free-text mood read, andsweep-summaryfor the monthly owner narrative. Claude Sonnet 4.6 (global.anthropic.claude-sonnet-4-6-20250930-v1:0) is wired as an optional fallback for replies Haiku flags as genuinely ambiguous, but in practice a one-sentence mood read is squarely Haiku’s job and the fallback rarely fires. - Embeddings. Not used. Reading the mood of one short reply is a direct classification, not a retrieval problem. No Knowledge Base, no S3 Vectors, no Titan embeddings.
- Quotas. Default account quotas are more than enough at SMB volume. The common path (star taps) never calls Bedrock; only the free-text minority and the monthly summary do.
EventBridge Scheduler config
fc-drive-sync—rate(15 minutes). Target:drive-syncLambda.fc-calendar-sync—rate(1 hour). Target:calendar-syncLambda.fc-daily-sweep—cron(0 17 * * ? *)in TZ. Target:sweep-summaryLambda (daily mode).fc-monthly-summary—cron(0 9 ? * 2#1 *)(first Monday at 9am) in TZ. Target:sweep-summaryLambda (monthly mode).- One-off ask rules — created on the fly by
request-builder, one per scheduled ask. Useat(YYYY-MM-DDTHH:MM:SS)expressions inTZ_NAME(e.g.Asia/Singapore) with--action-after-completion DELETEso the rule self-cleans after firing.
SES inbound and outbound
- Set the MX record on a dedicated subdomain (e.g.
feedback.your-company.com) toinbound-smtp.ap-southeast-1.amazonaws.com. - SES inbound rule set
fc-inbound-rules: one rule with recipientfeedback@your-company.com→ spam scan → S3 PUT tos3://fc-raw-mime/<message-id>→ stop. The S3 PUT triggersreply-handler. The signed token rides in the unique reply-to address per ask, so the handler can match a written reply to its ask. - SES outbound for the asks, the happy review nudges, and email owner pings: verify a sender identity at
hello@your-company.comwith DKIM and SPF on the parent domain. Out of sandbox by request. If you send asks as SMS instead, the request-builder calls your SMS provider (or SNS) rather than SES for that leg.
IAM (least privilege per Lambda)
Each Lambda has its own role with policies scoped to exact ARNs. Sketch:
- request-builder role:
s3:GetObjecton the customer-list and rules keys;scheduler:CreateSchedulefor the one-off ask rules;secretsmanager:GetSecretValueon the token-signing secret;ses:SendRawEmailfrom the verified sender;dynamodb:PutItemonfc-state. Nobedrock:*. - reply-handler role:
s3:GetObjectonfc-raw-mime;secretsmanager:GetSecretValueon the token-signing secret;dynamodb:GetItem+PutItemonfc-feedback;lambda:InvokeFunctiononrouter. Nobedrock:*. - router role:
bedrock:InvokeModelon the Haiku (and optional Sonnet) ARNs;s3:GetObjecton the rules key;dynamodb:PutItemonfc-feedback;lambda:InvokeFunctionondispatch. - dispatch role:
ses:SendRawEmailfrom the verified sender;secretsmanager:GetSecretValueon the Slack bot token (for owner DMs);dynamodb:PutItemonfc-audit; outbound network access tohooks.slack.com/slack.comand, if used, the SMS provider. - intake-webhook role:
secretsmanager:GetSecretValueon the POS webhook secret and the Drive service-account secret; outbound network tosheets.googleapis.com. - drive-sync and calendar-sync roles:
secretsmanager:GetSecretValueon the Google service-account secret;s3:PutObjecton the customer-list and rules buckets; outbound network towww.googleapis.com.
Signed-token flow
Every ask carries a token: an HMAC-SHA256 over customer_id|ask_id|expiry, signed with a secret in Secrets Manager (fc/token/signing-secret). For star taps the token is a URL parameter on each star link alongside the star count; for written replies it’s embedded in a unique reply-to address. The reply-handler recomputes the HMAC and rejects anything tampered, expired, or replayed (a token whose (customer_id, ask_id) already has a row in fc-feedback is a no-op). This is what lets the star links be plain, login-free URLs while staying un-forgeable — nobody can spoof a five-star tap for a customer they aren’t.
Observability and cost gates
- CloudWatch Logs: all Lambdas, 7-day retention, structured JSON. Subscription filter on
"error"+"throttle"+"timeout"to a CloudWatch metric for alerting. - Alarms: request-builder failures > 0 in a day (an ask that never schedules is a lost review); reply-handler signature-verification failures > 5/hour (might mean the signing secret rotated); dispatch failure rate > 1% in 24h.
- 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
fc-cost-alarmsubscribed to the on-call admin’s email and Slack.
Config and secrets
Service-account credentials for Drive, Sheets, and Calendar APIs all live in Secrets Manager under fc/drive/sa (one service account with scopes for all three APIs). The token-signing secret is fc/token/signing-secret; the POS webhook secret is fc/pos/webhook-secret; the Slack bot token for owner DMs is fc/slack/bot-token. The configured timezone, holiday list reference, quiet-hours window, per-visit-type wait times, the star thresholds, the model confidence threshold, the public-review link, and the owner’s private channel all live in Parameter Store under /fc/config/. Lambdas fetch config on cold start and cache for the lifetime of the execution environment.
Deploy
GitHub Actions with OIDC into a deploy role, AWS SAM for the stack — no long-lived keys. The opinionated bits: deploy the SES rule set as a separate stack (rule-set changes affect mail flow), turn on S3 versioning for both fc-customers-source and fc-rules-source so a bad Drive edit can be rolled back in one click, and version the EventBridge Scheduler timezone setting so you don’t accidentally start sending asks at 3am after a CI rotation. Total deployable surface: around eight Lambdas, three DDB tables, three S3 buckets, the recurring and one-off 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