Skip to main content

graphforge_core/
canonical.rs

1//! Shared `graphforge-canonical/1` envelope and byte primitives.
2//!
3//! Domain owners define their registered schemas and logical-value encoders.
4//! This module owns the common bounded byte writer, closed fingerprint domains,
5//! SHA-256 envelope, and UUIDv8 projection.
6//!
7//! Ontology document `/1` digests intentionally retain their separate sorted-JSON
8//! grammar and domain prefixes in `graphforge-ontology::composition`. They must
9//! not be wrapped in this envelope: doing so changes persisted module, bridge,
10//! and composition identities. The compatibility decision is documented in
11//! `docs/book/architecture/canonical-fingerprints-v1.md`.
12
13use crate::hash_observation::ContractSha256 as Sha256;
14use sha2::Digest;
15
16/// Frozen canonical contract version.
17pub const CANONICAL_CONTRACT_VERSION: u32 = 1;
18/// Maximum canonical payload size in version 1.
19pub const MAX_CANONICAL_PAYLOAD_BYTES: u64 = 2_147_483_648;
20/// Maximum UTF-8 value size in version 1.
21pub const MAX_CANONICAL_TEXT_BYTES: u64 = 16_777_216;
22/// Maximum binary value size in version 1.
23pub const MAX_CANONICAL_BINARY_BYTES: u64 = 67_108_864;
24
25/// Closed fingerprint domains in `graphforge-canonical/1`.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27pub enum CanonicalDomain {
28    /// Authoritative schema bytes.
29    Schema,
30    /// Assertion record/table.
31    Assertion,
32    /// Evidence-link record/table.
33    EvidenceLink,
34    /// Confidence-assessment record/table.
35    ConfidenceAssessment,
36    /// Immutable reasoning record/table.
37    Reasoning,
38    /// Append-only assertion-status event/table.
39    AssertionStatus,
40    /// Append-only assertion-validity event/table.
41    AssertionValidity,
42    /// Immutable assertion-supersession relation/table.
43    AssertionSupersession,
44    /// Immutable hypothesis-group record/table.
45    HypothesisGroup,
46    /// Append-only hypothesis-membership event/table.
47    HypothesisMembership,
48    /// Append-only hypothesis-selection event/table.
49    HypothesisSelection,
50    /// Ordered composite graph-mutation content.
51    CompositeGraphMutationContent,
52    /// Composite graph and knowledge request content.
53    CompositeRequest,
54    /// Provenance event/table.
55    ProvenanceEvent,
56    /// Lineage record/table.
57    Lineage,
58    /// Neutral algorithm invocation descriptor.
59    InvocationDescriptor,
60    /// Resolved graph projection.
61    GraphProjection,
62    /// Resolved-belief policy bytes.
63    BeliefProjectionPolicy,
64    /// Append-only interpretation attachment.
65    BeliefProjectionAttachment,
66    /// Public Arrow result table.
67    ArrowResult,
68    /// Immutable research Source identity.
69    ResearchSource,
70    /// Immutable research Artifact identity.
71    ResearchArtifact,
72    /// Immutable artifact-derivation edge record/table.
73    ArtifactDerivation,
74    /// Append-only artifact-preference event record/table.
75    ArtifactPreference,
76    /// Retention-dependency record/table.
77    RetentionDependency,
78    /// Immutable research classification of an existing assertion.
79    ResearchClaim,
80    /// Explicit relation between immutable assertions.
81    ResearchClaimRelation,
82    /// Scoped integration and canonical authority decision.
83    ResearchDecision,
84    /// Immutable scoped knowledge suppression event.
85    ResearchClaimSuppression,
86}
87
88impl CanonicalDomain {
89    /// Exact lowercase domain tag.
90    #[must_use]
91    pub const fn as_str(self) -> &'static str {
92        match self {
93            Self::Schema => "graphforge/schema",
94            Self::Assertion => "graphforge/assertion",
95            Self::EvidenceLink => "graphforge/evidence-link",
96            Self::ConfidenceAssessment => "graphforge/confidence-assessment",
97            Self::Reasoning => "graphforge/reasoning",
98            Self::AssertionStatus => "graphforge/assertion-status",
99            Self::AssertionValidity => "graphforge/assertion-validity",
100            Self::AssertionSupersession => "graphforge/assertion-supersession",
101            Self::HypothesisGroup => "graphforge/hypothesis-group",
102            Self::HypothesisMembership => "graphforge/hypothesis-membership",
103            Self::HypothesisSelection => "graphforge/hypothesis-selection",
104            Self::CompositeGraphMutationContent => "graphforge/composite-graph-mutation-content",
105            Self::CompositeRequest => "graphforge/composite-request",
106            Self::ProvenanceEvent => "graphforge/provenance-event",
107            Self::Lineage => "graphforge/lineage",
108            Self::InvocationDescriptor => "graphforge/invocation-descriptor",
109            Self::GraphProjection => "graphforge/graph-projection",
110            Self::BeliefProjectionPolicy => "graphforge/belief-projection-policy",
111            Self::BeliefProjectionAttachment => "graphforge/belief-projection-attachment",
112            Self::ArrowResult => "graphforge/arrow-result",
113            Self::ResearchSource => "graphforge/research-source",
114            Self::ResearchArtifact => "graphforge/research-artifact",
115            Self::ArtifactDerivation => "graphforge/artifact-derivation",
116            Self::ArtifactPreference => "graphforge/artifact-preference",
117            Self::RetentionDependency => "graphforge/retention-dependency",
118            Self::ResearchClaim => "graphforge/research-claim",
119            Self::ResearchClaimRelation => "graphforge/research-claim-relation",
120            Self::ResearchDecision => "graphforge/research-decision",
121            Self::ResearchClaimSuppression => "graphforge/research-claim-suppression",
122        }
123    }
124}
125
126/// Structured canonicalization failures.
127#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
128pub enum CanonicalError {
129    /// A bounded byte or item limit was exceeded.
130    #[error("canonical limit exceeded for {item}: observed {observed}, limit {limit}")]
131    Limit {
132        /// Safe item category.
133        item: &'static str,
134        /// Observed byte/item count.
135        observed: u64,
136        /// Frozen maximum.
137        limit: u64,
138    },
139    /// A caller requested an unsupported canonical contract version.
140    #[error("unsupported canonical contract version {version}")]
141    UnsupportedVersion {
142        /// Unsupported version.
143        version: u32,
144    },
145    /// Canonical bytes are truncated or structurally invalid.
146    #[error("invalid canonical payload: {0}")]
147    Malformed(&'static str),
148}
149
150impl CanonicalError {
151    /// Stable public error code.
152    #[must_use]
153    pub const fn code(&self) -> &'static str {
154        match self {
155            Self::Limit { .. } => "GF_CANONICAL_LIMIT",
156            Self::UnsupportedVersion { .. } => "GF_UNSUPPORTED_CONTRACT_VERSION",
157            Self::Malformed(_) => "GF_CANONICAL_INVALID",
158        }
159    }
160}
161
162/// Bounded writer for the fixed-width canonical grammar.
163#[derive(Clone, Debug, Default, PartialEq, Eq)]
164pub struct CanonicalWriter {
165    bytes: Vec<u8>,
166}
167
168/// Strict reader for the fixed-width canonical grammar.
169#[derive(Clone, Debug)]
170pub struct CanonicalReader<'a> {
171    bytes: &'a [u8],
172    offset: usize,
173}
174
175impl<'a> CanonicalReader<'a> {
176    /// Construct a reader after enforcing the top-level payload bound.
177    pub fn new(bytes: &'a [u8]) -> Result<Self, CanonicalError> {
178        let observed = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
179        if observed > MAX_CANONICAL_PAYLOAD_BYTES {
180            return Err(CanonicalError::Limit {
181                item: "payload",
182                observed,
183                limit: MAX_CANONICAL_PAYLOAD_BYTES,
184            });
185        }
186        Ok(Self { bytes, offset: 0 })
187    }
188
189    /// Read an exact number of bytes.
190    pub fn raw(&mut self, length: usize) -> Result<&'a [u8], CanonicalError> {
191        let end = self
192            .offset
193            .checked_add(length)
194            .ok_or(CanonicalError::Malformed("byte range overflow"))?;
195        let value = self
196            .bytes
197            .get(self.offset..end)
198            .ok_or(CanonicalError::Malformed("truncated payload"))?;
199        self.offset = end;
200        Ok(value)
201    }
202
203    /// Read one unsigned byte.
204    pub fn u8(&mut self) -> Result<u8, CanonicalError> {
205        Ok(self.raw(1)?[0])
206    }
207
208    /// Read one big-endian unsigned 32-bit integer.
209    pub fn u32(&mut self) -> Result<u32, CanonicalError> {
210        Ok(u32::from_be_bytes(
211            self.raw(4)?.try_into().expect("reader returned four bytes"),
212        ))
213    }
214
215    /// Read one big-endian unsigned 64-bit integer.
216    pub fn u64(&mut self) -> Result<u64, CanonicalError> {
217        Ok(u64::from_be_bytes(
218            self.raw(8)?
219                .try_into()
220                .expect("reader returned eight bytes"),
221        ))
222    }
223
224    /// Read one bounded length-prefixed UTF-8 value.
225    pub fn text(&mut self) -> Result<&'a str, CanonicalError> {
226        let length = self.u64()?;
227        if length > MAX_CANONICAL_TEXT_BYTES {
228            return Err(CanonicalError::Limit {
229                item: "text",
230                observed: length,
231                limit: MAX_CANONICAL_TEXT_BYTES,
232            });
233        }
234        let length = usize::try_from(length)
235            .map_err(|_| CanonicalError::Malformed("text length exceeds usize"))?;
236        std::str::from_utf8(self.raw(length)?)
237            .map_err(|_| CanonicalError::Malformed("text is not valid UTF-8"))
238    }
239
240    /// Reject unconsumed trailing bytes.
241    pub fn finish(self) -> Result<(), CanonicalError> {
242        if self.offset == self.bytes.len() {
243            Ok(())
244        } else {
245            Err(CanonicalError::Malformed("trailing bytes"))
246        }
247    }
248}
249
250impl CanonicalWriter {
251    /// Construct an empty writer.
252    #[must_use]
253    pub const fn new() -> Self {
254        Self { bytes: Vec::new() }
255    }
256
257    /// Append exact bytes while enforcing the payload bound.
258    pub fn raw(&mut self, bytes: &[u8]) -> Result<(), CanonicalError> {
259        let next = self
260            .bytes
261            .len()
262            .checked_add(bytes.len())
263            .and_then(|value| u64::try_from(value).ok())
264            .ok_or(CanonicalError::Limit {
265                item: "payload",
266                observed: u64::MAX,
267                limit: MAX_CANONICAL_PAYLOAD_BYTES,
268            })?;
269        if next > MAX_CANONICAL_PAYLOAD_BYTES {
270            return Err(CanonicalError::Limit {
271                item: "payload",
272                observed: next,
273                limit: MAX_CANONICAL_PAYLOAD_BYTES,
274            });
275        }
276        self.bytes.extend_from_slice(bytes);
277        Ok(())
278    }
279
280    /// Append one unsigned byte.
281    pub fn u8(&mut self, value: u8) -> Result<(), CanonicalError> {
282        self.raw(&[value])
283    }
284
285    /// Append an unsigned big-endian 16-bit integer.
286    pub fn u16(&mut self, value: u16) -> Result<(), CanonicalError> {
287        self.raw(&value.to_be_bytes())
288    }
289
290    /// Append an unsigned big-endian 32-bit integer.
291    pub fn u32(&mut self, value: u32) -> Result<(), CanonicalError> {
292        self.raw(&value.to_be_bytes())
293    }
294
295    /// Append an unsigned big-endian 64-bit integer.
296    pub fn u64(&mut self, value: u64) -> Result<(), CanonicalError> {
297        self.raw(&value.to_be_bytes())
298    }
299
300    /// Append a signed big-endian 64-bit integer.
301    pub fn i64(&mut self, value: i64) -> Result<(), CanonicalError> {
302        self.raw(&value.to_be_bytes())
303    }
304
305    /// Append a bounded binary item (`u64` length plus bytes).
306    pub fn binary(&mut self, value: &[u8]) -> Result<(), CanonicalError> {
307        let length = u64::try_from(value.len()).map_err(|_| CanonicalError::Limit {
308            item: "binary",
309            observed: u64::MAX,
310            limit: MAX_CANONICAL_BINARY_BYTES,
311        })?;
312        if length > MAX_CANONICAL_BINARY_BYTES {
313            return Err(CanonicalError::Limit {
314                item: "binary",
315                observed: length,
316                limit: MAX_CANONICAL_BINARY_BYTES,
317            });
318        }
319        self.u64(length)?;
320        self.raw(value)
321    }
322
323    /// Append a bounded UTF-8 item (`u64` length plus exact UTF-8 bytes).
324    pub fn text(&mut self, value: &str) -> Result<(), CanonicalError> {
325        let length = u64::try_from(value.len()).map_err(|_| CanonicalError::Limit {
326            item: "text",
327            observed: u64::MAX,
328            limit: MAX_CANONICAL_TEXT_BYTES,
329        })?;
330        if length > MAX_CANONICAL_TEXT_BYTES {
331            return Err(CanonicalError::Limit {
332                item: "text",
333                observed: length,
334                limit: MAX_CANONICAL_TEXT_BYTES,
335            });
336        }
337        self.u64(length)?;
338        self.raw(value.as_bytes())
339    }
340
341    /// Consume the writer.
342    #[must_use]
343    pub fn finish(self) -> Vec<u8> {
344        self.bytes
345    }
346}
347
348/// Build the frozen fingerprint preimage for a canonical payload.
349pub fn fingerprint_preimage(
350    domain: CanonicalDomain,
351    contract_version: u32,
352    payload: &[u8],
353) -> Result<Vec<u8>, CanonicalError> {
354    if contract_version != CANONICAL_CONTRACT_VERSION {
355        return Err(CanonicalError::UnsupportedVersion {
356            version: contract_version,
357        });
358    }
359    let payload_length = u64::try_from(payload.len()).map_err(|_| CanonicalError::Limit {
360        item: "payload",
361        observed: u64::MAX,
362        limit: MAX_CANONICAL_PAYLOAD_BYTES,
363    })?;
364    if payload_length > MAX_CANONICAL_PAYLOAD_BYTES {
365        return Err(CanonicalError::Limit {
366            item: "payload",
367            observed: payload_length,
368            limit: MAX_CANONICAL_PAYLOAD_BYTES,
369        });
370    }
371    let domain_bytes = domain.as_str().as_bytes();
372    let domain_length = u16::try_from(domain_bytes.len()).expect("closed domains fit UInt16");
373    let mut writer = CanonicalWriter::new();
374    writer.raw(b"GFFP")?;
375    writer.u8(1)?;
376    writer.u16(domain_length)?;
377    writer.raw(domain_bytes)?;
378    writer.u32(contract_version)?;
379    writer.u64(payload_length)?;
380    writer.raw(payload)?;
381    Ok(writer.finish())
382}
383
384/// Compute the domain-separated SHA-256 fingerprint.
385pub fn fingerprint(
386    domain: CanonicalDomain,
387    contract_version: u32,
388    payload: &[u8],
389) -> Result<[u8; 32], CanonicalError> {
390    Ok(Sha256::digest(fingerprint_preimage(domain, contract_version, payload)?).into())
391}
392
393/// Project a full canonical fingerprint into an RFC 9562 UUIDv8.
394#[must_use]
395pub fn uuid_v8(fingerprint: [u8; 32]) -> uuid::Uuid {
396    let mut bytes = [0_u8; 16];
397    bytes.copy_from_slice(&fingerprint[..16]);
398    bytes[6] = (bytes[6] & 0x0f) | 0x80;
399    bytes[8] = (bytes[8] & 0x3f) | 0x80;
400    uuid::Uuid::from_bytes(bytes)
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    fn decode_hex(value: &str) -> Vec<u8> {
408        value
409            .as_bytes()
410            .chunks_exact(2)
411            .map(|pair| {
412                let text = std::str::from_utf8(pair).unwrap();
413                u8::from_str_radix(text, 16).unwrap()
414            })
415            .collect()
416    }
417
418    #[test]
419    fn envelope_hash_and_uuid_match_the_frozen_vector() {
420        let payload = decode_hex(
421            "474654310000000000000027474653310000000100000000000000096e6f64655f757569640032000000100000000000000000000000000000000101018f47d2a1b27c3d8e4f001122334455",
422        );
423        let preimage = fingerprint_preimage(
424            CanonicalDomain::ArrowResult,
425            CANONICAL_CONTRACT_VERSION,
426            &payload,
427        )
428        .unwrap();
429        assert_eq!(
430            preimage,
431            decode_hex(
432                "474646500100176772617068666f7267652f6172726f772d726573756c7400000001000000000000004c474654310000000000000027474653310000000100000000000000096e6f64655f757569640032000000100000000000000000000000000000000101018f47d2a1b27c3d8e4f001122334455",
433            )
434        );
435        let digest = fingerprint(
436            CanonicalDomain::ArrowResult,
437            CANONICAL_CONTRACT_VERSION,
438            &payload,
439        )
440        .unwrap();
441        assert_eq!(
442            digest,
443            decode_hex("3cc432a310818554c11f6eb272fead2c549bcd108ab059453a641ab379c7bbb9")
444                .as_slice()
445        );
446        assert_eq!(
447            uuid_v8(digest).to_string(),
448            "3cc432a3-1081-8554-811f-6eb272fead2c"
449        );
450    }
451
452    #[test]
453    fn writer_is_big_endian_exact_and_bounded() {
454        let mut writer = CanonicalWriter::new();
455        writer.u16(0x0102).unwrap();
456        writer.u32(0x0304_0506).unwrap();
457        writer.u64(7).unwrap();
458        writer.text("é").unwrap();
459        assert_eq!(
460            writer.finish(),
461            [
462                1, 2, 3, 4, 5, 6, 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0, 2, 0xc3, 0xa9,
463            ]
464        );
465    }
466
467    #[test]
468    fn unknown_versions_fail_before_hashing() {
469        let error = fingerprint(CanonicalDomain::Schema, 2, b"").unwrap_err();
470        assert_eq!(error.code(), "GF_UNSUPPORTED_CONTRACT_VERSION");
471    }
472
473    #[test]
474    fn reader_round_trips_and_rejects_truncation_trailing_and_utf8() {
475        let mut writer = CanonicalWriter::new();
476        writer.u8(7).unwrap();
477        writer.u32(9).unwrap();
478        writer.u64(11).unwrap();
479        writer.text("é").unwrap();
480        let bytes = writer.finish();
481        let mut reader = CanonicalReader::new(&bytes).unwrap();
482        assert_eq!(reader.u8().unwrap(), 7);
483        assert_eq!(reader.u32().unwrap(), 9);
484        assert_eq!(reader.u64().unwrap(), 11);
485        assert_eq!(reader.text().unwrap(), "é");
486        reader.finish().unwrap();
487
488        assert!(CanonicalReader::new(&[0, 0]).unwrap().u32().is_err());
489        let mut trailing = CanonicalReader::new(&[1, 2]).unwrap();
490        assert_eq!(trailing.u8().unwrap(), 1);
491        assert!(trailing.finish().is_err());
492        let mut invalid = CanonicalReader::new(&[0, 0, 0, 0, 0, 0, 0, 1, 0xff]).unwrap();
493        assert!(invalid.text().is_err());
494    }
495
496    #[test]
497    fn binary_signed_integer_and_declared_text_limit_contracts_are_exact() {
498        let mut writer = CanonicalWriter::new();
499        writer.i64(-7).unwrap();
500        writer.binary(&[1, 2, 3]).unwrap();
501        assert_eq!(
502            writer.finish(),
503            [
504                0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf9, 0, 0, 0, 0, 0, 0, 0, 3, 1, 2, 3,
505            ]
506        );
507
508        let mut declared = (MAX_CANONICAL_TEXT_BYTES + 1).to_be_bytes().to_vec();
509        declared.extend_from_slice(b"unused");
510        let error = CanonicalReader::new(&declared).unwrap().text().unwrap_err();
511        assert_eq!(error.code(), "GF_CANONICAL_LIMIT");
512        assert!(matches!(error, CanonicalError::Limit { item: "text", .. }));
513    }
514
515    #[test]
516    fn every_domain_and_error_code_has_a_frozen_external_spelling() {
517        let domains = [
518            (CanonicalDomain::ResearchClaim, "graphforge/research-claim"),
519            (
520                CanonicalDomain::ResearchClaimSuppression,
521                "graphforge/research-claim-suppression",
522            ),
523            (
524                CanonicalDomain::ResearchClaimRelation,
525                "graphforge/research-claim-relation",
526            ),
527            (
528                CanonicalDomain::ResearchDecision,
529                "graphforge/research-decision",
530            ),
531            (CanonicalDomain::Schema, "graphforge/schema"),
532            (CanonicalDomain::Assertion, "graphforge/assertion"),
533            (CanonicalDomain::EvidenceLink, "graphforge/evidence-link"),
534            (
535                CanonicalDomain::ConfidenceAssessment,
536                "graphforge/confidence-assessment",
537            ),
538            (CanonicalDomain::Reasoning, "graphforge/reasoning"),
539            (
540                CanonicalDomain::AssertionStatus,
541                "graphforge/assertion-status",
542            ),
543            (
544                CanonicalDomain::AssertionValidity,
545                "graphforge/assertion-validity",
546            ),
547            (
548                CanonicalDomain::AssertionSupersession,
549                "graphforge/assertion-supersession",
550            ),
551            (
552                CanonicalDomain::HypothesisGroup,
553                "graphforge/hypothesis-group",
554            ),
555            (
556                CanonicalDomain::HypothesisMembership,
557                "graphforge/hypothesis-membership",
558            ),
559            (
560                CanonicalDomain::HypothesisSelection,
561                "graphforge/hypothesis-selection",
562            ),
563            (
564                CanonicalDomain::CompositeGraphMutationContent,
565                "graphforge/composite-graph-mutation-content",
566            ),
567            (
568                CanonicalDomain::CompositeRequest,
569                "graphforge/composite-request",
570            ),
571            (
572                CanonicalDomain::ProvenanceEvent,
573                "graphforge/provenance-event",
574            ),
575            (CanonicalDomain::Lineage, "graphforge/lineage"),
576            (
577                CanonicalDomain::InvocationDescriptor,
578                "graphforge/invocation-descriptor",
579            ),
580            (
581                CanonicalDomain::GraphProjection,
582                "graphforge/graph-projection",
583            ),
584            (
585                CanonicalDomain::BeliefProjectionPolicy,
586                "graphforge/belief-projection-policy",
587            ),
588            (
589                CanonicalDomain::BeliefProjectionAttachment,
590                "graphforge/belief-projection-attachment",
591            ),
592            (CanonicalDomain::ArrowResult, "graphforge/arrow-result"),
593            (
594                CanonicalDomain::ResearchSource,
595                "graphforge/research-source",
596            ),
597            (
598                CanonicalDomain::ResearchArtifact,
599                "graphforge/research-artifact",
600            ),
601            (
602                CanonicalDomain::ArtifactDerivation,
603                "graphforge/artifact-derivation",
604            ),
605            (
606                CanonicalDomain::ArtifactPreference,
607                "graphforge/artifact-preference",
608            ),
609            (
610                CanonicalDomain::RetentionDependency,
611                "graphforge/retention-dependency",
612            ),
613        ];
614        for (domain, spelling) in domains {
615            assert_eq!(domain.as_str(), spelling);
616        }
617
618        assert_eq!(
619            CanonicalError::Limit {
620                item: "x",
621                observed: 2,
622                limit: 1
623            }
624            .code(),
625            "GF_CANONICAL_LIMIT"
626        );
627        assert_eq!(
628            CanonicalError::UnsupportedVersion { version: 0 }.code(),
629            "GF_UNSUPPORTED_CONTRACT_VERSION"
630        );
631        assert_eq!(
632            CanonicalError::Malformed("x").code(),
633            "GF_CANONICAL_INVALID"
634        );
635    }
636
637    #[test]
638    fn reader_rejects_declared_text_limit_and_offset_overflow() {
639        let bytes = (MAX_CANONICAL_TEXT_BYTES + 1).to_be_bytes();
640        let error = CanonicalReader::new(&bytes).unwrap().text().unwrap_err();
641        assert!(matches!(error, CanonicalError::Limit { item: "text", .. }));
642
643        let mut reader = CanonicalReader::new(&[0]).unwrap();
644        reader.u8().unwrap();
645        assert_eq!(
646            reader.raw(usize::MAX).unwrap_err(),
647            CanonicalError::Malformed("byte range overflow")
648        );
649    }
650
651    #[test]
652    fn writer_covers_signed_binary_and_single_byte_grammar() {
653        let mut writer = CanonicalWriter::new();
654        writer.u8(0xab).unwrap();
655        writer.i64(-2).unwrap();
656        writer.binary(&[1, 2, 3]).unwrap();
657        assert_eq!(
658            writer.finish(),
659            [
660                0xab, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0, 0, 0, 0, 0, 0, 0, 3, 1, 2,
661                3
662            ]
663        );
664    }
665
666    #[test]
667    fn wave11_writer_rejects_oversized_binary_and_text_before_publication() {
668        let binary = vec![0_u8; usize::try_from(MAX_CANONICAL_BINARY_BYTES + 1).unwrap()];
669        let mut writer = CanonicalWriter::new();
670        let error = writer.binary(&binary).unwrap_err();
671        assert!(matches!(
672            error,
673            CanonicalError::Limit {
674                item: "binary",
675                observed,
676                limit: MAX_CANONICAL_BINARY_BYTES,
677            } if observed == MAX_CANONICAL_BINARY_BYTES + 1
678        ));
679        assert!(writer.finish().is_empty());
680        drop(binary);
681
682        let text = "x".repeat(usize::try_from(MAX_CANONICAL_TEXT_BYTES + 1).unwrap());
683        let mut writer = CanonicalWriter::new();
684        let error = writer.text(&text).unwrap_err();
685        assert!(matches!(
686            error,
687            CanonicalError::Limit {
688                item: "text",
689                observed,
690                limit: MAX_CANONICAL_TEXT_BYTES,
691            } if observed == MAX_CANONICAL_TEXT_BYTES + 1
692        ));
693        assert!(writer.finish().is_empty());
694    }
695}