AWS Builder Center
PantryPass: the person who says "done" shouldn't be the one who benefits from it

PantryPass: the person who says "done" shouldn't be the one who benefits from it

A surplus-food handoff record on AWS where the count stays right, because AWS decides who is allowed to report what.

On Friday night, a donor writes in the group chat: "10 trays going to the shelter." A volunteer replies "done 👍." By Monday, the spreadsheet says ten trays were distributed.
Were they? Nobody can say. Nobody lied, either. The system just never asked who was allowed to say "done."
Surplus food runs on goodwill and chat messages, and three different facts get squashed into one number: what was offered, what was handed over, and what was received and distributed. Whoever updated last wins.
The missing piece isn't a better form. It's authority: who is allowed to report which fact.
The idea in one rule
A donor tries to report distribution. Verified Permissions refuses it before any data is touched, and the attempt is recorded as a DENY event.

The person who benefits from saying "done" isn't allowed to say it.
  • The donor reports what they handed over.
  • The receiver reports what they received, and what they returned or disposed of.
  • The coordinator sees what's confirmed, what's disputed, and what nobody answered.
Anything unconfirmed stays visibly open. If a donor tries to report distribution, the system refuses and writes the refusal into the record. That's a ten-second demo, but it isn't the product. The rest of this post is about what keeps a count honest once you stop trusting people to be careful.
The architecture follows the rule
Verified Permissions allows or denies each action before data is touched.

▎ - Who may do what: Amazon Verified Permissions (Cedar) ▎ - Sign-in and roles: Amazon Cognito, API Gateway JWT authorizer ▎ - Business logic: AWS Lambda (Python, plus Go for some routes) ▎ - Lots, events and chat links: Amazon DynamoDB and DynamoDB Streams ▎ - Waiting and escalation: AWS Step Functions (Standard) ▎ - Site and photo evidence: Amazon S3 and CloudFront ▎ - Bank details and secrets: AWS KMS and Secrets Manager ▎ - Visibility: CloudWatch and X-Ray
 Every request goes API Gateway → Lambda → Verified Permissions.

The point is where the checks live. Every request goes API Gateway, then Lambda, then Verified Permissions, and only an ALLOW reaches DynamoDB. The role comes from the signed Cognito token, never from anything the browser sends.
Authority as a policy you can read
A role check in handler code is one if, and one forgotten if breaks it. So the rule lives in Cedar, outside the application. Here is the whole policy for claiming an open offer:
permit(principal, action == PantryPass::Action::"Accept", resource) when { principal.role == "receiver" && resource.receiver == PantryPass::User::"unclaimed" };
A new lot has no receiver yet, so policy sees a stand-in user, "unclaimed." Any receiver may claim it, and donors and coordinators can't. When we added open offers, this one policy was the whole authorization change. Because Verified Permissions decides before any data is read or written, a refused request leaves the lot exactly as it was, and a DENY event records the attempt.
Keeping the numbers honest
Once authority was settled, we listed the other ways a count could quietly go wrong.
Two people at once. DynamoDB conditional writes make one of them lose cleanly instead of overwriting the other.
A double-tap or a retry. Each request carries an idempotency key, so doing it twice counts once.
Numbers that don't balance. Every write checks received = distributed + returned + disposed + remaining. If it doesn't add up, it's rejected.
Two receivers claiming the same offer. The Verified Permissions decision plus one conditional DynamoDB transaction means exactly one claim commits. The later claimant is rejected by the version condition.
Nobody chasing. An unanswered pickup becomes a coordinator exception through a Step Functions durable wait. We didn't need a cron job or a queue.
Late actions. Deadlines are checked against server time, not the client's clock.
Proving it happened, and wasn't edited
The donor gets a one-time pickup code at handoff.

receipt, and the lot is marked "confirmed in person.
At handoff the donor gets a one-time 6-digit pickup code, and only a salted hash is stored. The receiver types it at. Wrong guesses are limited to five and codes expire.
Every event is hash-chained: hash = sha256(prev_hash + the event's stored fields), and the lot holds the chain head, updated in the same conditional write. GET /lots/{id}/verify recomputes the chain. Edit, remove or reorder an event and verification fails, and the UI stops showing "Record verified."
Donors and receivers can attach one photo each. The browser uploads straight to a private, versioned S3 bucket through a short-lived presigned URL. On commit, the API reads the object, checks it's a real image, and records its SHA-256 in the chained event, so a photo can't be swapped afterwards.
Meeting people where they are
Volunteers live on WhatsApp, so the same rules run there. A command like DISTRIBUTED 5 goes to a channel Lambda, which calls the same API and the same Verified Permissions check. A donor texting it is refused exactly as on the web. The webhook must carry a valid signature, and the sender maps to a Cognito user only through a links table. The message id is the idempotency key, so a redelivered message can't count twice.
Why donors keep donating
The donor fund: sponsors top up a monthly pool shared by verified trust points. In this demo the pool is a configured number, simulated peers are labelled, and no money moves.

Donors do the hard part for free, so there's a business model. Sponsors, such as company CSR budgets and local businesses, and community donors top up a monthly pool. It's shared among donors in proportion to trust points, and points come only from verified, receiver-confirmed handoffs: accepted before the deadline, confirmed in person, counts that matched.
We designed it so inflating a lot doesn't pay. The quantity bonus grows with the log of the amount and is capped per lot. It shrinks for repeat handoffs between the same two people. A lot whose chain fails Verify earns nothing. No donor can take more than 30% of the pool. Sponsors get what CSR reports usually lack: a tamper-evident record of what was really received.
In this demo the pool is a configured number, a few labelled simulated peers join it so the sharing is visible, and no money moves.
What fought back
The mock hid a broken sign-in. The UI has an in-memory mock mode, and everything worked there. Against the real Cognito stack, neither clicking Sign in nor pressing Enter did anything. The button helper in our UI code creates type=button, so nothing triggered the form's submit handler. Mock mode skipped that path. The fix was one line (type=submit), but finding it taught us to test the real stack early.
The UI showed the wrong lot. Clicking quickly between lots sometimes rendered the previous one. Two route renders were racing, and the slower one finished last and overwrote the faster. The fix: only the latest render is allowed to win.
A module missing from the deployed Lambda. The rewards logic worked locally, but the deployed API couldn't serve GET /rewards because rewards.py wasn't in the Lambda zip. Unit tests can't see this. We fixed the packaging script and test against the deployed stack as well.
A failed callback leaves a lot half-finished. Writing to DynamoDB and calling back into Step Functions are two separate systems with no shared transaction. If the callback fails after the write, the lot would look finished while the workflow still waited. So a failed callback leaves the lot non-final, and a retry reconciles it. We test that path on purpose.
Our own test suites broke the demo logins. The end-to-end suites reset the Cognito demo passwords, so a clean test run would lock us out of the demo. We now restore them with a script (infra/25_demo_passwords.sh) and no longer treat a test run as harmless.
Presigned uploads trust nothing, so we couldn't either. A presigned S3 URL doesn't enforce what the file actually is. So uploads are size-limited, the API verifies the image on commit, and the API role is denied s3:DeleteObject.
Why these AWS services and not something else
  • Verified Permissions, because the rule has to hold even when our own code has a bug, and a non-programmer can read it.
  • DynamoDB, because conditional writes and transactions give us "exactly one wins" without running a lock service, and Streams drive the notifications with no polling.
  • Step Functions, because "wait for a person, then escalate" is a durable-wait problem that has to survive restarts.
  • KMS, because donors' account numbers are encrypted before storage and only ever shown masked.
  • Serverless throughout, so a food-rescue group pays for use, not for idle servers.
What the evidence supports
212 Python unit tests pass, covering the hash chain, pickup code, API handlers, rewards, payouts, photos and the chat adapter. End-to-end checks run against the deployed stack: pickup code, chain verification, tamper detection and signed webhooks. All demo data is synthetic.
What it does not show:
  • A pickup code proves two people were together, not that the count is true. Two parties who agree on a false number would still pass.
  • The hash chain detects edits. It doesn't stop someone who can rewrite both the events table and the chain head.
  • It doesn't prove food was eaten or safe, and it doesn't measure avoided waste.
  • The WhatsApp channel is tested with signed payloads, not a live round trip.
  • The donor fund moves no money.
What we learned
  1. Put the rule where the code can't forget it. Authority belongs in a policy, not in an if.
  1. Treat silence as a state. An unanswered pickup is information, so model it instead of waiting for someone to notice.
  1. A mock can lie. Anything the mock skips needs a test against the real stack.
What's next
One real operator and one real lot.
Try it
  1. Open  https://dmf0mqgt27zf9.cloudfront.net  and sign in as the donor
  1. Try to report distribution. It's refused, and the DENY event shows in the history.
  1. Switch to the receiver, claim the offer, enter the pickup code, and watch "Record verified" appear.
No AWS access? Run the UI locally with ?mock=1.
Parts of the backend, infrastructure scripts, tests and UI were written with AI coding assistance (Claude and DeepSeek), then reviewed and tested by the team.
What should a system do when a handoff may have succeeded, but nobody ever confirms it?
Built for Environmental Hacks, Waste and Energy track.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article