Skip to main content

qualia_core_db/p2p/
protocol.rs

1use async_trait::async_trait;
2use libp2p::futures::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
3use libp2p::request_response::Codec;
4use libp2p::StreamProtocol;
5use serde::{Deserialize, Serialize};
6use std::io;
7use std::sync::Arc;
8
9#[cfg(not(target_arch = "wasm32"))]
10use crate::q42_lexicon::{CborLdError, Q42CborLdParser, Q42Context, SemanticPayload};
11#[cfg(not(target_arch = "wasm32"))]
12use crate::q42_volume::Q42Volume;
13
14#[repr(C)]
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct NQuin {
17    pub subject: [u8; 8],
18    pub predicate: [u8; 8],
19    pub object: [u8; 8],
20    pub context: [u8; 8],
21    pub clock_sig: [u8; 16],
22}
23const _: () = assert!(std::mem::size_of::<NQuin>() == 48);
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub enum QualiaRequest {
27    Handshake {
28        // CBOR-LD semantic payload with Q42 lexicon resolution
29        #[serde(rename = "@context")]
30        context: String,
31        #[serde(rename = "type")]
32        request_type: String,
33        #[serde(rename = "did_q42")]
34        did_q42: u64,
35        #[serde(rename = "semantic_context")]
36        semantic_context: u64,
37        // Flattened buffer containing sequences of (48-byte Quin + 64-byte Ed25519 Signature)
38        credentials: Vec<u8>,
39    },
40    Sync {
41        // CBOR-LD semantic payload
42        #[serde(rename = "@context")]
43        context: String,
44        #[serde(rename = "type")]
45        request_type: String,
46        #[serde(rename = "did_q42")]
47        did_q42: u64,
48        hop_count: u8,
49        gatekeeper_token: Option<String>,
50        #[serde(rename = "target_shapes")]
51        target_shapes: Vec<String>,
52        #[serde(rename = "routing_constraints")]
53        routing_constraints: u8,
54    },
55}
56
57#[cfg(not(target_arch = "wasm32"))]
58impl QualiaRequest {
59    /// Convert semantic payload to QualiaRequest
60    pub fn from_semantic_payload(payload: SemanticPayload) -> Self {
61        let did_q42 = match payload.did_q42 {
62            Some(d) => crate::q_hash(&d),
63            None => 0,
64        };
65
66        // Extract semantic context hash from HashMap
67        let semantic_context = payload
68            .semantic_context
69            .get("context")
70            .map(|s| crate::q_hash(s))
71            .unwrap_or(0);
72
73        Self::Handshake {
74            context: "https://webizen.org/ld/context/v1".to_string(),
75            request_type: "Handshake".to_string(),
76            did_q42,
77            semantic_context,
78            credentials: Vec::new(), // TODO: Extract from payload
79        }
80    }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub enum QualiaResponse {
85    HandshakeAck {
86        // CBOR-LD semantic response
87        #[serde(rename = "@context")]
88        context: String,
89        #[serde(rename = "type")]
90        response_type: String,
91        success: bool,
92        #[serde(rename = "did_q42")]
93        did_q42: u64,
94        #[serde(rename = "semantic_context")]
95        semantic_context: u64,
96    },
97    SyncAck {
98        // CBOR-LD semantic response
99        #[serde(rename = "@context")]
100        context: String,
101        #[serde(rename = "type")]
102        response_type: String,
103        success: bool,
104        message: String,
105        blocks_sent: u64,
106        #[serde(rename = "did_q42")]
107        did_q42: u64,
108        #[serde(rename = "routing_constraints")]
109        routing_constraints: u8,
110    },
111}
112
113#[cfg(not(target_arch = "wasm32"))]
114impl QualiaResponse {
115    /// Convert semantic payload to QualiaResponse
116    pub fn from_semantic_payload(payload: SemanticPayload) -> Self {
117        let did_q42 = match payload.did_q42 {
118            Some(d) => crate::q_hash(&d),
119            None => 0,
120        };
121
122        let semantic_context = payload
123            .semantic_context
124            .get("context")
125            .map(|s| crate::q_hash(s))
126            .unwrap_or(0);
127
128        Self::HandshakeAck {
129            context: "https://webizen.org/ld/context/v1".to_string(),
130            response_type: "HandshakeAck".to_string(),
131            success: true,
132            did_q42,
133            semantic_context,
134        }
135    }
136}
137
138/// Q42 lexicon-compacted **CBOR-LD** wire codec for the sync protocol
139/// (`qualia-sync-protocol.md` §13).
140///
141/// The JSON-LD `@context` IRI and every field *term* is resolved through the Q42
142/// lexicon to a 64-bit key, so the wire payload is genuine CBOR-LD term-compacted
143/// CBOR — **not** plain CBOR of the Rust enum. The map is self-identifying (a magic
144/// term key carrying the codec version) so the reader can tell CBOR-LD frames from a
145/// plain-ciborium fallback, and the mapping is **lossless** (proven by the round-trip
146/// tests). When a term has no lexicon entry the deterministic FNV-1a `q_hash` is used,
147/// so the codec works with the default (volume-less) lexicon too.
148#[cfg(not(target_arch = "wasm32"))]
149pub(crate) mod qcborld {
150    use super::{QualiaRequest, QualiaResponse};
151    use crate::q42::q42_lexicon::Q42Lexicon;
152    use ciborium::value::Value;
153
154    pub const CONTEXT_IRI: &str = "https://webizen.org/ld/context/v1";
155    const MAGIC_TERM: &str = "@cbor-ld/q42";
156    const VERSION: u64 = 1;
157
158    /// Resolve a field term to its 64-bit wire key via the lexicon (term compaction),
159    /// falling back to the deterministic `q_hash` when the term is not in the lexicon.
160    #[inline]
161    fn key(lex: &Q42Lexicon, term: &str) -> u64 {
162        lex.resolve_term(term)
163            .unwrap_or_else(|| crate::q_hash(term))
164    }
165    #[inline]
166    fn kv(lex: &Q42Lexicon, term: &str, v: Value) -> (Value, Value) {
167        (Value::from(key(lex, term)), v)
168    }
169    fn get<'a>(map: &'a [(Value, Value)], lex: &Q42Lexicon, term: &str) -> Option<&'a Value> {
170        let k = key(lex, term);
171        map.iter().find_map(|(kk, vv)| match kk.as_integer() {
172            Some(i) if i128::from(i) as u64 == k => Some(vv),
173            _ => None,
174        })
175    }
176    fn u64v(v: &Value) -> Option<u64> {
177        v.as_integer().map(|i| i128::from(i) as u64)
178    }
179    fn header(lex: &Q42Lexicon, ty: &str) -> Vec<(Value, Value)> {
180        vec![
181            kv(lex, MAGIC_TERM, Value::from(VERSION)),
182            kv(lex, "@context", Value::from(crate::q_hash(CONTEXT_IRI))),
183            kv(lex, "type", Value::Text(ty.to_string())),
184        ]
185    }
186    fn is_cbor_ld(map: &[(Value, Value)], lex: &Q42Lexicon) -> bool {
187        get(map, lex, MAGIC_TERM).and_then(u64v) == Some(VERSION)
188    }
189    fn encode(entries: Vec<(Value, Value)>) -> Result<Vec<u8>, ()> {
190        let mut buf = Vec::new();
191        ciborium::into_writer(&Value::Map(entries), &mut buf).map_err(|_| ())?;
192        Ok(buf)
193    }
194
195    /// Encode a request as Q42 CBOR-LD. Lossless over both variants.
196    pub fn encode_request(lex: &Q42Lexicon, req: &QualiaRequest) -> Result<Vec<u8>, ()> {
197        let mut m = header(
198            lex,
199            match req {
200                QualiaRequest::Handshake { .. } => "Handshake",
201                QualiaRequest::Sync { .. } => "Sync",
202            },
203        );
204        match req {
205            QualiaRequest::Handshake {
206                did_q42,
207                semantic_context,
208                credentials,
209                ..
210            } => {
211                m.push(kv(lex, "did_q42", Value::from(*did_q42)));
212                m.push(kv(lex, "semantic_context", Value::from(*semantic_context)));
213                m.push(kv(lex, "credentials", Value::Bytes(credentials.clone())));
214            }
215            QualiaRequest::Sync {
216                did_q42,
217                hop_count,
218                gatekeeper_token,
219                target_shapes,
220                routing_constraints,
221                ..
222            } => {
223                m.push(kv(lex, "did_q42", Value::from(*did_q42)));
224                m.push(kv(lex, "hop_count", Value::from(*hop_count as u64)));
225                m.push(kv(
226                    lex,
227                    "gatekeeper_token",
228                    match gatekeeper_token {
229                        Some(t) => Value::Text(t.clone()),
230                        None => Value::Null,
231                    },
232                ));
233                m.push(kv(
234                    lex,
235                    "target_shapes",
236                    Value::Array(
237                        target_shapes
238                            .iter()
239                            .map(|s| Value::Text(s.clone()))
240                            .collect(),
241                    ),
242                ));
243                m.push(kv(
244                    lex,
245                    "routing_constraints",
246                    Value::from(*routing_constraints as u64),
247                ));
248            }
249        }
250        encode(m)
251    }
252
253    /// Decode a Q42 CBOR-LD request. `Err(())` when the frame is not Q42 CBOR-LD
254    /// (so the codec can fall back to plain ciborium).
255    pub fn decode_request(lex: &Q42Lexicon, data: &[u8]) -> Result<QualiaRequest, ()> {
256        let val: Value = ciborium::from_reader(data).map_err(|_| ())?;
257        let map = val.as_map().ok_or(())?;
258        if !is_cbor_ld(map, lex) {
259            return Err(());
260        }
261        let ty = get(map, lex, "type").and_then(|v| v.as_text()).ok_or(())?;
262        let did_q42 = get(map, lex, "did_q42").and_then(u64v).unwrap_or(0);
263        match ty {
264            "Handshake" => Ok(QualiaRequest::Handshake {
265                context: CONTEXT_IRI.to_string(),
266                request_type: "Handshake".to_string(),
267                did_q42,
268                semantic_context: get(map, lex, "semantic_context")
269                    .and_then(u64v)
270                    .unwrap_or(0),
271                credentials: get(map, lex, "credentials")
272                    .and_then(|v| v.as_bytes())
273                    .cloned()
274                    .unwrap_or_default(),
275            }),
276            "Sync" => Ok(QualiaRequest::Sync {
277                context: CONTEXT_IRI.to_string(),
278                request_type: "Sync".to_string(),
279                did_q42,
280                hop_count: get(map, lex, "hop_count").and_then(u64v).unwrap_or(0) as u8,
281                gatekeeper_token: get(map, lex, "gatekeeper_token")
282                    .and_then(|v| v.as_text().map(|s| s.to_string())),
283                target_shapes: get(map, lex, "target_shapes")
284                    .and_then(|v| v.as_array())
285                    .map(|a| {
286                        a.iter()
287                            .filter_map(|x| x.as_text().map(|s| s.to_string()))
288                            .collect()
289                    })
290                    .unwrap_or_default(),
291                routing_constraints: get(map, lex, "routing_constraints")
292                    .and_then(u64v)
293                    .unwrap_or(0) as u8,
294            }),
295            _ => Err(()),
296        }
297    }
298
299    /// Encode a response as Q42 CBOR-LD. Lossless over both variants.
300    pub fn encode_response(lex: &Q42Lexicon, res: &QualiaResponse) -> Result<Vec<u8>, ()> {
301        let mut m = header(
302            lex,
303            match res {
304                QualiaResponse::HandshakeAck { .. } => "HandshakeAck",
305                QualiaResponse::SyncAck { .. } => "SyncAck",
306            },
307        );
308        match res {
309            QualiaResponse::HandshakeAck {
310                success,
311                did_q42,
312                semantic_context,
313                ..
314            } => {
315                m.push(kv(lex, "success", Value::Bool(*success)));
316                m.push(kv(lex, "did_q42", Value::from(*did_q42)));
317                m.push(kv(lex, "semantic_context", Value::from(*semantic_context)));
318            }
319            QualiaResponse::SyncAck {
320                success,
321                message,
322                blocks_sent,
323                did_q42,
324                routing_constraints,
325                ..
326            } => {
327                m.push(kv(lex, "success", Value::Bool(*success)));
328                m.push(kv(lex, "message", Value::Text(message.clone())));
329                m.push(kv(lex, "blocks_sent", Value::from(*blocks_sent)));
330                m.push(kv(lex, "did_q42", Value::from(*did_q42)));
331                m.push(kv(
332                    lex,
333                    "routing_constraints",
334                    Value::from(*routing_constraints as u64),
335                ));
336            }
337        }
338        encode(m)
339    }
340
341    /// Decode a Q42 CBOR-LD response. `Err(())` when the frame is not Q42 CBOR-LD.
342    pub fn decode_response(lex: &Q42Lexicon, data: &[u8]) -> Result<QualiaResponse, ()> {
343        let val: Value = ciborium::from_reader(data).map_err(|_| ())?;
344        let map = val.as_map().ok_or(())?;
345        if !is_cbor_ld(map, lex) {
346            return Err(());
347        }
348        let ty = get(map, lex, "type").and_then(|v| v.as_text()).ok_or(())?;
349        let success = get(map, lex, "success")
350            .and_then(|v| v.as_bool())
351            .unwrap_or(false);
352        let did_q42 = get(map, lex, "did_q42").and_then(u64v).unwrap_or(0);
353        match ty {
354            "HandshakeAck" => Ok(QualiaResponse::HandshakeAck {
355                context: CONTEXT_IRI.to_string(),
356                response_type: "HandshakeAck".to_string(),
357                success,
358                did_q42,
359                semantic_context: get(map, lex, "semantic_context")
360                    .and_then(u64v)
361                    .unwrap_or(0),
362            }),
363            "SyncAck" => Ok(QualiaResponse::SyncAck {
364                context: CONTEXT_IRI.to_string(),
365                response_type: "SyncAck".to_string(),
366                success,
367                message: get(map, lex, "message")
368                    .and_then(|v| v.as_text().map(|s| s.to_string()))
369                    .unwrap_or_default(),
370                blocks_sent: get(map, lex, "blocks_sent").and_then(u64v).unwrap_or(0),
371                did_q42,
372                routing_constraints: get(map, lex, "routing_constraints")
373                    .and_then(u64v)
374                    .unwrap_or(0) as u8,
375            }),
376            _ => Err(()),
377        }
378    }
379}
380
381#[derive(Clone)]
382pub struct QualiaSyncCodec {
383    #[cfg(not(target_arch = "wasm32"))]
384    q42_context: Option<Arc<Q42Context>>,
385    #[cfg(not(target_arch = "wasm32"))]
386    cbor_ld_parser: Option<Arc<Q42CborLdParser>>,
387}
388
389impl Default for QualiaSyncCodec {
390    fn default() -> Self {
391        Self {
392            #[cfg(not(target_arch = "wasm32"))]
393            q42_context: None,
394            #[cfg(not(target_arch = "wasm32"))]
395            cbor_ld_parser: None,
396        }
397    }
398}
399
400#[cfg(not(target_arch = "wasm32"))]
401impl QualiaSyncCodec {
402    /// Initialize codec with Q42 volume for CBOR-LD support
403    pub fn with_q42_volume(volume: &Q42Volume) -> Result<Self, CborLdError> {
404        let context =
405            Arc::new(Q42Context::from_volume(volume).map_err(|_| CborLdError::InvalidOffset)?);
406        let parser =
407            Arc::new(Q42CborLdParser::from_volume(volume).map_err(|_| CborLdError::InvalidOffset)?);
408
409        Ok(Self {
410            q42_context: Some(context),
411            cbor_ld_parser: Some(parser),
412        })
413    }
414
415    /// Get Q42 context reference
416    pub fn q42_context(&self) -> Option<&Arc<Q42Context>> {
417        self.q42_context.as_ref()
418    }
419
420    /// Get CBOR-LD parser reference
421    pub fn cbor_ld_parser(&self) -> Option<&Arc<Q42CborLdParser>> {
422        self.cbor_ld_parser.as_ref()
423    }
424}
425
426#[async_trait]
427impl Codec for QualiaSyncCodec {
428    type Protocol = StreamProtocol;
429    type Request = QualiaRequest;
430    type Response = QualiaResponse;
431
432    async fn read_request<T>(&mut self, _: &Self::Protocol, io: &mut T) -> io::Result<Self::Request>
433    where
434        T: AsyncRead + Unpin + Send,
435    {
436        let mut len_buf = [0u8; 4];
437        io.read_exact(&mut len_buf).await?;
438        let len = u32::from_be_bytes(len_buf) as usize;
439
440        let mut buf = vec![0u8; len];
441        io.read_exact(&mut buf).await?;
442
443        // Decode a Q42 CBOR-LD frame (qualia-sync-protocol.md §13) when a lexicon is
444        // present; a non-CBOR-LD frame falls through to the plain-ciborium decode.
445        #[cfg(not(target_arch = "wasm32"))]
446        if let Some(ref parser) = self.cbor_ld_parser {
447            if let Ok(req) = qcborld::decode_request(parser.lexicon(), &buf) {
448                return Ok(req);
449            }
450        }
451
452        // Fallback to regular CBOR parsing
453        ciborium::from_reader(&buf[..])
454            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
455    }
456
457    async fn read_response<T>(
458        &mut self,
459        _: &Self::Protocol,
460        io: &mut T,
461    ) -> io::Result<Self::Response>
462    where
463        T: AsyncRead + Unpin + Send,
464    {
465        let mut len_buf = [0u8; 4];
466        io.read_exact(&mut len_buf).await?;
467        let len = u32::from_be_bytes(len_buf) as usize;
468
469        let mut buf = vec![0u8; len];
470        io.read_exact(&mut buf).await?;
471
472        // Decode a Q42 CBOR-LD frame (qualia-sync-protocol.md §13) when a lexicon is
473        // present; a non-CBOR-LD frame falls through to the plain-ciborium decode.
474        #[cfg(not(target_arch = "wasm32"))]
475        if let Some(ref parser) = self.cbor_ld_parser {
476            if let Ok(res) = qcborld::decode_response(parser.lexicon(), &buf) {
477                return Ok(res);
478            }
479        }
480
481        // Fallback to regular CBOR parsing
482        ciborium::from_reader(&buf[..])
483            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
484    }
485
486    async fn write_request<T>(
487        &mut self,
488        _: &Self::Protocol,
489        io: &mut T,
490        req: Self::Request,
491    ) -> io::Result<()>
492    where
493        T: AsyncWrite + Unpin + Send,
494    {
495        // Emit Q42 CBOR-LD (qualia-sync-protocol.md §13) when a lexicon is present;
496        // otherwise plain ciborium CBOR.
497        #[cfg(not(target_arch = "wasm32"))]
498        if let Some(ref parser) = self.cbor_ld_parser {
499            if let Ok(bytes) = qcborld::encode_request(parser.lexicon(), &req) {
500                io.write_all(&(bytes.len() as u32).to_be_bytes()).await?;
501                io.write_all(&bytes).await?;
502                return Ok(());
503            }
504        }
505
506        let mut buf = Vec::new();
507        ciborium::into_writer(&req, &mut buf)
508            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
509
510        let len = buf.len() as u32;
511        io.write_all(&len.to_be_bytes()).await?;
512        io.write_all(&buf).await?;
513        Ok(())
514    }
515
516    async fn write_response<T>(
517        &mut self,
518        _: &Self::Protocol,
519        io: &mut T,
520        res: Self::Response,
521    ) -> io::Result<()>
522    where
523        T: AsyncWrite + Unpin + Send,
524    {
525        // Emit Q42 CBOR-LD (qualia-sync-protocol.md §13) when a lexicon is present;
526        // otherwise plain ciborium CBOR.
527        #[cfg(not(target_arch = "wasm32"))]
528        if let Some(ref parser) = self.cbor_ld_parser {
529            if let Ok(bytes) = qcborld::encode_response(parser.lexicon(), &res) {
530                io.write_all(&(bytes.len() as u32).to_be_bytes()).await?;
531                io.write_all(&bytes).await?;
532                return Ok(());
533            }
534        }
535
536        let mut buf = Vec::new();
537        ciborium::into_writer(&res, &mut buf)
538            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
539
540        let len = buf.len() as u32;
541        io.write_all(&len.to_be_bytes()).await?;
542        io.write_all(&buf).await?;
543        Ok(())
544    }
545}
546
547#[cfg(all(test, not(target_arch = "wasm32")))]
548mod cbor_ld_tests {
549    use super::*;
550    use crate::q42::q42_lexicon::Q42Lexicon;
551
552    // CBOR-LD round-trip is lossless for every request/response variant, and the
553    // wire bytes are genuine CBOR-LD (a magic-tagged term-compacted map), NOT a
554    // plain ciborium encoding of the enum — qualia-sync-protocol.md §13.
555    #[test]
556    fn cbor_ld_request_roundtrip_is_lossless() {
557        let lex = Q42Lexicon::new();
558        let hs = QualiaRequest::Handshake {
559            context: qcborld::CONTEXT_IRI.to_string(),
560            request_type: "Handshake".to_string(),
561            did_q42: 0xDEAD_BEEF,
562            semantic_context: 0x1234_5678,
563            credentials: vec![1, 2, 3, 4, 5, 250, 251, 252],
564        };
565        let bytes = qcborld::encode_request(&lex, &hs).unwrap();
566        // A genuine CBOR-LD frame does NOT decode as the plain ciborium enum.
567        assert!(ciborium::from_reader::<QualiaRequest, _>(&bytes[..]).is_err());
568        let back = qcborld::decode_request(&lex, &bytes).unwrap();
569        match back {
570            QualiaRequest::Handshake {
571                did_q42,
572                semantic_context,
573                credentials,
574                ..
575            } => {
576                assert_eq!(did_q42, 0xDEAD_BEEF);
577                assert_eq!(semantic_context, 0x1234_5678);
578                assert_eq!(credentials, vec![1, 2, 3, 4, 5, 250, 251, 252]);
579            }
580            _ => panic!("variant changed across round-trip"),
581        }
582
583        let sync = QualiaRequest::Sync {
584            context: qcborld::CONTEXT_IRI.to_string(),
585            request_type: "Sync".to_string(),
586            did_q42: 42,
587            hop_count: 2,
588            gatekeeper_token: Some("tok-abc".to_string()),
589            target_shapes: vec!["foaf:Person".to_string(), "qualia:Vault".to_string()],
590            routing_constraints: 7,
591        };
592        let back =
593            qcborld::decode_request(&lex, &qcborld::encode_request(&lex, &sync).unwrap()).unwrap();
594        match back {
595            QualiaRequest::Sync {
596                did_q42,
597                hop_count,
598                gatekeeper_token,
599                target_shapes,
600                routing_constraints,
601                ..
602            } => {
603                assert_eq!(did_q42, 42);
604                assert_eq!(hop_count, 2);
605                assert_eq!(gatekeeper_token.as_deref(), Some("tok-abc"));
606                assert_eq!(
607                    target_shapes,
608                    vec!["foaf:Person".to_string(), "qualia:Vault".to_string()]
609                );
610                assert_eq!(routing_constraints, 7);
611            }
612            _ => panic!("variant changed across round-trip"),
613        }
614    }
615
616    #[test]
617    fn cbor_ld_response_roundtrip_is_lossless() {
618        let lex = Q42Lexicon::new();
619        let ack = QualiaResponse::SyncAck {
620            context: qcborld::CONTEXT_IRI.to_string(),
621            response_type: "SyncAck".to_string(),
622            success: true,
623            message: "synced".to_string(),
624            blocks_sent: 1234,
625            did_q42: 99,
626            routing_constraints: 3,
627        };
628        let back =
629            qcborld::decode_response(&lex, &qcborld::encode_response(&lex, &ack).unwrap()).unwrap();
630        match back {
631            QualiaResponse::SyncAck {
632                success,
633                message,
634                blocks_sent,
635                did_q42,
636                routing_constraints,
637                ..
638            } => {
639                assert!(success);
640                assert_eq!(message, "synced");
641                assert_eq!(blocks_sent, 1234);
642                assert_eq!(did_q42, 99);
643                assert_eq!(routing_constraints, 3);
644            }
645            _ => panic!("variant changed across round-trip"),
646        }
647    }
648
649    #[test]
650    fn plain_ciborium_is_not_mistaken_for_cbor_ld() {
651        // A plain-ciborium frame must NOT be accepted by the CBOR-LD decoder, so the
652        // codec's fallback path stays correct.
653        let lex = Q42Lexicon::new();
654        let hs = QualiaRequest::Handshake {
655            context: qcborld::CONTEXT_IRI.to_string(),
656            request_type: "Handshake".to_string(),
657            did_q42: 1,
658            semantic_context: 2,
659            credentials: vec![],
660        };
661        let mut plain = Vec::new();
662        ciborium::into_writer(&hs, &mut plain).unwrap();
663        assert!(qcborld::decode_request(&lex, &plain).is_err());
664    }
665}