Engineering reference: the safety incident logger 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 three tables, the routing path, and the one model call.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 4 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
- 3 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 |
|---|---|---|---|
si-report | Function URL | Accepts the report, resolves the zone, enqueues; never blocks on a photo | 10s / 512 MB |
si-triage | SQS report queue | Keyword check, then one Bedrock call; routes by proposal | 20s / 512 MB |
si-action | Function URL + EventBridge daily | Creates and closes actions; runs the upward escalation | 15s / 512 MB |
si-count | SQS confirmed queue + EventBridge quarterly | Increments the three counters; surfaces patterns; quarterly report | 30s / 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 |
|---|---|---|
si-report-role | s3:PutObject, sqs:SendMessage | The photos prefix; the report queue only |
si-triage-role | bedrock:InvokeModel, sns:Publish, ses:SendEmail | One model arn; staff numbers only; one identity |
si-action-role | dynamodb:UpdateItem, ses:SendEmail | Reports and actions; one verified identity |
si-count-role | dynamodb:UpdateItem/Query | Counters; reports, read |
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: reports
PK report_id S inc_2026_07_25_2f9c
reported_at S 2026-07-25T14:02:11Z
happened_at S editable; defaults to reported_at
site S ashford
zone S roller-door
description S in the reporter’s own words
photo_keys L [s3 keys]
reporter S an address, or the literal ’anonymous’
severity_proposed S high | medium | low | unclear
severity S set by a person; what everything counts
equipment S from the description
activity S from the description
state S new | routed | reviewed
ttl N epoch, +7 years
There is no `fault`, `blame` or `at_fault_person` field, and adding one
would change what this system is.
Table: actions
PK report_id S inc_2026_07_25_2f9c
SK action_id S act_1
change S Restack pallets, mark a maximum height
owner S a person, never a team
due S 2026-07-31
state S open | done
closed_change S REQUIRED to close: what actually changed
escalated_to L [who, when]
GSI open-index PK state, SK due — the daily escalation sweep
Table: counters
PK dimension S zone | equipment | activity
SK key_quarter S roller-door#2026-Q3
count N 3
report_ids L the reports behind the count
Three dimensions. There is no `reporter` dimension, deliberately: near
misses are reported by the people paying attention, so counting by person
identifies your best staff and looks like the opposite.
Inbound and outbound
- The report page is static files in S3 behind CloudFront, reached through a signed staff link saved to a home screen.
- Photos upload with a presigned PUT and the report submits independently. A report must never fail because a photo did not finish uploading on a yard connection.
- Anonymous reports carry the literal string rather than a null, so no code path can mistake a missing reporter for an unset field.
- Action links are signed, scoped to one action, and do not expire — an open safety action should still be closeable in six months.
The model call
- Model:
anthropic.claude-haiku-4-5-20251001-v1:0on Bedrock, proposing a severity and extracting the equipment and activity from a short description. - Keywords run first. Injury and vehicle words route immediately without waiting for the model, so the highest-consequence path does not depend on it.
- Grounded with your own severity guide, written in your terms, so improving the proposal is a sheet edit rather than prompt engineering.
- Output is a JSON schema with severity, equipment and activity, all nullable. A null severity routes as medium, not low.
- It never proposes fault, cause or a person. The schema has no field for any of them.
Things worth knowing before you build it
- Run the injury and vehicle keyword check before the model call. Over-triggering costs thirty seconds; a serious report waiting on a slow model call costs more.
- Resolve locations to named zones. Raw coordinates never group, so patterns never reach a threshold.
- Require a description to close an action. A one-tap close is indistinguishable from clearing a list.
- Never add a per-person counter. It identifies your most attentive staff and ends reporting within a quarter.
- Explain the rising report count wherever it is published. It reads like bad news and is the single clearest sign the system is working.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts