Skip to content

Part 7 of 7 · Timesheet validator series ~7 min read

Engineering reference: the timesheet validator architecture

The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions and what each is allowed to touch, the two tables and their keys, and the specific model.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 5 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
  • One Bedrock model, called once, with a JSON schema it must fill or leave null.
  • Nothing always-on: no instance, no container, no provisioned capacity.

The system, by service name

The timesheet validator drawn with AWS service namesThree boxes across the top outside the AWS account. Submission, covering the web form, the spreadsheet upload and the photographed sheet. Roster and jobs, read through the Google Sheets API read-only. And SES outbound, carrying the questions and the summaries. Inside the account, three groups. S3 holding the original submissions and SQS carrying one sheet queue. Five Lambda functions named intake, read, compare, ask and close. And two DynamoDB tables named sheets and findings. A note gives the region as us-east-1, one account, with Secrets Manager holding the Sheets credential and the link-signing key.AWS ACCOUNTSubmissionform, upload, photoRoster + jobsSheets API, read-onlySES outboundquestions, summariesS3 + SQSoriginals,one sheet queueLambda x5intake, read, compare,ask, closeDynamoDB x2sheets, findingsingroundsoutus-east-1. One account. Secrets Manager holds the Sheets credential and the signing key.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • Compute
  • Storage
  • Database
  • App integration
  • Front-end & mobile

Region and account

  • Region: us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one.
  • Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A dev and a prod stack in the same account, with distinct resource prefixes, is the right size here.
  • Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.

Lambda inventory

FunctionTriggerDoesTimeout / memory
ts-intakeS3 ObjectCreated + Function URLNormalises all three lanes, replaces any draft for the week10s / 512 MB
ts-readSQS sheet queueTextract on photos, then one Bedrock call into rows60s / 1024 MB
ts-compareSQS read queueThe five checks against roster, jobs and rules10s / 512 MB
ts-askEventBridge + SQSBatches findings into one message; runs the escalation sweep15s / 512 MB
ts-closeFunction URL + EventBridgeHandles signed answers; builds the period export30s / 1024 MB

Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.

IAM, scoped

RoleAllowedOn
ts-intake-roles3:GetObject, sqs:SendMessageThe submissions prefix; the sheet queue only
ts-read-roletextract:AnalyzeDocument, bedrock:InvokeModelThe submissions prefix; one model arn
ts-compare-roledynamodb:PutItem, secretsmanager:GetSecretValueSheets and findings; the Sheets credential only
ts-ask-roleses:SendEmail, dynamodb:QueryOne verified identity; the findings status index
ts-close-roledynamodb:UpdateItem, s3:PutObjectSheets and findings; the exports prefix

No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.

DynamoDB schemas

Table: sheets

PK   person            S   sam@example.com
SK   week_ending       S   2026-07-12
     state             S   draft | asked | clean | carried | exported
     source            S   form | sheet | photo
     image_digest      S   sha256 of the original, for the read cache
     rows              L   [{date, start, finish, break_min, job, confidence}]
     hours             N   44.0
     overtime          N   4.0
     rules             S   break 30/6h, OT>40, max 12h
     overrides         L   [{field, from, to, by, reason, at}]
     ttl               N   epoch, +7 years

GSI  period-index        PK week_ending, SK state   — the export sweep

Table: findings

PK   sheet_key         S   person|week_ending
SK   finding_id        S   gap#2026-07-09
     kind              S   gap | job | break | long_day | overtime
     owner             S   submitter | manager
     detail            S   Thursday 9 July is blank; roster had Aldershot
     state             S   open | answered | overridden
     answered_by       S   sam@example.com
     answered_at       S   2026-07-10T16:04:11Z

GSI  open-index          PK state, SK created_at    — the escalation sweep

Inbound and outbound

  • The web form is static files in S3 behind CloudFront with an origin access control. There is no login: the link carries a signed staff token minted when somebody is added to the roster.
  • Photo uploads go straight to S3 with a presigned PUT, so a phone on a site connection is not holding a Lambda open while it uploads.
  • Answer links are signed, scoped to one finding, single-use by conditional write, and expire after fourteen days.
  • Function URLs are public by default. Both ts-intake and ts-close verify an HMAC on the first line of the handler.

The model call

  • Model: anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock. The task is turning a Textract table into rows, which is extraction.
  • Called once per photographed sheet, keyed on the image digest, so correcting one cell and resubmitting the same image does not pay again.
  • Not called at all for the form and spreadsheet lanes. Those are already rows and a model would only add a way to be wrong.
  • Output is a JSON schema with a row array and a per-cell confidence. A confidence below the floor becomes an unreadable-cell question with the crop attached.
  • Grounded with the roster names and the dates in that week, so people and days are matched against a list rather than generated.

Things worth knowing before you build it

  • Textract’s table extraction is much better on a flat, well-lit sheet than on a photo taken at an angle in a van. A one-line hint in the form about laying the sheet flat is worth more than any prompt engineering.
  • Key the read cache on the image digest and not the sheet id, or every resubmission pays for a full re-read.
  • Week ending must be a fixed day of the week, chosen once. Mixing Sunday and Saturday week endings across a workforce makes the period index useless.
  • Nobody can override their own week. It is the only permission rule here, and it is the reason the system needs to know who reports to whom at all.
  • Stamp the rules on the sheet at close. A threshold change in the rules tab will otherwise silently restate every past period.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts