Telemetry Wire Contract
Normative rules for telemetry frame identity, acknowledgement, and MQTT transport between a GCS adapter and the UDAI platform.
1. What this contract provides
- Idempotency: a frame delivered twice is stored once.
- Storage-gated deletion: the adapter deletes its local copy only once the platform has committed the frame to durable storage.
- Liveness: every sequence number reaches a terminal outcome in bounded time, so acknowledgement can never stall permanently.
The adapter mints a local flight identity for its own encrypted queue bookkeeping and uses it as the opaque Idempotency-Key for session creation. It is not a Core telemetry field and never appears in a frame, acknowledgement or status message.
2. Frame identity
Logical frame identity is the tuple:
(asset_uuid, session_uuid, sequence_number)This tuple is unique platform-wide and is the key the ingest ledger enforces.
| Concept | Field | Notes |
|---|---|---|
| Logical identity | asset_uuid + session_uuid + sequence_number | The dedup key. |
| Correlation id | event_id | Globally unique per frame. For tracing and log correlation: not the dedup key. |
| Session identity | session_uuid | Core-issued and required before a frame is published or replayed. |
| Event time | timestamp | The device capture time, referred to as recorded_at. Data, never identity. |
| Ingest time | received_at | Stamped by the platform, never sent by the adapter. |
Three rules that are easy to get wrong:
session_uuidis identity. A flight may begin offline before any session exists, so the adapter queues frames locally first. Before publishing or replaying them, it creates or recovers the backdated session and assigns itssession_uuid; Core never receives a frame without one. One local flight maps to one session.timestampis not identity. Two frames may share a timestamp. Deduplication never considers it.- The platform never assigns or rewrites
sequence_number. It only validates contiguity, gaps and duplicates.
3. Telemetry frame (adapter → platform)
Topic: udai/v1/telemetry/\{asset\}/frames · QoS 1 · JSON
3.1 Identity fields
| Field | Type | Required | Description |
|---|---|---|---|
schema_version | integer | yes | 1. See §7. |
event_id | UUID string | yes | Unique per frame. A retransmitted frame carries the same event_id. |
session_uuid | UUID string | yes | Core-issued session that owns the frame. The adapter creates a backdated session before offline replay, so every frame reaches the platform with one already attached. The local flight identity is retained only inside the adapter and is never included in the frame body. |
sequence_number | integer (int64) | yes | Starts at 1, increments by 1 per session. The adapter’s local flight store preserves it across restarts and session assignment. |
3.2 Payload fields
asset_uuid, asset_uin, latitude, longitude, altitude_agl, absolute_altitude_m, heading, horizontal_speed, vertical_speed, velocity_north_mps, velocity_east_mps, velocity_down_mps, roll_deg, pitch_deg, yaw_deg, rollspeed, pitchspeed, yawspeed, airspeed_mps, groundspeed_mps, climb_mps, gps_fix_type, satellites_visible, battery_temperature_cdegc, battery_voltage0_mv, battery_voltage_v, battery_current_a, battery_remaining_percent, is_armed, is_in_air, flight_mode, status_text, timestamp.
3.3 Example
{
"schema_version": 1,
"event_id": "9f2c1b7e-5a44-4c8d-9f21-6b3ad0c7e114",
"session_uuid": "c1d2e3f4-1234-4abc-9000-aabbccddeeff",
"sequence_number": 1843,
"asset_uuid": "e7a6d7a1-ffb2-4d62-bd7a-034fb8a34a55",
"asset_uin": "UIN-IN-0042",
"latitude": 28.6139,
"longitude": 77.209,
"altitude_agl": 118.4,
"heading": 214.5,
"horizontal_speed": 9.2,
"vertical_speed": -0.3,
"battery_remaining_percent": 61,
"is_armed": true,
"is_in_air": true,
"flight_mode": "AUTO",
"timestamp": "2026-09-07T09:15:02.500Z"
}3.4 Persistence rules for the adapter
next_sequence_numbermust survive a service restart. A restart must never reset or reuse a sequence number for an active local flight/session.- A frame is persisted locally before it is published (step 1 of the acknowledgement flow).
- A retransmitted frame is byte-identical to the original, including
session_uuid,sequence_numberandevent_id.
4. Acknowledgement (platform → adapter)
Topic: udai/v1/telemetry/\{asset\}/acks · JSON
The only message the adapter needs in order to free local storage.
{
"type": "telemetry_ack",
"schema_version": 1,
"session_uuid": "c1d2e3f4-1234-4abc-9000-aabbccddeeff",
"highest_contiguous_sequence": 1842,
"committed_at": "2026-09-07T09:15:03.412Z"
}| Field | Type | Description |
|---|---|---|
type | string | Always telemetry_ack. |
schema_version | integer | 1. |
session_uuid | UUID string | The session this acknowledgement covers. |
highest_contiguous_sequence | integer (int64) | Every sequence from 1 to this value inclusive has reached a terminal outcome. |
committed_at | RFC 3339 timestamp | When the resolving transaction committed. |
4.1 Semantics
- Cumulative. On receipt the adapter deletes every locally held frame with
sequence_number ≤ highest_contiguous_sequencefor that session. The adapter may use its local flight identity to find the queue rows, but the acknowledgement is session-scoped. - Published only after commit. The acknowledgement is emitted after the durable transaction commits, never before.
- Monotonic. The value never decreases for a session. An adapter receiving a lower value than one already applied must ignore it.
- Idempotent and self-healing. A lost acknowledgement needs no recovery protocol: the next one supersedes it. The platform also re-publishes the current value whenever it detects a duplicate, so a lost final acknowledgement is recovered even when no newer frame follows.
- Not a validity signal. A sequence counts toward
highest_contiguous_sequenceonce it reaches any terminal disposition (§6): including a frame that was rejected as invalid. The adapter does not need to know which; the platform holds the body. This is what stops one bad frame stalling an entire flight.
⚠️ Do not confuse this with the legacy WebSocket
telemetry_ack, which carriedstatus: "received"and meant only that a socket accepted bytes. That message is superseded and must never be treated as a deletion signal. Deletion is gated exclusively on the message above.
5. Status and source-gap declarations (adapter → platform)
Topic: udai/v1/telemetry/\{asset\}/status · JSON
Two message types share this topic, discriminated by type.
5.1 adapter_status
Routine health reporting.
{
"type": "adapter_status",
"schema_version": 1,
"session_uuid": "c1d2e3f4-1234-4abc-9000-aabbccddeeff",
"connection_state": "connected",
"last_acknowledged_sequence": 1842,
"queue_depth_frames": 120,
"queue_depth_bytes": 245760,
"queue_pressure": "normal",
"reported_at": "2026-09-07T09:15:04.000Z"
}| Field | Values |
|---|---|
connection_state | connected · reconnecting · offline |
queue_pressure | normal · elevated · decimating · discarding |
queue_pressure escalates through the overflow policy: elevated on crossing the high-water mark, decimating once the retained sample rate has been reduced, discarding once frames are being dropped.
5.2 source_gap
The declaration that keeps acknowledgement alive when the adapter has had to discard data locally. Without it, a locally discarded range would stall highest_contiguous_sequence for the remaining life of the session.
{
"type": "source_gap",
"schema_version": 1,
"session_uuid": "c1d2e3f4-1234-4abc-9000-aabbccddeeff",
"from_sequence": 100,
"to_sequence": 500,
"reason": "queue_overflow",
"declared_at": "2026-09-07T09:14:58.000Z"
}| Field | Type | Description |
|---|---|---|
session_uuid | UUID string | The Core session that owns the discarded range. The session must already exist. |
from_sequence / to_sequence | integer (int64) | Inclusive range of sequences the adapter has discarded and will never send. |
reason | string | queue_overflow today. Extensible. |
declared_at | RFC 3339 timestamp | When the discard happened. |
Rules:
- The adapter declares a range only after it has stopped publishing those sequences, and only once the session exists. A range discarded while the flight is still offline is held back until the backdated session is created, then declared.
- The adapter repeats the declaration until an acknowledgement covers the range. It is idempotent.
- The platform verifies the declaring client certificate owns the asset and the referenced session, then records the range as
abandonedusing insert-if-absent semantics: a sequence already stored is never overwritten by a declaration. - Declarations are alerted and retained for audit. They are the only legitimate source of a permanent gap, and platform metrics report them separately from loss in transit.
6. Terminal dispositions (platform-internal)
Every sequence the platform learns about resolves to exactly one terminal disposition in the ingest ledger. These do not appear on the wire: the adapter only ever sees highest_contiguous_sequence: but they are listed here because they define what that number means.
| Disposition | Meaning | Body retained in |
|---|---|---|
stored | Valid frame, committed to telemetry_frames. | hypertable |
rejected | Failed validation: schema, identity or authorisation. | quarantine store |
quarantined | Accepted but held for inspection rather than storage. | quarantine store |
failed | The platform could not process the frame after exhausting its retry budget. An operational fault, not a validation outcome. | quarantine store |
abandoned | Never received: declared discarded by the adapter under §5.2. | none, no body exists |
Every sequence is either absent from the ledger or resolved to one of these dispositions. A frame that enters the platform’s processing path always ends in a terminal ledger row: otherwise the deletion gate would stall permanently.
7. Versioning
schema_version is an integer carried on every message in this contract. It is 1.
- Additive changes: new optional fields: do not increment it. Both sides ignore fields they do not recognise.
- Breaking changes: removing a field, changing a type, or changing the meaning of an existing field: increment it.
- The platform accepts frames at the current version and the immediately preceding version.
- The adapter must not reject an acknowledgement carrying a
schema_versionhigher than it recognises if it can still readsession_uuidandhighest_contiguous_sequence; those two fields are stable by contract.
8. Transport bindings
| Message | Topic | Direction | QoS |
|---|---|---|---|
| Telemetry frame | udai/v1/telemetry/\{asset\}/frames | adapter → platform | 1 |
| Acknowledgement | udai/v1/telemetry/\{asset\}/acks | platform → adapter | 1 |
| Status / source-gap | udai/v1/telemetry/\{asset\}/status | adapter → platform | 1 |
MQTT 5 over mTLS on port 8883. Topics are keyed by asset, not by organisation: organisation ownership can change (assets can be transferred between organisations) while a certificate cannot, so nothing that changes independently of the asset is encoded in the topic or the certificate. The broker derives the \{asset\} segment from the certificate itself, never from what the client claims, so one asset cannot publish or subscribe under another asset’s segment. A decision that depends on organisation membership uses a live database lookup keyed on the asset instead.
udai/v1/commands/\{asset\} is reserved. Broker ACLs for command-and-control are withheld until a dedicated threat model is approved.
8.1 Ingest paths
Frame identity fields in §3 apply on MQTT, WebSocket, and REST ingest. Acknowledgement and status messages use the MQTT topics in the table above. All ingestion paths pass through the same idempotent ingest boundary and the same ledger.
9. Example: the full round trip
1 Adapter persists frame 1843 locally (encrypted SQLite)
2 Adapter publishes to .../frames at QoS 1 -> broker PUBACK
3 Platform resolves cert identity, validates the frame
4 Platform publishes to the durable stream -> stream ack
5 Platform commits ledger row + telemetry_frames row in one transaction
6 Platform publishes to .../acks:
{ "type": "telemetry_ack", "session_uuid": "c1d2…",
"highest_contiguous_sequence": 1843, "committed_at": "…" }
7 Adapter deletes all local frames <= 1843 for that sessionSteps 2 and 4 are observable progress only. Step 5 is the only one that permits deletion. The adapter’s local queue must therefore be sized for the worst-case interval between publish and step 6: the storage flush interval plus margin: and, more importantly, for the maximum expected offline period.