Whitepaper · Technical plan
MMS Receipt: Azure IoT Hub Backup Plan
Summary
The recommendation is to keep the IoT Hub and Blob archive already scripted in the simulator, then add Azure Functions, a Cosmos DB account and a small receipts web app on Azure Static Web Apps. Each receipt is certified by MoreMeasure staff and by the owner, and its hash is then anchored to a public blockchain. Every piece fits in a free tier for the 24-unit pilot, and nothing is created until this plan is approved.
The simulator already defines the device IDs, the telemetry fields per floor, the Blob archive route and the SHA-256 receipt hash. This plan keeps all of that and fills in the missing "Receipt service" box: turning readings into stored, verifiable MMS Receipts the website can show, print and check.
All six decisions are approved and listed at the end. Version 2 extends the plan to every type of IoT device for real-world assets and to a points ledger that credits subscribers 1 point per receipt timestamped on Bitcoin; its sections are marked Version 2.
Architecture overview
IoT Hub copies each reading to a locked Blob archive and to the Ingest Function, which grades it and keeps Cosmos DB current. The website only talks to the Receipt API, which issues receipts from Cosmos DB and checks them against the archive when someone scans a QR code.
Device registration and identity
Each storage unit gets one IoT Hub device identity, so adding or deleting a unit of storage is the same action as adding or deleting a device. The naming scheme from the simulator stays: moremeasure-f{floor}-{unit}, for example moremeasure-f3-a301 for wheat bin A-301.
| Stage | How devices register | Credential |
|---|---|---|
| Pilot (now) | az iot hub device-identity create, as in the simulator's setup script | Symmetric key, short-lived SAS token per device |
| Production | Azure Device Provisioning Service (DPS) with one enrollment group per floor; a new sensor node self-registers on first boot | X.509 certificate per node, signed by a MoreMeasure CA |
Device twin as the unit's label. Each device twin carries tags (floor, unitCode, commodity, lot, grade) and desired properties for its quality limits (for example moisture_pct hard limit 13.5). Pushing limits through the twin means a rule change does not need a firmware update, and the node reports back the limits it is using.
Unit lifecycle. Adding a unit creates the device identity, its twin tags and a units record in the database in one admin action. Deleting a unit disables the device (no more telemetry accepted) and marks the unit retired; it never deletes readings or receipts, because issued receipts must stay verifiable.
Version 2: every IoT device type
Version 2 accepts any sensor that measures a real-world asset's quantity, quality or storage condition, not only the 14 pilot sensors. A device is registered by its class and assigned to a unit, so new sensor types need a catalog entry rather than new code. The v2 app already registers new sensor fields in its catalog with default limits and attaches them to units. The v2 message format and new device IDs below are the next build step; until then every device keeps its pilot ID.
| Device class | Measures | Typical assets | Connects through |
|---|---|---|---|
| Load cells, platform and drum scales | Weight | Grain, metals, rare-earth drums, livestock feed | Direct MQTT (Wi-Fi or Ethernet) |
| Level sensors (radar, ultrasonic, laser) | Volume in bins, silos and tanks | Grain, oilseeds, liquids, fuel | Direct MQTT or cellular |
| Moisture probes and grain analysers (NIR) | Moisture, protein, oil | Grain, coffee, cocoa, nuts | Direct MQTT or gateway |
| Temperature cables and probes | Temperature through the stored mass | Grain, cold-stored food | Direct MQTT or LoRaWAN |
| Humidity, CO₂, O₂, gas sensors | Storage atmosphere, spoilage, leaks | Grain, rare earths under argon | Direct MQTT or LoRaWAN |
| XRF analysers and assay lab feeds | Fineness, purity, composition | Precious metals, rare earths, ores | Gateway or lab-system upload |
| RFID and barcode portals | Bar, drum or bag counts and movements | Metals, bagged goods, pallets | Gateway (IoT Edge) |
| Seals, door and reed switches, accelerometers | Tamper and handling events | Vault cages, containers | Direct MQTT or LoRaWAN |
| Radiation dosimeters | Dose rate | Rare earths with thorium or uranium | Direct MQTT |
| Cameras with AI counting | Visual count and condition | Bags, pallets, livestock | Gateway (IoT Edge) |
| GPS and cellular trackers | Location of goods in transit | Containers, trucks | Cellular (LTE-M or NB-IoT) |
The full catalog of 58 device types is in the Device coverage section of the MMS Receipt White Paper.
Provisioning. All new devices register through Azure Device Provisioning Service with X.509 certificates, one enrollment group per device class. Each device's twin carries deviceClass, sensorType, serial, calibration date and the unit it is assigned to. Pilot device IDs (moremeasure-f{floor}-{unit}) keep working; new ones will use mms-{deviceClass}-{serial}, because one unit can now have several devices and a device can be moved to another unit.
Devices that cannot reach IoT Hub directly. LoRaWAN sensors connect through a LoRaWAN network server's IoT Hub integration. RFID portals, cameras and lab instruments connect through an Azure IoT Edge gateway at the facility, which signs and forwards their readings as its own child devices.
Message format v2. mms.telemetry.v2 will be the v1 envelope plus a source object (deviceClass, sensorType, serial, calibratedUtc). Each reading field is defined once in a fieldCatalog container (label, unit, decimals, and whether it is a quantity, quality or condition), and the Ingest Function will reject fields that are not in the catalog. A reading from a device past its calibration date is graded Near limit, so a receipt cannot rest on an uncalibrated sensor without staff seeing it.
Message format
Every node sends one JSON message per reading cycle: a fixed envelope plus a readings object whose fields depend on the floor. This keeps the simulator's field names and adds four envelope fields (schema, seq, ts, msgId) so the receipt service can order readings, spot gaps and reject duplicates.
{
"schema": "mms.telemetry.v1",
"msgId": "3f6c1e2a-...",
"seq": 18452,
"ts": "2026-10-19T13:40:00Z",
"unit": "A-301",
"lot": "AG-2607-4100",
"commodity": "Hard red winter wheat",
"readings": {
"netWeight_t": 48.21,
"moisture_pct": 12.4,
"temp_c": 16.5,
"rh_pct": 57,
"co2_ppm": 620
},
"alert": false
}
Messages are sent with content type application/json and encoding utf-8 so IoT Hub routing can read the body, plus two application properties: floor (1, 2 or 3) and schema.
| Floor | Quantity fields | Quality and condition fields |
|---|---|---|
| 1 · Precious metals | grossWeight_kg, barCount | fineness_ppt, rh_pct, vibration_g, cageDoor |
| 2 · Rare earths | netWeight_kg | purity_pct, rh_pct, o2_pct, dose_uSvh |
| 3 · Agricultural | netWeight_t | moisture_pct, temp_c, rh_pct, co2_ppm |
The device ID is never trusted from the body. The receipt service uses the iothub-connection-device-id system property that IoT Hub stamps on each message, so a node cannot report readings for another unit.
Routing and processing
IoT Hub sends every message down two paths: a raw archive that is never edited, and a processing path that keeps the database current. Both routes already exist in the simulator's setup script; the only new piece is the Function reading the second one.
| Route | Condition | Endpoint | Purpose |
|---|---|---|---|
to-archive | all device messages | Blob Storage container telemetry (JSON, batched by IoT Hub) | Permanent raw record that receipts are checked against. Turn on an immutability (WORM) policy so no one can alter it. |
to-builtin | all device messages | Built-in Event Hub-compatible endpoint, consumer group receipts | Feeds the ingest Function. |
to-alerts (later) | $body.alert = true | Service Bus queue or email via Logic App | Notifies staff when a unit goes out of spec. |
Ingest Function (Azure Functions, IoT Hub trigger, Python). For each message it:
- Reads the device ID from the system property and looks up the unit and its limits.
- Drops duplicates by
msgIdand records gaps inseq. - Grades each reading against the limits as In spec, Near limit or Out of spec, using the same rules as the simulator.
- Writes the reading to the
readingscontainer and updates the unit'slatestsnapshot and verdict (PASS, CONDITIONAL or HOLD).
Receipt Function (HTTP trigger, called from the website). Issuing a receipt takes the unit's latest graded snapshot, builds the canonical receipt record, hashes it with SHA-256 and stores it. The simulator prints a receipt whatever the verdict; in this plan a HOLD blocks issuance, so a lender never receives a receipt for out-of-spec goods (decision 3).
Certification. A receipt is final only when both a MoreMeasure staff member and the owner have certified it. Each certification is a signed-in action recorded in receiptEvents with who signed and when, and both are part of the record that is hashed. Receipts are non-negotiable: they name the owner, and a pledge to a lender is recorded as an event rather than a change of holder.
Anchoring Function (timer trigger, hourly). It collects the hashes of receipts certified since the last run, combines them into one Merkle root and timestamps that root on the Bitcoin blockchain through the public OpenTimestamps calendar servers. Each receipt keeps its own proof file (.ots, stored in Blob Storage) linking its hash to that root. OpenTimestamps needs no wallet or transaction fees, and a proof becomes complete once the Bitcoin block is confirmed, usually within a few hours. Until then the receipt shows "anchoring pending".
Storage
The database is Azure Cosmos DB for NoSQL, because each floor reports different fields and Cosmos stores each reading as its own JSON document with no schema change. Its free tier (1,000 RU/s and 25 GB per account, approximate) covers the pilot.
| Container | Partition key | Holds | Kept for |
|---|---|---|---|
units | /unitId | Unit code, floor, commodity, lot, grade, limits, status (active or retired), latest snapshot and verdict | Life of the facility |
readings | /unitId | One document per graded message | 90 days (time-to-live); the Blob archive keeps the raw copy permanently |
receipts | /receiptNo | Canonical receipt record, SHA-256 hash, status, owner, both certifications, anchoring status and Bitcoin block, PDF link | Permanent, never edited |
receiptEvents | /receiptNo | Issued, certified by staff, certified by owner, anchored, pledged to lender, released, cancelled | Permanent, append-only |
Receipts are append-only. A receipt document is written once. Any later change, such as a certification, the blockchain anchor, goods released or a pledge to a lender, is a new document in receiptEvents, so the full history of a receipt can be shown and nothing can be quietly rewritten. The Cosmos change feed can drive PDF creation and notifications from these events.
Why not Azure SQL. Azure SQL fits better if accounting and billing reports become the main use, and its ledger tables give built-in tamper evidence. It would need a column or JSON field per sensor type, which slows the pilot down. It can be added later as a reporting copy fed from the change feed.
Version 2: subscriber points ledger
Each subscriber earns 1 point for every one of their receipts whose timestamp is confirmed on the Bitcoin blockchain. The point is credited when the OpenTimestamps proof is complete, meaning the Bitcoin block is confirmed, not when the hash is first submitted. A point therefore always stands for a timestamp anyone can check.
- The Anchoring Function checks pending OpenTimestamps proofs on each hourly run and upgrades those whose Bitcoin block has confirmed.
- For each receipt in a confirmed batch, it writes one
ANCHOREDevent and, if the owner's subscription is active at that moment and the receipt is not void, one ledger entry crediting the owner's account. - The ledger entry uses the receipt number as its key, so a retry or a re-run can never credit the same receipt twice.
| Container | Partition key | Holds |
|---|---|---|
subscribers | /accountId | Subscriber account, plan, status, linked owner IDs |
pointsLedger | /accountId | One entry per credit or adjustment: points, receipt number, batch ID, Merkle root, Bitcoin block height, time |
The ledger is append-only like receipts. A balance is the sum of an account's entries. Admins can add an adjustment entry with a reason, for a correction or a redemption, but never edit an entry, and a balance can never go below 0. The account ID is the owner ID printed on receipts, for example OWN-00187. Each credit carries the receipt number and Bitcoin block, so any point can be traced to its receipt and checked on the verify page. Points are non-transferable account credits; what they can be redeemed for is set on the website.
Backups
Receipts, certifications, anchoring proofs and points are the records that must survive any failure, so each is kept in two independent places.
| Record | Primary store | Backup |
|---|---|---|
| Raw telemetry | Blob telemetry, immutable | Geo-redundant storage (second Azure region) |
| Receipts, receipt events, points ledger, subscribers | Cosmos DB | Continuous backup with 7-day point-in-time restore, plus a nightly change-feed export to Blob backups (immutable) |
Anchoring proofs (.ots per batch) | Blob anchors, immutable | Geo-redundant storage; the Merkle root and Bitcoin block are also stored on each receipt event |
| Device certificates and keys | Key Vault and DPS | Key Vault soft delete and purge protection |
A restore is tested once a quarter by rebuilding the receipts and points balances from the nightly export and comparing them with Cosmos DB.
How the website reads and prints receipts
The website never talks to IoT Hub or the database directly; it calls a small HTTP API, and only that API holds database keys. The recommended host is Azure Static Web Apps (free plan), which serves the receipts pages and runs the API as built-in Functions with sign-in handled for you. The marketing site and the unit application form can stay on Squarespace and link to it, for example at a receipts. subdomain of your domain.
| Endpoint | Who can call it | Returns |
|---|---|---|
GET /api/units | Staff | All units with latest verdict, as on the simulator's floor view |
GET /api/units/{unit}/readings?from=&to= | Staff, unit's customer | Reading history for charts |
POST /api/units and DELETE /api/units/{unit} | Admin | Adds or retires a unit of storage (see Device registration) |
POST /api/receipts | Staff | Issues a receipt for a unit and returns its number and hash |
GET /api/receipts/{receiptNo} | Receipt holder, staff | The receipt record and its event history |
GET /api/receipts/{receiptNo}/pdf | Receipt holder, staff | Printable PDF, stored once in Blob Storage |
GET /verify/{receiptNo}?h={hash} | Anyone with the QR code | PASS or FAIL, with the readings and issue time |
Version 2 adds GET /api/points (a signed-in subscriber's balance and the receipt behind each point) and GET /api/accounts/{accountId}/points (staff), and the device endpoints POST /api/devices and PUT /api/devices/{deviceId}/assignment (admin) for registering a device and assigning it to a unit.
Sign-in. Staff and customers sign in with Microsoft Entra External ID (email or Microsoft account). Roles are admin, staff and customer; a customer only sees receipts where they are the owner.
E-receipt and print. The receipt page is the e-receipt: it shows the same layout as the simulator's paper receipt and can be shared as a link. The PDF endpoint produces the print copy; it is generated once at issue time so every printout is identical. Physical printing is the browser's print dialog, or a thermal receipt printer at the facility using the same PDF.
Verification. The QR code holds a URL to /verify/{receiptNo}?h={hash}, so any phone camera opens it. (The simulator's practice receipts instead link to the customer's Receipts page in the app; see the White Paper.) The verify page reloads the stored record, recomputes the SHA-256 hash, compares it with the QR hash, checks the matching messages in the Blob archive and checks the OpenTimestamps proof against the Bitcoin block. Any change to quantity or quality breaks the match, and the blockchain proof shows the receipt existed in that form no later than the block's time, without having to trust MoreMeasure's own database.
Simulated device script outline
The simulator's fleet.py becomes mms_sim.py: same floor profiles, plus the v1 envelope, scenario switches for testing HOLD verdicts, and add or remove commands for units. It needs only the azure-iot-device Python package and one connection string per device, read from a local file that is never committed.
# mms_sim.py (outline, not final code)
# Usage:
# python mms_sim.py run # all units, every 300 s
# python mms_sim.py run --unit A-301 --every 10 --scenario wet
# python mms_sim.py add --unit A-309 --floor 3 --commodity "Barley"
# python mms_sim.py retire --unit A-309
import argparse, json, random, time, uuid
from datetime import datetime, timezone
from azure.iot.device import IoTHubDeviceClient, Message
PROFILES = {...} # floor 1, 2, 3 reading generators, copied from fleet.py
SCENARIOS = { # nudges applied on top of a profile
"normal": {},
"wet": {"moisture_pct": +1.8}, # floor 3 -> HOLD
"short": {"barCount": -1}, # floor 1 -> HOLD
"leak": {"o2_pct": +4.0}, # floor 2 -> HOLD
}
def envelope(unit, lot, commodity, seq, readings):
return {"schema": "mms.telemetry.v1", "msgId": str(uuid.uuid4()), "seq": seq,
"ts": datetime.now(timezone.utc).isoformat(), "unit": unit, "lot": lot,
"commodity": commodity, "readings": readings, "alert": False}
def send(client, floor, body):
msg = Message(json.dumps(body))
msg.content_type, msg.content_encoding = "application/json", "utf-8"
msg.message_id = body["msgId"]
msg.custom_properties.update({"floor": floor, "schema": body["schema"]})
client.send_message(msg)
def on_twin_patch(patch): # limits pushed from the twin are logged and applied
...
def run(args): ... # connect each device, loop: build readings, apply scenario, send, sleep
def add(args): ... # calls the website API POST /api/units (admin), saves the new connection string
def retire(args): ... # calls DELETE /api/units/{unit}; device stops sending
At 24 units every 5 minutes the pilot sends 6,912 messages a day, inside the F1 tier's 8,000. Faster intervals for testing should target one or two units.
Security and cost
Security. Secrets (device keys, Cosmos keys) live in Azure Key Vault and the Functions use a managed identity, so no key sits in website code. The Wokwi sketch's tls.setInsecure() is replaced with the pinned DigiCert Global Root G2 certificate before any real sensor is connected. The Blob archive gets an immutability policy, and receipts are never updated in place.
Monthly cost. Figures are approximate list prices, not checked against Azure's current price sheet. Bitcoin anchoring through OpenTimestamps adds no cost.
| Service | Pilot (24 units) | Growth (a few hundred units) |
|---|---|---|
| IoT Hub | F1 free, 8,000 messages a day | S1, about $25 for 400,000 messages a day |
| Device Provisioning Service | Not used | Cents per 1,000 registrations |
| Functions (consumption) | Free grant covers it | A few dollars |
| Cosmos DB | Free tier | Serverless or provisioned, roughly $25 and up |
| Blob Storage | Under $1 | A few dollars |
| Static Web Apps | Free plan | Standard, about $9 for custom auth and SLA |
Decisions
Approved 19 October 2026.
- Database: Cosmos DB for NoSQL.
- Website host: Azure Static Web Apps for the receipts app, with Squarespace kept for the marketing site.
- A HOLD verdict blocks issuing a receipt.
- Readings kept 90 days in the database, with the raw archive kept permanently in Blob Storage.
- Receipt numbers keep the simulator's format,
MMS-2026-000001. - Blockchain anchoring: Bitcoin through OpenTimestamps, batched hourly.