Skip to content

Part 7 of 7 · Asset register keeper series ~7 min read

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

The asset register keeper drawn with AWS service namesThree boxes across the top outside the AWS account. Purchasing, providing invoices read only. A phone, scanning labels. And Labels, printed at acquisition. Inside the account, three groups. An API for scans and EventBridge running the monthly sample. Three Lambda functions named acquire, scan and sample. And two DynamoDB tables named assets and movements. A note gives the region as us-east-1, one account, and states that disposal is a state rather than a deletion and movements are append-only.AWS ACCOUNTPurchasinginvoices, read onlyA phonescanning labelsLabelsprinted at acquisitionAPI + EventBridgescans,monthly sampleLambda x3acquire, scan, sampleDynamoDB x2assets, movementsingroundsoutus-east-1. One account. Disposal is a state, never a deletion; movements are append-only.
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
  • 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 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
ar-acquirePurchasing event or APICreates the asset above the threshold, applies exclusions, queues a label print30s / 512 MB
ar-scanAPI, from the phoneRecords a movement or a verification; updates location; handles disposal scans10s / 512 MB
ar-sampleEventBridge, monthlyChooses the weighted sample, sends the list, escalates repeatedly-not-found assets60s / 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
ar-acquire-roledynamodb:PutItem, sqs:SendMessageAssets; the label print queue
ar-scan-roledynamodb:UpdateItem, dynamodb:PutItemAssets; appends to movements
ar-sample-roledynamodb:Query, dynamodb:UpdateItem, ses:SendEmailBoth 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