mbx-cache-protocol 0.5.12

Shared wire contract for mbx remote cache clients and servers
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
//! Wire types and constants shared by mbx remote cache clients and servers.
//!
//! These records are protocol-owned: changing their serialized shape or a
//! framing constant is a wire-format change. Transport, authentication, local
//! storage, and adapter behavior deliberately live outside this crate.
#![deny(missing_docs)]

use serde::{Deserialize, Serialize};
use sha2::Digest as _;
use std::collections::BTreeMap;
use std::fmt;
use std::fs::File;
use std::io::Read;
use std::path::Path;

/// Major version of the HTTP cache protocol.
pub const PROTOCOL_VERSION: u8 = 1;
/// Header carrying the negotiated cache protocol version.
pub const PROTOCOL_HEADER: &str = "mbx-cache-protocol";
/// Header carrying the caller's isolated cache namespace.
pub const NAMESPACE_HEADER: &str = "mbx-cache-namespace";
/// Media type for canonical [`ActionResult`] JSON records.
pub const ACTION_RESULT_MEDIA_TYPE: &str = "application/vnd.mbx.cache-action-result.v1+json";
/// Media type for canonical [`Directory`] JSON records.
pub const DIRECTORY_MEDIA_TYPE: &str = "application/vnd.mbx.cache-directory.v1+json";
/// Media type for adapter-specific action metadata blobs.
pub const CLIENT_METADATA_MEDIA_TYPE: &str = "application/vnd.mbx.cache-client-metadata.v1+json";
/// Media type for task-to-action prediction manifests.
pub const TASK_ACTION_MANIFEST_MEDIA_TYPE: &str =
    "application/vnd.mbx.cache-task-action-manifest.v1+json";
/// Media type for opaque content-addressed blobs.
pub const BLOB_MEDIA_TYPE: &str = "application/octet-stream";
/// Media type for framed batches of content-addressed blobs.
pub const BLOB_PACK_MEDIA_TYPE: &str = "application/vnd.mbx.cache-blob-pack.v1";
/// Media type for a JSON list of digests.
pub const DIGEST_LIST_MEDIA_TYPE: &str = "application/vnd.mbx.cache-digests.v1+json";
/// Media type for a JSON batch of [`ActionResult`] records.
///
/// Each record names the action it belongs to, so the batch is unordered and
/// carries only the results a service actually holds.
pub const ACTION_RESULT_BATCH_MEDIA_TYPE: &str =
    "application/vnd.mbx.cache-action-result-batch.v1+json";
/// Media type for ephemeral, invocation-keyed compilation promises.
pub const ACTION_PROMISE_MEDIA_TYPE: &str = "application/vnd.mbx.cache-action-promise.v1+json";
/// Media type for the receipt describing an accepted blob-pack upload.
pub const BLOB_PACK_RECEIPT_MEDIA_TYPE: &str =
    "application/vnd.mbx.cache-blob-pack-receipt.v1+json";
/// Header declaring the number of blobs in a blob pack.
pub const BLOB_PACK_BLOBS_HEADER: &str = "mbx-cache-pack-blobs";
/// Header declaring the total payload bytes in a blob pack.
pub const BLOB_PACK_BYTES_HEADER: &str = "mbx-cache-pack-bytes";
/// Magic prefix identifying a version-one blob pack.
pub const BLOB_PACK_MAGIC: &[u8; 8] = b"MBXPACK1";
/// Bytes before each blob pack payload: algorithm, hash, and length.
pub const BLOB_PACK_HEADER_BYTES: u64 = 1 + 32 + 8;
/// Maximum number of digests in a batch request supported by the protocol.
pub const MAX_BATCH_ITEMS: usize = 10_000;
/// Maximum predictions carried by one task action manifest.
pub const MAX_TASK_ACTION_PREDICTIONS: usize = 16 * 1024;
/// Maximum serialized adapter payload in one action prediction.
pub const MAX_ACTION_PREDICTION_PAYLOAD: usize = 256 * 1024;
/// Maximum opaque lease-token length accepted for an action promise.
pub const MAX_ACTION_PROMISE_CLAIM_BYTES: usize = 256;

/// Serialize a protocol record using the JSON Canonicalization Scheme.
pub fn canonical_json(value: &impl Serialize) -> serde_json::Result<Vec<u8>> {
    serde_json_canonicalizer::to_vec(value)
}

/// Algorithm-tagged digest and exact byte length of a cache object.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Digest {
    /// Hash algorithm name (`blake3` or `sha256`).
    pub algorithm: String,
    /// Lowercase hexadecimal hash value.
    pub hash: String,
    /// Exact uncompressed object length in bytes.
    pub size: u64,
}

impl Digest {
    /// Compute a BLAKE3 digest for in-memory bytes.
    pub fn blake3(bytes: &[u8]) -> Self {
        Self {
            algorithm: DigestAlgorithm::Blake3.into(),
            hash: blake3::hash(bytes).to_hex().to_string(),
            size: bytes.len() as u64,
        }
    }

    /// Hash a file with BLAKE3 while counting bytes in the same pass.
    pub fn blake3_file(path: &Path) -> eyre::Result<Self> {
        let (hash, size) = hash_file(path, DigestAlgorithm::Blake3)?;
        Ok(Self {
            algorithm: DigestAlgorithm::Blake3.into(),
            hash,
            size,
        })
    }

    /// Validate the algorithm and lowercase hexadecimal representation.
    pub fn validate(&self) -> eyre::Result<()> {
        self.algorithm_kind()?;
        if self.hash.len() != 64
            || !self
                .hash
                .bytes()
                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
        {
            eyre::bail!("invalid remote cache digest");
        }
        Ok(())
    }

    /// Return whether `bytes` have this digest and declared length.
    pub fn matches_bytes(&self, bytes: &[u8]) -> eyre::Result<bool> {
        self.validate()?;
        if self.size != bytes.len() as u64 {
            return Ok(false);
        }
        let hash = match self.algorithm_kind()? {
            DigestAlgorithm::Blake3 => blake3::hash(bytes).to_hex().to_string(),
            DigestAlgorithm::Sha256 => hex::encode(sha2::Sha256::digest(bytes)),
        };
        Ok(self.hash == hash)
    }

    /// Stream a file and return whether it has this digest and length.
    pub fn matches_file(&self, path: &Path) -> eyre::Result<bool> {
        self.validate()?;
        let (hash, size) = hash_file(path, self.algorithm_kind()?)?;
        Ok(self.size == size && self.hash == hash)
    }

    /// Stable storage key containing the algorithm, hash, and byte length.
    pub fn key(&self) -> String {
        format!("{}/{}/{}", self.algorithm, self.hash, self.size)
    }

    /// Parse the algorithm tag into its closed version-one enum.
    pub fn algorithm_kind(&self) -> eyre::Result<DigestAlgorithm> {
        Ok(self.algorithm.parse()?)
    }
}

/// Hash algorithms supported by protocol version one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DigestAlgorithm {
    /// BLAKE3.
    Blake3,
    /// SHA-256.
    Sha256,
}

impl DigestAlgorithm {
    /// Lowercase name serialized on the wire and used in endpoint paths.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Blake3 => "blake3",
            Self::Sha256 => "sha256",
        }
    }
}

impl fmt::Display for DigestAlgorithm {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl std::str::FromStr for DigestAlgorithm {
    type Err = ParseDigestAlgorithmError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value {
            "blake3" => Ok(Self::Blake3),
            "sha256" => Ok(Self::Sha256),
            _ => Err(ParseDigestAlgorithmError),
        }
    }
}

impl From<DigestAlgorithm> for String {
    fn from(algorithm: DigestAlgorithm) -> Self {
        algorithm.as_str().into()
    }
}

/// A digest algorithm name was outside the version-one contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ParseDigestAlgorithmError;

impl fmt::Display for ParseDigestAlgorithmError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("unsupported remote cache digest algorithm")
    }
}

impl std::error::Error for ParseDigestAlgorithmError {}

fn hash_file(path: &Path, algorithm: DigestAlgorithm) -> eyre::Result<(String, u64)> {
    let mut file = File::open(path)?;
    let mut buffer = [0; 64 * 1024];
    let mut size = 0;
    let mut blake3 = blake3::Hasher::new();
    let mut sha256 = sha2::Sha256::new();
    loop {
        let count = file.read(&mut buffer)?;
        if count == 0 {
            break;
        }
        match algorithm {
            DigestAlgorithm::Blake3 => {
                blake3.update(&buffer[..count]);
            }
            DigestAlgorithm::Sha256 => {
                sha256.update(&buffer[..count]);
            }
        }
        size += count as u64;
    }
    let hash = match algorithm {
        DigestAlgorithm::Blake3 => blake3.finalize().to_hex().to_string(),
        DigestAlgorithm::Sha256 => hex::encode(sha256.finalize()),
    };
    Ok((hash, size))
}

/// A canonical action-result record referencing objects in the CAS.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ActionResult {
    /// Digest of the canonical action descriptor this record satisfies.
    pub action: Digest,
    /// Optional adapter metadata blob.
    #[serde(default)]
    pub metadata: Option<Digest>,
    /// Optional digest of the root [`Directory`] containing outputs.
    #[serde(default)]
    pub output_root: Option<Digest>,
    /// Action-result schema version.
    pub version: u8,
}

/// A canonical directory object stored in the CAS.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Directory {
    /// Child directory entries, sorted canonically by name.
    pub directories: Vec<DirectoryNode>,
    /// Child file entries, sorted canonically by name.
    pub files: Vec<FileNode>,
    /// Child symbolic-link entries, sorted canonically by name.
    pub symlinks: Vec<SymlinkNode>,
    /// Directory-object schema version.
    pub version: u8,
}

/// A child directory entry in a canonical cache directory.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DirectoryNode {
    /// Digest of the child [`Directory`].
    pub digest: Digest,
    /// Platform mode bits recorded for the directory.
    pub mode: u32,
    /// Single path-component name within the parent directory.
    pub name: String,
}

/// A file entry in a canonical cache directory.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileNode {
    /// Digest of the file contents.
    pub digest: Digest,
    /// Whether the file should be restored as executable.
    pub executable: bool,
    /// Platform mode bits recorded for the file.
    pub mode: u32,
    /// Single path-component name within the parent directory.
    pub name: String,
}

/// A symbolic-link entry in a canonical cache directory.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SymlinkNode {
    /// Platform mode bits recorded for the symbolic link.
    pub mode: u32,
    /// Single path-component name within the parent directory.
    pub name: String,
    /// Link target text exactly as recorded by the producer.
    pub target: String,
}

/// Rust-specific action metadata stored alongside compiled outputs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RustcMetadata {
    /// Metadata schema version.
    pub version: u8,
    /// Adapter-defined output kind.
    pub kind: String,
    /// Digest of captured compiler standard output.
    pub stdout: Digest,
    /// Digest of captured compiler standard error.
    pub stderr: Digest,
}

impl RustcMetadata {
    /// Whether the metadata satisfies the version-one rustc schema invariants.
    pub fn validate(&self) -> bool {
        self.version == 1
            && self.kind == "rustc"
            && self.stdout.validate().is_ok()
            && self.stderr.validate().is_ok()
    }
}

/// C and C++ action metadata stored alongside compiled objects.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CcMetadata {
    /// Metadata schema version.
    pub version: u8,
    /// Adapter-defined output kind.
    pub kind: String,
    /// Digest of captured compiler standard output.
    pub stdout: Digest,
    /// Digest of captured compiler standard error.
    pub stderr: Digest,
}

impl CcMetadata {
    /// Whether the metadata satisfies the version-one cc schema invariants.
    pub fn validate(&self) -> bool {
        self.version == 1
            && self.kind == "cc"
            && self.stdout.validate().is_ok()
            && self.stderr.validate().is_ok()
    }
}

/// Adapter-owned data needed to reconstruct an action from a prior task run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ActionPrediction {
    /// Invocation digest used to locate this prediction.
    pub invocation: Digest,
    /// Full action digest produced when the prediction was recorded.
    pub action: Digest,
    /// Adapter name that owns and understands `payload`.
    pub adapter: String,
    /// Adapter-defined serialized input prediction.
    pub payload: String,
}

/// Request to join or claim one invocation-wide compilation promise.
///
/// The invocation digest lives in the endpoint URL. The adapter remains in
/// the body so a server can reject a client trying to join a promise created
/// by a different action adapter under the same digest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ActionPromiseJoin {
    /// Action adapter that will interpret the completed prediction.
    pub adapter: String,
}

/// State returned when a client joins an invocation-wide promise.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
#[non_exhaustive]
pub enum ActionPromiseState {
    /// This client owns the lease and should perform the compilation.
    Claimed {
        /// Opaque bearer token required to complete this claim.
        claim: String,
    },
    /// Another client owns the lease; retry after the stated delay.
    Pending {
        /// Minimum delay before asking again.
        retry_after_ms: u64,
    },
    /// The owner published a prediction through which this client can restore.
    Complete {
        /// Prediction for reconstructing and looking up the final action key.
        prediction: ActionPrediction,
    },
}

/// Immutable completion of an invocation-wide compilation promise.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ActionPromiseCompletion {
    /// Opaque token returned to the client that claimed the promise.
    pub claim: String,
    /// Prediction naming the action result that was published first.
    pub prediction: ActionPrediction,
}

impl ActionPromiseJoin {
    /// Whether the join names a valid action adapter.
    pub fn validate(&self) -> eyre::Result<()> {
        if !valid_adapter_name(&self.adapter) {
            eyre::bail!("invalid action promise adapter name");
        }
        Ok(())
    }
}

impl ActionPromiseState {
    /// Check the bounds and prediction invariants of a server response.
    pub fn validate(&self) -> eyre::Result<()> {
        match self {
            Self::Claimed { claim }
                if claim.is_empty() || claim.len() > MAX_ACTION_PROMISE_CLAIM_BYTES =>
            {
                eyre::bail!("invalid action promise claim token")
            }
            Self::Complete { prediction } => prediction.validate(),
            _ => Ok(()),
        }
    }
}

impl ActionPromiseCompletion {
    /// Check the claim-token bound and completed prediction.
    pub fn validate(&self) -> eyre::Result<()> {
        if self.claim.is_empty() || self.claim.len() > MAX_ACTION_PROMISE_CLAIM_BYTES {
            eyre::bail!("invalid action promise claim token");
        }
        self.prediction.validate()
    }
}

/// Predictions associated with one stable task identity.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TaskActionManifest {
    /// Manifest schema version.
    pub version: u8,
    /// Stable task identity.
    pub task: String,
    /// Predicted actions, uniquely keyed by invocation digest.
    pub predictions: Vec<ActionPrediction>,
}

#[derive(Serialize)]
struct TaskActionManifestSelector<'a> {
    kind: &'static str,
    task: &'a str,
    version: u8,
}

impl TaskActionManifest {
    /// Whether the manifest satisfies the version-one wire invariants.
    pub fn validate(&self) -> bool {
        let mut invocations = std::collections::BTreeSet::new();
        self.version == 1
            && valid_task_identity(&self.task)
            && self.predictions.len() <= MAX_TASK_ACTION_PREDICTIONS
            && self.predictions.iter().all(|prediction| {
                prediction.validate().is_ok() && invocations.insert(&prediction.invocation)
            })
    }

    /// Digest selecting this task identity's action manifest.
    pub fn selector_digest(&self) -> Digest {
        Self::selector(&self.task)
            .expect("manifest task identity must be valid")
            .1
    }

    /// Canonical selector bytes and digest for a task identity.
    pub fn selector(task: &str) -> eyre::Result<(Vec<u8>, Digest)> {
        if !valid_task_identity(task) {
            eyre::bail!("invalid task action manifest identity");
        }
        let selector = canonical_json(&TaskActionManifestSelector {
            kind: "task_action_manifest",
            task,
            version: 1,
        })?;
        let digest = Digest::blake3(&selector);
        Ok((selector, digest))
    }
}

impl ActionPrediction {
    /// Check the version-one wire invariants, naming the one that fails.
    ///
    /// A rejected prediction is only ever reported to someone working out why
    /// a build stopped predicting, and "invalid" on its own tells them nothing
    /// about which of these constraints to go looking at. The message stands on
    /// its own because the agent sends callers `Display`, not the error chain.
    pub fn validate(&self) -> eyre::Result<()> {
        match self.constraint_violation() {
            Some(reason) => eyre::bail!("invalid action prediction: {reason}"),
            None => Ok(()),
        }
    }

    fn constraint_violation(&self) -> Option<String> {
        if self.action.algorithm != DigestAlgorithm::Blake3.as_str()
            || self.action.validate().is_err()
        {
            return Some("action digest is not a valid blake3 digest".into());
        }
        if self.invocation.algorithm != DigestAlgorithm::Blake3.as_str()
            || self.invocation.validate().is_err()
        {
            return Some("invocation digest is not a valid blake3 digest".into());
        }
        if self.adapter.is_empty() {
            return Some("adapter name is empty".into());
        }
        if !valid_adapter_name(&self.adapter) {
            return Some(format!(
                "adapter name {:?} is not alphanumeric, '-', or '_'",
                self.adapter
            ));
        }
        if self.payload.len() > MAX_ACTION_PREDICTION_PAYLOAD {
            return Some(format!(
                "payload is {} bytes, over the {MAX_ACTION_PREDICTION_PAYLOAD} byte limit",
                self.payload.len()
            ));
        }
        if serde_json::from_str::<serde_json::Value>(&self.payload).is_err() {
            return Some("payload is not valid JSON".into());
        }
        None
    }
}

fn valid_adapter_name(value: &str) -> bool {
    !value.is_empty()
        && value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
}

fn valid_task_identity(value: &str) -> bool {
    value.len() == 64
        && value
            .bytes()
            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}

/// Version advertised by a cache service.
///
/// Capability records are the protocol's additive surface: a server may
/// advertise fields a client does not know, so every type below stays open to
/// extension rather than requiring a major release per advertised field.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CapabilityProtocol {
    /// Protocol major version.
    pub major: u8,
    /// Backward-compatible protocol revision.
    #[serde(default)]
    pub minor: u8,
}

impl CapabilityProtocol {
    /// A protocol version advertisement.
    pub fn new(major: u8, minor: u8) -> Self {
        Self { major, minor }
    }
}

/// Schemas supported for one action adapter.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ActionKindCapability {
    /// Action descriptor schema version.
    pub action_schema: u8,
    /// Adapter metadata schema version.
    pub metadata_schema: u8,
}

impl ActionKindCapability {
    /// The schema pair one adapter accepts.
    pub fn new(action_schema: u8, metadata_schema: u8) -> Self {
        Self {
            action_schema,
            metadata_schema,
        }
    }
}

/// Optional server protocol features.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CapabilityFeatures {
    /// Conditional action-manifest endpoints are available.
    #[serde(default)]
    pub action_manifests: bool,
    /// Missing-blob batch queries are available.
    #[serde(default)]
    pub batch: bool,
    /// Batched action-result lookups are available.
    ///
    /// Distinct from [`Self::batch`], which covers missing-blob queries only. A
    /// service that answered those before this feature existed advertises
    /// `batch` without implementing the action-result endpoint.
    #[serde(default)]
    pub action_batch: bool,
    /// Framed blob-pack downloads are available.
    #[serde(default)]
    pub blob_packs: bool,
    /// Framed blob-pack uploads are available.
    #[serde(default)]
    pub blob_pack_uploads: bool,
    /// Resumable uploads are available.
    #[serde(default)]
    pub resumable_uploads: bool,
    /// Delegated transfers are available.
    #[serde(default)]
    pub delegated_transfers: bool,
    /// Invocation-wide claim and promise endpoints are available.
    #[serde(default)]
    pub action_promises: bool,
}

/// Server-advertised request and object limits.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CapabilityLimits {
    /// Maximum digests accepted by one batch request.
    #[serde(default)]
    pub max_batch_items: u64,
    /// Maximum blob size eligible for inline transfer.
    #[serde(default)]
    pub max_inline_blob_bytes: u64,
    /// Maximum size of an individual blob.
    #[serde(default)]
    pub max_blob_bytes: u64,
    /// Maximum declared payload bytes in one blob pack.
    #[serde(default)]
    pub max_pack_bytes: u64,
}

/// Cache service capabilities negotiated before optional protocol features.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Capabilities {
    /// Protocol version implemented by the server.
    pub protocol: CapabilityProtocol,
    /// Digest algorithms accepted by the service.
    #[serde(default)]
    pub digest_algorithms: Vec<String>,
    /// Content codings accepted and produced by the service.
    #[serde(default)]
    pub compressors: Vec<String>,
    /// Adapter schemas accepted by the service.
    #[serde(default)]
    pub action_kinds: BTreeMap<String, ActionKindCapability>,
    /// Optional endpoint features.
    #[serde(default)]
    pub features: CapabilityFeatures,
    /// Server-enforced request limits.
    #[serde(default)]
    pub limits: CapabilityLimits,
}

impl Capabilities {
    /// A baseline advertisement for `protocol`, claiming no optional features.
    ///
    /// The remaining fields are public and assignable, so a service adds only
    /// what it actually supports.
    pub fn new(protocol: CapabilityProtocol) -> Self {
        Self {
            protocol,
            digest_algorithms: Vec::new(),
            compressors: Vec::new(),
            action_kinds: BTreeMap::new(),
            features: CapabilityFeatures::default(),
            limits: CapabilityLimits::default(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn digest_validation_is_exact() {
        let valid = Digest {
            algorithm: DigestAlgorithm::Blake3.into(),
            hash: "a".repeat(64),
            size: 42,
        };
        assert!(valid.validate().is_ok());
        assert!(
            Digest {
                hash: "A".repeat(64),
                ..valid.clone()
            }
            .validate()
            .is_err()
        );
        assert!(
            Digest {
                algorithm: "md5".into(),
                ..valid
            }
            .validate()
            .is_err()
        );
    }

    #[test]
    fn a_rejected_prediction_names_the_constraint_it_violated() {
        let digest = Digest::blake3(b"action");
        let prediction = ActionPrediction {
            invocation: digest.clone(),
            action: digest,
            adapter: "rustc".into(),
            payload: "{}".into(),
        };
        assert!(prediction.validate().is_ok());

        let reason = |prediction: ActionPrediction| {
            prediction
                .validate()
                .expect_err("the prediction violates a constraint")
                .to_string()
        };
        let oversized = ActionPrediction {
            payload: format!("\"{}\"", "p".repeat(MAX_ACTION_PREDICTION_PAYLOAD)),
            ..prediction.clone()
        };
        let oversized_len = oversized.payload.len();
        let message = reason(oversized);
        assert!(
            message.contains(&oversized_len.to_string())
                && message.contains(&MAX_ACTION_PREDICTION_PAYLOAD.to_string()),
            "the message must carry both sizes so a build log says how far over it went: {message}"
        );

        // Every message stands alone, because the agent sends callers the
        // outermost `Display` rather than the error chain.
        assert_eq!(
            reason(ActionPrediction {
                adapter: "rust c".into(),
                ..prediction.clone()
            }),
            r#"invalid action prediction: adapter name "rust c" is not alphanumeric, '-', or '_'"#
        );
        assert_eq!(
            reason(ActionPrediction {
                payload: "not json".into(),
                ..prediction.clone()
            }),
            "invalid action prediction: payload is not valid JSON"
        );
        assert_eq!(
            reason(ActionPrediction {
                action: Digest {
                    algorithm: DigestAlgorithm::Sha256.into(),
                    ..prediction.action.clone()
                },
                ..prediction
            }),
            "invalid action prediction: action digest is not a valid blake3 digest"
        );
    }

    #[test]
    fn canonical_json_is_independent_of_map_insertion_order() {
        #[derive(Serialize)]
        struct ZThenA {
            z: u8,
            a: bool,
        }

        #[derive(Serialize)]
        struct AThenZ {
            a: bool,
            z: u8,
        }

        assert_eq!(
            canonical_json(&ZThenA { z: 1, a: true }).unwrap(),
            canonical_json(&AThenZ { a: true, z: 1 }).unwrap()
        );
    }
}