Skip to content

Part 7 of 7 · Safety incident logger series ~7 min read

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

The safety incident logger drawn with AWS service namesThree boxes across the top outside the AWS account. The Report page, served as static files from S3 behind CloudFront. Zones and routing rules, read through the Google Sheets API read-only. And SNS with SES, carrying routing and escalation messages. Inside the account, three groups. S3 holding photographs and SQS carrying one report queue. Four Lambda functions named report, triage, action and count. And three DynamoDB tables named reports, actions and counters. A note gives the region as us-east-1, one account, and states that no field anywhere in it records fault.AWS ACCOUNTReport pageCloudFront + S3Zones + routingSheets API, read-onlySNS + SESrouting, escalationS3 + SQSphotos,one report queueLambda x4report, triage,action, countDynamoDB x3reports, actions,countersingroundsoutus-east-1. One account. No field anywhere in it records fault.
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
si-reportFunction URLAccepts the report, resolves the zone, enqueues; never blocks on a photo10s / 512 MB
si-triageSQS report queueKeyword check, then one Bedrock call; routes by proposal20s / 512 MB
si-actionFunction URL + EventBridge dailyCreates and closes actions; runs the upward escalation15s / 512 MB
si-countSQS confirmed queue + EventBridge quarterlyIncrements the three counters; surfaces patterns; quarterly report30s / 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

RoleAllowedOn
si-report-roles3:PutObject, sqs:SendMessageThe photos prefix; the report queue only
si-triage-rolebedrock:InvokeModel, sns:Publish, ses:SendEmailOne model arn; staff numbers only; one identity
si-action-roledynamodb:UpdateItem, ses:SendEmailReports and actions; one verified identity
si-count-roledynamodb:UpdateItem/QueryCounters; 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:0 on 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