1use super::*;
2pub(super) use crate::store_commit::domain_json;
3
4#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
5pub struct ProviderProbeId([u8; 32]);
6
7impl ProviderProbeId {
8 pub fn from_bytes(bytes: [u8; 32]) -> Self {
9 Self(bytes)
10 }
11
12 pub fn as_bytes(&self) -> &[u8; 32] {
13 &self.0
14 }
15}
16
17impl fmt::Debug for ProviderProbeId {
18 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
19 formatter.write_str(&hex::encode(self.0))
20 }
21}
22
23impl Serialize for ProviderProbeId {
24 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
25 where
26 S: Serializer,
27 {
28 serializer.serialize_str(&hex::encode(self.0))
29 }
30}
31
32impl<'de> Deserialize<'de> for ProviderProbeId {
33 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
34 where
35 D: Deserializer<'de>,
36 {
37 let value = String::deserialize(deserializer)?;
38 if value.len() != 64
39 || value
40 .bytes()
41 .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte))
42 {
43 return Err(serde::de::Error::custom(
44 "provider probe id must be 64 lowercase hexadecimal characters",
45 ));
46 }
47 let bytes: [u8; 32] = hex::decode(value)
48 .map_err(serde::de::Error::custom)?
49 .try_into()
50 .map_err(|_| serde::de::Error::custom("provider probe id has the wrong length"))?;
51 Ok(Self(bytes))
52 }
53}
54
55#[derive(Clone, Copy)]
56pub enum ProbePayloadLabel {
57 ExactCreateFirst,
58 ExactCreateSecond,
59 ConditionalInitial,
60 ConditionalFirst,
61 ConditionalSecond,
62 LostResponse,
63 CrossAdministrator,
64 CrossPeer,
65}
66
67impl ProbePayloadLabel {
68 fn bytes(self) -> &'static [u8] {
69 match self {
70 Self::ExactCreateFirst => b"exact-create-first",
71 Self::ExactCreateSecond => b"exact-create-second",
72 Self::ConditionalInitial => b"conditional-initial",
73 Self::ConditionalFirst => b"conditional-first",
74 Self::ConditionalSecond => b"conditional-second",
75 Self::LostResponse => b"lost-response",
76 Self::CrossAdministrator => b"cross-administrator",
77 Self::CrossPeer => b"cross-peer",
78 }
79 }
80}
81
82pub fn probe_payload(probe_id: &ProviderProbeId, label: ProbePayloadLabel) -> Vec<u8> {
83 let mut output = Vec::with_capacity(PROBE_PAYLOAD_LEN);
84 let mut counter = 0u32;
85 while output.len() < PROBE_PAYLOAD_LEN {
86 let mut digest = Sha256::new();
87 digest.update(PAYLOAD_DOMAIN);
88 digest.update(probe_id.as_bytes());
89 digest.update(label.bytes());
90 digest.update(counter.to_be_bytes());
91 output.extend_from_slice(&digest.finalize());
92 counter += 1;
93 }
94 output.truncate(PROBE_PAYLOAD_LEN);
95 output
96}
97
98#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct ExactSlotProbeReceipt {
101 pub transcript: ExactSlotProbeTranscript,
102 pub transcript_hash: ObjectHash,
103}
104
105impl ExactSlotProbeReceipt {
106 pub fn from_transcript(
107 transcript: ExactSlotProbeTranscript,
108 store: &StoreProviderBinding,
109 device: &ProviderDeviceBinding,
110 ) -> Self {
111 let transcript_hash = exact_transcript_hash(store, device, &transcript);
112 Self {
113 transcript,
114 transcript_hash,
115 }
116 }
117
118 pub fn verify(
119 &self,
120 store: &StoreProviderBinding,
121 device: &ProviderDeviceBinding,
122 ) -> Result<(), ProviderProbeError> {
123 store.validate().map_err(ProviderProbeError::Storage)?;
124 device
125 .validate_for(store)
126 .map_err(ProviderProbeError::Storage)?;
127 let t = &self.transcript;
128 if self.transcript_hash != exact_transcript_hash(store, device, t) {
129 return invalid("exact-slot transcript hash does not match its context");
130 }
131 if t.logical_key != t.slot.logical_key() || t.accepted.slot() != &t.slot {
132 return invalid("exact-slot transcript disagrees with its allocated slot");
133 }
134 let payloads = [
135 probe_payload(&t.probe_id, ProbePayloadLabel::ExactCreateFirst),
136 probe_payload(&t.probe_id, ProbePayloadLabel::ExactCreateSecond),
137 ];
138 let expected_hashes = [
139 ObjectHash::digest(&payloads[0]),
140 ObjectHash::digest(&payloads[1]),
141 ];
142 if t.contenders[0].payload_hash != expected_hashes[0]
143 || t.contenders[1].payload_hash != expected_hashes[1]
144 {
145 return invalid("exact-slot contender payload hashes are not deterministic");
146 }
147 let winners: Vec<_> = t
148 .contenders
149 .iter()
150 .enumerate()
151 .filter_map(|(index, attempt)| {
152 (attempt.outcome == ProbeCreateOutcome::Created).then_some(index)
153 })
154 .collect();
155 let rejected = t
156 .contenders
157 .iter()
158 .filter(|attempt| attempt.outcome == ProbeCreateOutcome::RejectedOccupied)
159 .count();
160 if winners.len() != 1 || rejected != 1 {
161 return invalid("exact-slot race must contain one create and one occupied rejection");
162 }
163 let winner = &payloads[winners[0]];
164 if t.accepted.stored_size() != winner.len() as u64
165 || t.accepted.stored_hash() != ObjectHash::digest(winner)
166 || t.full_read_hash != ObjectHash::digest(winner)
167 || t.range.start != PROBE_RANGE_START
168 || t.range.end != PROBE_RANGE_END
169 || t.range.bytes_hash
170 != ObjectHash::digest(&winner[PROBE_RANGE_START as usize..PROBE_RANGE_END as usize])
171 {
172 return invalid("exact-slot read, range, reference, or deletion evidence is invalid");
173 }
174 t.conditional.verify(&t.probe_id)?;
175 let lost = probe_payload(&t.probe_id, ProbePayloadLabel::LostResponse);
176 let lost_hash = ObjectHash::digest(&lost);
177 if t.lost_response.logical_key != t.lost_response.slot.logical_key()
178 || t.lost_response.settled.slot() != &t.lost_response.slot
179 || t.lost_response.payload_hash != lost_hash
180 || t.lost_response.settled.stored_size() != lost.len() as u64
181 || t.lost_response.settled.stored_hash() != lost_hash
182 || t.lost_response.readback_hash != lost_hash
183 {
184 return invalid("lost-response exact-slot evidence is invalid");
185 }
186 Ok(())
187 }
188}
189
190#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192pub struct ExactSlotProbeTranscript {
193 pub probe_id: ProviderProbeId,
194 pub logical_key: String,
195 pub slot: ObjectSlot,
196 pub contenders: [ProbeCreateAttempt; 2],
197 pub accepted: ExactObjectRef,
198 pub full_read_hash: ObjectHash,
199 pub range: ProbeRangeReceipt,
200 pub conditional: ConditionalUpdateProbeReceipt,
201 pub lost_response: LostResponseProbeReceipt,
202}
203
204#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(deny_unknown_fields)]
206pub struct ConditionalUpdateProbeReceipt {
207 pub logical_key: String,
208 pub slot: ObjectSlot,
209 pub starting_payload_hash: ObjectHash,
210 pub contenders: [ProbeConditionalAttempt; 2],
211 pub accepted_payload_hash: ObjectHash,
212}
213
214impl ConditionalUpdateProbeReceipt {
215 fn verify(&self, probe_id: &ProviderProbeId) -> Result<(), ProviderProbeError> {
216 if self.logical_key != self.slot.logical_key() {
217 return invalid("conditional-update transcript disagrees with its allocated slot");
218 }
219 let initial = probe_payload(probe_id, ProbePayloadLabel::ConditionalInitial);
220 let payloads = [
221 probe_payload(probe_id, ProbePayloadLabel::ConditionalFirst),
222 probe_payload(probe_id, ProbePayloadLabel::ConditionalSecond),
223 ];
224 let allowed_starting_hashes = [
225 ObjectHash::digest(&initial),
226 ObjectHash::digest(&payloads[0]),
227 ObjectHash::digest(&payloads[1]),
228 ];
229 if !allowed_starting_hashes.contains(&self.starting_payload_hash)
230 || self.contenders[0].payload_hash != ObjectHash::digest(&payloads[0])
231 || self.contenders[1].payload_hash != ObjectHash::digest(&payloads[1])
232 {
233 return invalid("conditional-update payload hashes are not deterministic");
234 }
235 let winners = self
236 .contenders
237 .iter()
238 .enumerate()
239 .filter_map(|(index, attempt)| {
240 (attempt.outcome == ProbeConditionalOutcome::Replaced).then_some(index)
241 })
242 .collect::<Vec<_>>();
243 let rejected = self
244 .contenders
245 .iter()
246 .filter(|attempt| attempt.outcome == ProbeConditionalOutcome::RejectedRevision)
247 .count();
248 if winners.len() != 1
249 || rejected != 1
250 || self.accepted_payload_hash != ObjectHash::digest(&payloads[winners[0]])
251 {
252 return invalid(
253 "conditional-update race must contain one replacement and one revision rejection",
254 );
255 }
256 Ok(())
257 }
258}
259
260#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(deny_unknown_fields)]
262pub struct ProbeConditionalAttempt {
263 pub payload_hash: ObjectHash,
264 pub outcome: ProbeConditionalOutcome,
265}
266
267#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "snake_case")]
269pub enum ProbeConditionalOutcome {
270 Replaced,
271 RejectedRevision,
272}
273
274#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(deny_unknown_fields)]
276pub struct ProbeCreateAttempt {
277 pub payload_hash: ObjectHash,
278 pub outcome: ProbeCreateOutcome,
279}
280
281#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
282#[serde(rename_all = "snake_case")]
283pub enum ProbeCreateOutcome {
284 Created,
285 RejectedOccupied,
286}
287
288#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(deny_unknown_fields)]
290pub struct LostResponseProbeReceipt {
291 pub logical_key: String,
292 pub slot: ObjectSlot,
293 pub payload_hash: ObjectHash,
294 pub settled: ExactObjectRef,
295 pub readback_hash: ObjectHash,
296}
297
298#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(deny_unknown_fields)]
300pub struct ProbeRangeReceipt {
301 pub start: u64,
302 pub end: u64,
303 pub bytes_hash: ObjectHash,
304}
305
306#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(deny_unknown_fields)]
308pub struct ProbeExactObjectReceipt {
309 pub slot: ObjectSlot,
310 pub payload_hash: ObjectHash,
311 pub object: ExactObjectRef,
312}
313
314#[derive(Debug, thiserror::Error)]
315pub enum ProviderProbeError {
316 #[error(transparent)]
317 Storage(#[from] StorageError),
318 #[error("provider capability receipt Store protocol: {0}")]
319 Protocol(#[from] crate::store_commit::StoreProtocolError),
320 #[error("provider capability receipt is invalid: {0}")]
321 InvalidReceipt(String),
322}
323
324#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(rename_all = "snake_case", deny_unknown_fields)]
326pub enum ProviderProbeJournalRecord {
327 Exact(ExactProbeJournal),
328 CrossPrincipal(CrossPrincipalCompletionJournal),
329}
330
331impl ProviderProbeJournalRecord {
332 pub fn probe_id(&self) -> ProviderProbeId {
333 match self {
334 Self::Exact(record) => record.probe_id,
335 Self::CrossPrincipal(record) => record.probe_id,
336 }
337 }
338
339 pub fn validate_begin(&self) -> Result<(), ProviderProbeJournalError> {
340 let prepared = match self {
341 Self::Exact(record) => matches!(record.progress, ExactProbeProgress::Prepared),
342 Self::CrossPrincipal(record) => {
343 matches!(record.progress, CrossPrincipalCompletionProgress::Prepared)
344 }
345 };
346 if !prepared {
347 return Err(ProviderProbeJournalError::BeginNotPrepared);
348 }
349 Ok(())
350 }
351
352 pub fn validate_transition(&self, next: &Self) -> Result<(), ProviderProbeJournalError> {
353 match (self, next) {
354 (Self::Exact(previous), Self::Exact(next)) => {
355 if previous.probe_id != next.probe_id
356 || previous.binding != next.binding
357 || previous.slot != next.slot
358 || previous.conditional_slot != next.conditional_slot
359 || previous.lost_response_slot != next.lost_response_slot
360 {
361 return Err(ProviderProbeJournalError::ImmutableFactsChanged);
362 }
363 validate_exact_progress_transition(&previous.progress, &next.progress)
364 }
365 (Self::CrossPrincipal(previous), Self::CrossPrincipal(next)) => {
366 if previous.probe_id != next.probe_id
367 || previous.store != next.store
368 || previous.context != next.context
369 || previous.challenge != next.challenge
370 || previous.response != next.response
371 {
372 return Err(ProviderProbeJournalError::ImmutableFactsChanged);
373 }
374 let expected_read_hash = ObjectHash::digest(&probe_payload(
375 &previous.probe_id,
376 ProbePayloadLabel::CrossPeer,
377 ));
378 if cross_progress_evidence_hash(&next.progress)
379 .is_some_and(|hash| hash != expected_read_hash)
380 {
381 return Err(ProviderProbeJournalError::EvidenceChanged);
382 }
383 validate_cross_progress_transition(&previous.progress, &next.progress)
384 }
385 _ => Err(ProviderProbeJournalError::ProbeKindChanged),
386 }
387 }
388}
389
390#[derive(Debug, thiserror::Error, PartialEq, Eq)]
391pub enum ProviderProbeJournalError {
392 #[error("provider probe journal must begin at prepared")]
393 BeginNotPrepared,
394 #[error("provider probe journal advance changes immutable facts")]
395 ImmutableFactsChanged,
396 #[error("provider probe journal advance changes the probe kind")]
397 ProbeKindChanged,
398 #[error("provider probe journal advance skips or reverses progress")]
399 NonAdjacentProgress,
400 #[error("provider probe journal advance changes established evidence")]
401 EvidenceChanged,
402}
403
404#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(deny_unknown_fields)]
406pub struct ExactProbeJournal {
407 pub probe_id: ProviderProbeId,
408 pub binding: crate::objects::ResolvedProviderBinding,
409 pub slot: ObjectSlot,
410 pub conditional_slot: ObjectSlot,
411 pub lost_response_slot: ObjectSlot,
412 pub progress: ExactProbeProgress,
413}
414
415#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
416#[serde(rename_all = "snake_case", deny_unknown_fields)]
417pub enum ExactProbeProgress {
418 Prepared,
419 Created {
420 outcomes: [ProbeCreateOutcome; 2],
421 },
422 ReadsVerified {
423 outcomes: [ProbeCreateOutcome; 2],
424 },
425 ConditionalVerified {
426 outcomes: [ProbeCreateOutcome; 2],
427 conditional: ConditionalUpdateProbeReceipt,
428 },
429 PrimaryAbsent {
430 outcomes: [ProbeCreateOutcome; 2],
431 conditional: ConditionalUpdateProbeReceipt,
432 },
433 LostResponseCreated {
434 outcomes: [ProbeCreateOutcome; 2],
435 conditional: ConditionalUpdateProbeReceipt,
436 },
437 LostResponseReadVerified {
438 outcomes: [ProbeCreateOutcome; 2],
439 conditional: ConditionalUpdateProbeReceipt,
440 },
441 Absent {
442 outcomes: [ProbeCreateOutcome; 2],
443 conditional: ConditionalUpdateProbeReceipt,
444 },
445 ReceiptReady {
446 receipt: ExactSlotProbeReceipt,
447 },
448}
449
450#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
451#[serde(deny_unknown_fields)]
452pub struct CrossPrincipalCompletionJournal {
453 pub probe_id: ProviderProbeId,
454 pub store: StoreProviderBinding,
455 pub context: CrossPrincipalResponseContext,
456 pub challenge: CrossPrincipalProbeChallenge,
457 pub response: CrossPrincipalProbeResponse,
458 pub progress: CrossPrincipalCompletionProgress,
459}
460
461#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
462#[serde(rename_all = "snake_case", deny_unknown_fields)]
463pub enum CrossPrincipalCompletionProgress {
464 Prepared,
465 ReadsVerified {
466 administrator_read_peer_hash: ObjectHash,
467 },
468 PeerAbsent {
469 administrator_read_peer_hash: ObjectHash,
470 },
471 Absent {
472 administrator_read_peer_hash: ObjectHash,
473 },
474 ReceiptReady {
475 receipt: CrossPrincipalProbeReceipt,
476 },
477}
478
479pub(super) fn validate_exact_progress_transition(
480 previous: &ExactProbeProgress,
481 next: &ExactProbeProgress,
482) -> Result<(), ProviderProbeJournalError> {
483 let evidence_matches = match (previous, next) {
484 (ExactProbeProgress::Prepared, ExactProbeProgress::Created { .. }) => true,
485 (
486 ExactProbeProgress::Created { outcomes: previous },
487 ExactProbeProgress::ReadsVerified { outcomes: next },
488 ) => previous == next,
489 (
490 ExactProbeProgress::ReadsVerified { outcomes: previous },
491 ExactProbeProgress::ConditionalVerified { outcomes: next, .. },
492 ) => previous == next,
493 (
494 ExactProbeProgress::ConditionalVerified {
495 outcomes: previous_outcomes,
496 conditional: previous_conditional,
497 },
498 ExactProbeProgress::PrimaryAbsent {
499 outcomes: next_outcomes,
500 conditional: next_conditional,
501 },
502 )
503 | (
504 ExactProbeProgress::PrimaryAbsent {
505 outcomes: previous_outcomes,
506 conditional: previous_conditional,
507 },
508 ExactProbeProgress::LostResponseCreated {
509 outcomes: next_outcomes,
510 conditional: next_conditional,
511 },
512 )
513 | (
514 ExactProbeProgress::LostResponseCreated {
515 outcomes: previous_outcomes,
516 conditional: previous_conditional,
517 },
518 ExactProbeProgress::LostResponseReadVerified {
519 outcomes: next_outcomes,
520 conditional: next_conditional,
521 },
522 )
523 | (
524 ExactProbeProgress::LostResponseReadVerified {
525 outcomes: previous_outcomes,
526 conditional: previous_conditional,
527 },
528 ExactProbeProgress::Absent {
529 outcomes: next_outcomes,
530 conditional: next_conditional,
531 },
532 ) => previous_outcomes == next_outcomes && previous_conditional == next_conditional,
533 (
534 ExactProbeProgress::Absent {
535 outcomes,
536 conditional,
537 },
538 ExactProbeProgress::ReceiptReady { receipt },
539 ) => {
540 receipt
541 .transcript
542 .contenders
543 .iter()
544 .map(|attempt| attempt.outcome)
545 .eq(outcomes.iter().copied())
546 && receipt.transcript.conditional == *conditional
547 }
548 _ => return Err(ProviderProbeJournalError::NonAdjacentProgress),
549 };
550 if !evidence_matches {
551 return Err(ProviderProbeJournalError::EvidenceChanged);
552 }
553 Ok(())
554}
555
556pub(super) fn validate_cross_progress_transition(
557 previous: &CrossPrincipalCompletionProgress,
558 next: &CrossPrincipalCompletionProgress,
559) -> Result<(), ProviderProbeJournalError> {
560 let evidence_matches = match (previous, next) {
561 (
562 CrossPrincipalCompletionProgress::Prepared,
563 CrossPrincipalCompletionProgress::ReadsVerified { .. },
564 ) => true,
565 (
566 CrossPrincipalCompletionProgress::ReadsVerified {
567 administrator_read_peer_hash: previous,
568 },
569 CrossPrincipalCompletionProgress::PeerAbsent {
570 administrator_read_peer_hash: next,
571 },
572 )
573 | (
574 CrossPrincipalCompletionProgress::PeerAbsent {
575 administrator_read_peer_hash: previous,
576 },
577 CrossPrincipalCompletionProgress::Absent {
578 administrator_read_peer_hash: next,
579 },
580 ) => previous == next,
581 (
582 CrossPrincipalCompletionProgress::Absent {
583 administrator_read_peer_hash,
584 },
585 CrossPrincipalCompletionProgress::ReceiptReady { receipt },
586 ) => receipt.transcript.administrator_read_peer_hash == *administrator_read_peer_hash,
587 _ => return Err(ProviderProbeJournalError::NonAdjacentProgress),
588 };
589 if !evidence_matches {
590 return Err(ProviderProbeJournalError::EvidenceChanged);
591 }
592 Ok(())
593}
594
595pub(super) fn cross_progress_evidence_hash(
596 progress: &CrossPrincipalCompletionProgress,
597) -> Option<ObjectHash> {
598 match progress {
599 CrossPrincipalCompletionProgress::Prepared => None,
600 CrossPrincipalCompletionProgress::ReadsVerified {
601 administrator_read_peer_hash,
602 }
603 | CrossPrincipalCompletionProgress::PeerAbsent {
604 administrator_read_peer_hash,
605 }
606 | CrossPrincipalCompletionProgress::Absent {
607 administrator_read_peer_hash,
608 } => Some(*administrator_read_peer_hash),
609 CrossPrincipalCompletionProgress::ReceiptReady { receipt } => {
610 Some(receipt.transcript.administrator_read_peer_hash)
611 }
612 }
613}
614
615#[async_trait]
616pub trait ProviderProbeJournal: Send + Sync {
617 async fn load(
618 &self,
619 probe_id: ProviderProbeId,
620 ) -> Result<Option<ProviderProbeJournalRecord>, StorageError>;
621
622 async fn begin(
625 &self,
626 prepared: ProviderProbeJournalRecord,
627 ) -> Result<ProviderProbeJournalRecord, StorageError>;
628
629 async fn advance(
632 &self,
633 previous: &ProviderProbeJournalRecord,
634 next: ProviderProbeJournalRecord,
635 ) -> Result<(), StorageError>;
636}
637
638pub(super) fn validate_probe_exact_object(
639 receipt: &ProbeExactObjectReceipt,
640 expected_logical_key: &str,
641 payload: &[u8],
642 label: &str,
643) -> Result<(), ProviderProbeError> {
644 let payload_hash = ObjectHash::digest(payload);
645 if receipt.slot.logical_key() != expected_logical_key
646 || receipt.slot != *receipt.object.slot()
647 || receipt.payload_hash != payload_hash
648 || receipt.object.stored_size() != payload.len() as u64
649 || receipt.object.stored_hash() != payload_hash
650 {
651 return invalid(&format!(
652 "{label} object reference or payload hash is invalid"
653 ));
654 }
655 Ok(())
656}
657
658pub fn invalid<T>(reason: &str) -> Result<T, ProviderProbeError> {
659 Err(ProviderProbeError::InvalidReceipt(reason.to_string()))
660}
661
662pub(super) fn exact_transcript_hash(
663 store: &StoreProviderBinding,
664 device: &ProviderDeviceBinding,
665 transcript: &ExactSlotProbeTranscript,
666) -> ObjectHash {
667 ObjectHash::digest(&domain_json(
668 EXACT_TRANSCRIPT_DOMAIN,
669 &(store, device, transcript),
670 ))
671}