Skip to content

IoT Data Model

This page consolidates everything that lives in PostgreSQL when Granit.IoT is deployed: two isolated DbContexts, three core tables, the JSONB telemetry convention, the secrets-at-rest model, migration ordering, and sizing guidelines that scale from a 100-device pilot to a billion-row hypertable.

Granit.IoT ships two independent EF Core contexts — one for the cloud-agnostic domain, one for the AWS bridge. Each owns its own migrations history (__EFMigrationsHistory_*), its own table prefix, and can be added or removed from a deployment without touching the other.

flowchart LR
  subgraph "Granit.IoT.EntityFrameworkCore"
    CTX1["IoTDbContext"]
    T1["iot_devices"]
    T2["iot_telemetry_points"]
    CTX1 --> T1
    CTX1 --> T2
  end

  subgraph "Granit.IoT.Aws.EntityFrameworkCore (opt-in)"
    CTX2["AwsBindingDbContext"]
    T3["iotaws_thing_bindings"]
    CTX2 --> T3
  end

  T1 -. "Device.Id (logical FK,<br/>not enforced at DB level)" .-> T3
  T1 -. "Device.Id (FK)" .-> T2
DbContextSchema prefixTablesPackage
IoTDbContextiot_iot_devices, iot_telemetry_pointsGranit.IoT.EntityFrameworkCore
AwsBindingDbContextiotaws_iotaws_thing_bindingsGranit.IoT.Aws.EntityFrameworkCore
erDiagram
  IOT_DEVICES ||--o{ IOT_TELEMETRY_POINTS : "device_id (FK)"
  IOT_DEVICES ||--o| IOTAWS_THING_BINDINGS : "device_id (logical 1:1)"

  IOT_DEVICES {
    uuid Id PK
    uuid TenantId "nullable — null = host scope"
    text SerialNumber "max 64"
    text Model "max 128"
    text Firmware "max 64"
    text Status "Provisioning|Active|Suspended|Decommissioned"
    text Label "nullable, max 256"
    timestamptz LastHeartbeatAt "nullable"
    text SuspensionReason "nullable, max 1024"
    text Credential_CredentialType "owned VO"
    text Credential_ProtectedSecret "owned VO, encrypted at rest"
    jsonb Tags "free-form key/value"
    text CreatedBy "FullAuditedAggregateRoot"
    timestamptz CreatedAt
    text ModifiedBy "nullable"
    timestamptz ModifiedAt "nullable"
    text DeletedBy "nullable"
    timestamptz DeletedAt "nullable, soft delete"
  }

  IOT_TELEMETRY_POINTS {
    uuid Id PK
    uuid DeviceId FK "→ iot_devices.Id"
    uuid TenantId "nullable, denormalised for purge speed"
    timestamptz RecordedAt "BRIN-indexed"
    text MessageId "nullable, max 128, transport id"
    text Source "nullable, max 64, e.g. scaleway/mqtt/awsiotsns"
    jsonb Metrics "{ metricName: numericValue }"
    text CreatedBy "CreationAuditedEntity"
    timestamptz CreatedAt
  }

  IOTAWS_THING_BINDINGS {
    uuid Id PK
    uuid DeviceId "logical 1:1 → iot_devices.Id"
    uuid TenantId "nullable"
    text ThingName "max 128, unique GLOBALLY"
    text ThingArn "nullable, max 256"
    text CertificateArn "nullable, max 256"
    text CertificateSecretArn "nullable, max 2048"
    text ProvisioningStatus "Pending|ThingCreated|CertIssued|SecretStored|Active|Failed|Decommissioned"
    timestamptz LastShadowReportedAt "nullable"
    timestamptz ClaimCertificateExpiresAt "nullable, JITP only"
    boolean ProvisionedViaJitp
    text FailureReason "nullable, max 1024"
    text CreatedBy "FullAuditedAggregateRoot"
    timestamptz CreatedAt
    text ModifiedBy
    timestamptz ModifiedAt
    text DeletedBy
    timestamptz DeletedAt "nullable, soft delete"
  }

The aggregate root for IoT device management. One row per physical / logical device, per tenant.

ColumnTypeConstraintsNotes
IduuidPKDDD aggregate identifier
TenantIduuidnullablenull = host-scope; otherwise tenant-scoped
SerialNumbertextNOT NULL, max 64Uppercase alphanumeric + dash, enforced by DeviceSerialNumber VO
ModeltextNOT NULL, max 128Hardware model identifier (HardwareModel VO)
FirmwaretextNOT NULL, max 64Semver-like (FirmwareVersion VO)
StatustextNOT NULL, max 32Enum stored as string for forward-compat
Labeltextnullable, max 256Operator-friendly name
LastHeartbeatAttimestamptznullableUpdated by IDeviceWriter.UpdateHeartbeatAsync (bulk SQL)
SuspensionReasontextnullable, max 1024Set on Suspend(reason)
Credential_CredentialTypetextnullableOwned VO; e.g. mqtt-x509, psk, bearer
Credential_ProtectedSecrettextnullableOwned VO; encrypted at rest via Granit.Encryption
TagsjsonbnullableFree-form key/value (e.g. {"location": "WH-A"})
CreatedBy / CreatedAttext / timestamptzNOT NULLFullAuditedAggregateRoot
ModifiedBy / ModifiedAttext / timestamptznullableFullAuditedAggregateRoot
DeletedBy / DeletedAttext / timestamptznullableSoft delete
NameColumnsTypePurpose
pk_iot_devices(Id)PRIMARY KEYAggregate lookup
ix_iot_devices_tenant_serial(TenantId, SerialNumber)UNIQUEPer-tenant serial uniqueness — the FAIL-CLOSED safety net
ix_iot_devices_tenant_status(TenantId, Status)btreeFilter by status within a tenant (admin list)

The append-only ledger of every device payload. One row per device send, metrics co-located in a single JSONB column.

ColumnTypeConstraintsNotes
IduuidPKSurrogate per row
DeviceIduuidNOT NULLFK target → iot_devices.Id (not enforced at DB level for partitioning compatibility)
TenantIduuidnullableDenormalised from iot_devices so GDPR purge avoids a join
RecordedAttimestamptzNOT NULLDevice-reported timestamp; BRIN-indexed
MessageIdtextnullable, max 128Transport message ID (Scaleway X-Scaleway-Message-Id, MQTT packet id, SNS ID)
Sourcetextnullable, max 64scaleway, mqtt, awsiotsns, awsiotdirect, awsiotapigw
MetricsjsonbNOT NULL{ metricName: numericValue } (see JSONB schema)
CreatedBy / CreatedAttext / timestamptzNOT NULLCreationAuditedEntity — append-only, no Modified*
NameColumnsTypePurpose
pk_iot_telemetry_points(Id)PRIMARY KEYRow lookup
ix_iot_telemetry_device_time(DeviceId, RecordedAt DESC)btreeCovering index for the most common query
ix_iot_telemetry_tenant_time(TenantId, RecordedAt)btreeGDPR bulk erasure + per-tenant purge
ix_iot_telemetry_brin_recorded_at(RecordedAt)BRIN10× smaller than B-tree for append-only data
ix_iot_telemetry_metrics_gin(Metrics)GIN jsonb_opsPer-key filters: WHERE Metrics @> '{"temperature": 22.5}'

The BRIN and GIN indexes are created by the Granit.IoT.EntityFrameworkCore.Postgres migration helpers — EF Core cannot emit them declaratively:

migrationBuilder.CreateTelemetryBrinIndex(); // BRIN(recorded_at)
migrationBuilder.CreateTelemetryGinIndex(); // GIN(metrics jsonb_ops)
migrationBuilder.CreateIoTPostgresIndexes(); // both, in one call
flowchart LR
  T["iot_telemetry_points<br/>(parent — RANGE on RecordedAt)"]
  T --> P1["iot_telemetry_points_2026_04"]
  T --> P2["iot_telemetry_points_2026_05"]
  T --> P3["iot_telemetry_points_2026_06<br/>(created by maintenance job)"]
  T --> PD["iot_telemetry_points_default<br/>(catch-all, should stay empty)"]

Each partition carries its own BRIN(RecordedAt) and GIN(Metrics) indexes — dropping a partition drops its indexes with it. That’s why GDPR erasure at a month boundary is O(1). See Operations — Partition maintenance.

TimescaleDB hypertable (alternative opt-in)

Section titled “TimescaleDB hypertable (alternative opt-in)”

When Granit.IoT.EntityFrameworkCore.Timescale is registered, the same table is converted to a hypertable with 7-day chunks, plus two continuous aggregates:

ObjectTypeRefresh policyUsed by
iot_telemetry_pointshypertable, 7-day chunksn/aAll raw-row queries
iot_telemetry_hourlycontinuous aggregateevery 30 min, lag 1 h, window 3 hWindow 1 h – 24 h
iot_telemetry_dailycontinuous aggregateevery 6 h, lag 1 d, window 3 dWindow ≥ 24 h

See Time-series storage for the full decision tree between PostgreSQL-native and TimescaleDB.

The AWS-companion aggregate. 1:1 with iot_devices (logical) but lives in its own schema so a non-AWS deployment can drop it without touching the core tables.

ColumnTypeConstraintsNotes
IduuidPKAggregate identifier
DeviceIduuidNOT NULLLogical FK → iot_devices.Id (not enforced at DB level)
TenantIduuidnullableMatches Device.TenantId
ThingNametextNOT NULL, max 128Format: t{tenantId:N}-{serial}; globally unique on the AWS account
ThingArntextnullable, max 256AWS Thing ARN
CertificateArntextnullable, max 256X.509 cert ARN issued by CreateKeysAndCertificate
CertificateSecretArntextnullable, max 2048Secrets Manager ARN holding the private key
ProvisioningStatustextNOT NULL, max 32Saga state machine — see AWS bridge
LastShadowReportedAttimestamptznullableUpdated by DeviceLifecycleShadowHandler
ClaimCertificateExpiresAttimestamptznullableJITP-only; surfaced as expiring by ClaimCertificateRotationCheckService
ProvisionedViaJitpbooleanNOT NULLSaga skips Thing creation when true
FailureReasontextnullable, max 1024Set when ProvisioningStatus = Failed for operator triage
CreatedBy / CreatedAt / ModifiedBy / ModifiedAt / DeletedBy / DeletedAtFullAuditedAggregateRoot
NameColumnsTypePurpose
pk_iotaws_thing_bindings(Id)PRIMARY KEYAggregate lookup
ix_iotaws_thing_bindings_tenant_device(TenantId, DeviceId)UNIQUEEnforces the 1:1 relation per tenant
ix_iotaws_thing_bindings_thing_name(ThingName)UNIQUE (GLOBAL)AWS Thing names are global; the t{tenantId}-* prefix doubles as tenant isolation at the DB level
ix_iotaws_thing_bindings_tenant_status(TenantId, ProvisioningStatus)btreeReconciliation queries (find stuck or expired bindings)

Metrics is a single JSONB column storing one device payload — one entry per metric the device emitted. The schema is intentionally minimal: keys are metric names, values are doubles.

{
"temperature": 22.5,
"humidity": 45.0,
"battery": 90,
"rssi": -67
}

MetricName enforces:

  • Lowercase dot-notation segments — pressure.intake is valid; Pressure-Intake is not.
  • Max 10 segments — guards against pathological deep paths.
  • Length-bounded — total length capped (see VO source for current limit).

These rules make MQTT-topic ↔ metric mapping predictable and keep GIN index size sane.

SELECT *
FROM iot_telemetry_points
WHERE device_id = $1
AND recorded_at >= $2
AND metrics @> '{"temperature": 22.5}';

The GIN index makes this a direct lookup, not a JSONB scan.

The only column carrying a secret is iot_devices.Credential_ProtectedSecret. It is encrypted at rest by the host’s IDeviceCredentialProtector implementation (shipped by Granit.Encryption):

sequenceDiagram
  participant API as Endpoint handler
  participant W as IDeviceWriter
  participant P as IDeviceCredentialProtector
  participant V as Vault (or KMS)
  participant DB as PostgreSQL
  API->>P: Protect(plaintextSecret)
  P->>V: Encrypt(plaintextSecret)
  V-->>P: ciphertext
  P-->>API: ProtectedSecret(ciphertext)
  API->>W: AddAsync(device)
  W->>DB: INSERT iot_devices (Credential_ProtectedSecret = ciphertext)

Mitigations carried by the framework:

  • [SensitiveData] on DeviceCredential.ProtectedSecret — scrubbed from logs, OpenAPI schemas, and 422 validation payloads by Granit.Http.Sanitization.
  • Read-time Unprotect happens inside the credential consumer (MQTT bridge, ingestion validator), never in an API response.
  • Plaintext secrets are rejected at startup if appsettings.json carries a credential without __FROM_SECRET_STORE__ placeholder semantics — see security baseline.

Each context has its own EFMigrationsHistory table and migrates independently. Apply them in this order on a fresh deployment:

Terminal window
# 1. Core IoT schema (iot_devices, iot_telemetry_points)
dotnet ef database update \
--context IoTDbContext \
--project MyApp.Host

Indicative numbers — exact ratios depend on the average Metrics payload size, retention window, and provider.

Scale (rows/day)Recommended backendRow sizeStorage / monthNotes
< 1 MPostgreSQL native, no partitioning~250-400 B~10 GBBRIN + GIN; default backend
1 M – 10 MPostgreSQL native + monthly partitioning~250-400 B~100 GBEnable EnableTelemetryPartitioning()
10 M – 100 MPostgreSQL or TimescaleDB~200-350 B (Timescale chunks compress better)~1 TBConsider hypertable for dashboard speed-up
> 100 MTimescaleDB hypertable + continuous aggregates~150-300 B≥ 10 TBContinuous aggregates become mandatory

A typical row in iot_telemetry_points:

ComponentBytes (typical)
Header + visibility28
Id (uuid)16
DeviceId (uuid)16
TenantId (uuid)16
RecordedAt (timestamptz)8
MessageId (text, ~36 chars)~40
Source (text, ~8 chars)~12
Metrics (jsonb, 3-5 numeric keys)~80-150
Audit columns~40
Index overhead (per row)~50-80
Total~300-400 B

A 256 KB MaxPayloadBytes cap on ingestion (see Telemetry ingestion) guards against degenerate payloads that would balloon Metrics.

ActionAffected tablesMechanism
Per-tenant retentioniot_telemetry_pointsStaleTelemetryPurgeJob — bucketed ExecuteDeleteAsync
Month rollover (partitioned)iot_telemetry_points_YYYY_MMDROP TABLE of the oldest partition (O(1))
Device decommissioniot_devices (soft delete) + iotaws_thing_bindings (hard delete on AWS side)Device.Decommission() raises events; bridges tear down
GDPR Art. 17 erasureiot_telemetry_points (full purge); iot_devices (soft delete + audit retention per policy)Combination of purge job + manual aggregate flow
Tenant offboardingAll iot_* and iotaws_* rows with matching TenantIdHost-driven bulk SQL inside the offboarding pipeline

The Timeline bridge keeps an immutable audit trail — see Timeline bridge — so erasure of telemetry does not erase the record that the device existed.