Part 7 of 7 · Applicant screener series ~8 min read

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

AWS topology of the applicant screener A topology diagram with three regions stacked vertically inside one AWS account boundary. Top region: ingress. Three boxes show the three intake lanes — an SES inbound rule set that writes emailed resumes to s3://as-raw-mail/ and triggers the intake Lambda, a careers-form upload via a Function URL that puts files in s3://as-uploads/, and a jobboard-sync Lambda triggered on a schedule by EventBridge Scheduler that drops exported resumes into s3://as-jobboard/. All three feed the intake Lambda, which turns each resume into text (Textract for scanned PDFs), strips the personal fields named in the rubric, keeps the original in S3, and writes the stripped text to the application queue. Middle region: scoring. The reader Lambda is triggered per application; it loads the role rubric mirrored from Drive to s3://as-rubric-source/, calls Bedrock Haiku 4.5 to check each must-have and quote the matching resume line, counts the met must-haves in plain Python against the pass marks, and writes a yes, maybe, no, or needs-human result to DynamoDB as-scores. It emits an as.scored event to the EventBridge default bus. Bottom region: routing and decision. The router Lambda is triggered by the as.scored event; it dedupes the applicant, runs the fairness check, writes a short summary, and places a card in DynamoDB as-cards in the manager's review queue. The hiring manager opens the queue through a Function URL Lambda review-ui and taps advance, hold, or pass; those button clicks land on the same Function URL, which writes to as-decisions and as-audit and, on advance or pass, drafts a message for the manager to review and send — never auto-sent. 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 as-cost-alarm. A note at the bottom: the screener routes and explains — a human makes every decision, and every decision is logged to as-audit. Ingress SES inbound rule set as-inbound-rules action: S3 PUT s3://as-raw-mail/ trigger: intake Careers form upload Function URL puts file to s3://as-uploads/ trigger: intake Lambda · jobboard-sync scheduled poll export resumes to s3://as-jobboard/ trigger: intake Lambda · intake to text, strip fields · to queue Scoring Lambda · drive-sync every 15 min Docs API → s3://as-rubric-source/ rubric per role Lambda · reader reads rubric from S3 Haiku 4.5 checks must-haves counts vs pass marks, writes as-scores EventBridge default bus as.scored label: yes/maybe/no or needs-human (none auto-rejected) Routing & decision Lambda · router dedupe, fairness check, summary; places card in as-cards queue (no auto-reject) Manager review queue card: [Advance] [Hold] [Pass] button clicks → Function URL Lambda · review-ui writes as-decisions, as-audit; on advance or pass drafts a msg — never auto-sent The screener routes and explains — a human decides, and every decision is logged to as-audit.
Fig 7. AWS topology, in three regions of the diagram: ingress (three lanes into the queue), scoring (the per-application read emitting a labelled event), routing and decision (the card is placed and the human acts). Every Lambda is event- or schedule-driven; nothing is synchronous-chained, and nothing is auto-rejected.

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 on as-raw-mail, as-uploads, and as-jobboard. Parses MIME or reads the uploaded file, extracts the resume, and converts to text: real-text PDFs via pdfminer.six, DOCX via python-docx, and scanned PDFs/images via Textract DetectDocumentText. 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 in s3://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 under as/drive/sa) to export each role’s rubric doc as plain text to s3://as-rubric-source/<role>.txt only if changed since the last sync. The rubric parses into must-haves (each with a required flag), 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 from s3://as-rubric-source/ and calls Bedrock Haiku 4.5 (anthropic.claude-haiku-4-5-20251001-v1:0 via global.anthropic.claude-haiku-4-5-20251001-v1:0) once: a tool-use/JSON-mode prompt that returns, per must-have, met (bool) and evidence (the quoted resume line) or a missing flag, plus an unreadable flag. Plain Python tallies met against the pass marks — a missing required must-have caps the label at no — producing yes, maybe, no, or needs_human. Writes the result to as-scores and emits as.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 on as.scored. Dedupes the applicant against existing as-cards for the same role (contact + content hash, not the stripped name); runs the fairness check (confirms strip ran; scans evidence for leaked personal tokens and routes to needs_human if found); builds the short summary from the structured result; and writes a card to as-cards with 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_IAM behind an IAM-authenticated session (or a signed cookie issued after SSO); not public. GET renders the queue and cards; POST handles advance/hold/pass. Writes as-decisions and an as-audit row (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 through notify. 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 SES SendEmail from 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 into s3://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. PK application_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. PK application_id; sort key decided_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:0 via the Global cross-Region inference profile global.anthropic.claude-haiku-4-5-20251001-v1:0. Two callsites: reader for the per-application must-have check, and review-ui for drafting the (human-reviewed) invite/decline messages. The heavier anthropic.claude-sonnet-4-6-20250930-v1:0 is 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-syncrate(15 minutes). Target: drive-sync Lambda.
  • as-jobboard-syncrate(1 hour). Target: jobboard-sync Lambda.
  • as.scored rule — EventBridge default bus rule matching detail-type: as.scored. Target: router Lambda.
  • 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 is Asia/Singapore (TZ_NAME).

SES inbound and outbound

  • Set the MX record on a dedicated subdomain (e.g. apply.your-company.com) to inbound-smtp.ap-southeast-1.amazonaws.com.
  • SES inbound rule set as-inbound-rules: one rule with recipient apply@your-company.com → spam scan → S3 PUT to s3://as-raw-mail/<message-id> → stop. The S3 PUT triggers intake.
  • SES outbound (used only by notify, only on a human send): verify a sender identity at hiring@your-company.com with 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:GetObject on the three intake buckets; s3:PutObject on as-originals; textract:DetectDocumentText; sqs:SendMessage to the reader queue. No bedrock:*.
  • reader role: sqs:ReceiveMessage/DeleteMessage on the queue; s3:GetObject on as-rubric-source; bedrock:InvokeModel on the Haiku ARN; dynamodb:PutItem on as-scores; events:PutEvents on the default bus.
  • router role: dynamodb:Query/PutItem on as-cards; dynamodb:GetItem on as-scores. No SES, no Bedrock — the router never sends and never re-scores.
  • review-ui role: dynamodb:Query on as-cards/as-scores; dynamodb:PutItem on as-decisions and as-audit; bedrock:InvokeModel on the Haiku ARN (drafts only); lambda:InvokeFunction on notify. Function URL is AuthType: AWS_IAM.
  • notify role: ses:SendEmail from the verified sender only. Invocable only by review-ui on an explicit human send.
  • drive-sync / jobboard-sync roles: secretsmanager:GetSecretValue on the relevant secret; s3:PutObject on 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-alarm subscribed 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