Engineering reference: the event RSVP manager architecture
Same system, drawn for engineers. Region, service names, resource identifiers, Bedrock model IDs, Lambda inventory, IAM scopes, the conditional-write capacity logic, the SES inbound rule set, EventBridge Scheduler config, the DynamoDB schemas, and the signed-link 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 a small event is a guest missing a reminder, not a regional outage. One AWS account dedicated to the RSVP manager (separate from your other workloads) keeps the IAM blast radius small and lets a single AWS Budgets alarm cover the whole system.
Topology
Lambda functions
All Lambdas use the arm64 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.
register— Lambda Function URL (AuthType: NONE), invoked by the static register form. Validates input, runs a duplicate check by email againstev-guests, and calls into the keeper for a seat request. Returns the resulting state (confirmed or waitlist position) as JSON for the form to render. Memory: 256 MB. Timeout: 15 s.intake-ses-parser— S3 PUT trigger ons3://ev-raw-mime/. Parses the MIME, reads the sender display name and address from the headers, and scans the body for a clear cancel intent (“cancel”, “can’t make it”). On a sign-up it runs a seat request via the keeper; on a cancel intent it routes to the keeper’s release path. Sends a short SES reply with the result. Memory: 256 MB. Timeout: 30 s.host-import— on-demand invoke from a small host admin action. Walks a pasted list of(name, email)rows and runs a seat request per row, honoring apre_confirmedflag. People past the cap land on the waitlist in list order. Memory: 256 MB. Timeout: 60 s.keeper— the core state machine. Invoked by the three ingress lanes and bylink-handler. Claims a seat with a single conditionalUpdateItemon theev-eventitem:SET confirmed_count = confirmed_count + 1with a condition expressionconfirmed_count < cap. Releases a seat with the mirror update guarded byattribute_existsand a guest-state condition so a double-click can’t double-decrement. Moves a waitlisted guest to confirmed on a claim with the same capacity condition. Emitsev.confirmed,ev.cancelled, orev.seat_freedto the default bus. Writes every change toev-audit. No Bedrock calls. Memory: 256 MB. Timeout: 15 s.messenger— EventBridge rule on the three state events. Renders the right template (confirmation, reminder, waitlist offer, cancellation ack), checks the quiet-hours window fromev-event.settings, and ships via SESSendRawEmail. Onev.confirmedit books the guest’s one-off reminder rules through EventBridge Scheduler; onev.seat_freedit books a one-off claim-timeout rule at the end of the claim window. A quiet-hours deferral re-books the message at the next morning’s business minute. Memory: 256 MB. Timeout: 30 s.link-handler— Lambda Function URL (AuthType: NONE), serving the confirm, cancel, and claim links. Verifies the HMAC-signed token on the query string (signing key in Secrets Manager underev/links/signing-key), checks it hasn’t expired, and calls the matching keeper path. Renders a small HTML confirmation page back to the guest. Memory: 256 MB. Timeout: 15 s.reminder-fire— EventBridge Scheduler target for each booked reminder one-off. Re-checks that the guest is still confirmed (a cancelled guest’s jobs are deleted, but this guards a race), then invokesmessengerto send that reminder. Self-cleans via--action-after-completion DELETE. Memory: 256 MB. Timeout: 15 s.offer-timeout— EventBridge Scheduler target for each waitlist claim window. On fire, checks whether the offer was already claimed; if not, marks it expired and re-emitsev.seat_freedso the offer rolls to the next waitlister. Self-cleans. Memory: 256 MB. Timeout: 15 s.host-summary— EventBridge Scheduler target, daily in the run-up to the event and once after it. The daily run sends the host a short headcount email (no Bedrock). The post-event run calls Bedrock Haiku 4.5 to draft a thank-you note for the confirmed list. Also backs the host Q&A helper (an on-demand invoke that answers plain-English questions about the guest list via Haiku 4.5). Memory: 512 MB.
Storage
- DynamoDB ·
ev-guests— one row per guest. PK(event_id, guest_id); GSI on(event_id, email)for the duplicate check and on(event_id, waitlist_pos)for the front-of-line lookup. Attributes:name,email,state(pending/confirmed/waitlisted/cancelled),reminder_jobs(list),offer_expires. On-demand. - DynamoDB ·
ev-event— one item per event holding the contended counter. PKevent_id; attributes:cap,confirmed_count,waitlist_len, and asettingsmap (timezone, reminder offsets, quiet-hours window, claim-window length). The conditional write that defends the cap targets this item. On-demand. - DynamoDB ·
ev-audit— one row per state change of any kind. PK(event_id, ts); attributes:guest_id,action(confirm/cancel/offer/claim/expire/import),by,before,after. On-demand. No TTL — this is the long-term record so the guest list is reconstructable. - S3 ·
ev-raw-mime— raw inbound MIME from the email reply lane. Versioning enabled. Lifecycle to Glacier at 30 days; expiry at 1 year. - S3 ·
ev-assets— the static register page and email template fragments. Versioning enabled.
The capacity cap, exactly
The cap is enforced by one DynamoDB UpdateItem against the single ev-event item. To claim a seat: UpdateExpression: "SET confirmed_count = confirmed_count + :one" with ConditionExpression: "confirmed_count < cap". If the condition fails, the SDK raises ConditionalCheckFailedException; the keeper catches it and routes the guest to the waitlist instead of erroring. Because UpdateItem is atomic, a burst of concurrent claims on the last seat resolves to exactly one success and the rest fall through to the waitlist — no read-then-write gap, no optimistic-lock retry loop. Releasing a seat is the mirror: confirmed_count = confirmed_count - :one guarded by a guest-state condition so a double-click can’t decrement twice. This single-item counter is the reason the system can promise it never oversells.
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: the post-event thank-you draft and the host Q&A helper, both inhost-summary. Heavier reasoning isn’t needed here, so Sonnet 4.6 is not wired in. - Embeddings. Not used. The guest list is structured rows; deterministic lookup beats vector retrieval here. No Knowledge Base, no S3 Vectors.
- Quotas. Default account quotas are more than enough. No hot path calls Bedrock; the model fires at most twice per event plus the occasional host question.
EventBridge Scheduler config
- Reminder one-offs —
at(YYYY-MM-DDTHH:MM:SS)in the event timezone, targetreminder-fire, one per scheduled reminder per confirmed guest.--action-after-completion DELETE. - Offer-timeout one-offs —
at(...)at the end of each waitlist claim window, targetoffer-timeout. Self-deleting. - Quiet-hours deferrals — created on the fly by
messengerwhen a send would land in the quiet window;at(...)at the next business minute, targetmessenger. Self-deleting. ev-host-daily—cron(0 9 * * ? *)in the event timezone during the run-up window. Target:host-summary.
SES inbound and outbound
- Set the MX record on a dedicated subdomain (e.g.
rsvp.your-event.com) toinbound-smtp.ap-southeast-1.amazonaws.com. - SES inbound rule set
ev-inbound-rules: one rule with recipientrsvp@your-event.com→ spam scan → S3 PUT tos3://ev-raw-mime/<message-id>→ stop. The S3 PUT triggersintake-ses-parser. - SES outbound for confirmations, reminders, and offers: verify a sender identity at
events@your-event.comwith DKIM and SPF on the parent domain. Out of sandbox by request. A configuration set with open/click tracking off keeps the emails plain and the bounce/complaint topics wired to SNS.
IAM (least privilege per Lambda)
Each Lambda has its own role with policies scoped to exact ARNs. Sketch:
- keeper role:
dynamodb:UpdateItem+GetItemonev-eventandev-guests;dynamodb:Queryon the GSIs;dynamodb:PutItemonev-audit;events:PutEventson the default bus. Nobedrock:*, noses:*. - messenger role:
ses:SendRawEmailfrom the verified identity;scheduler:CreateSchedule+DeleteSchedulefor reminder and timeout one-offs;dynamodb:GetItemonev-event+ev-guestsfor templating;secretsmanager:GetSecretValueon the link signing key (to mint signed links). - link-handler role:
secretsmanager:GetSecretValueonev/links/signing-key;lambda:InvokeFunctiononkeeper(or direct DDB if collapsed);dynamodb:PutItemonev-audit. - intake-ses-parser role:
s3:GetObjectonev-raw-mime;lambda:InvokeFunctiononkeeper;ses:SendRawEmailfor the reply. - host-summary role:
dynamodb:Queryonev-guests+ev-audit;bedrock:InvokeModelon the Haiku ARN;ses:SendRawEmailfrom the verified identity.
Signed-link flow
Every confirm, cancel, and claim link is a Function URL with a query string carrying event_id, guest_id, an action, an expiry timestamp, and an HMAC-SHA256 signature over those fields keyed by ev/links/signing-key. link-handler recomputes the HMAC and rejects any mismatch or past-expiry link before touching state. This gives passwordless, per-seat authorization: a guest can act only on their own seat, and only until the event passes. Claim links additionally carry an offer_id so a stale offer link (rolled on to the next person) is recognized and rejected with a friendly “this offer has expired” page.
Observability and cost gates
- CloudWatch Logs: all Lambdas, 7-day retention, structured JSON. Subscription filter on
"error"+"ConditionalCheckFailed"+"timeout"to a metric for alerting (note: aConditionalCheckFailedon a claim is normal — a full event — so alert only on unexpected rates). - Alarms: keeper failures > 0 excluding capacity-condition failures; messenger send failure rate > 1% in 24h; link-handler signature-verification failures > 5/hour (might mean a tampered link campaign or a rotated key).
- 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
ev-cost-alarmsubscribed to the host admin’s email.
Config and secrets
The link signing key lives in Secrets Manager under ev/links/signing-key; the SES sender identity lives in IAM and the verified-domain config. Per-event settings — cap, timezone, reminder offsets, quiet-hours window, claim-window length, and the time-critical-offer override — live in the settings map on the ev-event item so the host can change them without a deploy. Cross-event defaults live in Parameter Store under /ev/config/. Lambdas fetch config on cold start and cache it for the lifetime of the execution environment.
Deploy
Deploy with GitHub Actions + OIDC + AWS SAM — no long-lived keys. The opinionated bits: deploy the SES rule set as a separate stack (rule-set changes affect mail flow), keep the cap and counter on a single ev-event item so the conditional write stays atomic (don’t shard the counter unless you actually outgrow a single partition), and pin the EventBridge Scheduler timezone to the event timezone so reminders don’t silently start firing in UTC after a CI change. Total deployable surface: around nine Lambdas, three DDB tables (plus two GSIs), two S3 buckets, one EventBridge rule on the default bus (plus the on-the-fly Scheduler one-offs), 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 event, see Work with me.
All posts