Skip to main content

offline_protocol_telemetry_wire/
event.rs

1//! Event surface.
2//!
3//! Two layers live here:
4//!
5//! * [`RawEvent`] — the M1 envelope: an opaque, type-tagged record. The
6//!   `type` field is the stable wire identity (see SDK `ALL_TELEMETRY_NAMES`
7//!   at `record.rs:108-184`, or one of our own derived names like
8//!   `mesh_metrics_rollup` and `mesh_session_summary`); `ts_ms` is the emit
9//!   time; `data` is the per-type payload. The batch always deserializes into
10//!   `RawEvent`s — they are cheap and never reject.
11//!
12//! * The M3 typed layer ([`Event`] + the per-type `*Data` structs +
13//!   [`classify`]). After the envelope parses, the ingest server classifies
14//!   each `(type, &data)` into a typed variant and routes it to its hypertable
15//!   (plan §7.5). Unknown types fall through to [`Event::Extension`] and land
16//!   in the `raw_extension_events` catchall (plan §2.6).
17//!
18//! ## Why the field types mirror the storage schema, not the wire spec
19//!
20//! Each `*Data` struct types its fields to the **column** width of the matching
21//! `raw_*` hypertable (migration `0005_typed_raw_events.sql`): `SMALLINT`→`i16`,
22//! `INTEGER`→`i32`, `BIGINT`→`i64`, `REAL`→`f32`. That is deliberate: a JSON
23//! number outside the column's range then fails `serde_json::from_value`, so
24//! [`classify`] rejects the *event* (plan §7.5: "out-of-range drops the event")
25//! instead of letting the value reach the bulk INSERT, where a single bad row
26//! would fail the whole batch. The plan's §2.2/§2.3 `u16`/`u32` hints are the
27//! wire's logical ranges; the migration already widened them where a `u16`
28//! could exceed `i16` (see the §3.1 type-choice notes), and these structs
29//! follow the migration, not the hint.
30//!
31//! ### Negative values are accepted within the column range — by design
32//!
33//! The `raw_*` columns are **signed** (`SMALLINT`/`INTEGER`/`BIGINT`) with no
34//! `CHECK (col >= 0)` constraint, even though most of these fields are
35//! logically unsigned (counts, durations, latencies, battery levels). So the
36//! boundary `classify` enforces is exactly the column's storable range —
37//! `[i16::MIN, i16::MAX]` for `SMALLINT`, etc. — **not** the logical `[0, …]`
38//! range. A `hop_count: -1` deserializes and is stored verbatim.
39//!
40//! This is deliberate, not an oversight:
41//!   * The raw tables are an *unaggregated landing zone*; the privacy guarantee
42//!     is the 30-day retention drop, not field-level sanitisation. The M4
43//!     aggregation pass is the right place to clamp/discard nonsense (a
44//!     `WHERE col >= 0`), where the rule lives in one query instead of being
45//!     duplicated across ~40 nullable wire fields here.
46//!   * Per-field non-negativity would need a custom `deserialize_with` on every
47//!     numeric field — large surface area — and would *reject the whole event*
48//!     for a single negative, losing its other (valid) fields. Storing a
49//!     negative that an aggregation query can trivially filter is the better
50//!     failure mode.
51//!   * It keeps "passed `classify`" meaning precisely "fits the column", which
52//!     is the invariant the fan-out INSERT relies on: a classified event can
53//!     never fail its bind. (Overflow past the column range *is* still rejected.)
54//!
55//! The `negative_value_within_column_range_is_accepted` test pins this so the
56//! choice can't silently flip.
57//!
58//! All event-specific fields are `Option<_>`: the typed columns are nullable
59//! (migration 0005), the SDK listener emits a field only when present (its
60//! `pick` projection), and serde maps a missing `Option` field to `None`. The
61//! structs do **not** set `deny_unknown_fields` — an SDK that adds a field to a
62//! known event must not start failing validation (plan §2.6 forward-compat
63//! ethos); unknown keys within a known type are ignored.
64
65use serde::de::DeserializeOwned;
66use serde::{Deserialize, Serialize};
67use serde_json::Value;
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct RawEvent {
71    #[serde(rename = "type")]
72    pub event_type: String,
73    pub ts_ms: i64,
74    pub data: Value,
75}
76
77// --- Wire `type` discriminators ----------------------------------------------
78//
79// The stable per-event identity strings (plan §2.3 / §7.2). Pulled out as
80// constants so [`classify`] and any future caller can't drift apart on a typo,
81// and so the closed set is greppable.
82
83pub const TYPE_METRICS_ROLLUP: &str = "mesh_metrics_rollup";
84pub const TYPE_SESSION_SUMMARY: &str = "mesh_session_summary";
85pub const TYPE_MESSAGE_DELIVERED: &str = "protocol.message.delivered";
86pub const TYPE_MESSAGE_FAILED: &str = "protocol.message.failed";
87pub const TYPE_MESSAGE_DEFERRED: &str = "protocol.message.deferred";
88pub const TYPE_MESSAGE_RELAYED: &str = "protocol.message.relayed";
89pub const TYPE_RELAY_PROMOTED: &str = "protocol.relay.promoted";
90pub const TYPE_RELAY_DEMOTED: &str = "protocol.relay.demoted";
91pub const TYPE_RELAY_DEMOTED_BATTERY: &str = "protocol.relay.demoted_battery";
92pub const TYPE_ROUTING_DECISION: &str = "protocol.routing.decision";
93pub const TYPE_TRANSPORT_STATE: &str = "protocol.transport.state_changed";
94pub const TYPE_DEVICE_CAPABILITY: &str = "protocol.device.capability_changed";
95pub const TYPE_MLS_DECRYPTION_FAILED: &str = "mls.decryption_failed";
96pub const TYPE_MLS_SESSION_MISSING: &str = "mls.session_missing";
97
98// --- Shared nested wire shapes -----------------------------------------------
99
100/// Nested `transport_time_ms` object (plan §2.1 / §2.2). The server flattens
101/// these into the per-transport `*_time_ms` columns. `none` is a real bucket in
102/// the rollup ("no transport currently selected") but has no column in
103/// `raw_session_summary`, so the session-summary insert ignores it.
104#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105pub struct TransportTimeMs {
106    pub ble: Option<i32>,
107    pub wifi_direct: Option<i32>,
108    pub internet: Option<i32>,
109    pub none: Option<i32>,
110}
111
112/// Nested `escalation_reasons` object on `mesh_session_summary` (plan §2.2).
113/// Flattened into the `escalation_*` columns server-side.
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115pub struct EscalationReasons {
116    pub poor_signal: Option<i32>,
117    pub low_success_rate: Option<i32>,
118    pub retry_threshold: Option<i32>,
119    pub congestion: Option<i32>,
120    pub low_ttl: Option<i32>,
121    pub current_unavailable: Option<i32>,
122}
123
124// --- Per-type `data` payloads ------------------------------------------------
125
126/// `mesh_metrics_rollup` (plan §2.1, Appendix A). Wire→column renames:
127/// `retry_queue_critical_count_max`→`retry_queue_critical_max`, the
128/// `sends_*_sum`/`bytes_*_sum` suffixes are dropped, and `transport_time_ms`
129/// is flattened. `bucket_start_ms` is on the wire but maps to the row `ts`
130/// (taken from the envelope's `ts_ms`), so it has no field here.
131#[derive(Debug, Clone, Default, Serialize, Deserialize)]
132pub struct MetricsRollupData {
133    pub bucket_duration_s: Option<i16>,
134    pub neighbor_count_avg: Option<f32>,
135    pub neighbor_count_max: Option<i32>,
136    pub neighbor_count_p50: Option<i32>,
137    pub ack_pending_avg: Option<f32>,
138    pub ack_pending_max: Option<i32>,
139    pub retry_queue_total_avg: Option<f32>,
140    pub retry_queue_total_max: Option<i32>,
141    #[serde(rename = "retry_queue_critical_count_max")]
142    pub retry_queue_critical_max: Option<i32>,
143    pub transport_time_ms: Option<TransportTimeMs>,
144    pub is_local_relay_duration_s: Option<i32>,
145    pub current_transport_at_bucket_end: Option<String>,
146    #[serde(rename = "sends_attempted_sum")]
147    pub sends_attempted: Option<i32>,
148    #[serde(rename = "sends_succeeded_sum")]
149    pub sends_succeeded: Option<i32>,
150    #[serde(rename = "sends_failed_sum")]
151    pub sends_failed: Option<i32>,
152    #[serde(rename = "bytes_sent_sum")]
153    pub bytes_sent: Option<i64>,
154    #[serde(rename = "bytes_received_sum")]
155    pub bytes_received: Option<i64>,
156}
157
158/// `protocol.message.delivered` — `{ latency_ms, hop_count, transport }`.
159#[derive(Debug, Clone, Default, Serialize, Deserialize)]
160pub struct MessageDeliveredData {
161    pub latency_ms: Option<i32>,
162    pub hop_count: Option<i16>,
163    pub transport: Option<String>,
164}
165
166/// `protocol.message.{failed,deferred}` — `{ reason_class, retry_count }`.
167/// Both events share this shape (plan §2.3); the `reason_class` enum is
168/// classified client-side (Appendix B — 21 buckets at SDK 0.24.1, the
169/// original twelve retained so older rows keep their meaning) and arrives as
170/// a string. The column is free `TEXT` with no CHECK, so a client that grows
171/// a bucket needs no migration here.
172#[derive(Debug, Clone, Default, Serialize, Deserialize)]
173pub struct ReasonRetryData {
174    pub reason_class: Option<String>,
175    pub retry_count: Option<i16>,
176    /// The SDK's own fixed reason token or literal, verbatim. Additive over
177    /// wire v1: the SDK sends it in place of classifying on the device, and
178    /// `reason_class` is derived from it server-side. Absent from batches
179    /// produced by the earlier TypeScript client, so it stays optional.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub reason: Option<String>,
182}
183
184/// `protocol.message.relayed` — `{ hop_count, remaining_ttl }`.
185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
186pub struct MessageRelayedData {
187    pub hop_count: Option<i16>,
188    pub remaining_ttl: Option<i16>,
189}
190
191/// `protocol.relay.promoted` — `{ connection_count, battery_level }`.
192/// Emitted from the `RelayActivity::Began` arm of `evaluate_relay_role`
193/// (SDK PR #110, shipped in 0.11.0 — the release that introduced telemetry,
194/// so no SDK the client supports lacks it).
195#[derive(Debug, Clone, Default, Serialize, Deserialize)]
196pub struct RelayPromotedData {
197    pub connection_count: Option<i16>,
198    pub battery_level: Option<i16>,
199}
200
201/// `protocol.relay.demoted` — `{ reason_class }`. Emitted from
202/// `evaluate_relay_role` (SDK ≥ 0.11.0): the configuration gate and the
203/// `RelayActivity::Ceased` arm. Those two literals are the whole vocabulary —
204/// the anticipated `battery_low`/`role_lost`/`shutdown` were never written
205/// (plan Appendix B).
206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207pub struct RelayDemotedData {
208    pub reason_class: Option<String>,
209    /// The SDK's demotion literal, verbatim; see [`ReasonRetryData::reason`].
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub reason: Option<String>,
212}
213
214/// `protocol.relay.demoted_battery` — `{ battery_level, min_required }`.
215/// Emitted from the battery gate in `evaluate_relay_role` (SDK ≥ 0.11.0).
216/// Battery demotion is its own typed event, which is why it never reaches
217/// the `reason_class` classifier.
218#[derive(Debug, Clone, Default, Serialize, Deserialize)]
219pub struct RelayDemotedBatteryData {
220    pub battery_level: Option<i16>,
221    pub min_required: Option<i16>,
222}
223
224/// `protocol.routing.decision` — `{ phase, from?, to?, winning_score?,
225/// reason_code? }`. Wire `from`/`to` map to the `from_transport`/`to_transport`
226/// columns; the optional `scores[]` array (only present under
227/// `routing_diagnostic`) has no column and is ignored.
228#[derive(Debug, Clone, Default, Serialize, Deserialize)]
229pub struct RoutingDecisionData {
230    pub phase: Option<String>,
231    #[serde(rename = "from")]
232    pub from_transport: Option<String>,
233    #[serde(rename = "to")]
234    pub to_transport: Option<String>,
235    pub winning_score: Option<f32>,
236    pub reason_code: Option<String>,
237}
238
239/// `protocol.transport.state_changed` — `{ transport, previous, current }`.
240/// Wire `previous`/`current` map to the `previous_status`/`current_status`
241/// columns (per-transport Available/Unavailable status, not selection).
242#[derive(Debug, Clone, Default, Serialize, Deserialize)]
243pub struct TransportStateData {
244    pub transport: Option<String>,
245    #[serde(rename = "previous")]
246    pub previous_status: Option<String>,
247    #[serde(rename = "current")]
248    pub current_status: Option<String>,
249}
250
251/// `protocol.device.capability_changed` — `{ battery_level?, is_charging,
252/// relay_role, changed_fields }`. `changed_fields` is the SMALLINT bitmask
253/// (`CHANGED_BATTERY=1, CHANGED_CHARGING=2, CHANGED_RELAY_ROLE=4`).
254#[derive(Debug, Clone, Default, Serialize, Deserialize)]
255pub struct DeviceCapabilityData {
256    pub battery_level: Option<i16>,
257    pub is_charging: Option<bool>,
258    pub relay_role: Option<String>,
259    pub changed_fields: Option<i16>,
260}
261
262/// `mls.decryption_failed` — `{ failure_kind, context }`. Wire `context` maps
263/// to the `mls_context` column. All IDs are stripped at the listener.
264#[derive(Debug, Clone, Default, Serialize, Deserialize)]
265pub struct MlsDecryptionFailedData {
266    pub failure_kind: Option<String>,
267    #[serde(rename = "context")]
268    pub mls_context: Option<String>,
269}
270
271/// `mls.session_missing` — `{ context }`. Wire `context` maps to `mls_context`.
272#[derive(Debug, Clone, Default, Serialize, Deserialize)]
273pub struct MlsSessionMissingData {
274    #[serde(rename = "context")]
275    pub mls_context: Option<String>,
276}
277
278/// `mesh_session_summary` (plan §2.2 + §2.4) — one row per session, emitted on
279/// app-background. The nested `transport_time_ms`/`escalation_reasons` objects
280/// are flattened into columns server-side; `mls_decryption_failures_by_kind`
281/// is stored as JSONB as-is.
282///
283/// The client populates six of these: `session_duration_s`,
284/// `routing_switches`, `routing_escalations`, `escalation_reasons`,
285/// `mls_session_ready_latency_p50_ms`, `mls_encryption_used_count`. Those six
286/// land in **eleven** columns — `escalation_reasons` flattens into six — of
287/// which the aggregation job reads ten and has no other source for any of
288/// them (`pipeline.rs` step 3e, `app_plane.rs`). `session_duration_s` is the
289/// eleventh and the exception: stored, but read by neither plane today.
290///
291/// That exception is load-bearing, because `session_duration_s` changed
292/// meaning in SDK 0.26.0. It was the span since the session opened, restated
293/// by every summary of that session; it is now the span since the previous
294/// summary, so a session's rows have to be summed to recover its length.
295/// Nothing reads the column yet, which is what made the change safe to make
296/// unversioned, and a reader added later must treat rows from before 0.26.0
297/// as totals rather than deltas.
298///
299/// The rest stay nullable and absent by design, not by omission: each has a
300/// second source the job already reads (`mesh_metrics_rollup` or a typed
301/// `raw_*` table), so a value here would duplicate a populated column rather
302/// than fill an empty one.
303#[derive(Debug, Clone, Default, Serialize, Deserialize)]
304pub struct SessionSummaryData {
305    pub session_duration_s: Option<i32>,
306    pub delivery_latency_p50_ms: Option<i32>,
307    pub delivery_latency_p99_ms: Option<i32>,
308    pub messages_sent: Option<i32>,
309    pub messages_delivered: Option<i32>,
310    pub messages_failed: Option<i32>,
311    pub transport_time_ms: Option<TransportTimeMs>,
312    pub routing_switches: Option<i32>,
313    pub routing_escalations: Option<i32>,
314    pub escalation_reasons: Option<EscalationReasons>,
315    pub mls_decryption_failures_by_kind: Option<Value>,
316    pub mls_session_ready_latency_p50_ms: Option<i32>,
317    pub mls_encryption_used_count: Option<i32>,
318    pub relay_duration_s: Option<i32>,
319    pub relay_battery_at_demote: Option<Vec<i16>>,
320    pub battery_start: Option<i16>,
321    pub battery_end: Option<i16>,
322}
323
324// --- The typed event ---------------------------------------------------------
325
326/// A classified, schema-validated event ready to route to its hypertable.
327///
328/// One variant per typed `raw_*` table, plus [`Event::Extension`] for any
329/// `type` the server doesn't recognise — the forward-compat catchall that
330/// keeps a new SDK event from failing the batch (plan §2.6).
331#[derive(Debug, Clone)]
332pub enum Event {
333    MetricsRollup(MetricsRollupData),
334    MessageDelivered(MessageDeliveredData),
335    MessageFailed(ReasonRetryData),
336    MessageDeferred(ReasonRetryData),
337    MessageRelayed(MessageRelayedData),
338    RelayPromoted(RelayPromotedData),
339    RelayDemoted(RelayDemotedData),
340    RelayDemotedBattery(RelayDemotedBatteryData),
341    RoutingDecision(RoutingDecisionData),
342    TransportState(TransportStateData),
343    DeviceCapability(DeviceCapabilityData),
344    MlsDecryptionFailed(MlsDecryptionFailedData),
345    MlsSessionMissing(MlsSessionMissingData),
346    SessionSummary(SessionSummaryData),
347    /// Unknown `type` — stored verbatim in `raw_extension_events`. `ext_name`
348    /// is the wire `type` string; `payload` is the untouched `data`.
349    Extension {
350        ext_name: String,
351        payload: Value,
352    },
353}
354
355/// Deserialize `data` into `T`, tagging the error with the event type so a
356/// per-event rejection reason names which event and field failed.
357///
358/// Deserializes straight from `&Value` (which implements `Deserializer`)
359/// rather than `serde_json::from_value(data.clone())` — on the hot ingest
360/// path that avoids cloning the entire `data` JSON (including fields with no
361/// column, like a routing `scores[]` array) just to throw the clone away
362/// after the typed struct is built. The `DeserializeOwned` bound still holds:
363/// it is `for<'de> Deserialize<'de>`, so it satisfies the borrowed `'de` of
364/// `&Value`.
365fn parse<T: DeserializeOwned>(event_type: &str, data: &Value) -> Result<T, String> {
366    T::deserialize(data).map_err(|e| format!("{event_type}: {e}"))
367}
368
369/// Classify a wire `(type, data)` pair into a typed [`Event`].
370///
371/// Known types are validated against their `*Data` schema; an `Err` is a
372/// per-event rejection reason (the batch still succeeds for the rest, per
373/// plan §7.5). Unknown types never error — they become [`Event::Extension`].
374pub fn classify(event_type: &str, data: &Value) -> Result<Event, String> {
375    Ok(match event_type {
376        TYPE_METRICS_ROLLUP => Event::MetricsRollup(parse(event_type, data)?),
377        TYPE_SESSION_SUMMARY => Event::SessionSummary(parse(event_type, data)?),
378        TYPE_MESSAGE_DELIVERED => Event::MessageDelivered(parse(event_type, data)?),
379        TYPE_MESSAGE_FAILED => Event::MessageFailed(parse(event_type, data)?),
380        TYPE_MESSAGE_DEFERRED => Event::MessageDeferred(parse(event_type, data)?),
381        TYPE_MESSAGE_RELAYED => Event::MessageRelayed(parse(event_type, data)?),
382        TYPE_RELAY_PROMOTED => Event::RelayPromoted(parse(event_type, data)?),
383        TYPE_RELAY_DEMOTED => Event::RelayDemoted(parse(event_type, data)?),
384        TYPE_RELAY_DEMOTED_BATTERY => Event::RelayDemotedBattery(parse(event_type, data)?),
385        TYPE_ROUTING_DECISION => Event::RoutingDecision(parse(event_type, data)?),
386        TYPE_TRANSPORT_STATE => Event::TransportState(parse(event_type, data)?),
387        TYPE_DEVICE_CAPABILITY => Event::DeviceCapability(parse(event_type, data)?),
388        TYPE_MLS_DECRYPTION_FAILED => Event::MlsDecryptionFailed(parse(event_type, data)?),
389        TYPE_MLS_SESSION_MISSING => Event::MlsSessionMissing(parse(event_type, data)?),
390        other => Event::Extension {
391            ext_name: other.to_string(),
392            payload: data.clone(),
393        },
394    })
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use serde_json::json;
401
402    #[test]
403    fn classifies_metrics_rollup_with_renames_and_nesting() {
404        let data = json!({
405            "bucket_start_ms": 1_747_000_000_000_i64,
406            "bucket_duration_s": 60,
407            "neighbor_count_avg": 4.5,
408            "neighbor_count_max": 9,
409            "neighbor_count_p50": 4,
410            "ack_pending_avg": 1.25,
411            "ack_pending_max": 3,
412            "retry_queue_total_avg": 2.0,
413            "retry_queue_total_max": 5,
414            "retry_queue_critical_count_max": 1,
415            "transport_time_ms": { "ble": 40000, "wifi_direct": 15000, "internet": 5000, "none": 0 },
416            "is_local_relay_duration_s": 12,
417            "current_transport_at_bucket_end": "ble",
418            "sends_attempted_sum": 100,
419            "sends_succeeded_sum": 98,
420            "sends_failed_sum": 2,
421            "bytes_sent_sum": 5_000_000_000_i64,
422            "bytes_received_sum": 6_000_000_000_i64
423        });
424        let Event::MetricsRollup(d) = classify(TYPE_METRICS_ROLLUP, &data).unwrap() else {
425            panic!("expected MetricsRollup");
426        };
427        assert_eq!(d.retry_queue_critical_max, Some(1));
428        assert_eq!(d.sends_attempted, Some(100));
429        assert_eq!(d.bytes_received, Some(6_000_000_000));
430        let t = d.transport_time_ms.unwrap();
431        assert_eq!(t.ble, Some(40000));
432        assert_eq!(t.none, Some(0));
433        assert_eq!(d.current_transport_at_bucket_end.as_deref(), Some("ble"));
434    }
435
436    #[test]
437    fn classifies_routing_decision_from_to_renames() {
438        let data = json!({
439            "phase": "select",
440            "from": "ble",
441            "to": "internet",
442            "winning_score": 0.87,
443            "reason_code": "poor_signal",
444            "scores": [0.1, 0.87]  // no column — must be ignored, not rejected
445        });
446        let Event::RoutingDecision(d) = classify(TYPE_ROUTING_DECISION, &data).unwrap() else {
447            panic!("expected RoutingDecision");
448        };
449        assert_eq!(d.from_transport.as_deref(), Some("ble"));
450        assert_eq!(d.to_transport.as_deref(), Some("internet"));
451    }
452
453    #[test]
454    fn classifies_mls_context_rename() {
455        let data = json!({ "context": "receive" });
456        let Event::MlsSessionMissing(d) = classify(TYPE_MLS_SESSION_MISSING, &data).unwrap() else {
457            panic!("expected MlsSessionMissing");
458        };
459        assert_eq!(d.mls_context.as_deref(), Some("receive"));
460    }
461
462    #[test]
463    fn transport_state_status_renames() {
464        let data = json!({ "transport": "ble", "previous": "available", "current": "unavailable" });
465        let Event::TransportState(d) = classify(TYPE_TRANSPORT_STATE, &data).unwrap() else {
466            panic!("expected TransportState");
467        };
468        assert_eq!(d.previous_status.as_deref(), Some("available"));
469        assert_eq!(d.current_status.as_deref(), Some("unavailable"));
470    }
471
472    #[test]
473    fn empty_data_object_is_all_none_not_rejected() {
474        // Absent optional fields are valid — the columns are nullable.
475        let Event::MessageDelivered(d) = classify(TYPE_MESSAGE_DELIVERED, &json!({})).unwrap()
476        else {
477            panic!("expected MessageDelivered");
478        };
479        assert!(d.latency_ms.is_none() && d.hop_count.is_none() && d.transport.is_none());
480    }
481
482    #[test]
483    fn out_of_range_smallint_is_rejected() {
484        // hop_count is SMALLINT (i16, max 32767). A larger value must reject
485        // the event at classify time so it never reaches the bulk INSERT.
486        let data = json!({ "hop_count": 40000 });
487        let err = classify(TYPE_MESSAGE_DELIVERED, &data).unwrap_err();
488        assert!(
489            err.contains(TYPE_MESSAGE_DELIVERED),
490            "reason names the type: {err}"
491        );
492    }
493
494    #[test]
495    fn wrong_field_type_is_rejected() {
496        // latency_ms must be a number, not a string.
497        let data = json!({ "latency_ms": "fast" });
498        assert!(classify(TYPE_MESSAGE_DELIVERED, &data).is_err());
499    }
500
501    #[test]
502    fn non_object_data_for_known_type_is_rejected() {
503        assert!(classify(TYPE_MESSAGE_FAILED, &Value::Null).is_err());
504        assert!(classify(TYPE_MESSAGE_FAILED, &json!("nope")).is_err());
505    }
506
507    #[test]
508    fn unknown_type_falls_through_to_extension() {
509        let data = json!({ "anything": 1 });
510        let Event::Extension { ext_name, payload } =
511            classify("protocol.brand.new_event", &data).unwrap()
512        else {
513            panic!("expected Extension");
514        };
515        assert_eq!(ext_name, "protocol.brand.new_event");
516        assert_eq!(payload, data);
517    }
518
519    #[test]
520    fn failed_and_deferred_share_reason_retry_shape() {
521        let data = json!({ "reason_class": "retries_exhausted", "retry_count": 3 });
522        assert!(matches!(
523            classify(TYPE_MESSAGE_FAILED, &data).unwrap(),
524            Event::MessageFailed(_)
525        ));
526        assert!(matches!(
527            classify(TYPE_MESSAGE_DEFERRED, &data).unwrap(),
528            Event::MessageDeferred(_)
529        ));
530    }
531
532    #[test]
533    fn session_summary_partial_payload() {
534        // A partial payload is legal at every field: an older client (or one
535        // whose session saw no routing) sends a subset, and every field is
536        // `Option`. The full set the current client emits is pinned by
537        // `tests/wire_contract.rs` against the shared fixture.
538        let data = json!({ "session_duration_s": 312, "mls_session_ready_latency_p50_ms": 88 });
539        let Event::SessionSummary(d) = classify(TYPE_SESSION_SUMMARY, &data).unwrap() else {
540            panic!("expected SessionSummary");
541        };
542        assert_eq!(d.session_duration_s, Some(312));
543        assert_eq!(d.mls_session_ready_latency_p50_ms, Some(88));
544        assert!(d.messages_sent.is_none());
545    }
546
547    #[test]
548    fn negative_value_within_column_range_is_accepted() {
549        // Contract pin (see module doc "Negative values are accepted within the
550        // column range"): the signed columns have no CHECK (>= 0), so a
551        // logically-impossible negative is in-range for SMALLINT and is
552        // accepted, not rejected. Sanitising nonsense is M4's job, not the
553        // landing zone's. If this ever needs to flip to rejection, this test —
554        // and the module doc — must change together.
555        let data = json!({ "hop_count": -1, "latency_ms": -5 });
556        let Event::MessageDelivered(d) = classify(TYPE_MESSAGE_DELIVERED, &data).unwrap() else {
557            panic!("expected MessageDelivered");
558        };
559        assert_eq!(d.hop_count, Some(-1));
560        assert_eq!(d.latency_ms, Some(-5));
561    }
562
563    #[test]
564    fn smallint_min_and_max_round_trip_through_classify() {
565        // The struct width must match the SMALLINT column width exactly: i16
566        // bounds classify cleanly, one past either bound rejects (proving the
567        // type isn't accidentally wider than the column).
568        for v in [i16::MIN, i16::MAX] {
569            let data = json!({ "hop_count": v });
570            let Event::MessageDelivered(d) = classify(TYPE_MESSAGE_DELIVERED, &data).unwrap()
571            else {
572                panic!("expected MessageDelivered for hop_count={v}");
573            };
574            assert_eq!(d.hop_count, Some(v));
575        }
576        assert!(classify(
577            TYPE_MESSAGE_DELIVERED,
578            &json!({ "hop_count": i16::MAX as i64 + 1 })
579        )
580        .is_err());
581        assert!(classify(
582            TYPE_MESSAGE_DELIVERED,
583            &json!({ "hop_count": i16::MIN as i64 - 1 })
584        )
585        .is_err());
586    }
587}