Whitepaper · Technical plan

MMS Receipt: Azure IoT Hub Backup Plan

MoreMeasure Storage · Approved 19 October 2026 · Version 2 sections added 21 October 2026

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

Ingest
Sensor nodesOne device identity per unit
Azure IoT HubRoutes every message two ways
Blob archiveRaw and immutable
Ingest FunctionGrades readings
Cosmos DBUnits, readings, receipts
Issue and verify
WebsiteStatic Web Apps
Receipt APIIssues and checks receipts
Anchoring FunctionHourly batch
BitcoinOpenTimestamps proof

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.

StageHow devices registerCredential
Pilot (now)az iot hub device-identity create, as in the simulator's setup scriptSymmetric key, short-lived SAS token per device
ProductionAzure Device Provisioning Service (DPS) with one enrollment group per floor; a new sensor node self-registers on first bootX.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 classMeasuresTypical assetsConnects through
Load cells, platform and drum scalesWeightGrain, metals, rare-earth drums, livestock feedDirect MQTT (Wi-Fi or Ethernet)
Level sensors (radar, ultrasonic, laser)Volume in bins, silos and tanksGrain, oilseeds, liquids, fuelDirect MQTT or cellular
Moisture probes and grain analysers (NIR)Moisture, protein, oilGrain, coffee, cocoa, nutsDirect MQTT or gateway
Temperature cables and probesTemperature through the stored massGrain, cold-stored foodDirect MQTT or LoRaWAN
Humidity, CO₂, O₂, gas sensorsStorage atmosphere, spoilage, leaksGrain, rare earths under argonDirect MQTT or LoRaWAN
XRF analysers and assay lab feedsFineness, purity, compositionPrecious metals, rare earths, oresGateway or lab-system upload
RFID and barcode portalsBar, drum or bag counts and movementsMetals, bagged goods, palletsGateway (IoT Edge)
Seals, door and reed switches, accelerometersTamper and handling eventsVault cages, containersDirect MQTT or LoRaWAN
Radiation dosimetersDose rateRare earths with thorium or uraniumDirect MQTT
Cameras with AI countingVisual count and conditionBags, pallets, livestockGateway (IoT Edge)
GPS and cellular trackersLocation of goods in transitContainers, trucksCellular (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.

FloorQuantity fieldsQuality and condition fields
1 · Precious metalsgrossWeight_kg, barCountfineness_ppt, rh_pct, vibration_g, cageDoor
2 · Rare earthsnetWeight_kgpurity_pct, rh_pct, o2_pct, dose_uSvh
3 · AgriculturalnetWeight_tmoisture_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.

RouteConditionEndpointPurpose
to-archiveall device messagesBlob 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-builtinall device messagesBuilt-in Event Hub-compatible endpoint, consumer group receiptsFeeds the ingest Function.
to-alerts (later)$body.alert = trueService Bus queue or email via Logic AppNotifies staff when a unit goes out of spec.

Ingest Function (Azure Functions, IoT Hub trigger, Python). For each message it:

  1. Reads the device ID from the system property and looks up the unit and its limits.
  2. Drops duplicates by msgId and records gaps in seq.
  3. Grades each reading against the limits as In spec, Near limit or Out of spec, using the same rules as the simulator.
  4. Writes the reading to the readings container and updates the unit's latest snapshot 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.

ContainerPartition keyHoldsKept for
units/unitIdUnit code, floor, commodity, lot, grade, limits, status (active or retired), latest snapshot and verdictLife of the facility
readings/unitIdOne document per graded message90 days (time-to-live); the Blob archive keeps the raw copy permanently
receipts/receiptNoCanonical receipt record, SHA-256 hash, status, owner, both certifications, anchoring status and Bitcoin block, PDF linkPermanent, never edited
receiptEvents/receiptNoIssued, certified by staff, certified by owner, anchored, pledged to lender, released, cancelledPermanent, 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.

  1. The Anchoring Function checks pending OpenTimestamps proofs on each hourly run and upgrades those whose Bitcoin block has confirmed.
  2. For each receipt in a confirmed batch, it writes one ANCHORED event 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.
  3. The ledger entry uses the receipt number as its key, so a retry or a re-run can never credit the same receipt twice.
ContainerPartition keyHolds
subscribers/accountIdSubscriber account, plan, status, linked owner IDs
pointsLedger/accountIdOne 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.

RecordPrimary storeBackup
Raw telemetryBlob telemetry, immutableGeo-redundant storage (second Azure region)
Receipts, receipt events, points ledger, subscribersCosmos DBContinuous 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, immutableGeo-redundant storage; the Merkle root and Bitcoin block are also stored on each receipt event
Device certificates and keysKey Vault and DPSKey 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.

EndpointWho can call itReturns
GET /api/unitsStaffAll units with latest verdict, as on the simulator's floor view
GET /api/units/{unit}/readings?from=&to=Staff, unit's customerReading history for charts
POST /api/units and DELETE /api/units/{unit}AdminAdds or retires a unit of storage (see Device registration)
POST /api/receiptsStaffIssues a receipt for a unit and returns its number and hash
GET /api/receipts/{receiptNo}Receipt holder, staffThe receipt record and its event history
GET /api/receipts/{receiptNo}/pdfReceipt holder, staffPrintable PDF, stored once in Blob Storage
GET /verify/{receiptNo}?h={hash}Anyone with the QR codePASS 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.

ServicePilot (24 units)Growth (a few hundred units)
IoT HubF1 free, 8,000 messages a dayS1, about $25 for 400,000 messages a day
Device Provisioning ServiceNot usedCents per 1,000 registrations
Functions (consumption)Free grant covers itA few dollars
Cosmos DBFree tierServerless or provisioned, roughly $25 and up
Blob StorageUnder $1A few dollars
Static Web AppsFree planStandard, about $9 for custom auth and SLA

Decisions

Approved 19 October 2026.

  1. Database: Cosmos DB for NoSQL.
  2. Website host: Azure Static Web Apps for the receipts app, with Squarespace kept for the marketing site.
  3. A HOLD verdict blocks issuing a receipt.
  4. Readings kept 90 days in the database, with the raw archive kept permanently in Blob Storage.
  5. Receipt numbers keep the simulator's format, MMS-2026-000001.
  6. Blockchain anchoring: Bitcoin through OpenTimestamps, batched hourly.