Engineering reference: the applicant screener architecture
Same system, drawn for engineers. Region, service names, resource identifiers, Bedrock model IDs, Lambda inventory, IAM scopes, the SES inbound rule set, the EventBridge config, the DynamoDB schemas, and the fairness and human-in-the-loop controls expressed as concrete design decisions. 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 cross-Region inference, and Textract are all available there. A second region for multi-region resilience isn’t worth the extra setup at small-business volume — the failure mode for an SMB hiring round is a delayed read, not a regional outage. One AWS account dedicated to the screener (separate from your other workloads) keeps the IAM blast radius small, isolates candidate data, and lets a single AWS Budgets alarm cover the whole system. Bucket policies block public access; candidate PII lives only in this account.
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.
intake— triggered by S3 PUT onas-raw-mail,as-uploads, andas-jobboard. Parses MIME or reads the uploaded file, extracts the resume, and converts to text: real-text PDFs viapdfminer.six, DOCX viapython-docx, and scanned PDFs/images via TextractDetectDocumentText. Then applies the field-stripping rules from the rubric (regex + a small allow/deny pass) to remove name, age/DOB, gender, photo, home address, and school, writing the stripped text and the role to the application queue while keeping the untouched original ins3://as-originals/. Memory: 512 MB. Timeout: 60 s.drive-sync— EventBridge Scheduler target, every 15 minutes. Uses the Google Docs/Drive API (service-account credentials in Secrets Manager underas/drive/sa) to export each role’s rubric doc as plain text tos3://as-rubric-source/<role>.txtonly if changed since the last sync. The rubric parses into must-haves (each with arequiredflag), nice-to-haves, the strip-list, and the yes/maybe pass marks. Memory: 256 MB. Timeout: 30 s.reader— triggered per application off the queue (SQS with a DLQ). Loads the role rubric froms3://as-rubric-source/and calls Bedrock Haiku 4.5 (anthropic.claude-haiku-4-5-20251001-v1:0viaglobal.anthropic.claude-haiku-4-5-20251001-v1:0) once: a tool-use/JSON-mode prompt that returns, per must-have,met(bool) andevidence(the quoted resume line) or a missing flag, plus anunreadableflag. Plain Python talliesmetagainst the pass marks — a missingrequiredmust-have caps the label atno— producingyes,maybe,no, orneeds_human. Writes the result toas-scoresand emitsas.scored. The model never returns the label; only the per-must-have facts. The label is computed. Memory: 512 MB. Timeout: 60 s.router— EventBridge rule onas.scored. Dedupes the applicant against existingas-cardsfor the same role (contact + content hash, not the stripped name); runs the fairness check (confirms strip ran; scansevidencefor leaked personal tokens and routes toneeds_humanif found); builds the short summary from the structured result; and writes a card toas-cardswith a queue position (yes → top, maybe → mid, no → parked-reviewable). Never sends anything outbound. Memory: 256 MB. Timeout: 30 s.review-ui— Lambda Function URL, the hiring manager’s queue and the action handler.AuthType: AWS_IAMbehind an IAM-authenticated session (or a signed cookie issued after SSO); not public. GET renders the queue and cards; POST handles advance/hold/pass. Writesas-decisionsand anas-auditrow (user, ts, label-before, action, reason). On advance or pass, drafts a message via Haiku 4.5 and returns it to the UI for the human to edit and send — the send is a separate, explicit human action throughnotify. Memory: 256 MB. Timeout: 15 s.notify— invoked only when a human presses send on a reviewed draft. Sends the interview invite or the courteous decline via SESSendEmailfrom the verified sender. There is no code path that sends candidate-facing email without a human send action. Memory: 256 MB. Timeout: 15 s.jobboard-sync— EventBridge Scheduler target, hourly (or per the board’s export cadence). Pulls exported applications from the configured job board’s API/SFTP intos3://as-jobboard/. Flags any file it can’t parse cleanly for human review rather than guessing. Memory: 256 MB. Timeout: 60 s.
Storage
- DynamoDB ·
as-scores— one row per scored application. PKapplication_id; attributes:role,label,met_count,results(per-must-have met/missing + evidence),rubric_version. On-demand. - DynamoDB ·
as-cards— one row per routed card. PK(role, queue_pos); attributes:application_id,label,summary,state(queued/held/decided). On-demand. - DynamoDB ·
as-decisions— one row per human action. PKapplication_id; sort keydecided_at; attributes:action(advance/hold/pass),by_user,reason(if pass),label_before. On-demand. - DynamoDB ·
as-audit— one row per write action of any kind, including overrides. PK(application_id, ts); attributes:action,by_user,before,after. On-demand. No TTL — this is the long-term fairness audit trail. - S3 ·
as-originals— untouched original resumes. Versioning enabled. Lifecycle to Glacier at 90 days; expiry per your data-retention policy (default: deleted after the role closes + the retention window). - S3 ·
as-rubric-source— mirrored rubric text per role. Versioning enabled, so a bad rubric edit can be rolled back in one click. - S3 ·
as-raw-mail,as-uploads,as-jobboard— raw intake by lane. Lifecycle to Glacier at 30 days; expiry per retention policy. All buckets block public access.
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:readerfor the per-application must-have check, andreview-uifor drafting the (human-reviewed) invite/decline messages. The heavieranthropic.claude-sonnet-4-6-20250930-v1:0is wired but off by default; switch the reader to it only if a role’s must-haves need deeper reasoning than Haiku gives, accepting the higher per-read cost. - Embeddings. Not used. The rubric is a short structured list; the model reads the resume directly against it. No Knowledge Base, no S3 Vectors. (Titan Text Embeddings V2, 1024-dim, would be the choice if a future feature needed semantic resume search.)
- Guardrails. The reader prompt forbids considering anything outside the listed must-haves and forbids inferring protected attributes; combined with field-stripping upstream and the fairness check downstream, the model has neither the inputs nor the instruction to score on protected grounds.
EventBridge and scheduling
as-drive-sync—rate(15 minutes). Target:drive-syncLambda.as-jobboard-sync—rate(1 hour). Target:jobboard-syncLambda.as.scoredrule — EventBridge default bus rule matchingdetail-type: as.scored. Target:routerLambda.- Queue — SQS standard queue feeding
reader, with a dead-letter queue after 3 receives so a poison resume parks instead of looping. TZ for any human-facing timestamps isAsia/Singapore(TZ_NAME).
SES inbound and outbound
- Set the MX record on a dedicated subdomain (e.g.
apply.your-company.com) toinbound-smtp.ap-southeast-1.amazonaws.com. - SES inbound rule set
as-inbound-rules: one rule with recipientapply@your-company.com→ spam scan → S3 PUT tos3://as-raw-mail/<message-id>→ stop. The S3 PUT triggersintake. - SES outbound (used only by
notify, only on a human send): verify a sender identity athiring@your-company.comwith DKIM and SPF on the parent domain. Out of sandbox by request.
IAM (least privilege per Lambda)
Each Lambda has its own role with policies scoped to exact ARNs. Sketch:
- intake role:
s3:GetObjecton the three intake buckets;s3:PutObjectonas-originals;textract:DetectDocumentText;sqs:SendMessageto the reader queue. Nobedrock:*. - reader role:
sqs:ReceiveMessage/DeleteMessageon the queue;s3:GetObjectonas-rubric-source;bedrock:InvokeModelon the Haiku ARN;dynamodb:PutItemonas-scores;events:PutEventson the default bus. - router role:
dynamodb:Query/PutItemonas-cards;dynamodb:GetItemonas-scores. No SES, no Bedrock — the router never sends and never re-scores. - review-ui role:
dynamodb:Queryonas-cards/as-scores;dynamodb:PutItemonas-decisionsandas-audit;bedrock:InvokeModelon the Haiku ARN (drafts only);lambda:InvokeFunctiononnotify. Function URL isAuthType: AWS_IAM. - notify role:
ses:SendEmailfrom the verified sender only. Invocable only byreview-uion an explicit human send. - drive-sync / jobboard-sync roles:
secretsmanager:GetSecretValueon the relevant secret;s3:PutObjecton the rubric / jobboard buckets; outbound network to the Google or job-board API host only.
Human-in-the-loop and fairness, as controls
The fairness and human-in-the-loop guarantees aren’t prose — they’re enforced by where permissions and code paths exist. There is no Lambda with permission to send a candidate-facing rejection, and no code path that marks an applicant rejected without an as-decisions row authored by a user. The label is computed from counts, not emitted by the model. Protected attributes are stripped before the model sees the text, the model is instructed to ignore anything off-rubric, and a post-score fairness check parks anything where personal data leaked. Overrides (a human advancing a screener no, or passing a yes) are first-class audit rows; a recurring override pattern is the signal to fix the rubric, surfaced in a monthly review. Every step that touches a candidate is in as-audit, retained for the long term, so any hiring round can be reconstructed and shown to be decided on job-related grounds by named people.
Observability and cost gates
- CloudWatch Logs: all Lambdas, 7-day retention, structured JSON (no resume text or PII in logs — only IDs). Subscription filter on
"error"+"throttle"+"timeout"to a metric for alerting. - Alarms: reader DLQ depth > 0 (a resume failed to score); review-ui 5xx rate > 1% in 24h; fairness-check leak rate > 0 over a day (the stripper may need tightening).
- 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
as-cost-alarmsubscribed to the admin’s email.
Config and secrets
Google service-account credentials for the Docs/Drive API live in Secrets Manager under as/drive/sa; job-board API credentials under as/jobboard/*. SES sender identity lives in IAM and the verified-domain config. The configured timezone, the data-retention window, and the admin contact live in Parameter Store under /as/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 (no long-lived keys) and 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 as-rubric-source and as-originals so a bad rubric edit or a wrong delete can be rolled back, and keep candidate buckets locked to block-public-access at the account level. Total deployable surface: around seven Lambdas, four DDB tables, six S3 buckets, one EventBridge rule on the default bus (plus the Scheduler rules), one SQS queue with a DLQ, 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