Skip to content

Part 7 of 7 · Lost property matcher series ~7 min read

Engineering reference: the lost property matcher 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 matching query, and the single 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.
  • 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 lost property matcher drawn with AWS service namesThree boxes across the top outside the AWS account. Finders, using a phone mid-shift. Claimants, arriving by form, email or a call taken by staff. And Staff at the counter, deciding and being recorded. Inside the account, three groups. API Gateway and S3 for logging, photos and claim intake. Four Lambda functions named log, claim, match and retire. And two DynamoDB tables named items and claims. A note gives the region as us-east-1, one account, and states that photographs are staff-only and no claimant is ever served an image.AWS ACCOUNTFindersa phone, mid-shiftClaimantsa form, an email,a call taken by staffStaff at the counterdeciding, andrecordedAPI Gateway + S3logging, photos,claim intakeLambda x4log, claim,match, retireDynamoDB x2items, claimsingroundsoutus-east-1. One account. Photographs are staff-only; no claimant is ever served an image.
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
  • App integration
  • Networking
  • Front-end & mobile
  • People

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
lp-logAPI, from the finder’s phoneWrites the item, stores the resized photograph, returns the short label code10s / 512 MB
lp-claimAPI and SES inboundOne model call; normalises any claim format into zone, date window, category and detail30s / 1024 MB
lp-matchOn new claim, and on every new itemStructural query, then ranking; writes the shortlist with the reason for each candidate30s / 512 MB
lp-retireEventBridge, dailyApplies retention by category, queues disposal batches, closes claims that timed out60s / 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
lp-log-roledynamodb:PutItem, s3:PutObjectItems; the photos prefix
lp-claim-rolebedrock:InvokeModel, dynamodb:PutItemOne model id; claims
lp-match-roledynamodb:Query, dynamodb:UpdateItemItems read; claims write
lp-retire-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: items

PK   zone              S   the matching partition, deliberately
SK   found_at#item_id  S   so a date window is a range query
     category          S   from a list of twelve
     photo_key         S   resized on upload; staff-only access
     label_code        S   the sticker on the physical item
     found_by          S
     detail            S   optional; the ownership question lives here
     handling          S   normal | valuable | document | hazard
     retain_until      S   set at write time from category
     state             S   held | released | disposed
     released_to       S   a typed name
     released_by       S   the staff login
     released_how      S   collected | posted#
     outcome           S   returned | charity | recycled | destroyed | staff

Partitioning on zone is what makes the match one query per
neighbouring zone rather than a scan of the cupboard.

Table: claims

PK   claim_id          S
SK   ’#claim’          S
     zone              S   normalised to the zone list, or null
     lost_from         S   date window start
     lost_to           S   date window end
     category          S
     detail            S   raw words, kept for ranking
     contact           S
     state             S   open | matched | closed_returned | closed_nothing
     shortlist         L   [{item_id, reason}] — reason, never a score
     expires_at        S   the day the claimant is told plainly

Claims stay open and are re-checked against every new item until
they expire. A large share of returns come from that path.

Inbound and outbound

  • Claims arrive in three formats — a web form, an email, and a note typed by whoever answered the phone. All three land in the same normalising function.
  • Photographs are never served to claimants. The signed URL is issued to a staff session only, which is the technical half of the ownership rule.
  • New items are matched against open claims, not just the other way round. That path produces returns that would otherwise be missed entirely.
  • Nothing is deleted. Items move to a disposed state with an outcome; the record and its photograph stay, because the question always comes later than you expect.

The model call

  • One call, per claim. Free text into zone, date window, category and distinctive detail, with anything absent left null.
  • A small, fast model. This is normalisation of a short paragraph, and a larger model produces the same four fields.
  • It never scores a match. The candidate list comes from a query and the decision comes from a person, because the failure case is handing a stranger somebody’s belongings.
  • It never looks at a photograph. Image recognition is the expensive way to reproduce information the zone and date already gave you.
  • Colour is captured but never filtered on, because roughly half of claimants get their own item’s colour wrong.

Things worth knowing before you build it

  • Hold the logging step to ninety seconds. Every field you add costs you a percentage of the items that get logged at all, and an unlogged item is worse than no system.
  • Partition items by zone and range on the found date. Matching then costs three queries instead of a scan, which is what keeps the counter interaction under a second.
  • Never serve an item photograph to a claimant. Once they have seen it, they can describe it, and your only ownership test is gone.
  • Set retain_until at write time from the category. Computing retention at disposal time means the policy change quietly reapplies itself to items already held.
  • Keep claims open and re-check them against new items. The item found on Tuesday matching Saturday’s claim is one of the most common successful paths in the whole system.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts