Engineering reference: the asset register keeper 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 state model, and the sampling weights.
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
- Database
- Networking
- 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 |
|---|---|---|---|
ar-acquire | Purchasing event or API | Creates the asset above the threshold, applies exclusions, queues a label print | 30s / 512 MB |
ar-scan | API, from the phone | Records a movement or a verification; updates location; handles disposal scans | 10s / 512 MB |
ar-sample | EventBridge, monthly | Chooses the weighted sample, sends the list, escalates repeatedly-not-found assets | 60s / 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 |
|---|---|---|
ar-acquire-role | dynamodb:PutItem, sqs:SendMessage | Assets; the label print queue |
ar-scan-role | dynamodb:UpdateItem, dynamodb:PutItem | Assets; appends to movements |
ar-sample-role | dynamodb:Query, dynamodb:UpdateItem, ses:SendEmail | Both tables; one verified 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: assets
PK asset_id S the code on the label
description S
category S drives expected life
serial S
purchase_pence N
purchased_at S
replacement_pence N kept roughly current, for insurance
expected_life_yrs N from the category, refined by disposals
location S
custodian S where it matters
state S in_use | awaiting_disposal | disposed
| not_found | presumed_disposed
disposal_reason S scrapped | sold | traded | stolen | lost
disposed_at S
last_verified_at S the field the insurance claim leans on
not_found_count N three consecutive triggers presumed_disposed
photos L
There is no delete path in any role. A disposed asset stays, which is
what makes the disposal-reason analysis possible.
Table: movements
PK asset_id S
SK at S
kind S placed | moved | verified | not_found | disposed
from_location S
to_location S
by S a person, always
Append-only. The location on the asset is derived from the latest
movement, and ’never moved in three years’ comes from this table.
Inbound and outbound
- Acquisitions are triggered from purchasing above a threshold, with an exclusion list for licences, leases and stock.
- The label print is queued at acquisition, not left as a task. A label applied later is a label never applied.
- Every scan is a movement record with a person attached, and the current location is derived rather than overwritten.
- The replacement-purchase question is asked in the purchasing flow: which asset does this replace? It is the best available disposal trigger.
The model call
- There is no model in this system. It is a table with a state machine and a weighted sample.
- The tempting use is matching invoice lines to asset categories automatically. A supplier and a description mapping table does it more predictably.
- A defensible use is reading a serial number from a photograph at acquisition, which saves typing a long alphanumeric string.
- The wrong use is inferring disposal from inactivity. Part 4 escalates through three failed verifications and a person’s confirmation instead, which is slow on purpose.
- The cost page assumes none, which is why the bill is fixed.
Things worth knowing before you build it
- Print the label as part of acquisition. A register entry with no physical label can never be verified in either direction.
- Give the assets table no delete path. Disposal is a state, and deleting it loses the accounting event and the pattern.
- Set the threshold higher than instinct suggests. Four thousand items is a register nobody maintains; four hundred is one that stays true.
- Sample twenty a month rather than planning an annual count. The annual count is planned every year and completed about one year in four.
- Publish the accuracy figure. It is the only honest answer to whether anybody should rely on the register, and it is what makes improvements visible.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts