Skip to main content

zerodds_durability_service/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Standalone DDS Durability-Service daemon (ADR 0009).
5//!
6//! Crate `zerodds-durability-service`. Safety classification: **STANDARD**.
7//!
8//! From an RTPS point of view the service is just another participant. For
9//! each served topic it:
10//!
11//! 1. **Ingests** via a `RELIABLE / TRANSIENT_LOCAL / KEEP_ALL` reader — it
12//!    receives every sample the application writers publish (and, thanks to
13//!    `TRANSIENT_LOCAL`, the writer history if it joins late).
14//! 2. **Stores** each sample in a [`DurabilityStore`] (sqlite/file/in-memory).
15//! 3. **Replays** via a `TRANSIENT_LOCAL / KEEP_ALL` writer — a late-joining
16//!    reader matches it and the standard RTPS path delivers the history. This
17//!    keeps working after the original writer's process has died.
18//! 4. **Startup-sync**: on [`serve`](DurabilityService::serve) the replay
19//!    writer is primed from the store, so `PERSISTENT` history survives a full
20//!    service restart with no application writer present.
21//!
22//! ## Scope (increment 1)
23//! Unkeyed topics (`RawBytes` = single instance). The replay writer assigns
24//! its own sequence numbers — a durability service is a new logical source, so
25//! the original writer's sequence numbers need not be preserved. Per-instance
26//! history for KEYED topics + exact sequence preservation need the per-sample
27//! KeyHash/sequence exposed on the reader API and are a documented follow-up.
28
29#![forbid(unsafe_code)]
30
31use std::sync::Arc;
32use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
33use std::sync::{Mutex, MutexGuard, mpsc};
34use std::thread::JoinHandle;
35use std::time::{Duration, SystemTime};
36
37use zerodds_dcps::runtime::{UserReaderConfig, UserSample, UserWriterConfig};
38use zerodds_dcps::{
39    DataReader, DataReaderQos, DataWriter, DataWriterQos, DomainParticipant,
40    DomainParticipantFactory, DomainParticipantQos, Publisher, PublisherQos, RawBytes, Subscriber,
41    SubscriberQos, Topic, TopicQos,
42};
43use zerodds_durability_store::{Contract, DurabilitySample, DurabilityStore};
44use zerodds_qos::policies::durability::DurabilityKind;
45use zerodds_qos::policies::history::HistoryKind;
46use zerodds_qos::policies::reliability::ReliabilityKind;
47use zerodds_qos::policies::resource_limits::LENGTH_UNLIMITED;
48use zerodds_qos::{
49    DeadlineQosPolicy, LifespanQosPolicy, LivelinessKind, LivelinessQosPolicy, OwnershipKind,
50};
51
52/// Errors from the durability daemon.
53#[derive(Debug)]
54pub enum ServiceError {
55    /// A DCPS/RTPS operation failed.
56    Dcps(zerodds_dcps::DdsError),
57    /// A storage operation failed.
58    Store(zerodds_durability_store::StoreError),
59    /// An internal lock was poisoned.
60    Poisoned(&'static str),
61}
62
63impl core::fmt::Display for ServiceError {
64    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
65        match self {
66            Self::Dcps(e) => write!(f, "durability service: dcps: {e}"),
67            Self::Store(e) => write!(f, "durability service: store: {e}"),
68            Self::Poisoned(w) => write!(f, "durability service: poisoned: {w}"),
69        }
70    }
71}
72impl core::error::Error for ServiceError {}
73impl From<zerodds_dcps::DdsError> for ServiceError {
74    fn from(e: zerodds_dcps::DdsError) -> Self {
75        Self::Dcps(e)
76    }
77}
78impl From<zerodds_durability_store::StoreError> for ServiceError {
79    fn from(e: zerodds_durability_store::StoreError) -> Self {
80        Self::Store(e)
81    }
82}
83
84/// Result alias.
85pub type Result<T> = core::result::Result<T, ServiceError>;
86
87/// Reader QoS for ingest: receive every sample reliably, **including a matched
88/// writer's TransientLocal history** (O2 P5). A service that starts after a
89/// writer published now back-fetches that writer's history. The history+live
90/// overlap — and a service restart that re-receives the same history — are
91/// deduped by the pump on `(writer_guid, source_sequence_number)`, which
92/// `UserSample::Alive` now carries and the store persists; without that dedup
93/// this had to be `VOLATILE` to avoid double-storing.
94fn ingest_reader_qos() -> DataReaderQos {
95    let mut q = DataReaderQos::default();
96    q.reliability.kind = ReliabilityKind::Reliable;
97    q.durability.kind = DurabilityKind::TransientLocal;
98    q.history.kind = HistoryKind::KeepAll;
99    q.resource_limits.max_samples = LENGTH_UNLIMITED;
100    q
101}
102
103/// Writer QoS for replay: hold the full history and deliver it to late-joiners.
104fn replay_writer_qos() -> DataWriterQos {
105    let mut q = DataWriterQos::default();
106    q.reliability.kind = ReliabilityKind::Reliable;
107    q.durability.kind = DurabilityKind::TransientLocal;
108    q.history.kind = HistoryKind::KeepAll;
109    q.resource_limits.max_samples = LENGTH_UNLIMITED;
110    q
111}
112
113struct Served {
114    name: String,
115    stop: Arc<AtomicBool>,
116    handle: Option<JoinHandle<()>>,
117}
118
119/// The durability daemon. Uses TWO participants per domain — one for ingest
120/// (reader) and one for replay (writer) — that mutually **ignore each other**.
121/// This is set up before any endpoint exists, so the ingest reader can never
122/// match the replay writer (race-free echo-loop prevention). Both serve any
123/// number of TRANSIENT/PERSISTENT topics over a single [`DurabilityStore`].
124pub struct DurabilityService {
125    ingest: DomainParticipant,
126    replay: DomainParticipant,
127    publisher: Publisher,
128    subscriber: Subscriber,
129    store: Arc<dyn DurabilityStore>,
130    served: Mutex<Vec<Served>>,
131}
132
133impl DurabilityService {
134    /// Starts the daemon on `domain`, backed by `store`.
135    ///
136    /// # Errors
137    /// Participant creation failed.
138    pub fn start(domain: i32, store: Arc<dyn DurabilityStore>) -> Result<Self> {
139        let factory = DomainParticipantFactory::instance();
140        let ingest = factory.create_participant(domain, DomainParticipantQos::default())?;
141        let replay = factory.create_participant(domain, DomainParticipantQos::default())?;
142        // Race-free: mutual participant ignore BEFORE any reader/writer exists,
143        // so the ingest reader and replay writer never match each other. Use the
144        // GUID-derived `participant_handle()` (the discovery/ignore key space),
145        // NOT `instance_handle()` (a local allocator counter that the ignore
146        // filter never sees).
147        let _ = ingest.ignore_participant(replay.participant_handle());
148        let _ = replay.ignore_participant(ingest.participant_handle());
149        let publisher = replay.create_publisher(PublisherQos::default());
150        let subscriber = ingest.create_subscriber(SubscriberQos::default());
151        Ok(Self {
152            ingest,
153            replay,
154            publisher,
155            subscriber,
156            store,
157            served: Mutex::new(Vec::new()),
158        })
159    }
160
161    fn served(&self) -> Result<MutexGuard<'_, Vec<Served>>> {
162        self.served
163            .lock()
164            .map_err(|_| ServiceError::Poisoned("served topics"))
165    }
166
167    /// Begins serving `topic_name` with the given retention `contract`. Creates
168    /// the ingest reader + replay writer, primes the writer from the store
169    /// (startup-sync), and spawns the ingest pump.
170    ///
171    /// # Errors
172    /// Topic/reader/writer creation or the initial store/replay failed.
173    pub fn serve(&self, topic_name: &str, contract: Contract) -> Result<()> {
174        self.store.set_contract(topic_name, contract)?;
175
176        // Topics are per-participant: the reader's on the ingest participant,
177        // the writer's on the replay participant.
178        let rtopic: Topic<RawBytes> = self
179            .ingest
180            .create_topic::<RawBytes>(topic_name, TopicQos::default())?;
181        let wtopic: Topic<RawBytes> = self
182            .replay
183            .create_topic::<RawBytes>(topic_name, TopicQos::default())?;
184        let writer = self
185            .publisher
186            .create_datawriter::<RawBytes>(&wtopic, replay_writer_qos())?;
187        let reader = self
188            .subscriber
189            .create_datareader::<RawBytes>(&rtopic, ingest_reader_qos())?;
190
191        // Startup-sync: prime the replay writer's history from durable storage.
192        for sample in self.store.replay_for_topic(topic_name)? {
193            writer.write(&RawBytes::new(sample.payload))?;
194        }
195
196        let stop = Arc::new(AtomicBool::new(false));
197        let pump_stop = Arc::clone(&stop);
198        let store = Arc::clone(&self.store);
199        let topic_owned = topic_name.to_string();
200        let own_pub = writer.instance_handle();
201        let seq = AtomicU64::new(self.store.stats(topic_name)?.samples as u64);
202
203        let handle = std::thread::Builder::new()
204            .name(format!("durability-pump-{topic_name}"))
205            .spawn(move || {
206                pump(
207                    &pump_stop,
208                    &reader,
209                    &writer,
210                    store.as_ref(),
211                    &topic_owned,
212                    own_pub,
213                    &seq,
214                );
215            })
216            .map_err(|_| ServiceError::Poisoned("spawn pump"))?;
217
218        self.served()?.push(Served {
219            name: topic_name.to_string(),
220            stop,
221            handle: Some(handle),
222        });
223        Ok(())
224    }
225
226    /// Like [`serve`](Self::serve) but for a topic whose application type-name
227    /// is `type_name` rather than the native `zerodds::RawBytes` — e.g. a
228    /// foreign-vendor `TRANSIENT` writer (Cyclone/FastDDS/OpenDDS) discovered on
229    /// the wire. The typed `RawBytes` topic is locked to `zerodds::RawBytes`, so
230    /// it would never match a foreign writer under `use_xtypes=no`
231    /// (match keyed on `topic_name` + `type_name`). This path uses the
232    /// **runtime-level** user-entity API so the ingest reader + replay writer
233    /// register under the *discovered* `type_name` (and the matching
234    /// keyed/no-key kind) — exactly how the byte-oriented C-FFI achieves
235    /// cross-vendor matching.
236    ///
237    /// Echo is avoided structurally: the ingest reader lives on the `ingest`
238    /// participant and the replay writer on the `replay` participant, which
239    /// mutually `ignore_participant` each other (set in [`start`](Self::start)).
240    ///
241    /// `keyed` must match the foreign writer's key kind (RTPS entityKind, Spec
242    /// §9.3.1.2). Increment-1 scope is unkeyed; `false` is the safe default.
243    ///
244    /// # Errors
245    /// Participant runtime unavailable, reader/writer registration failed, or
246    /// the initial store read / replay-prime failed.
247    pub fn serve_typed(
248        &self,
249        topic_name: &str,
250        type_name: &str,
251        keyed: bool,
252        contract: Contract,
253    ) -> Result<()> {
254        self.store.set_contract(topic_name, contract)?;
255
256        let irt = self
257            .ingest
258            .runtime()
259            .ok_or(ServiceError::Poisoned("ingest runtime"))?
260            .clone();
261        let rrt = self
262            .replay
263            .runtime()
264            .ok_or(ServiceError::Poisoned("replay runtime"))?
265            .clone();
266
267        let weid = rrt
268            .register_user_writer_kind(user_writer_cfg(topic_name, type_name), keyed)
269            .map_err(|_| ServiceError::Poisoned("register replay writer"))?;
270        let (_reid, rx) = irt
271            .register_user_reader_kind(user_reader_cfg(topic_name, type_name), keyed)
272            .map_err(|_| ServiceError::Poisoned("register ingest reader"))?;
273
274        // Startup-sync: prime the replay writer's history from durable storage.
275        // Each stored sample carries its own representation + byte order; set
276        // the writer overrides so the primed replay re-declares the original
277        // wire (a big-endian peer's persisted sample replays with a BE encap).
278        for sample in self.store.replay_for_topic(topic_name)? {
279            let off: i16 = if sample.representation == 1 { 2 } else { 0 };
280            let _ = rrt.set_user_writer_data_rep_override(weid, Some(vec![off]));
281            let _ = rrt.set_user_writer_byte_order_override(weid, sample.big_endian);
282            let _ = rrt.write_user_sample_borrowed(weid, &sample.payload);
283        }
284
285        let stop = Arc::new(AtomicBool::new(false));
286        let pump_stop = Arc::clone(&stop);
287        let store = Arc::clone(&self.store);
288        let topic_owned = topic_name.to_string();
289        let pump_rrt = Arc::clone(&rrt);
290        let seq = AtomicU64::new(self.store.stats(topic_name)?.samples as u64);
291
292        let handle = std::thread::Builder::new()
293            .name(format!("durability-pump-{topic_name}"))
294            .spawn(move || {
295                // Representation-faithful replay: the ingested `payload` is the
296                // CDR body in the SOURCE writer's representation (a foreign
297                // FINAL-type writer emits XCDR1). The replay writer must declare
298                // an encap that matches that body, else a strict foreign reader
299                // misparses an alignment-sensitive type. We track the source
300                // representation and override the replay writer's encap when it
301                // changes (a topic is representation-consistent, so this is
302                // effectively a one-time set). 255 = "not yet observed".
303                let mut last_rep: u8 = 255;
304                // Tracks the last byte order pushed to the replay writer's encap
305                // override, so a BE peer's samples replay with a BE encap header.
306                let mut last_be = false;
307                // Seed the source-identity dedup set from what is already
308                // durable, so a restart's re-received TransientLocal history is
309                // recognised and not re-stored.
310                let mut seen: std::collections::HashSet<([u8; 16], i64)> = store
311                    .replay_for_topic(&topic_owned)
312                    .map(|v| {
313                        v.into_iter()
314                            .filter(|s| s.source_sequence >= 0)
315                            .map(|s| (s.source_guid, s.source_sequence))
316                            .collect()
317                    })
318                    .unwrap_or_default();
319                // Event-driven: block on the reader's channel (no busy-poll),
320                // waking on each sample or the 200ms stop-flag re-check.
321                while !pump_stop.load(Ordering::Relaxed) {
322                    let sample = match rx.recv_timeout(Duration::from_millis(200)) {
323                        Ok(s) => s,
324                        Err(mpsc::RecvTimeoutError::Timeout) => continue,
325                        Err(mpsc::RecvTimeoutError::Disconnected) => break,
326                    };
327                    let (payload, representation, big_endian, writer_guid, source_seq) =
328                        match sample {
329                            UserSample::Alive {
330                                payload,
331                                representation,
332                                big_endian,
333                                writer_guid,
334                                source_sequence_number,
335                                ..
336                            } => (
337                                payload.to_vec(),
338                                representation,
339                                big_endian,
340                                writer_guid,
341                                source_sequence_number,
342                            ),
343                            UserSample::Lifecycle { .. } => continue,
344                        };
345                    // O2 P5 dedup: a wire-delivered sample carries its source
346                    // identity (writer_guid, source_seq). Skip one already
347                    // stored — this makes a TransientLocal ingest reader safe
348                    // (history+live overlap, and a service restart that
349                    // re-receives a writer's history) without duplicating.
350                    if source_seq >= 0 && !seen.insert((writer_guid, source_seq)) {
351                        continue;
352                    }
353                    if representation != last_rep {
354                        // UserSample.representation: 0 = XCDR1, 1 = XCDR2 →
355                        // data_representation offer id 0 (XCDR) / 2 (XCDR2).
356                        let off: i16 = if representation == 1 { 2 } else { 0 };
357                        let _ = pump_rrt.set_user_writer_data_rep_override(weid, Some(vec![off]));
358                        last_rep = representation;
359                    }
360                    if big_endian != last_be {
361                        // The stored body bytes are big-endian for a BE peer, so
362                        // the replay writer must emit a matching `_BE` encap
363                        // header — otherwise the late-joiner mis-decodes.
364                        let _ = pump_rrt.set_user_writer_byte_order_override(weid, big_endian);
365                        last_be = big_endian;
366                    }
367                    let ds = DurabilitySample {
368                        topic: topic_owned.clone(),
369                        instance_key: [0u8; 16], // unkeyed (increment 1)
370                        sequence: seq.fetch_add(1, Ordering::Relaxed),
371                        payload: payload.clone(),
372                        representation,
373                        big_endian,
374                        created_at: SystemTime::now(),
375                        source_guid: writer_guid,
376                        source_sequence: source_seq,
377                    };
378                    if store.store(ds).is_ok() {
379                        // Grow the replay writer's history for future late-joiners.
380                        let _ = pump_rrt.write_user_sample_borrowed(weid, &payload);
381                    }
382                }
383            })
384            .map_err(|_| ServiceError::Poisoned("spawn pump"))?;
385
386        self.served()?.push(Served {
387            name: topic_name.to_string(),
388            stop,
389            handle: Some(handle),
390        });
391        Ok(())
392    }
393
394    /// Topics currently served.
395    ///
396    /// # Errors
397    /// Lock poisoned.
398    pub fn served_topics(&self) -> Result<Vec<String>> {
399        Ok(self.served()?.iter().map(|s| s.name.clone()).collect())
400    }
401
402    /// Auto-serve any topic whose discovered writers declare a durability of
403    /// `TRANSIENT` or `PERSISTENT` — observed via the standard `DCPSPublication`
404    /// builtin reader. This needs no application-side code: apps simply set
405    /// their `DurabilityQosPolicy`, and the service picks the topic up. Topics
406    /// use `default_contract` (the `DurabilityServiceQosPolicy` is not carried
407    /// in discovery; a per-topic override would come from service config).
408    ///
409    /// # Errors
410    /// Spawning the discovery thread failed.
411    pub fn enable_auto_discovery(self: &Arc<Self>, default_contract: Contract) -> Result<()> {
412        let reader = self.ingest.get_builtin_subscriber().publication_reader();
413        let me = Arc::clone(self);
414        let stop = Arc::new(AtomicBool::new(false));
415        let pstop = Arc::clone(&stop);
416        let handle = std::thread::Builder::new()
417            .name("durability-autodiscover".to_string())
418            .spawn(move || {
419                while !pstop.load(Ordering::Relaxed) {
420                    let _ = reader.wait_for_data(Duration::from_millis(300));
421                    let Ok(pubs) = reader.take_with_info() else {
422                        continue;
423                    };
424                    for s in pubs {
425                        if !s.info.valid_data || s.data.durability < DurabilityKind::Transient {
426                            continue;
427                        }
428                        let topic = s.data.topic_name;
429                        let type_name = s.data.type_name;
430                        let already = me
431                            .served_topics()
432                            .map(|v| v.iter().any(|t| t == &topic))
433                            .unwrap_or(true);
434                        if !already {
435                            // Runtime-level path keyed on the *discovered* type
436                            // name → matches foreign-vendor writers too (the
437                            // typed RawBytes topic is locked to
438                            // `zerodds::RawBytes`). Increment-1 scope: unkeyed.
439                            let _ = me.serve_typed(&topic, &type_name, false, default_contract);
440                        }
441                    }
442                }
443            })
444            .map_err(|_| ServiceError::Poisoned("spawn auto-discovery"))?;
445        self.served()?.push(Served {
446            name: "<auto-discovery>".to_string(),
447            stop,
448            handle: Some(handle),
449        });
450        Ok(())
451    }
452
453    /// Stops all pumps + the auto-discovery thread and tears the daemon down.
454    /// Takes `&self` (not `self`) so it is callable on an `Arc<DurabilityService>`
455    /// even while the auto-discovery thread still holds a clone. Drains the join
456    /// handles under the lock, then joins WITHOUT holding it (the auto-discovery
457    /// thread may itself be inside `serve` taking the lock).
458    pub fn shutdown(&self) {
459        let handles: Vec<JoinHandle<()>> = {
460            let Ok(mut served) = self.served.lock() else {
461                return;
462            };
463            for s in served.iter() {
464                s.stop.store(true, Ordering::Relaxed);
465            }
466            served.iter_mut().filter_map(|s| s.handle.take()).collect()
467        };
468        for h in handles {
469            let _ = h.join();
470        }
471    }
472}
473
474/// Ingest pump: receive → store → replay-write, until `stop`.
475fn pump(
476    stop: &AtomicBool,
477    reader: &DataReader<RawBytes>,
478    writer: &DataWriter<RawBytes>,
479    store: &dyn DurabilityStore,
480    topic: &str,
481    own_pub: zerodds_dcps::InstanceHandle,
482    seq: &AtomicU64,
483) {
484    while !stop.load(Ordering::Relaxed) {
485        // Block until data or a short timeout (re-checks the stop flag).
486        let _ = reader.wait_for_data(Duration::from_millis(200));
487        let samples = match reader.take_with_info() {
488            Ok(s) => s,
489            Err(_) => continue,
490        };
491        for sample in samples {
492            if !sample.info.valid_data {
493                continue; // lifecycle marker, no payload
494            }
495            // Skip our own replay echo (defence in depth beyond ignore_publication).
496            if sample.info.publication_handle == own_pub {
497                continue;
498            }
499            let payload = sample.data.data;
500            let ds = DurabilitySample {
501                topic: topic.to_string(),
502                instance_key: [0u8; 16], // unkeyed (increment 1)
503                sequence: seq.fetch_add(1, Ordering::Relaxed),
504                payload: payload.clone(),
505                // The high-level `DataReader<RawBytes>` SampleInfo does not
506                // surface the wire representation / byte order, so this DDS-API
507                // ingest path assumes the canonical XCDR2 little-endian wire.
508                // For a big-endian peer use `serve_typed` (the runtime-level
509                // UserSample path), which captures both and replays them.
510                representation: 1,
511                big_endian: false,
512                created_at: SystemTime::now(),
513                // The high-level DataReader<RawBytes> path surfaces no source
514                // identity, so this path does not participate in P5 dedup.
515                source_guid: [0u8; 16],
516                source_sequence: -1,
517            };
518            if store.store(ds).is_ok() {
519                // Grow the replay writer's history so future late-joiners get it.
520                let _ = writer.write(&RawBytes::new(payload));
521            }
522        }
523    }
524}
525
526/// Runtime-level ingest-reader config mirroring [`ingest_reader_qos`]
527/// (RELIABLE + VOLATILE) but with an explicit `type_name` for cross-vendor
528/// matching. KEEP_ALL history is the reader default at the runtime level.
529fn user_reader_cfg(topic_name: &str, type_name: &str) -> UserReaderConfig {
530    UserReaderConfig {
531        topic_name: topic_name.to_string(),
532        type_name: type_name.to_string(),
533        reliable: true,
534        durability: DurabilityKind::Volatile,
535        deadline: DeadlineQosPolicy::default(),
536        liveliness: LivelinessQosPolicy {
537            kind: LivelinessKind::Automatic,
538            ..Default::default()
539        },
540        ownership: OwnershipKind::Shared,
541        partition: Vec::new(),
542        user_data: Vec::new(),
543        topic_data: Vec::new(),
544        group_data: Vec::new(),
545        // Cross-vendor byte-path: no local TypeObject (matched by topic+name).
546        type_identifier: zerodds_types::TypeIdentifier::None,
547        type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
548        // Accept BOTH XCDR1 (0) and XCDR2 (2): a foreign-vendor writer of a
549        // FINAL-extensibility type offers only XCDR1 (verified live against
550        // CycloneDDS — a final `dur::Sample` writer advertises
551        // data_representation=0). An XCDR2-only reader is RxO-incompatible and
552        // never matches it. A durability service must ingest whatever
553        // representation the source emits, so it offers both.
554        data_representation_offer: Some(vec![0, 2]),
555    }
556}
557
558/// Runtime-level replay-writer config mirroring [`replay_writer_qos`]
559/// (RELIABLE + TRANSIENT_LOCAL/KEEP_ALL) with an explicit `type_name`.
560fn user_writer_cfg(topic_name: &str, type_name: &str) -> UserWriterConfig {
561    UserWriterConfig {
562        topic_name: topic_name.to_string(),
563        type_name: type_name.to_string(),
564        reliable: true,
565        durability: DurabilityKind::TransientLocal,
566        deadline: DeadlineQosPolicy::default(),
567        lifespan: LifespanQosPolicy::default(),
568        liveliness: LivelinessQosPolicy {
569            kind: LivelinessKind::Automatic,
570            ..Default::default()
571        },
572        ownership: OwnershipKind::Shared,
573        ownership_strength: 0,
574        partition: Vec::new(),
575        user_data: Vec::new(),
576        topic_data: Vec::new(),
577        group_data: Vec::new(),
578        type_identifier: zerodds_types::TypeIdentifier::None,
579        data_representation_offer: None,
580    }
581}