1use crate::*;
8use std::time::{Duration, Instant};
9
10pub const DISPATCH_MAX_BYTES: usize = 16 * 1024;
13pub const CLAIM_REPLY_MAX_BYTES: usize = 16 * 1024 * 1024;
15pub const MAX_PUBLICATION_BATCH: u32 = 100;
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct DispatchRef {
23 pub scope: Scope,
24 pub queue: String,
25 pub task_id: String,
26 pub generation: u32,
27}
28impl DispatchRef {
29 pub fn validate(&self) -> Result<()> {
30 self.scope.validate()?;
31 validate_text(&self.queue, 128)?;
32 validate_text(&self.task_id, 128)?;
33 if !(1..=1_000).contains(&self.generation) {
34 return Err(invalid("dispatch generation must be between 1 and 1000"));
35 }
36 Ok(())
37 }
38
39 pub fn decode(bytes: &[u8]) -> Result<Self> {
40 let value: Self = decode_unique_json(bytes, DISPATCH_MAX_BYTES)?;
41 value.validate()?;
42 Ok(value)
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct PublishedDispatch {
51 pub dispatch: DispatchRef,
52 pub publication_id: String,
53}
54impl PublishedDispatch {
55 pub fn validate(&self) -> Result<()> {
56 self.dispatch.validate()?;
57 validate_text(&self.publication_id, 128)
58 }
59
60 pub fn decode(bytes: &[u8]) -> Result<Self> {
61 let value: Self = decode_unique_json(bytes, DISPATCH_MAX_BYTES)?;
62 value.validate()?;
63 Ok(value)
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct ClaimCommand {
72 pub acquisition: AcquireCommand,
73 pub dispatch: DispatchRef,
74}
75impl ClaimCommand {
76 pub fn validate(&self) -> Result<()> {
77 self.dispatch.validate()?;
78 let acquisition = &self.acquisition;
79 acquisition.scope.validate()?;
80 validate_text(&acquisition.queue, 128)?;
81 validate_text(&acquisition.worker_session_id, 128)?;
82 if acquisition.sequence == 0 {
83 return Err(invalid("claim sequence must be nonzero"));
84 }
85 if acquisition.scope != self.dispatch.scope || acquisition.queue != self.dispatch.queue {
86 return Err(invalid("claim consumer and dispatch scope/queue differ"));
87 }
88 Ok(())
89 }
90
91 pub fn decode(bytes: &[u8]) -> Result<Self> {
92 let value: Self = decode_unique_json(bytes, DISPATCH_MAX_BYTES)?;
93 value.validate()?;
94 Ok(value)
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
100pub enum ClaimDisposition {
101 Claimed { reply: AcquireReply },
104 AlreadyHandedOff { attempt: AttemptRef },
106 TerminalOrSuperseded,
109 Deferred { available_at: Timestamp },
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(deny_unknown_fields)]
118pub struct ClaimReply {
119 pub command: ClaimCommand,
120 pub disposition: ClaimDisposition,
121}
122impl ClaimReply {
123 pub fn validate_reply_against(&self, expected: &ClaimCommand) -> Result<()> {
126 expected.validate()?;
127 self.validate_identity(expected)
128 .map_err(|_| inconsistent("claim response does not match the requested dispatch"))
129 }
130
131 pub fn decode(bytes: &[u8], expected: &ClaimCommand) -> Result<Self> {
132 let value: Self = decode_unique_json(bytes, CLAIM_REPLY_MAX_BYTES)
133 .map_err(|_| inconsistent("invalid claim response JSON"))?;
134 value.validate_reply_against(expected)?;
135 Ok(value)
136 }
137
138 fn validate_identity(&self, expected: &ClaimCommand) -> Result<()> {
139 if self.command != *expected {
140 return Err(invalid("claim command changed"));
141 }
142 match &self.disposition {
143 ClaimDisposition::Claimed { reply } => match reply {
144 AcquireReply::Assigned {
145 sequence,
146 assignment,
147 } => {
148 if *sequence != expected.acquisition.sequence {
149 return Err(invalid("claim sequence changed"));
150 }
151 let owner = &assignment.lease.owner;
152 let event = &assignment.event;
153 if owner.scope != expected.dispatch.scope
154 || owner.task_id != expected.dispatch.task_id
155 || owner.generation != expected.dispatch.generation
156 || owner.worker_session_id != expected.acquisition.worker_session_id
157 || owner.consumer_id != expected.acquisition.consumer_id
158 || assignment.authority.owner != *owner
159 || assignment.authority.expires_at != assignment.lease.expires_at
160 || event.tenant_id() != owner.scope.tenant_id
161 || event.namespace() != owner.scope.namespace
162 || event.task_id() != owner.task_id
163 || event.attempt_id() != owner.attempt_id
164 || event.value()["ldgattemptno"].as_u64()
165 != Some(u64::from(owner.generation))
166 {
167 return Err(invalid("claim assignment identity changed"));
168 }
169 validate_text(&owner.attempt_id, 128)?;
170 validate_text(&owner.lease_id, 128)?;
171 assignment.descriptor.validate()?;
172 assignment.validate_workflow_identity()?;
173 for key in ["id", "ldgrunid", "ldgtaskid", "ldgattemptid"] {
174 validate_text(event.value()[key].as_str().unwrap_or_default(), 128)?;
175 }
176 validate_text(event.value()["source"].as_str().unwrap_or_default(), 2048)
177 }
178 AcquireReply::OwnershipLost {
179 sequence,
180 assignment,
181 } => {
182 if *sequence != expected.acquisition.sequence {
183 return Err(invalid("claim sequence changed"));
184 }
185 validate_attempt_ref(assignment, &expected.dispatch)
186 }
187 AcquireReply::Empty { .. } => Err(invalid("claimed response cannot be empty")),
188 },
189 ClaimDisposition::AlreadyHandedOff { attempt } => {
190 validate_attempt_ref(attempt, &expected.dispatch)
191 }
192 ClaimDisposition::TerminalOrSuperseded => Ok(()),
193 ClaimDisposition::Deferred { available_at } => {
194 if *available_at > i64::MAX as u64 {
195 return Err(invalid("deferred timestamp exceeds supported range"));
196 }
197 Ok(())
198 }
199 }
200 }
201}
202
203fn validate_attempt_ref(attempt: &AttemptRef, dispatch: &DispatchRef) -> Result<()> {
204 if attempt.task_id != dispatch.task_id {
205 return Err(invalid("claim attempt references another task"));
206 }
207 validate_text(&attempt.attempt_id, 128)
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(deny_unknown_fields)]
214pub struct DispatchRoute {
215 pub scope: Scope,
216 pub queue: String,
217 pub destination: String,
218}
219impl DispatchRoute {
220 pub fn validate(&self) -> Result<()> {
221 self.scope.validate()?;
222 validate_text(&self.queue, 128)?;
223 validate_text(&self.destination, 128)
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(deny_unknown_fields)]
230pub struct PublicationLease {
231 pub record: PublishedDispatch,
232 pub destination: String,
233 pub lease_token: String,
234}
235impl PublicationLease {
236 pub fn validate(&self) -> Result<()> {
237 self.record.validate()?;
238 validate_text(&self.destination, 128)?;
239 validate_text(&self.lease_token, 128)
240 }
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(rename_all = "snake_case")]
245pub enum PublicationOutcome {
246 Confirmed,
249 Retry,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(deny_unknown_fields)]
255pub struct PublicationCompletion {
256 pub dispatch: DispatchRef,
257 pub publication_id: String,
258 pub lease_token: String,
259 pub outcome: PublicationOutcome,
260}
261impl PublicationCompletion {
262 pub fn validate(&self) -> Result<()> {
263 self.dispatch.validate()?;
264 validate_text(&self.publication_id, 128)?;
265 validate_text(&self.lease_token, 128)
266 }
267}
268
269pub trait DispatchIntentStore: Send + Sync {
275 fn configure_route<'a>(&'a self, route: &'a DispatchRoute) -> ContractFuture<'a, ()>;
279
280 fn lease_publications<'a>(
284 &'a self,
285 destination: &'a str,
286 limit: u32,
287 deadline: Instant,
288 ) -> ContractFuture<'a, Vec<PublicationLease>>;
289
290 fn complete_publications<'a>(
294 &'a self,
295 completions: &'a [PublicationCompletion],
296 deadline: Instant,
297 ) -> ContractFuture<'a, ()>;
298}
299
300pub const QUEUE_RECEIPT_MAX_BYTES: usize = 16 * 1024;
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub struct QueueLimits {
308 pub max_publish_batch: u32,
309 pub max_receive_batch: u32,
310 pub max_ack_batch: u32,
311 pub max_message_bytes: usize,
312}
313impl QueueLimits {
314 pub fn validate(&self) -> Result<()> {
315 if [
316 self.max_publish_batch,
317 self.max_receive_batch,
318 self.max_ack_batch,
319 ]
320 .into_iter()
321 .any(|limit| !(1..=MAX_PUBLICATION_BATCH).contains(&limit))
322 || self.max_message_bytes == 0
323 {
324 return Err(invalid("queue limits exceed the portable contract"));
325 }
326 Ok(())
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct PublishResult {
332 pub publication_id: String,
333 pub outcome: PublicationOutcome,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq)]
339pub struct QueueDelivery {
340 pub body: Vec<u8>,
341 pub receipt: String,
342}
343impl QueueDelivery {
344 pub fn validate(&self, limits: QueueLimits) -> Result<()> {
347 limits.validate()?;
348 if self.body.len() > limits.max_message_bytes.min(DISPATCH_MAX_BYTES) {
349 return Err(invalid("queue record exceeds dispatch byte limit"));
350 }
351 validate_receipt(&self.receipt)
352 }
353}
354
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct AckResult {
357 pub receipt: String,
358 pub confirmed: bool,
361}
362
363pub trait DispatchPublisher: Send + Sync {
368 fn limits(&self) -> QueueLimits;
369 fn publish<'a>(
370 &'a self,
371 records: &'a [PublishedDispatch],
372 deadline: Instant,
373 ) -> ContractFuture<'a, Vec<PublishResult>>;
374}
375
376pub trait AckQueue: Send + Sync {
380 fn limits(&self) -> QueueLimits;
381 fn receive(
382 &self,
383 max: u32,
384 wait: Duration,
385 deadline: Instant,
386 ) -> ContractFuture<'_, Vec<QueueDelivery>>;
387 fn acknowledge<'a>(
390 &'a self,
391 receipts: &'a [String],
392 deadline: Instant,
393 ) -> ContractFuture<'a, Vec<AckResult>>;
394}
395
396fn validate_receipt(receipt: &str) -> Result<()> {
397 if receipt.is_empty() || receipt.len() > QUEUE_RECEIPT_MAX_BYTES {
398 return Err(invalid("invalid queue receipt byte length"));
399 }
400 Ok(())
401}
402
403fn invalid(message: &str) -> ContractError {
404 ContractError::InvalidInput(message.into())
405}
406fn inconsistent(message: &str) -> ContractError {
407 ContractError::Unavailable(message.into())
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use ledgence_worker_api::{CloudEvent, Digest, ProgramDescriptor, ProgramRef};
414 use serde_json::json;
415
416 fn dispatch() -> DispatchRef {
417 DispatchRef {
418 scope: Scope {
419 tenant_id: "acme".into(),
420 namespace: "billing".into(),
421 },
422 queue: "invoices".into(),
423 task_id: "task_1042".into(),
424 generation: 1,
425 }
426 }
427 fn command() -> ClaimCommand {
428 let dispatch = dispatch();
429 ClaimCommand {
430 acquisition: AcquireCommand {
431 scope: dispatch.scope.clone(),
432 queue: dispatch.queue.clone(),
433 worker_session_id: "worker_1".into(),
434 consumer_id: 0,
435 sequence: 1,
436 },
437 dispatch,
438 }
439 }
440 fn assigned() -> ClaimReply {
441 let command = command();
442 let owner = LeaseOwner {
443 scope: command.dispatch.scope.clone(),
444 task_id: command.dispatch.task_id.clone(),
445 attempt_id: "attempt_1".into(),
446 lease_id: "lease_1".into(),
447 generation: 1,
448 worker_session_id: command.acquisition.worker_session_id.clone(),
449 consumer_id: 0,
450 };
451 let assignment = Assignment {
452 workflow_activation_id: None, descriptor: ProgramDescriptor {
453 program: ProgramRef { id: "invoice".into(), version: "1".into() },
454 digest: Digest(format!("sha256:{}", "a".repeat(64))), size: 100,
455 },
456 event: CloudEvent::new(json!({
457 "specversion":"1.0","id":"event_1","source":"urn:ledgence:orchestrator",
458 "type":"com.ledgence.task.invocation.requested.v1","datacontenttype":"application/json",
459 "ldgtenantid":"acme","ldgnamespace":"billing","ldgrunid":"run_1042",
460 "ldgtaskid":"task_1042","ldgattemptid":"attempt_1","ldgattemptno":1,
461 "data":{"value":9007199254740993_u64}
462 })).unwrap(),
463 lease: Lease { owner: owner.clone(), expires_at: 61_000 },
464 authority: Authority {
465 owner, expires_at: 61_000, remaining_ms: 60_000, execution_remaining_ms: 60_000,
466 renew_sequence: 0, cancel_requested: false, dispatch_allowed: false,
467 },
468 attempt_deadline: 301_000,
469 };
470 ClaimReply {
471 command,
472 disposition: ClaimDisposition::Claimed {
473 reply: AcquireReply::Assigned {
474 sequence: 1,
475 assignment: Box::new(assignment),
476 },
477 },
478 }
479 }
480 fn assignment(reply: &mut ClaimReply) -> &mut Assignment {
481 let ClaimDisposition::Claimed {
482 reply: AcquireReply::Assigned { assignment, .. },
483 } = &mut reply.disposition
484 else {
485 panic!("assignment fixture")
486 };
487 assignment
488 }
489
490 #[test]
491 fn queue_limits_and_copied_transport_bytes_are_bounded() {
492 let limits = QueueLimits {
493 max_publish_batch: 10,
494 max_receive_batch: 10,
495 max_ack_batch: 10,
496 max_message_bytes: 1024 * 1024,
497 };
498 assert!(limits.validate().is_ok());
499 for bad in [
500 QueueLimits {
501 max_publish_batch: 0,
502 ..limits
503 },
504 QueueLimits {
505 max_receive_batch: 101,
506 ..limits
507 },
508 QueueLimits {
509 max_ack_batch: 101,
510 ..limits
511 },
512 QueueLimits {
513 max_message_bytes: 0,
514 ..limits
515 },
516 ] {
517 assert!(bad.validate().is_err());
518 }
519 let mut delivery = QueueDelivery {
520 body: vec![b' '; DISPATCH_MAX_BYTES],
521 receipt: "receipt".into(),
522 };
523 assert!(delivery.validate(limits).is_ok());
524 assert!(
525 delivery
526 .validate(QueueLimits {
527 max_message_bytes: DISPATCH_MAX_BYTES - 1,
528 ..limits
529 })
530 .is_err()
531 );
532 delivery.body.push(b' ');
533 assert!(delivery.validate(limits).is_err());
534 delivery.body.clear();
535 delivery.receipt = "r".repeat(QUEUE_RECEIPT_MAX_BYTES);
536 assert!(delivery.validate(limits).is_ok());
537 delivery.receipt.push('r');
538 assert!(delivery.validate(limits).is_err());
539 delivery.receipt.clear();
540 assert!(delivery.validate(limits).is_err());
541 }
542
543 #[test]
544 fn publication_and_command_round_trip_preserve_identity() {
545 let record = PublishedDispatch {
546 dispatch: dispatch(),
547 publication_id: "publication_1".into(),
548 };
549 assert_eq!(
550 PublishedDispatch::decode(&serde_json::to_vec(&record).unwrap()).unwrap(),
551 record
552 );
553 let mut command = command();
554 command.acquisition.sequence = u64::MAX;
555 assert_eq!(
556 ClaimCommand::decode(&serde_json::to_vec(&command).unwrap()).unwrap(),
557 command
558 );
559 }
560
561 #[test]
562 fn decoding_rejects_duplicate_unknown_fractional_and_oversized_records() {
563 let record = PublishedDispatch {
564 dispatch: dispatch(),
565 publication_id: "publication_1".into(),
566 };
567 let bytes = serde_json::to_vec(&record).unwrap();
568 let text = String::from_utf8(bytes.clone()).unwrap();
569 for invalid in [
570 text.replace("\"generation\":1", "\"generation\":1,\"generation\":1"),
571 text.replace(
572 "\"generation\":1",
573 "\"generation\":1,\"generatio\\u006e\":1",
574 ),
575 text.replace("\"generation\":1", "\"generation\":1.5"),
576 text.replace("\"generation\":1", "\"generation\":1,\"extra\":true"),
577 text.replacen('{', "{\"extra\":true,", 1),
578 ] {
579 assert!(
580 PublishedDispatch::decode(invalid.as_bytes()).is_err(),
581 "{invalid}"
582 );
583 }
584 let mut bounded = bytes;
585 bounded.resize(DISPATCH_MAX_BYTES, b' ');
586 assert!(PublishedDispatch::decode(&bounded).is_ok());
587 bounded.push(b' ');
588 assert!(PublishedDispatch::decode(&bounded).is_err());
589 }
590
591 #[test]
592 fn claim_validation_binds_queue_scope_sequence_and_generation() {
593 let original = command();
594 for mutate in [
595 |c: &mut ClaimCommand| c.acquisition.scope.tenant_id = "other".into(),
596 |c: &mut ClaimCommand| c.acquisition.queue = "other".into(),
597 |c: &mut ClaimCommand| c.acquisition.sequence = 0,
598 |c: &mut ClaimCommand| c.dispatch.generation = 0,
599 |c: &mut ClaimCommand| c.dispatch.generation = 1001,
600 |c: &mut ClaimCommand| c.dispatch.task_id = "x".repeat(129),
601 |c: &mut ClaimCommand| c.acquisition.worker_session_id = "bad\nvalue".into(),
602 ] {
603 let mut bad = original.clone();
604 mutate(&mut bad);
605 assert!(bad.validate().is_err());
606 }
607 }
608
609 #[test]
610 fn valid_claim_round_trip_preserves_large_user_integer() {
611 let reply = assigned();
612 let decoded = ClaimReply::decode(&serde_json::to_vec(&reply).unwrap(), &command()).unwrap();
613 let ClaimDisposition::Claimed {
614 reply: AcquireReply::Assigned { assignment, .. },
615 } = decoded.disposition
616 else {
617 panic!("assignment expected")
618 };
619 assert_eq!(
620 assignment.event.value()["data"]["value"].as_u64(),
621 Some(9007199254740993)
622 );
623 }
624
625 #[test]
626 fn changed_echoed_command_never_provides_handoff_evidence() {
627 for mutate in [
628 |c: &mut ClaimCommand| c.acquisition.sequence += 1,
629 |c: &mut ClaimCommand| c.acquisition.worker_session_id = "worker_2".into(),
630 |c: &mut ClaimCommand| c.acquisition.consumer_id = 1,
631 |c: &mut ClaimCommand| c.dispatch.task_id = "task_2".into(),
632 |c: &mut ClaimCommand| c.dispatch.generation = 2,
633 |c: &mut ClaimCommand| c.dispatch.queue = "other".into(),
634 |c: &mut ClaimCommand| c.dispatch.scope.namespace = "other".into(),
635 ] {
636 let mut reply = assigned();
637 mutate(&mut reply.command);
638 assert!(matches!(
639 reply.validate_reply_against(&command()),
640 Err(ContractError::Unavailable(_))
641 ));
642 }
643 }
644
645 #[test]
646 fn changed_assignment_authority_never_provides_handoff_evidence() {
647 for mutate in [
648 |a: &mut Assignment| a.lease.owner.task_id = "task_2".into(),
649 |a: &mut Assignment| a.lease.owner.attempt_id = "attempt_2".into(),
650 |a: &mut Assignment| a.lease.owner.generation = 2,
651 |a: &mut Assignment| a.lease.owner.scope.tenant_id = "other".into(),
652 |a: &mut Assignment| a.lease.owner.worker_session_id = "worker_2".into(),
653 |a: &mut Assignment| a.lease.owner.consumer_id = 1,
654 |a: &mut Assignment| a.authority.owner.lease_id = "lease_2".into(),
655 |a: &mut Assignment| a.authority.expires_at += 1,
656 |a: &mut Assignment| a.descriptor.size = 0,
657 ] {
658 let mut reply = assigned();
659 mutate(assignment(&mut reply));
660 assert!(matches!(
661 reply.validate_reply_against(&command()),
662 Err(ContractError::Unavailable(_))
663 ));
664 }
665 }
666
667 #[test]
668 fn event_identity_must_match_both_claim_and_lease() {
669 for (key, value) in [
670 ("ldgtenantid", json!("other")),
671 ("ldgnamespace", json!("other")),
672 ("ldgtaskid", json!("task_2")),
673 ("ldgattemptid", json!("attempt_2")),
674 ("ldgattemptno", json!(2)),
675 ("id", json!("x".repeat(129))),
676 ] {
677 let mut reply = assigned();
678 let a = assignment(&mut reply);
679 let mut event = a.event.value().clone();
680 event[key] = value;
681 a.event = CloudEvent::new(event).unwrap();
682 assert!(reply.validate_reply_against(&command()).is_err(), "{key}");
683 }
684 }
685
686 #[test]
687 fn only_identity_bound_durable_nonauthority_replies_are_accepted() {
688 let reference = AttemptRef {
689 task_id: "task_1042".into(),
690 attempt_id: "attempt_1".into(),
691 };
692 for disposition in [
693 ClaimDisposition::AlreadyHandedOff {
694 attempt: reference.clone(),
695 },
696 ClaimDisposition::TerminalOrSuperseded,
697 ClaimDisposition::Deferred {
698 available_at: 90_000,
699 },
700 ClaimDisposition::Claimed {
701 reply: AcquireReply::OwnershipLost {
702 sequence: 1,
703 assignment: reference,
704 },
705 },
706 ] {
707 assert!(
708 ClaimReply {
709 command: command(),
710 disposition
711 }
712 .validate_reply_against(&command())
713 .is_ok()
714 );
715 }
716 for disposition in [
717 ClaimDisposition::Claimed {
718 reply: AcquireReply::Empty { sequence: 1 },
719 },
720 ClaimDisposition::AlreadyHandedOff {
721 attempt: AttemptRef {
722 task_id: "other".into(),
723 attempt_id: "attempt_1".into(),
724 },
725 },
726 ClaimDisposition::Claimed {
727 reply: AcquireReply::OwnershipLost {
728 sequence: 2,
729 assignment: AttemptRef {
730 task_id: "task_1042".into(),
731 attempt_id: "attempt_1".into(),
732 },
733 },
734 },
735 ClaimDisposition::Deferred {
736 available_at: u64::MAX,
737 },
738 ] {
739 assert!(
740 ClaimReply {
741 command: command(),
742 disposition
743 }
744 .validate_reply_against(&command())
745 .is_err()
746 );
747 }
748 }
749
750 #[test]
751 fn publication_leases_and_completion_require_bounded_opaque_identity() {
752 let route = DispatchRoute {
753 scope: dispatch().scope,
754 queue: "invoices".into(),
755 destination: "billing-primary".into(),
756 };
757 assert!(route.validate().is_ok());
758 let mut lease = PublicationLease {
759 record: PublishedDispatch {
760 dispatch: dispatch(),
761 publication_id: "publication_1".into(),
762 },
763 destination: route.destination,
764 lease_token: "token_1".into(),
765 };
766 assert!(lease.validate().is_ok());
767 lease.lease_token.clear();
768 assert!(lease.validate().is_err());
769 let mut completion = PublicationCompletion {
770 dispatch: dispatch(),
771 publication_id: "publication_1".into(),
772 lease_token: "token_1".into(),
773 outcome: PublicationOutcome::Retry,
774 };
775 assert!(completion.validate().is_ok());
776 completion.publication_id = "x".repeat(129);
777 assert!(completion.validate().is_err());
778 }
779}