ScrapSetu: Chain of Custody for India’s Scrap Trade on Bedrock, Lambda and DynamoDB
ScrapSetu is a chain-of-custody app for India’s informal scrap trade, built for the AWS Hackathon Waste & Energy track. Amazon Nova Lite on Bedrock triages photos, Cognito separates collector, vendor and recycler roles, and DynamoDB holds a tamper-evident ledger with two-party handovers and mass-balanced recovery records. It works offline on 2G phones. This post covers the stack and four things that fought back: a silent AI fallback, a vanished deduction, replay attacks and invented kWh.
Our AI Called Every Photo an Electric Motor: Building ScrapSetu on AWS
At 2 AM, three days before the deadline, I uploaded a photo of a swollen lithium battery to our app and it told me, with 94% confidence, that I was holding an electric motor.
It had been saying that for a week. Every photo. Nobody noticed, because an electric motor is a perfectly believable thing to find in a scrap yard, and because the demo always worked.
That bug is the reason this post exists. Here's what we were trying to build, what we got wrong, and what it took to make an AI feature that's allowed to say "I don't know".
The idea
In India, most recycling runs through kabadiwalas, the door-to-door scrap buyers who collect newspaper, PET bottles, dead fans and old phones, and sell them up a chain of aggregators until something reaches a registered recycler. Nothing in that chain is written down. Payment is cash. The weight is whatever the buyer's scale says, minus whatever the buyer calls "dirt". And the person tearing open a phone battery for the copper inside usually has no idea it can catch fire.
We wanted to build three things for that chain:
- A photo triage step that flags hazards before anyone opens a sack.
- A handover where both sides sign off on the measured weight and the deduction, so the "dirt" has a paper trail.
- An impact number that only counts material a recycler actually recovered, not material somebody collected and multiplied by a constant.
And it all had to work on a cheap Android phone inside a metal warehouse with one bar of 2G.
The stack we landed on: a React PWA on Amplify, Cognito for four roles (collector, vendor, recycler, admin), FastAPI on Lambda behind API Gateway, Amazon Nova Lite on Bedrock for the photo, DynamoDB for the ledger, S3 for evidence photos, and Polly for Hindi and English audio so a collector who can't read still hears the safety warning. One SAM template, roughly $0 at idle.
That's the tidy version. Now the week.
Day 3: the motor
Our first Bedrock integration looked responsible. It called the Converse API, parsed the JSON, and if anything went wrong it "fell back to the rule engine". This is what the fallback actually was:
Python
except Exception as ex:logger.error(f"Bedrock API call failed, falling back to rule engine: {ex}")# Default fallback to high-confidence Electric Motordefault_item = PRESET_ITEMS_CATALOG["electric_motor"]
Missing credentials? Motor. Model not enabled in the region? Motor. Timeout, throttle, malformed JSON? Motor, 94% confidence, "safe to handle, recover the copper windings".
When I finally read the parser, it was worse. If the model forgot to include a confidence score, we assumed 0.75. If it forgot a hazard flag, we assumed no hazard. And the flags were parsed with
bool(data.get("battery_damage", False)) , which means if Nova ever returned the string "false" , Python would make it True . We had three separate ways of inventing certainty, and the fallback hid all of them behind a motor.The fix wasn't a better prompt. It was deciding that the model is untrusted input, the same as a form field.
Now every reply goes through a strict Pydantic model:
strict=True , extra="forbid" , every field required, no defaults anywhere. A missing hazard flag isn't "no", it's a validation error. We check that the reply's stopReason is end_turn , because a response cut off by the token limit can still look like the beginning of valid JSON. And the schema we validate against is the same schema we paste into the prompt, generated from the same class, so the two can't drift.Then we split failure into three honest answers:
What happened | HTTP |
|---|---|
The photo is corrupt, animated, or over 3.75 MB | 422 |
Bedrock is unreachable or denied us | 503 |
Bedrock replied but we couldn't trust the reply | 502 |
The phone shows each one differently, and none of them show a preset. The demo samples still exist, but only when you explicitly ask for one, and the result is stamped
analysis_source: "demo" .The last piece is a review policy the model can't argue with. Hazards are OR'd: if the collector ticks "this battery looks swollen" and the model says it's fine, it's hazardous. The model can add a hazard. It can never remove one. Anything hazardous, unknown, mixed, or below 85% confidence gets locked as
REVIEW_REQUIRED on the server, and no offer can be made on it until a human clears it. Even after that, a hazardous lot can only go to a facility whose permit covers hazardous material. We also throw away whatever handling advice the model wrote and replace it with fixed text per hazard, because I'm not letting a language model tell someone how to contain a leaking chemical.We wrote 53 tests for that one path. They pass against a mocked Bedrock client. What they can't tell you is how good Nova Lite is at real scrap photos, and I'd be lying if I said we'd measured that yet.
I thought that was the scary bug. Then I looked at the money.
Day 5: the deduction that vanished
The recycler's intake screen let them record a contamination deduction. The collector brings 10 kg, the recycler says 1 kg is moisture, pays for 9. That's the entire point of the product: making that 1 kg visible.
The screen sent
contamination_weight_deduction_kg . The settlement code read contamination_weight_deduction_kg . Fine. Except in between, one service rebuilt the request and put the value under contamination_deduction_kg . Our shared schema defined both fields, each defaulting to 0.0 . So Pydantic was happy, the arithmetic read the field that was zero, and the deduction silently disappeared from the payout. No error. No log line. The collector would have been paid for 10 kg while the recycler's records said 9.In most apps that's a bug. In an app whose pitch is "honest deductions", it's the one bug you're not allowed to have.
We didn't fix the field name. We threw the flow out, because it had a deeper flaw: the receiver recorded a deduction and that was final. The collector never agreed to anything.
The handover is now a state machine where both parties act. The sender offers at a rate and a disclosed fee. The receiver scans a QR, records the measured weight and the deduction. Then the sender has to look at those exact numbers, 10 kg weighed, 1 kg deducted, 9 kg at ₹100, ₹900, and press accept. Only then does custody move. If they disagree, they dispute, and an admin has to resolve it before the lot goes anywhere.
Python
if req.deduction_kg >= req.measured_weight_kg:raise HTTPException(422, 'Deduction must be smaller than measured weight')effective = round(req.measured_weight_kg - req.deduction_kg, 6)amount = money(Decimal(str(effective)) * Decimal(str(tx['rate'])) - Decimal(str(tx['fee'])))
One field name.
Decimal instead of float, rounded to the paisa. Every record carries a version, so accepting terms that changed since your screen loaded gets a 409 . And every event in the audit trail is hashed and append-only; the storage layer raises if anything tries to overwrite one. The old code could even invent downstream "recycled" events for a lot that had none, so the traceability graph looked complete. That's gone. A new lot has exactly one event, COLLECTED , and nothing else until something real happens.That gave us a handover I trusted on a laptop. Then I tried it on a phone with the Wi-Fi off.
Day 6: the phone that syncs twice
Here's the failure I kept picturing. A collector saves a lot in a basement with no signal. She walks to the gate, the phone reconnects, sync starts, signal drops halfway, sync retries. Or she taps "save" twice because nothing seemed to happen. Or a buyer screenshots the handover QR and scans it again tomorrow.
Every one of those has to produce exactly one lot and at most one custody transfer.
On the phone, drafts go into IndexedDB under a client-generated UUID, tagged to the signed-in user so a shared phone doesn't leak drafts between accounts. The service worker precaches the app shell and nothing under
/api/ , because a cached ledger is a stale ledger, and showing someone yesterday's "unpaid" is worse than showing "offline".That UUID becomes the
Idempotency-Key for every step of the sync. The server stores each result under user:key with a SHA-256 fingerprint of the request body. A retry gets the original response back. The same key with a different body gets a 409 , because that's almost always a client bug and silently returning the old result would hide it.The QR is a signed JWT bound to one transaction, one receiver, and a random nonce stored on the transaction. Recycler B can't use a QR addressed to Recycler A even with a valid signature. Once it's scanned, the transaction leaves
OFFERED and any replay gets 409 . If the sender reissues it, the nonce changes and every screenshot dies.The part I went back and forth on was DynamoDB. The textbook answer is per-item condition expressions. But one handover touches a lot, a transaction, an event, sometimes several source lots, and the idempotency record, and they all have to agree. Two aggregators merging overlapping stock at the same moment could each pass a per-item check and together allocate kilos that don't exist.
So for the pilot I chose correctness over throughput: one partition, and a single
_revision item used as a global optimistic lock. Read the revision, read the partition with consistent reads, read the revision again (if it moved during pagination, 409 , the snapshot could be torn), run the logic in memory, then write every changed item plus the bumped revision in one TransactWriteItems , with the revision write conditional on the version we read:Python
lock.update(ConditionExpression='body = :old',ExpressionAttributeValues={':old': {'S': json.dumps({'version': version})}})writes.append({'Put': lock})client.transact_write_items(TransactItems=writes) # all or nothing
If anyone committed in between, the whole transaction cancels, nothing is written, and the client retries with the same idempotency key. It serializes every write in the system. It would fall over at real scale. For a pilot with a few dozen users, it's the design I could reason about at 2 AM, and the per-item version is on the list for when we know the real access patterns.
A Playwright test now signs in, goes offline, reloads, saves a draft, comes back online, and checks the lot appears on the server exactly once. Watching that draft disappear from the queue was the first moment the whole thing felt real.
Then we had to put a number on the impact page, and that's where I almost cheated.
Day 7: the kWh I didn't invent
Every waste app has an impact panel. The easy version is
collected_kg × 4.5 = energy saved . We had something close to it, and the number went up every time someone made a lot. It looked great in the demo.It was also fiction. Collected isn't accepted. Accepted isn't recovered. If you don't know what came out of the recycler and what went to residue, the kWh is decoration.
So the impact page now reads from one source: a recycler recording an outcome on a lot they hold, with their own evidence photo, where recovered mass plus residue must equal the input mass to the gram or the request fails with a
422 . Energy is only calculated if an admin has entered a sourced factor for that material. No factor means None , not zero. And a copy of the factor is frozen into each outcome, so updating the aluminium factor next month doesn't quietly rewrite last month's totals. Demo lots and recorded lots are totalled separately and never mixed.We haven't loaded real life-cycle factors yet. Turning this into something that could back an EPR claim takes certified LCA numbers and an auditor, not a hackathon team. What we built is the arithmetic and the audit trail that would make such a claim checkable.
What I'd tell you if you're building on Bedrock
Write the test where the model call fails before the one where it succeeds. Our worst bug lived in the fallback, and the fallback was there to keep the demo working. In a domain with physical risk, a fallback that hides an outage is worse than the outage.
Defaults hide bugs. A
0.0 default on two field names let a deduction vanish with no error. strict=True and no defaults on anything that matters would have caught it on day one.Serverless fits this traffic. Consistency still needs a decision. Lambda and on-demand DynamoDB cost nothing while the yards are closed. But "DynamoDB has transactions" isn't a design. You have to decide what a consistent snapshot means for your data and pay for it somewhere.
Being honest about what you haven't done is cheaper than being caught. The SAM template is written and lint-clean. The live end-to-end run on a deployed account is still on our checklist. Every "tested" above means tested locally against mocked AWS calls.
/health literally returns "aws_inference_verified": false , because nothing in the running process can prove otherwise.What's next, in order: deploy and run the acceptance list in
infrastructure/README.md ; put the collector screen and the Hindi audio in front of real kabadiwalas in Delhi-NCR on the phones they actually own; get the safety text reviewed by someone qualified in battery handling; build a labelled photo set and measure how often Nova's "high confidence" is right; read weights straight from a Bluetooth scale.Try it
sh
git clone https://github.com/ravishkansal22/Scrape-saathibackend/.venv/bin/python -m pytest backend/tests -q # 68 passed
Sign in as
collector_demo / scrapsetu-local and save a lot with the Wi-Fi off. Setup details are in docs/setup/environment.md . The demo samples work with no AWS account; a real photo through Nova Lite needs credentials and model access in your region.If you upload a battery and it says "electric motor", please open an issue. It shouldn't. We checked.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article