Engineering reference: the offer letter generator 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, the two tables, the document store, and the single narrow model call.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 3 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
- 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
devand aprodstack 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
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
ol-capture | Function URL + S3 (SES) | Nine fields from a form, or one Bedrock call on a note | 20s / 512 MB |
ol-assemble | SQS draft queue | Clause matching, variable substitution, PDF and diff | 60s / 1024 MB |
ol-record | Function URL | Stores the sent PDF, records acceptance, hands to onboarding | 20s / 512 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
| Role | Allowed | On |
|---|---|---|
ol-capture-role | bedrock:InvokeModel, sqs:SendMessage | One model arn; the draft queue |
ol-assemble-role | s3:GetObject/PutObject, dynamodb:GetItem | Templates and drafts prefixes; the clauses table, read |
ol-record-role | s3:PutObject, dynamodb:PutItem, ses:SendEmail | The sent prefix, write-once; the offers table; one identity |
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: offers
PK offer_id S ofr_2026_07_20_3c8e
SK version S v1 | v2
candidate S Kwame Osei
role S Field service engineer
basis S salaried | hourly | fixed_term
terms M the nine confirmed fields
clause_versions L [{clause_id, version}]
state S drafted | sent | accepted | declined | superseded
pdf_key S s3://offers-sent/ofr_.../v1.pdf (write-once)
covering_email S s3 key of the message as sent
drafted_by S hiring manager
sent_by S whoever approves
accepted_at S with the route it arrived by
Versions are appended. A renegotiated offer is v2 and v1 stays, because
v1 is sitting in somebody’s inbox whatever the database thinks.
Table: clauses
PK clause_id S probation-3-months
SK version S v2
body S the approved wording, with variable markers
variables L [name | number | date]
approved_by S not the person who submits terms
approved_at S 2026-01-14
last_used S 2026-07-20
use_count N 41
`last_used` drives the annual stale-clause review. A clause nobody has
used in a year is either obsolete or quietly wrong.
Inbound and outbound
- The terms form is static files in S3 behind CloudFront, reached through a signed link. Nine fields, no login.
- Forwarded notes arrive through an SES receipt rule writing to S3. The read produces the same nine fields and always routes to the same confirmation screen.
- Sent PDFs go to a bucket with object lock in governance mode. A sent offer is not something that should be quietly replaceable.
- Clause approval links are signed, scoped to one request, and refuse a submission from the same address that captured the terms.
The model call
- Model:
anthropic.claude-haiku-4-5-20251001-v1:0on Bedrock, used only to turn a free-text note into the nine fields. - It never writes wording. There is no code path in which model output reaches a document; its output populates a confirmation form.
- Output is a JSON schema with all nine fields nullable. Pay, start date and role are refused rather than inferred where the note does not state them.
- Grounded with the clause library’s option lists, so probation and notice come back as one of your approved options or as null.
- Not called at all when the form lane was used, which is most of the time once people get used to it.
Things worth knowing before you build it
- Store the PDF as sent, with object lock. Re-rendering is the one shortcut here that produces a document nobody signed.
- Hold clause options rather than free values. It is more clauses to maintain and it makes an unapproved probation length impossible rather than merely unlikely.
- Keep the covering email. Offers are frequently qualified in the message body rather than the attachment.
- Refuse a clause approved by the person who submitted the terms. Without that separation the approval step is decorative.
- Hand the start date to onboarding rather than retyping it. Three copies of a date is where onboarding errors come from.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts