Skip to main content

ledgence_orchestration_api/
dispatch.rs

1//! Durable handoff contracts independent of broker receipts or SDK types.
2//!
3//! A dispatch record is a reference to existing task authority, not permission
4//! to execute. Adapters may settle transport delivery only after validating a
5//! successful, identity-bound claim response. Errors provide no handoff proof.
6
7use crate::*;
8use std::time::{Duration, Instant};
9
10/// Complete broker-record/claim-command limit, including JSON whitespace. These
11/// envelopes contain identifiers only; application payloads remain in task state.
12pub const DISPATCH_MAX_BYTES: usize = 16 * 1024;
13/// Claim responses can contain a complete assignment and its application data.
14pub const CLAIM_REPLY_MAX_BYTES: usize = 16 * 1024 * 1024;
15/// Maximum records leased or completed in one maintenance operation.
16pub const MAX_PUBLICATION_BATCH: u32 = 100;
17
18/// One readiness generation. Retransmission preserves this identity; a retry
19/// made eligible by the lifecycle advances the generation.
20#[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/// Publication identity remains unchanged across uncertain send retries. A
47/// deliberate repair publication gets a new identity for the same generation.
48#[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/// The session, consumer, and sequence identify one claim operation. Its exact
68/// dispatch binding is immutable even if the exchange outcome is unknown.
69#[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    /// New assignment or the same claimant's replay. OwnershipLost is a durable
102    /// replay of its former handoff; Empty is never a valid claimed disposition.
103    Claimed { reply: AcquireReply },
104    /// Another claim has established durable recovery. This grants no authority.
105    AlreadyHandedOff { attempt: AttemptRef },
106    /// The referenced generation is durably terminal or superseded. A missing or
107    /// unknown task is not evidence of this disposition.
108    TerminalOrSuperseded,
109    /// Matching future delivery is durably guaranteed by an unfulfilled intent.
110    /// The timestamp alone is not permission for a worker to create an attempt.
111    Deferred { available_at: Timestamp },
112}
113
114/// Every successful disposition consumes the claim sequence and is persisted
115/// with its exact command. Receipts survive subsequent consumer-cursor updates.
116#[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    /// Check handoff evidence before settling any broker record. Validation
124    /// failure is an unavailable/unknown result, never permission to acknowledge.
125    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/// External routing binds a logical queue to a stable opaque destination alias.
211/// Provider URLs, credentials, and SDK options belong in adapter configuration.
212#[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/// A bounded publication reservation. It contains no task execution authority.
228#[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    /// Positive broker evidence for this exact publication. The still-unclaimed
247    /// generation retains a durable intent with a bounded repair deadline.
248    Confirmed,
249    /// Retry failed or uncertain delivery with the same publication identity.
250    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
269/// Maintenance of durable delivery obligations. Implementations commit intent
270/// creation/invalidation atomically with the corresponding task transition.
271/// Publishing is external I/O and must never run while a state transaction is
272/// held. Finite leases and retry/repair delays are backend policy, not execution
273/// concurrency settings. Unknown operation outcomes are safe to retry.
274pub trait DispatchIntentStore: Send + Sync {
275    /// Idempotent for an identical existing route; reject changing its destination.
276    /// Initial external activation rejects a queue containing queued or active tasks. This
277    /// prevents already accepted integrated tasks being silently stranded.
278    fn configure_route<'a>(&'a self, route: &'a DispatchRoute) -> ContractFuture<'a, ()>;
279
280    /// Reserve at most `limit` due intents, where 1 <= limit <= 100. The deadline
281    /// bounds admission, database work, and commit acknowledgement. Expired leases
282    /// become recoverable; an uncertain send keeps its publication identity.
283    fn lease_publications<'a>(
284        &'a self,
285        destination: &'a str,
286        limit: u32,
287        deadline: Instant,
288    ) -> ContractFuture<'a, Vec<PublicationLease>>;
289
290    /// Complete at most 100 leases, conditional on dispatch generation,
291    /// publication identity, and lease token. Late/stale or repeated completions
292    /// are harmless. A later error may follow earlier per-item commits.
293    fn complete_publications<'a>(
294        &'a self,
295        completions: &'a [PublicationCompletion],
296        deadline: Instant,
297    ) -> ContractFuture<'a, ()>;
298}
299
300/// Upper bound for an opaque receipt copied into the shared handoff coordinator.
301/// Receipts remain transport handles; they never identify execution authority.
302pub const QUEUE_RECEIPT_MAX_BYTES: usize = 16 * 1024;
303
304/// Configured transport bounds. These advertise capacities, not ordering,
305/// scheduling, deduplication, durability, or exactly-once execution promises.
306#[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/// Individual-ack delivery model. A stream checkpoint requires a separate port;
337/// implementations must not disguise prefix commits as arbitrary receipt deletes.
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub struct QueueDelivery {
340    pub body: Vec<u8>,
341    pub receipt: String,
342}
343impl QueueDelivery {
344    /// Bound copied transport data before parsing it. Empty/malformed JSON is
345    /// deliberately a decode error handled by the coordinator, not an empty poll.
346    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    /// True requires positive evidence for this exact receipt. False preserves
359    /// an uncertain/retry outcome; it does not undo the durable task handoff.
360    pub confirmed: bool,
361}
362
363/// Publish compact dispatch references. Implementations obey both their declared
364/// limits and the enclosing deadline. They must bound response bytes before
365/// allocation where the transport permits it. Partial responses are per item;
366/// absent, malformed, duplicate, or unexpected identities are never confirmation.
367pub 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
376/// Receive and individually acknowledge transport records. The coordinator
377/// bounds records, bytes, and receipt sizes; these do not create additional
378/// execution concurrency. SDK prefetch/buffers must also have documented bounds.
379pub 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    /// Called only after verified durable handoff. Partial/missing results and
388    /// errors leave the corresponding transport acknowledgment unconfirmed.
389    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}