Skip to main content

ferrum_interfaces/vnext/resource/
batch.rs

1use super::{
2    fmt, invalid_resource, ActiveSequenceFrame, AdmissionDeferred, AdmissionDemand,
3    AdmissionRejected, AdmittedSequenceResources, Arc, BTreeMap, BackingClaimCertificate,
4    BatchInvocationId, BatchParticipantAuthority, BatchParticipantTokenSpan, BatchStepId,
5    BatchWorkShape, CommittedLaneBackingClaim, DeviceRuntime, Digest, DynamicBackingDeferred,
6    DynamicDeferredMaintenanceOutcome, ExecutionFrameId, ExecutionLane,
7    LaneStableArenaSlotIdentity, LaneStableArenaSlotLease, LogicalBackingSliceAuthority,
8    LogicalBatchCapacityLease, Mutex, NodeId, ParticipantFlightPhase, ParticipantNodeKey,
9    PlanBackingDeferral, PlanCapacityWaitRegistration, PlanHash, ProgramBindingExecutionBinding,
10    ProgramBindingLayout, ProgramBindingNodeBinding, RequestAuthorityId, SequenceAuthorityId,
11    SequenceBackingSnapshot, SequenceSession, SequenceSessionEpoch, SequenceSessionFingerprint,
12    SequenceSessionPhase, SequenceSessionSlot, SequenceSessionSlotState, Serialize, Sha256,
13    StepCompletedBoundaryProof, StepCompletedBoundarySlot, StepParticipantFrameAssignment,
14    TokenSpanWork, TrustedPlanRuntimeEvidence, VNextError,
15};
16use crate::vnext::DeviceReusableExecutionProgramId;
17use crate::vnext::{ReusableExecutionBucketId, ReusableExecutionBucketSpec};
18
19/// Resources whose lifetime is one exact continuous-batch execution frame.
20/// Child invocation leases retain this scope through `Arc`, so shared frame
21/// capacity and every participant authority outlive asynchronous device work.
22#[must_use = "step resources must live through every child invocation"]
23pub struct StepResourceLease<R>
24where
25    R: DeviceRuntime,
26{
27    // The transaction releases physical extents before its logical claim,
28    // then per-sequence frame guards release before their parent sessions.
29    pub(super) claimed_backing: ClaimedBackingTransaction,
30    pub(super) participants: Vec<AdmittedStepParticipant<R>>,
31    pub(super) invocation_registry: Arc<InvocationRegistry>,
32    pub(super) execution_lane: Arc<ExecutionLane<R>>,
33    pub(super) reusable_execution_bucket: Option<ReusableExecutionBucketSpec>,
34    pub(super) batch_step_id: BatchStepId,
35    pub(super) completed_boundary: Mutex<StepCompletedBoundarySlot>,
36    pub(super) finalized: bool,
37}
38
39/// Canonical non-empty set selected by the scheduler for one continuous
40/// batch. Membership is exact; capacity shapes may not claim a different
41/// sequence count and no global concurrency ceiling is embedded here.
42#[must_use = "a batch participant set is required to admit one execution frame"]
43pub struct ExecutionBatchParticipants<R>
44where
45    R: DeviceRuntime,
46{
47    pub(super) sessions: Vec<Arc<SequenceSession<R>>>,
48    plan_evidence: TrustedPlanRuntimeEvidence,
49}
50
51fn sequence_participant_key<R: DeviceRuntime>(
52    sequence: &AdmittedSequenceResources<R>,
53) -> (u32, u64, u32, u64) {
54    let sequence_authority = sequence.sequence_authority();
55    let request_authority = sequence.request_authority();
56    (
57        sequence_authority.sparse_id(),
58        sequence_authority.generation(),
59        request_authority.sparse_id(),
60        request_authority.generation(),
61    )
62}
63
64pub(super) fn session_participant_key<R: DeviceRuntime>(
65    session: &SequenceSession<R>,
66) -> (u32, u64, u32, u64) {
67    sequence_participant_key(session.resources())
68}
69
70impl<R> ExecutionBatchParticipants<R>
71where
72    R: DeviceRuntime,
73{
74    pub fn new(mut sessions: Vec<Arc<SequenceSession<R>>>) -> Result<Self, VNextError> {
75        sessions.sort_by_key(|session| session_participant_key(session));
76        if sessions.is_empty()
77            || sessions
78                .windows(2)
79                .any(|pair| session_participant_key(&pair[0]) == session_participant_key(&pair[1]))
80        {
81            return Err(invalid_resource(
82                "execution batch participants must be non-empty and unique",
83            ));
84        }
85        u32::try_from(sessions.len())
86            .map_err(|_| invalid_resource("execution batch participant count exceeds u32"))?;
87        let plan_evidence = sessions[0].resources().plan_evidence();
88        if sessions.iter().any(|session| {
89            session.resources().is_poisoned()
90                || session.resources().plan_evidence() != plan_evidence
91        }) {
92            return Err(invalid_resource(
93                "execution batch participants differ in plan, runtime, pool, coordinator, or health",
94            ));
95        }
96        let resources = Arc::clone(&sessions[0].resources().request.plan.resources);
97        if sessions
98            .iter()
99            .any(|session| !Arc::ptr_eq(&resources, &session.resources().request.plan.resources))
100        {
101            return Err(invalid_resource(
102                "execution batch participants belong to distinct plan runtime roots",
103            ));
104        }
105        let _lifecycle = resources.read_lifecycle("create execution batch participants")?;
106        Ok(Self {
107            sessions,
108            plan_evidence,
109        })
110    }
111
112    pub fn len(&self) -> u32 {
113        u32::try_from(self.sessions.len())
114            .expect("execution batch participant count is validated at construction")
115    }
116
117    pub fn is_empty(&self) -> bool {
118        false
119    }
120
121    pub fn sessions(&self) -> &[Arc<SequenceSession<R>>] {
122        &self.sessions
123    }
124
125    pub fn plan_evidence(&self) -> &TrustedPlanRuntimeEvidence {
126        &self.plan_evidence
127    }
128
129    pub fn bind_work_shape(
130        &self,
131        token_spans: Vec<TokenSpanWork>,
132    ) -> Result<BatchWorkShape, VNextError> {
133        if token_spans.len() != self.sessions.len() {
134            return Err(invalid_resource(
135                "batch token work count differs from its exact participant set",
136            ));
137        }
138        BatchWorkShape::new(
139            self.sessions
140                .iter()
141                .zip(token_spans)
142                .map(|(session, token_span)| {
143                    BatchParticipantTokenSpan::new(
144                        BatchParticipantAuthority::new(
145                            session.sequence_authority(),
146                            session.request_authority(),
147                        ),
148                        token_span,
149                    )
150                })
151                .collect(),
152        )
153    }
154}
155
156#[derive(Clone)]
157pub(super) struct SequenceFrameCandidate {
158    pub(super) slot: Arc<SequenceSessionSlot>,
159    pub(super) epoch: SequenceSessionEpoch,
160    pub(super) fingerprint: SequenceSessionFingerprint,
161}
162
163pub(super) struct SequenceFrameCaptureCandidate<R>
164where
165    R: DeviceRuntime,
166{
167    frame: SequenceFrameCandidate,
168    resources: Arc<AdmittedSequenceResources<R>>,
169}
170
171pub(super) struct SessionFrameHold {
172    pub(super) slot: Arc<SequenceSessionSlot>,
173    pub(super) epoch: SequenceSessionEpoch,
174    pub(super) fingerprint: SequenceSessionFingerprint,
175    pub(super) frame_id: ExecutionFrameId,
176    pub(super) batch_step_id: BatchStepId,
177    pub(super) finalized: bool,
178}
179
180pub(super) struct CapturedSessionFrame<R>
181where
182    R: DeviceRuntime,
183{
184    pub(super) hold: SessionFrameHold,
185    pub(super) backing_snapshot: Arc<SequenceBackingSnapshot<R>>,
186}
187
188#[derive(Clone)]
189pub(super) struct ParticipantFlightCandidate {
190    pub(super) slot: Arc<SequenceSessionSlot>,
191    pub(super) epoch: SequenceSessionEpoch,
192    pub(super) fingerprint: SequenceSessionFingerprint,
193    pub(super) frame: ActiveSequenceFrame,
194    pub(super) participant: BatchParticipantAuthority,
195}
196
197#[derive(Clone)]
198struct ParticipantNodeFlightCandidate {
199    candidate: ParticipantFlightCandidate,
200    node_id: NodeId,
201}
202
203impl ParticipantNodeFlightCandidate {
204    fn new(candidate: ParticipantFlightCandidate, node_id: NodeId) -> Self {
205        Self { candidate, node_id }
206    }
207
208    fn key(&self) -> ParticipantNodeKey {
209        ParticipantNodeKey::new(
210            self.candidate.participant,
211            self.candidate.frame.frame_id,
212            self.node_id.clone(),
213        )
214    }
215}
216
217/// One participant-local flight owned by an exact invocation. Dropping this
218/// hold removes the sequence-flight count, while the physical ledger guard
219/// independently retires its topology. Only the sealed device DNF path may
220/// reset an in-flight hold to Prepared for retry.
221pub(super) struct PreparedParticipantFlightHold {
222    slot: Arc<SequenceSessionSlot>,
223    pub(super) epoch: SequenceSessionEpoch,
224    pub(super) fingerprint: SequenceSessionFingerprint,
225    key: ParticipantNodeKey,
226    batch_step_id: BatchStepId,
227    pub(super) phase: ParticipantFlightPhase,
228}
229
230impl Drop for PreparedParticipantFlightHold {
231    fn drop(&mut self) {
232        let mut state = match self.slot.state.lock() {
233            Ok(state) => state,
234            Err(poisoned) => {
235                let mut state = poisoned.into_inner();
236                *state = SequenceSessionSlotState::FailClosed;
237                return;
238            }
239        };
240        match &mut *state {
241            SequenceSessionSlotState::Active(active)
242                if active.epoch == self.epoch && active.fingerprint == self.fingerprint =>
243            {
244                if active.participant_flights.remove(&self.key) != Some(self.phase) {
245                    active.phase = SequenceSessionPhase::Poisoned;
246                }
247            }
248            _ => *state = SequenceSessionSlotState::FailClosed,
249        }
250    }
251}
252
253pub(super) fn prepare_participant_flights(
254    candidates: &[ParticipantFlightCandidate],
255    node_id: &NodeId,
256) -> Result<Vec<PreparedParticipantFlightHold>, VNextError> {
257    prepare_participant_node_flights(
258        candidates
259            .iter()
260            .cloned()
261            .map(|candidate| ParticipantNodeFlightCandidate::new(candidate, node_id.clone()))
262            .collect(),
263    )
264}
265
266fn prepare_participant_node_flights(
267    candidates: Vec<ParticipantNodeFlightCandidate>,
268) -> Result<Vec<PreparedParticipantFlightHold>, VNextError> {
269    let mut candidates = candidates
270        .into_iter()
271        .map(|candidate| {
272            let key = candidate.key();
273            (candidate.candidate, key)
274        })
275        .collect::<Vec<_>>();
276    candidates.sort_by(|left, right| left.1.cmp(&right.1));
277    if candidates.is_empty() || candidates.windows(2).any(|pair| pair[0].1 >= pair[1].1) {
278        return Err(invalid_resource(
279            "prepared submission wave requires canonical non-empty unique participant-node keys",
280        ));
281    }
282
283    type ParticipantFrameKey = (u32, u64, u32, u64, u64);
284    let participant_frame_key = |key: &ParticipantNodeKey| -> ParticipantFrameKey {
285        (
286            key.sequence_authority().sparse_id(),
287            key.sequence_authority().generation(),
288            key.request_authority().sparse_id(),
289            key.request_authority().generation(),
290            key.frame_id().get(),
291        )
292    };
293    let mut slot_by_participant = BTreeMap::<ParticipantFrameKey, usize>::new();
294    let mut participant_by_slot = BTreeMap::<usize, ParticipantFrameKey>::new();
295    let mut unique_slots = Vec::<Arc<SequenceSessionSlot>>::new();
296    let mut slot_indices = Vec::with_capacity(candidates.len());
297    for (candidate, key) in &candidates {
298        let participant = participant_frame_key(key);
299        let slot_identity = Arc::as_ptr(&candidate.slot) as usize;
300        if slot_by_participant
301            .get(&participant)
302            .is_some_and(|known| *known != slot_identity)
303            || participant_by_slot
304                .get(&slot_identity)
305                .is_some_and(|known| *known != participant)
306        {
307            return Err(invalid_resource(
308                "submission wave participant/frame authority maps to inconsistent session slots",
309            ));
310        }
311        slot_by_participant.insert(participant, slot_identity);
312        participant_by_slot.insert(slot_identity, participant);
313        let slot_index = if let Some(index) = unique_slots
314            .iter()
315            .position(|slot| Arc::ptr_eq(slot, &candidate.slot))
316        {
317            index
318        } else {
319            unique_slots.push(Arc::clone(&candidate.slot));
320            unique_slots.len() - 1
321        };
322        slot_indices.push(slot_index);
323    }
324
325    let mut states = Vec::with_capacity(unique_slots.len());
326    for slot in &unique_slots {
327        states.push(
328            slot.state
329                .lock()
330                .map_err(|_| invalid_resource("sequence session state mutex is poisoned"))?,
331        );
332    }
333    for ((candidate, key), &slot_index) in candidates.iter().zip(&slot_indices) {
334        match &*states[slot_index] {
335            SequenceSessionSlotState::Active(active)
336                if active.epoch == candidate.epoch
337                    && active.fingerprint == candidate.fingerprint
338                    && active.phase == SequenceSessionPhase::Open
339                    && active.active_frame == Some(candidate.frame)
340                    && active.submission_wave_flight.is_none()
341                    && !active.participant_flights.contains_key(key) => {}
342            SequenceSessionSlotState::Active(active)
343                if active.epoch != candidate.epoch
344                    || active.fingerprint != candidate.fingerprint =>
345            {
346                return Err(invalid_resource(
347                    "stale prepared submission wave participant authority",
348                ));
349            }
350            SequenceSessionSlotState::Active(_) => {
351                return Err(invalid_resource(
352                    "cancelled, poisoned, duplicate, or cross-frame participant cannot enter a submission wave",
353                ));
354            }
355            _ => {
356                return Err(invalid_resource(
357                    "inactive or terminal participant cannot enter a submission wave",
358                ));
359            }
360        }
361    }
362
363    let mut inserted = Vec::<(usize, ParticipantNodeKey)>::new();
364    for ((_, key), &slot_index) in candidates.iter().zip(&slot_indices) {
365        let previous = {
366            let SequenceSessionSlotState::Active(active) = &mut *states[slot_index] else {
367                unreachable!("all prepared wave participants were validated");
368            };
369            active
370                .participant_flights
371                .insert(key.clone(), ParticipantFlightPhase::Prepared)
372        };
373        if let Some(previous) = previous {
374            for (rollback_slot, rollback_key) in inserted.into_iter().rev() {
375                if let SequenceSessionSlotState::Active(rollback) = &mut *states[rollback_slot] {
376                    rollback.participant_flights.remove(&rollback_key);
377                    rollback.phase = SequenceSessionPhase::Poisoned;
378                }
379            }
380            if let SequenceSessionSlotState::Active(active) = &mut *states[slot_index] {
381                active.participant_flights.insert(key.clone(), previous);
382                active.phase = SequenceSessionPhase::Poisoned;
383            }
384            return Err(invalid_resource(
385                "prepared participant wave changed during atomic insertion",
386            ));
387        }
388        inserted.push((slot_index, key.clone()));
389    }
390    drop(states);
391
392    Ok(candidates
393        .into_iter()
394        .map(|(candidate, key)| PreparedParticipantFlightHold {
395            slot: candidate.slot,
396            epoch: candidate.epoch,
397            fingerprint: candidate.fingerprint,
398            key,
399            batch_step_id: candidate.frame.batch_step_id,
400            phase: ParticipantFlightPhase::Prepared,
401        })
402        .collect())
403}
404
405/// One participant-local owner for a physical all-node submission wave. Node
406/// coverage remains in the immutable plan topology and invocation registry;
407/// the sequence session only tracks whether this participant has device work.
408pub(super) struct PreparedSubmissionWaveParticipantFlightHold {
409    slot: Arc<SequenceSessionSlot>,
410    pub(super) epoch: SequenceSessionEpoch,
411    pub(super) fingerprint: SequenceSessionFingerprint,
412    participant: BatchParticipantAuthority,
413    frame: ActiveSequenceFrame,
414    pub(super) phase: ParticipantFlightPhase,
415}
416
417impl PreparedSubmissionWaveParticipantFlightHold {
418    fn canonical_key(&self) -> (u32, u64, u32, u64, u64) {
419        let (sequence, sequence_generation, request, request_generation) =
420            self.participant.canonical_key();
421        (
422            sequence,
423            sequence_generation,
424            request,
425            request_generation,
426            self.frame.frame_id.get(),
427        )
428    }
429}
430
431impl Drop for PreparedSubmissionWaveParticipantFlightHold {
432    fn drop(&mut self) {
433        let mut state = match self.slot.state.lock() {
434            Ok(state) => state,
435            Err(poisoned) => {
436                let mut state = poisoned.into_inner();
437                *state = SequenceSessionSlotState::FailClosed;
438                return;
439            }
440        };
441        match &mut *state {
442            SequenceSessionSlotState::Active(active)
443                if active.epoch == self.epoch && active.fingerprint == self.fingerprint =>
444            {
445                if active.submission_wave_flight.take() != Some(self.phase) {
446                    active.phase = SequenceSessionPhase::Poisoned;
447                }
448            }
449            _ => *state = SequenceSessionSlotState::FailClosed,
450        }
451    }
452}
453
454pub(super) fn prepare_submission_wave_participant_flights(
455    candidates: &[ParticipantFlightCandidate],
456) -> Result<Vec<PreparedSubmissionWaveParticipantFlightHold>, VNextError> {
457    type ParticipantFrameKey = (u32, u64, u32, u64, u64);
458
459    let mut candidates = candidates
460        .iter()
461        .cloned()
462        .map(|candidate| {
463            let (sequence, sequence_generation, request, request_generation) =
464                candidate.participant.canonical_key();
465            let key = (
466                sequence,
467                sequence_generation,
468                request,
469                request_generation,
470                candidate.frame.frame_id.get(),
471            );
472            (candidate, key)
473        })
474        .collect::<Vec<_>>();
475    candidates.sort_by_key(|(_, key)| *key);
476    if candidates.is_empty() || candidates.windows(2).any(|pair| pair[0].1 >= pair[1].1) {
477        return Err(invalid_resource(
478            "submission wave requires canonical non-empty unique participant/frame authorities",
479        ));
480    }
481
482    let mut slot_by_participant = BTreeMap::<ParticipantFrameKey, usize>::new();
483    let mut participant_by_slot = BTreeMap::<usize, ParticipantFrameKey>::new();
484    let mut unique_slots = Vec::<Arc<SequenceSessionSlot>>::new();
485    let mut unique_slot_indices = BTreeMap::<usize, usize>::new();
486    let mut slot_indices = Vec::with_capacity(candidates.len());
487    for (candidate, participant) in &candidates {
488        let slot_identity = Arc::as_ptr(&candidate.slot) as usize;
489        if slot_by_participant
490            .get(participant)
491            .is_some_and(|known| *known != slot_identity)
492            || participant_by_slot
493                .get(&slot_identity)
494                .is_some_and(|known| known != participant)
495        {
496            return Err(invalid_resource(
497                "submission wave participant/frame authority maps to inconsistent session slots",
498            ));
499        }
500        slot_by_participant.insert(*participant, slot_identity);
501        participant_by_slot.insert(slot_identity, *participant);
502        let slot_index = match unique_slot_indices.get(&slot_identity).copied() {
503            Some(index) => index,
504            None => {
505                let index = unique_slots.len();
506                unique_slots.push(Arc::clone(&candidate.slot));
507                unique_slot_indices.insert(slot_identity, index);
508                index
509            }
510        };
511        slot_indices.push(slot_index);
512    }
513
514    let mut states = Vec::with_capacity(unique_slots.len());
515    for slot in &unique_slots {
516        states.push(
517            slot.state
518                .lock()
519                .map_err(|_| invalid_resource("sequence session state mutex is poisoned"))?,
520        );
521    }
522    for ((candidate, _), &slot_index) in candidates.iter().zip(&slot_indices) {
523        match &*states[slot_index] {
524            SequenceSessionSlotState::Active(active)
525                if active.epoch == candidate.epoch
526                    && active.fingerprint == candidate.fingerprint
527                    && active.phase == SequenceSessionPhase::Open
528                    && active.active_frame == Some(candidate.frame)
529                    && active.participant_flights.is_empty()
530                    && active.submission_wave_flight.is_none() => {}
531            SequenceSessionSlotState::Active(active)
532                if active.epoch != candidate.epoch
533                    || active.fingerprint != candidate.fingerprint =>
534            {
535                return Err(invalid_resource(
536                    "stale submission wave participant authority",
537                ));
538            }
539            SequenceSessionSlotState::Active(_) => {
540                return Err(invalid_resource(
541                    "cancelled, poisoned, duplicate, cross-frame, or node-busy participant cannot enter a submission wave",
542                ));
543            }
544            _ => {
545                return Err(invalid_resource(
546                    "inactive or terminal participant cannot enter a submission wave",
547                ));
548            }
549        }
550    }
551
552    let mut inserted_slots = Vec::<usize>::with_capacity(candidates.len());
553    for &slot_index in &slot_indices {
554        let previous = {
555            let SequenceSessionSlotState::Active(active) = &mut *states[slot_index] else {
556                unreachable!("all submission wave participants were validated");
557            };
558            active
559                .submission_wave_flight
560                .replace(ParticipantFlightPhase::Prepared)
561        };
562        if previous.is_some() {
563            for rollback_slot in inserted_slots.into_iter().rev() {
564                if let SequenceSessionSlotState::Active(rollback) = &mut *states[rollback_slot] {
565                    rollback.submission_wave_flight = None;
566                    rollback.phase = SequenceSessionPhase::Poisoned;
567                }
568            }
569            if let SequenceSessionSlotState::Active(active) = &mut *states[slot_index] {
570                active.submission_wave_flight = previous;
571                active.phase = SequenceSessionPhase::Poisoned;
572            }
573            return Err(invalid_resource(
574                "submission wave participant flights changed during atomic insertion",
575            ));
576        }
577        inserted_slots.push(slot_index);
578    }
579    drop(states);
580
581    Ok(candidates
582        .into_iter()
583        .map(
584            |(candidate, _)| PreparedSubmissionWaveParticipantFlightHold {
585                slot: candidate.slot,
586                epoch: candidate.epoch,
587                fingerprint: candidate.fingerprint,
588                participant: candidate.participant,
589                frame: candidate.frame,
590                phase: ParticipantFlightPhase::Prepared,
591            },
592        )
593        .collect())
594}
595
596pub(super) fn begin_submission_wave_participant_flights_dispatch(
597    holds: &mut [PreparedSubmissionWaveParticipantFlightHold],
598) -> Result<(), VNextError> {
599    transition_submission_wave_participant_flights(
600        holds,
601        ParticipantFlightPhase::Prepared,
602        ParticipantFlightPhase::InFlight,
603        "begin submission wave dispatch",
604    )
605}
606
607pub(super) fn reset_submission_wave_participant_flights_after_definitely_not_submitted(
608    holds: &mut [PreparedSubmissionWaveParticipantFlightHold],
609) -> Result<(), VNextError> {
610    transition_submission_wave_participant_flights(
611        holds,
612        ParticipantFlightPhase::InFlight,
613        ParticipantFlightPhase::Prepared,
614        "reset definitely-not-submitted submission wave dispatch",
615    )
616}
617
618fn transition_submission_wave_participant_flights(
619    holds: &mut [PreparedSubmissionWaveParticipantFlightHold],
620    expected: ParticipantFlightPhase,
621    next: ParticipantFlightPhase,
622    context: &'static str,
623) -> Result<(), VNextError> {
624    if holds.is_empty()
625        || holds.iter().any(|hold| hold.phase != expected)
626        || holds
627            .windows(2)
628            .any(|pair| pair[0].canonical_key() >= pair[1].canonical_key())
629    {
630        return Err(invalid_resource(format!(
631            "{context} requires canonical non-empty unique participant flights in the expected phase"
632        )));
633    }
634
635    let slots = holds
636        .iter()
637        .map(|hold| Arc::clone(&hold.slot))
638        .collect::<Vec<_>>();
639    let mut states = Vec::with_capacity(slots.len());
640    for slot in &slots {
641        states.push(
642            slot.state
643                .lock()
644                .map_err(|_| invalid_resource("sequence session state mutex is poisoned"))?,
645        );
646    }
647    for (hold, state) in holds.iter().zip(&states) {
648        match &**state {
649            SequenceSessionSlotState::Active(active)
650                if active.epoch == hold.epoch
651                    && active.fingerprint == hold.fingerprint
652                    && active.phase == SequenceSessionPhase::Open
653                    && active.active_frame == Some(hold.frame)
654                    && active.participant_flights.is_empty()
655                    && active.submission_wave_flight == Some(expected) => {}
656            SequenceSessionSlotState::Active(active)
657                if active.epoch != hold.epoch || active.fingerprint != hold.fingerprint =>
658            {
659                return Err(invalid_resource(
660                    "stale submission wave participant authority during phase transition",
661                ));
662            }
663            SequenceSessionSlotState::Active(_) => {
664                return Err(invalid_resource(format!(
665                    "cancelled, poisoned, wrong-phase, cross-frame, or node-busy participant cannot {context}"
666                )));
667            }
668            _ => {
669                return Err(invalid_resource(format!(
670                    "inactive or terminal participant cannot {context}"
671                )));
672            }
673        }
674    }
675    for state in &mut states {
676        let SequenceSessionSlotState::Active(active) = &mut **state else {
677            unreachable!("all submission wave participants were validated while locked");
678        };
679        active.submission_wave_flight = Some(next);
680    }
681    drop(states);
682    for hold in holds {
683        hold.phase = next;
684    }
685    Ok(())
686}
687
688pub(super) fn begin_participant_flights_dispatch(
689    holds: &mut [PreparedParticipantFlightHold],
690) -> Result<(), VNextError> {
691    transition_participant_flights(
692        holds,
693        ParticipantFlightPhase::Prepared,
694        ParticipantFlightPhase::InFlight,
695        "begin dispatch",
696    )
697}
698
699pub(super) fn reset_participant_flights_after_definitely_not_submitted(
700    holds: &mut [PreparedParticipantFlightHold],
701) -> Result<(), VNextError> {
702    transition_participant_flights(
703        holds,
704        ParticipantFlightPhase::InFlight,
705        ParticipantFlightPhase::Prepared,
706        "reset definitely-not-submitted dispatch",
707    )
708}
709
710fn transition_participant_flights(
711    holds: &mut [PreparedParticipantFlightHold],
712    expected: ParticipantFlightPhase,
713    next: ParticipantFlightPhase,
714    context: &'static str,
715) -> Result<(), VNextError> {
716    if holds.is_empty()
717        || holds.iter().any(|hold| hold.phase != expected)
718        || holds.windows(2).any(|pair| pair[0].key >= pair[1].key)
719    {
720        return Err(invalid_resource(format!(
721            "{context} requires canonical non-empty unique participant-node flights in the expected phase"
722        )));
723    }
724
725    let mut unique_slots = Vec::<Arc<SequenceSessionSlot>>::new();
726    let mut slot_indices = Vec::with_capacity(holds.len());
727    for hold in holds.iter() {
728        let slot_index = if let Some(index) = unique_slots
729            .iter()
730            .position(|slot| Arc::ptr_eq(slot, &hold.slot))
731        {
732            index
733        } else {
734            unique_slots.push(Arc::clone(&hold.slot));
735            unique_slots.len() - 1
736        };
737        slot_indices.push(slot_index);
738    }
739    let mut states = Vec::with_capacity(unique_slots.len());
740    for slot in &unique_slots {
741        states.push(
742            slot.state
743                .lock()
744                .map_err(|_| invalid_resource("sequence session state mutex is poisoned"))?,
745        );
746    }
747    for (hold, &slot_index) in holds.iter().zip(&slot_indices) {
748        match &*states[slot_index] {
749            SequenceSessionSlotState::Active(active)
750                if active.epoch == hold.epoch
751                    && active.fingerprint == hold.fingerprint
752                    && active.phase == SequenceSessionPhase::Open
753                    && active.active_frame
754                        == Some(ActiveSequenceFrame {
755                            frame_id: hold.key.frame_id(),
756                            batch_step_id: hold.batch_step_id,
757                        })
758                    && active.submission_wave_flight.is_none()
759                    && active.participant_flights.get(&hold.key) == Some(&expected) => {}
760            SequenceSessionSlotState::Active(active)
761                if active.epoch != hold.epoch || active.fingerprint != hold.fingerprint =>
762            {
763                return Err(invalid_resource(
764                    "stale invocation participant authority during phase transition",
765                ));
766            }
767            SequenceSessionSlotState::Active(_) => {
768                return Err(invalid_resource(format!(
769                    "cancelled, poisoned, wrong-phase, or cross-frame participant cannot {context}"
770                )));
771            }
772            _ => {
773                return Err(invalid_resource(format!(
774                    "inactive or terminal participant cannot {context}"
775                )));
776            }
777        }
778    }
779    for (hold, &slot_index) in holds.iter().zip(&slot_indices) {
780        let SequenceSessionSlotState::Active(active) = &mut *states[slot_index] else {
781            unreachable!("all dispatch participants were validated while locked");
782        };
783        let phase = active
784            .participant_flights
785            .get_mut(&hold.key)
786            .expect("validated participant flight remains present while locked");
787        *phase = next;
788    }
789    for hold in holds {
790        hold.phase = next;
791    }
792    Ok(())
793}
794
795impl Drop for SessionFrameHold {
796    fn drop(&mut self) {
797        if self.finalized {
798            return;
799        }
800        let mut state = match self.slot.state.lock() {
801            Ok(state) => state,
802            Err(poisoned) => poisoned.into_inner(),
803        };
804        if let SequenceSessionSlotState::Active(active) = &mut *state {
805            if active.epoch == self.epoch
806                && active.fingerprint == self.fingerprint
807                && active.active_frame
808                    == Some(ActiveSequenceFrame {
809                        frame_id: self.frame_id,
810                        batch_step_id: self.batch_step_id,
811                    })
812            {
813                active.phase = SequenceSessionPhase::Poisoned;
814            }
815        }
816    }
817}
818
819pub(super) struct AdmittedStepParticipant<R>
820where
821    R: DeviceRuntime,
822{
823    pub(super) frame: SessionFrameHold,
824    // Drop the independently retained backing before the session parent. The
825    // snapshot also owns a runtime keepalive for fence/reaper handoff.
826    pub(super) backing_snapshot: Arc<SequenceBackingSnapshot<R>>,
827    pub(super) session: Arc<SequenceSession<R>>,
828}
829
830fn execution_frame_successor(frame_id: ExecutionFrameId) -> Option<ExecutionFrameId> {
831    frame_id
832        .get()
833        .checked_add(1)
834        .and_then(|next| ExecutionFrameId::try_from(next).ok())
835}
836
837pub(super) fn acquire_session_frames(
838    candidates: &[SequenceFrameCandidate],
839    batch_step_id: BatchStepId,
840) -> Result<Vec<SessionFrameHold>, VNextError> {
841    if candidates.is_empty()
842        || candidates.iter().enumerate().any(|(index, candidate)| {
843            candidates[..index]
844                .iter()
845                .any(|prior| Arc::ptr_eq(&prior.slot, &candidate.slot))
846        })
847    {
848        return Err(invalid_resource(
849            "step frame acquisition requires non-empty unique session slots",
850        ));
851    }
852    let mut holds = Vec::with_capacity(candidates.len());
853    let mut states = Vec::with_capacity(candidates.len());
854    for candidate in candidates {
855        states.push(
856            candidate
857                .slot
858                .state
859                .lock()
860                .map_err(|_| invalid_resource("sequence session state mutex is poisoned"))?,
861        );
862    }
863    for (candidate, state) in candidates.iter().zip(&states) {
864        match &**state {
865            SequenceSessionSlotState::Active(active)
866                if active.epoch == candidate.epoch
867                    && active.fingerprint == candidate.fingerprint
868                    && active.phase == SequenceSessionPhase::Open
869                    && active.active_frame.is_none()
870                    && !active.state_transfer.is_reserved()
871                    && active.next_frame.is_some() => {}
872            SequenceSessionSlotState::Active(active)
873                if active.epoch != candidate.epoch
874                    || active.fingerprint != candidate.fingerprint =>
875            {
876                return Err(invalid_resource("stale sequence session frame authority"));
877            }
878            SequenceSessionSlotState::Active(_) => {
879                return Err(invalid_resource(
880                    "sequence session cannot acquire a frame in its current phase",
881                ));
882            }
883            _ => {
884                return Err(invalid_resource(
885                    "inactive or terminal sequence session cannot acquire a frame",
886                ));
887            }
888        }
889    }
890    for (candidate, state) in candidates.iter().zip(&mut states) {
891        let SequenceSessionSlotState::Active(active) = &mut **state else {
892            unreachable!("all session frame candidates were validated");
893        };
894        let frame_id = active
895            .next_frame
896            .take()
897            .expect("validated session has a next execution frame");
898        active.next_frame = execution_frame_successor(frame_id);
899        active.active_frame = Some(ActiveSequenceFrame {
900            frame_id,
901            batch_step_id,
902        });
903        holds.push(SessionFrameHold {
904            slot: Arc::clone(&candidate.slot),
905            epoch: candidate.epoch,
906            fingerprint: candidate.fingerprint.clone(),
907            frame_id,
908            batch_step_id,
909            finalized: false,
910        });
911    }
912    Ok(holds)
913}
914
915pub(super) fn session_frame_candidates<R: DeviceRuntime>(
916    sessions: &[Arc<SequenceSession<R>>],
917) -> Vec<SequenceFrameCandidate> {
918    sessions
919        .iter()
920        .map(|session| SequenceFrameCandidate {
921            slot: Arc::clone(&session.slot),
922            epoch: session.epoch,
923            fingerprint: session.fingerprint.clone(),
924        })
925        .collect()
926}
927
928pub(super) fn acquire_session_frames_with_backing<R>(
929    candidates: &[SequenceFrameCaptureCandidate<R>],
930    batch_step_id: BatchStepId,
931    work_shape: &BatchWorkShape,
932) -> Result<Vec<CapturedSessionFrame<R>>, VNextError>
933where
934    R: DeviceRuntime,
935{
936    if candidates.is_empty()
937        || candidates.len() != work_shape.participant_work().len()
938        || candidates.iter().enumerate().any(|(index, candidate)| {
939            candidates[..index]
940                .iter()
941                .any(|prior| Arc::ptr_eq(&prior.frame.slot, &candidate.frame.slot))
942        })
943    {
944        return Err(invalid_resource(
945            "step frame acquisition requires non-empty unique session slots",
946        ));
947    }
948    let mut states = candidates
949        .iter()
950        .map(|candidate| {
951            candidate
952                .frame
953                .slot
954                .state
955                .lock()
956                .map_err(|_| invalid_resource("sequence session state mutex is poisoned"))
957        })
958        .collect::<Result<Vec<_>, _>>()?;
959    for (candidate, state) in candidates.iter().zip(&states) {
960        match &**state {
961            SequenceSessionSlotState::Active(active)
962                if active.epoch == candidate.frame.epoch
963                    && active.fingerprint == candidate.frame.fingerprint
964                    && active.phase == SequenceSessionPhase::Open
965                    && active.active_frame.is_none()
966                    && !active.state_transfer.is_reserved()
967                    && active.next_frame.is_some() => {}
968            SequenceSessionSlotState::Active(active)
969                if active.epoch != candidate.frame.epoch
970                    || active.fingerprint != candidate.frame.fingerprint =>
971            {
972                return Err(invalid_resource("stale sequence session frame authority"));
973            }
974            SequenceSessionSlotState::Active(_) => {
975                return Err(invalid_resource(
976                    "sequence session cannot acquire a frame in its current phase",
977                ));
978            }
979            _ => {
980                return Err(invalid_resource(
981                    "inactive or terminal sequence session cannot acquire a frame",
982                ));
983            }
984        }
985    }
986    for ((candidate, state), work) in candidates
987        .iter()
988        .zip(&states)
989        .zip(work_shape.participant_work())
990    {
991        let SequenceSessionSlotState::Active(active) = &**state else {
992            unreachable!("all session frame candidates were validated");
993        };
994        active.completed_boundary.validate_imported_work(
995            work.token_span(),
996            candidate.resources.request.plan.plan_hash(),
997        )?;
998    }
999    // Frame capture and extension publication use the same slot -> backing
1000    // lock order. Allocator, device, copy, and fence work stay outside it.
1001    let backing_states = candidates
1002        .iter()
1003        .map(|candidate| candidate.resources.lock_backing_state())
1004        .collect::<Result<Vec<_>, _>>()?;
1005    let mut captured = Vec::with_capacity(candidates.len());
1006    for ((candidate, state), backing_state) in
1007        candidates.iter().zip(&mut states).zip(&backing_states)
1008    {
1009        let SequenceSessionSlotState::Active(active) = &mut **state else {
1010            unreachable!("all session frame candidates were validated");
1011        };
1012        let frame_id = active
1013            .next_frame
1014            .take()
1015            .expect("validated session has a next execution frame");
1016        active.next_frame = execution_frame_successor(frame_id);
1017        active.active_frame = Some(ActiveSequenceFrame {
1018            frame_id,
1019            batch_step_id,
1020        });
1021        captured.push(CapturedSessionFrame {
1022            hold: SessionFrameHold {
1023                slot: Arc::clone(&candidate.frame.slot),
1024                epoch: candidate.frame.epoch,
1025                fingerprint: candidate.frame.fingerprint.clone(),
1026                frame_id,
1027                batch_step_id,
1028                finalized: false,
1029            },
1030            backing_snapshot: Arc::clone(&backing_state.current),
1031        });
1032    }
1033    Ok(captured)
1034}
1035
1036pub(super) fn session_frame_capture_candidates<R: DeviceRuntime>(
1037    sessions: &[Arc<SequenceSession<R>>],
1038) -> Vec<SequenceFrameCaptureCandidate<R>> {
1039    sessions
1040        .iter()
1041        .map(|session| SequenceFrameCaptureCandidate {
1042            frame: SequenceFrameCandidate {
1043                slot: Arc::clone(&session.slot),
1044                epoch: session.epoch,
1045                fingerprint: session.fingerprint.clone(),
1046            },
1047            resources: Arc::clone(session.resources()),
1048        })
1049        .collect()
1050}
1051
1052pub(super) fn poison_session_frame(hold: &SessionFrameHold) {
1053    let mut state = match hold.slot.state.lock() {
1054        Ok(state) => state,
1055        Err(poisoned) => poisoned.into_inner(),
1056    };
1057    if let SequenceSessionSlotState::Active(active) = &mut *state {
1058        if active.epoch == hold.epoch
1059            && active.fingerprint == hold.fingerprint
1060            && active.active_frame
1061                == Some(ActiveSequenceFrame {
1062                    frame_id: hold.frame_id,
1063                    batch_step_id: hold.batch_step_id,
1064                })
1065        {
1066            active.phase = SequenceSessionPhase::Poisoned;
1067        }
1068    }
1069}
1070
1071#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1072pub(super) enum StepFrameFinalization {
1073    Commit,
1074    Abort,
1075    RollbackUnsubmitted,
1076}
1077
1078pub(super) fn finalize_session_frames(
1079    holds: &mut [&mut SessionFrameHold],
1080    finalization: StepFrameFinalization,
1081) -> Result<Vec<StepParticipantRetirementDisposition>, VNextError> {
1082    finalize_session_frames_with_boundary(holds, finalization, None)
1083}
1084
1085pub(super) fn finalize_session_frames_with_boundary(
1086    holds: &mut [&mut SessionFrameHold],
1087    finalization: StepFrameFinalization,
1088    completed: Option<&StepCompletedBoundaryProof>,
1089) -> Result<Vec<StepParticipantRetirementDisposition>, VNextError> {
1090    let slots = holds
1091        .iter()
1092        .map(|hold| Arc::clone(&hold.slot))
1093        .collect::<Vec<_>>();
1094    let mut states = Vec::with_capacity(slots.len());
1095    for slot in &slots {
1096        states.push(
1097            slot.state
1098                .lock()
1099                .map_err(|_| invalid_resource("sequence session state mutex is poisoned"))?,
1100        );
1101    }
1102    let mut dispositions = Vec::with_capacity(holds.len());
1103    for (hold, state) in holds.iter().zip(&states) {
1104        let hold = &**hold;
1105        let SequenceSessionSlotState::Active(active) = &**state else {
1106            return Err(invalid_resource(
1107                "step participant session is no longer active",
1108            ));
1109        };
1110        if active.epoch != hold.epoch
1111            || active.fingerprint != hold.fingerprint
1112            || active.active_frame
1113                != Some(ActiveSequenceFrame {
1114                    frame_id: hold.frame_id,
1115                    batch_step_id: hold.batch_step_id,
1116                })
1117            || active.next_frame != execution_frame_successor(hold.frame_id)
1118            || active.has_participant_flights()
1119            || (finalization == StepFrameFinalization::Commit
1120                && active.phase == SequenceSessionPhase::Poisoned)
1121            || (finalization == StepFrameFinalization::Commit && active.retired_frames == u64::MAX)
1122            || (finalization == StepFrameFinalization::RollbackUnsubmitted
1123                && active.phase != SequenceSessionPhase::Open)
1124        {
1125            return Err(invalid_resource(
1126                "step finalization differs from its exact session frame or has live participant work",
1127            ));
1128        }
1129        dispositions.push(match finalization {
1130            StepFrameFinalization::Abort => StepParticipantRetirementDisposition::Aborted,
1131            StepFrameFinalization::RollbackUnsubmitted => {
1132                StepParticipantRetirementDisposition::RolledBackUnsubmitted
1133            }
1134            StepFrameFinalization::Commit
1135                if active.phase == SequenceSessionPhase::CancelRequested =>
1136            {
1137                StepParticipantRetirementDisposition::DiscardedCancelled
1138            }
1139            StepFrameFinalization::Commit => StepParticipantRetirementDisposition::Committed,
1140        });
1141    }
1142    // Compute all frontier updates before changing any participant. A private
1143    // Step proof cannot partially commit a batch whose later member is stale.
1144    let frontiers = super::completed_frontier_updates(&holds, &states, &dispositions, completed)?;
1145    for ((hold, state), frontier) in holds.iter().zip(&mut states).zip(frontiers) {
1146        let hold = &**hold;
1147        let SequenceSessionSlotState::Active(active) = &mut **state else {
1148            unreachable!("all step participant sessions were validated");
1149        };
1150        active.active_frame = None;
1151        if let Some(frontier) = frontier {
1152            active.completed_boundary = frontier;
1153        }
1154        match finalization {
1155            StepFrameFinalization::Abort => active.phase = SequenceSessionPhase::Poisoned,
1156            StepFrameFinalization::Commit => active.retired_frames += 1,
1157            StepFrameFinalization::RollbackUnsubmitted => active.next_frame = Some(hold.frame_id),
1158        }
1159    }
1160    drop(states);
1161    for hold in holds {
1162        hold.finalized = true;
1163    }
1164    Ok(dispositions)
1165}
1166
1167#[derive(Default)]
1168pub(super) struct InvocationRegistryState {
1169    pub(super) entries: BTreeMap<ParticipantNodeKey, ParticipantNodeLedgerEntry>,
1170    pub(super) submission_wave: Option<SubmissionWaveLedgerEntry>,
1171    pub(super) poisoned: bool,
1172}
1173
1174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1175pub(super) enum PhysicalInvocationPhase {
1176    Prepared,
1177    NotSubmitted,
1178    InFlight,
1179    Retired,
1180}
1181
1182#[derive(Clone, PartialEq, Eq)]
1183pub(super) struct ParticipantNodeLedgerEntry {
1184    pub(super) batch_invocation_id: BatchInvocationId,
1185    pub(super) work_fingerprint: String,
1186    pub(super) phase: PhysicalInvocationPhase,
1187}
1188
1189#[derive(Clone, PartialEq, Eq)]
1190pub(super) struct SubmissionWaveLedgerEntry {
1191    // Full-plan preparation has already proved the exact node set. This compact entry is
1192    // therefore the unexpanded participant x plan-node Cartesian tombstone for the step.
1193    pub(super) ledger: ParticipantNodeLedgerEntry,
1194    pub(super) covered_participant_nodes: usize,
1195}
1196
1197#[derive(Default)]
1198pub(super) struct InvocationRegistry {
1199    pub(super) state: Mutex<InvocationRegistryState>,
1200}
1201
1202impl InvocationRegistry {
1203    pub(super) fn ensure_pristine_for_step_rollback(&self) -> Result<(), VNextError> {
1204        let state = self
1205            .state
1206            .lock()
1207            .map_err(|_| invalid_resource("invocation registry is poisoned"))?;
1208        if state.poisoned || !state.entries.is_empty() || state.submission_wave.is_some() {
1209            return Err(invalid_resource(
1210                "unsubmitted step rollback requires a pristine invocation registry",
1211            ));
1212        }
1213        Ok(())
1214    }
1215
1216    pub(super) fn enter(
1217        self: &Arc<Self>,
1218        keys: Vec<ParticipantNodeKey>,
1219        batch_invocation_id: BatchInvocationId,
1220        work_fingerprint: &str,
1221    ) -> Result<ActiveInvocationWaveGuard, VNextError> {
1222        if keys.is_empty() || keys.windows(2).any(|pair| pair[0] >= pair[1]) {
1223            return Err(invalid_resource(
1224                "physical invocation ledger requires canonical non-empty unique participant-node keys",
1225            ));
1226        }
1227        let mut state = self
1228            .state
1229            .lock()
1230            .map_err(|_| invalid_resource("invocation registry is poisoned"))?;
1231        if state.poisoned {
1232            return Err(invalid_resource("invocation registry is fail-closed"));
1233        }
1234        if state.submission_wave.is_some() || keys.iter().any(|key| state.entries.contains_key(key))
1235        {
1236            return Err(invalid_resource(
1237                "participant/frame/node topology is already prepared, in flight, or retired in this step",
1238            ));
1239        }
1240        let entry = ParticipantNodeLedgerEntry {
1241            batch_invocation_id,
1242            work_fingerprint: work_fingerprint.to_owned(),
1243            phase: PhysicalInvocationPhase::Prepared,
1244        };
1245        for key in &keys {
1246            if state.entries.insert(key.clone(), entry.clone()).is_some() {
1247                state.poisoned = true;
1248                return Err(invalid_resource(
1249                    "physical invocation ledger changed during atomic prepare",
1250                ));
1251            }
1252        }
1253        Ok(ActiveInvocationWaveGuard {
1254            registry: Arc::clone(self),
1255            topology: ActiveInvocationLedgerTopology::ParticipantNodes(keys),
1256            work_fingerprint: work_fingerprint.to_owned(),
1257            batch_invocation_id,
1258            phase: PhysicalInvocationPhase::Prepared,
1259        })
1260    }
1261
1262    pub(super) fn enter_submission_wave(
1263        self: &Arc<Self>,
1264        covered_participant_nodes: usize,
1265        batch_invocation_id: BatchInvocationId,
1266        topology_fingerprint: &str,
1267    ) -> Result<ActiveInvocationWaveGuard, VNextError> {
1268        if covered_participant_nodes == 0 {
1269            return Err(invalid_resource(
1270                "submission wave ledger requires non-zero participant-node coverage",
1271            ));
1272        }
1273        let mut state = self
1274            .state
1275            .lock()
1276            .map_err(|_| invalid_resource("invocation registry is poisoned"))?;
1277        if state.poisoned {
1278            return Err(invalid_resource("invocation registry is fail-closed"));
1279        }
1280        if state.submission_wave.is_some() || !state.entries.is_empty() {
1281            return Err(invalid_resource(
1282                "submission wave overlaps prepared, in-flight, or retired participant-node topology in this step",
1283            ));
1284        }
1285        let ledger = ParticipantNodeLedgerEntry {
1286            batch_invocation_id,
1287            work_fingerprint: topology_fingerprint.to_owned(),
1288            phase: PhysicalInvocationPhase::Prepared,
1289        };
1290        state.submission_wave = Some(SubmissionWaveLedgerEntry {
1291            ledger,
1292            covered_participant_nodes,
1293        });
1294        Ok(ActiveInvocationWaveGuard {
1295            registry: Arc::clone(self),
1296            topology: ActiveInvocationLedgerTopology::SubmissionWave {
1297                covered_participant_nodes,
1298            },
1299            work_fingerprint: topology_fingerprint.to_owned(),
1300            batch_invocation_id,
1301            phase: PhysicalInvocationPhase::Prepared,
1302        })
1303    }
1304}
1305
1306enum ActiveInvocationLedgerTopology {
1307    ParticipantNodes(Vec<ParticipantNodeKey>),
1308    SubmissionWave { covered_participant_nodes: usize },
1309}
1310
1311pub(super) struct ActiveInvocationWaveGuard {
1312    registry: Arc<InvocationRegistry>,
1313    topology: ActiveInvocationLedgerTopology,
1314    work_fingerprint: String,
1315    batch_invocation_id: BatchInvocationId,
1316    phase: PhysicalInvocationPhase,
1317}
1318
1319impl ActiveInvocationWaveGuard {
1320    pub(super) const fn physical_entry_count(&self) -> usize {
1321        match &self.topology {
1322            ActiveInvocationLedgerTopology::ParticipantNodes(keys) => keys.len(),
1323            ActiveInvocationLedgerTopology::SubmissionWave { .. } => 1,
1324        }
1325    }
1326
1327    fn transition(
1328        &mut self,
1329        expected: PhysicalInvocationPhase,
1330        next: PhysicalInvocationPhase,
1331        next_attempt: BatchInvocationId,
1332    ) -> Result<(), VNextError> {
1333        if self.phase != expected {
1334            return Err(invalid_resource(
1335                "physical invocation guard is not in the expected phase",
1336            ));
1337        }
1338        let mut state = self
1339            .registry
1340            .state
1341            .lock()
1342            .map_err(|_| invalid_resource("invocation registry is poisoned"))?;
1343        let expected_entry = ParticipantNodeLedgerEntry {
1344            batch_invocation_id: self.batch_invocation_id,
1345            work_fingerprint: self.work_fingerprint.clone(),
1346            phase: expected,
1347        };
1348        let authority_matches = match &self.topology {
1349            ActiveInvocationLedgerTopology::ParticipantNodes(keys) => {
1350                state.submission_wave.is_none()
1351                    && keys
1352                        .iter()
1353                        .all(|key| state.entries.get(key) == Some(&expected_entry))
1354            }
1355            ActiveInvocationLedgerTopology::SubmissionWave {
1356                covered_participant_nodes,
1357            } => {
1358                state.entries.is_empty()
1359                    && state.submission_wave
1360                        == Some(SubmissionWaveLedgerEntry {
1361                            ledger: expected_entry.clone(),
1362                            covered_participant_nodes: *covered_participant_nodes,
1363                        })
1364            }
1365        };
1366        if state.poisoned || !authority_matches {
1367            state.poisoned = true;
1368            return Err(invalid_resource(
1369                "physical invocation ledger differs from its exact transition authority",
1370            ));
1371        }
1372        match &self.topology {
1373            ActiveInvocationLedgerTopology::ParticipantNodes(keys) => {
1374                for key in keys {
1375                    let entry = state
1376                        .entries
1377                        .get_mut(key)
1378                        .expect("validated physical invocation key remains present");
1379                    entry.batch_invocation_id = next_attempt;
1380                    entry.phase = next;
1381                }
1382            }
1383            ActiveInvocationLedgerTopology::SubmissionWave { .. } => {
1384                let entry = &mut state
1385                    .submission_wave
1386                    .as_mut()
1387                    .expect("validated submission wave remains present")
1388                    .ledger;
1389                entry.batch_invocation_id = next_attempt;
1390                entry.phase = next;
1391            }
1392        }
1393        self.batch_invocation_id = next_attempt;
1394        self.phase = next;
1395        Ok(())
1396    }
1397
1398    pub(super) fn mark_not_submitted(&mut self) -> Result<(), VNextError> {
1399        self.transition(
1400            PhysicalInvocationPhase::Prepared,
1401            PhysicalInvocationPhase::NotSubmitted,
1402            self.batch_invocation_id,
1403        )
1404    }
1405
1406    pub(super) fn prepare_retry(
1407        &mut self,
1408        fresh_attempt: BatchInvocationId,
1409    ) -> Result<(), VNextError> {
1410        if fresh_attempt == self.batch_invocation_id {
1411            return Err(invalid_resource(
1412                "definitely-not-submitted retry requires a fresh physical attempt id",
1413            ));
1414        }
1415        self.transition(
1416            PhysicalInvocationPhase::NotSubmitted,
1417            PhysicalInvocationPhase::Prepared,
1418            fresh_attempt,
1419        )
1420    }
1421
1422    pub(super) fn mark_in_flight(&mut self) -> Result<(), VNextError> {
1423        self.transition(
1424            PhysicalInvocationPhase::Prepared,
1425            PhysicalInvocationPhase::InFlight,
1426            self.batch_invocation_id,
1427        )
1428    }
1429}
1430
1431impl Drop for ActiveInvocationWaveGuard {
1432    fn drop(&mut self) {
1433        if self.phase == PhysicalInvocationPhase::Retired {
1434            return;
1435        }
1436        let mut state = match self.registry.state.lock() {
1437            Ok(state) => state,
1438            Err(poisoned) => {
1439                let mut state = poisoned.into_inner();
1440                state.poisoned = true;
1441                state
1442            }
1443        };
1444        let expected = ParticipantNodeLedgerEntry {
1445            batch_invocation_id: self.batch_invocation_id,
1446            work_fingerprint: self.work_fingerprint.clone(),
1447            phase: self.phase,
1448        };
1449        let authority_matches = match &self.topology {
1450            ActiveInvocationLedgerTopology::ParticipantNodes(keys) => {
1451                state.submission_wave.is_none()
1452                    && keys
1453                        .iter()
1454                        .all(|key| state.entries.get(key) == Some(&expected))
1455            }
1456            ActiveInvocationLedgerTopology::SubmissionWave {
1457                covered_participant_nodes,
1458            } => {
1459                state.entries.is_empty()
1460                    && state.submission_wave
1461                        == Some(SubmissionWaveLedgerEntry {
1462                            ledger: expected,
1463                            covered_participant_nodes: *covered_participant_nodes,
1464                        })
1465            }
1466        };
1467        if !authority_matches {
1468            state.poisoned = true;
1469            return;
1470        }
1471        match &self.topology {
1472            ActiveInvocationLedgerTopology::ParticipantNodes(keys) => {
1473                for key in keys {
1474                    state
1475                        .entries
1476                        .get_mut(key)
1477                        .expect("validated physical invocation key remains present")
1478                        .phase = PhysicalInvocationPhase::Retired;
1479                }
1480            }
1481            ActiveInvocationLedgerTopology::SubmissionWave { .. } => {
1482                state
1483                    .submission_wave
1484                    .as_mut()
1485                    .expect("validated submission wave remains present")
1486                    .ledger
1487                    .phase = PhysicalInvocationPhase::Retired;
1488            }
1489        }
1490        self.phase = PhysicalInvocationPhase::Retired;
1491    }
1492}
1493
1494pub enum StepResourceAdmissionDecision<R>
1495where
1496    R: DeviceRuntime,
1497{
1498    Admitted(Arc<StepResourceLease<R>>),
1499    Deferred(AdmissionDeferred),
1500    BackingDeferred(StepAdmissionBackingDeferral<R>),
1501    PermanentRejected(AdmissionRejected),
1502}
1503
1504/// Non-cloneable physical-backing authority for one exact batch participant
1505/// set and immutable step work shape.
1506#[must_use = "step backing deferral retains its exact participant parents"]
1507pub struct StepAdmissionBackingDeferral<R>
1508where
1509    R: DeviceRuntime,
1510{
1511    backing: PlanBackingDeferral<R>,
1512    participants: Vec<Arc<SequenceSession<R>>>,
1513    work_fingerprint: String,
1514}
1515
1516impl<R> StepAdmissionBackingDeferral<R>
1517where
1518    R: DeviceRuntime,
1519{
1520    pub(super) fn new(
1521        evidence: DynamicBackingDeferred,
1522        participants: Vec<Arc<SequenceSession<R>>>,
1523        work_fingerprint: String,
1524    ) -> Result<Self, VNextError> {
1525        let first = participants
1526            .first()
1527            .ok_or_else(|| invalid_resource("step backing deferral requires participants"))?;
1528        let resources = Arc::clone(&first.resources().request.plan.resources);
1529        if participants.iter().any(|participant| {
1530            !Arc::ptr_eq(&resources, &participant.resources().request.plan.resources)
1531        }) {
1532            return Err(invalid_resource(
1533                "step backing deferral participants belong to different plans",
1534            ));
1535        }
1536        Ok(Self {
1537            backing: PlanBackingDeferral::new(resources, evidence)?,
1538            participants,
1539            work_fingerprint,
1540        })
1541    }
1542
1543    pub fn evidence(&self) -> &DynamicBackingDeferred {
1544        self.backing.evidence()
1545    }
1546
1547    pub fn participant_count(&self) -> usize {
1548        self.participants.len()
1549    }
1550
1551    pub fn work_fingerprint(&self) -> &str {
1552        &self.work_fingerprint
1553    }
1554
1555    pub fn maintain(&self) -> Result<DynamicDeferredMaintenanceOutcome, VNextError> {
1556        for participant in &self.participants {
1557            participant.ensure_open_identity()?;
1558        }
1559        self.backing.maintain()
1560    }
1561
1562    pub fn register_waiter(&self) -> Result<PlanCapacityWaitRegistration<R>, VNextError> {
1563        self.backing.register_waiter()
1564    }
1565}
1566
1567#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1568pub enum StepParticipantRetirementDisposition {
1569    Committed,
1570    DiscardedCancelled,
1571    RolledBackUnsubmitted,
1572    Aborted,
1573}
1574
1575#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1576pub struct StepParticipantRetirement {
1577    pub(super) assignment: StepParticipantFrameAssignment,
1578    pub(super) disposition: StepParticipantRetirementDisposition,
1579}
1580
1581impl StepParticipantRetirement {
1582    pub const fn assignment(&self) -> StepParticipantFrameAssignment {
1583        self.assignment
1584    }
1585
1586    pub const fn disposition(&self) -> StepParticipantRetirementDisposition {
1587        self.disposition
1588    }
1589}
1590
1591/// One atomic physical/logical backing claim bound to an immutable batch work
1592/// authority. Even an empty resource demand retains the work shape and claim
1593/// fingerprint through dispatch and fence ownership.
1594#[must_use = "claimed backing must remain owned through its device fence"]
1595pub struct ClaimedBackingTransaction {
1596    // Physical extents release before the logical capacity claim.
1597    backing_slices: Vec<LogicalBackingSliceAuthority>,
1598    logical_capacity: Option<LogicalBatchCapacityLease>,
1599    work_shape: Arc<BatchWorkShape>,
1600    demand: AdmissionDemand,
1601    fingerprint: String,
1602    physical_claim_count: usize,
1603    has_shared_physical_claims: bool,
1604    // Occupancy releases after every physical/logical claim field above.
1605    _lane_slot_lease: Option<LaneStableArenaSlotLease>,
1606}
1607
1608fn logical_capacity_matches(
1609    logical_capacity: &Option<LogicalBatchCapacityLease>,
1610    demand: &AdmissionDemand,
1611    participants: &[BatchParticipantAuthority],
1612    backing_slices: &[LogicalBackingSliceAuthority],
1613) -> bool {
1614    match logical_capacity {
1615        Some(capacity) => {
1616            capacity.claims() == demand.immediate_claim()
1617                && capacity.parents().len() == participants.len()
1618                && capacity
1619                    .parents()
1620                    .iter()
1621                    .zip(participants)
1622                    .all(|(parent, participant)| {
1623                        parent.sequence() == participant.sequence_authority()
1624                            && parent.request() == participant.request_authority()
1625                    })
1626        }
1627        None => demand.immediate_claim().is_empty() && backing_slices.is_empty(),
1628    }
1629}
1630
1631impl ClaimedBackingTransaction {
1632    pub(super) fn new(
1633        work_shape: Arc<BatchWorkShape>,
1634        demand: AdmissionDemand,
1635        logical_capacity: Option<LogicalBatchCapacityLease>,
1636        backing_slices: Vec<LogicalBackingSliceAuthority>,
1637        lane_slot_lease: Option<LaneStableArenaSlotLease>,
1638    ) -> Result<Self, VNextError> {
1639        let certificate = Arc::new(BackingClaimCertificate::from_slices(&backing_slices)?);
1640        Self::new_certified(
1641            work_shape,
1642            demand,
1643            logical_capacity,
1644            backing_slices,
1645            certificate,
1646            lane_slot_lease,
1647        )
1648    }
1649
1650    pub(super) fn new_lane_stable(
1651        work_shape: Arc<BatchWorkShape>,
1652        demand: AdmissionDemand,
1653        logical_capacity: Option<LogicalBatchCapacityLease>,
1654        committed_backing: CommittedLaneBackingClaim,
1655    ) -> Result<Self, VNextError> {
1656        let (backing_slices, certificate, lane_slot_lease) =
1657            committed_backing.into_certified_parts();
1658        Self::new_certified(
1659            work_shape,
1660            demand,
1661            logical_capacity,
1662            backing_slices,
1663            certificate,
1664            lane_slot_lease,
1665        )
1666    }
1667
1668    fn new_certified(
1669        work_shape: Arc<BatchWorkShape>,
1670        demand: AdmissionDemand,
1671        logical_capacity: Option<LogicalBatchCapacityLease>,
1672        backing_slices: Vec<LogicalBackingSliceAuthority>,
1673        certificate: Arc<BackingClaimCertificate>,
1674        lane_slot_lease: Option<LaneStableArenaSlotLease>,
1675    ) -> Result<Self, VNextError> {
1676        let bound_certificate = certificate.bind(&backing_slices, &demand)?;
1677        if !logical_capacity_matches(
1678            &logical_capacity,
1679            &demand,
1680            work_shape.participants(),
1681            &backing_slices,
1682        ) {
1683            return Err(invalid_resource(
1684                "logical backing claim differs from work participants or evaluated demand",
1685            ));
1686        }
1687        #[derive(Serialize)]
1688        struct FingerprintInput<'a> {
1689            domain: &'static str,
1690            work_fingerprint: &'a str,
1691            demand: &'a AdmissionDemand,
1692            backing_certificate_fingerprint: &'a str,
1693            capacity_parents: Vec<(SequenceAuthorityId, RequestAuthorityId)>,
1694        }
1695        let input = FingerprintInput {
1696            domain: "ferrum.runtime-vnext.claimed-backing.v5",
1697            work_fingerprint: work_shape.fingerprint(),
1698            demand: &demand,
1699            backing_certificate_fingerprint: bound_certificate.fingerprint(),
1700            capacity_parents: logical_capacity
1701                .as_ref()
1702                .map(|capacity| {
1703                    capacity
1704                        .parents()
1705                        .iter()
1706                        .map(|parent| (parent.sequence(), parent.request()))
1707                        .collect()
1708                })
1709                .unwrap_or_default(),
1710        };
1711        let bytes = serde_json::to_vec(&input).map_err(|error| {
1712            invalid_resource(format!(
1713                "claimed backing fingerprint encode failed: {error}"
1714            ))
1715        })?;
1716        Ok(Self {
1717            backing_slices,
1718            logical_capacity,
1719            work_shape,
1720            demand,
1721            fingerprint: format!("{:x}", Sha256::digest(bytes)),
1722            physical_claim_count: bound_certificate.physical_claim_count(),
1723            has_shared_physical_claims: bound_certificate.has_shared_physical_claims(),
1724            _lane_slot_lease: lane_slot_lease,
1725        })
1726    }
1727
1728    pub fn work_shape(&self) -> &BatchWorkShape {
1729        self.work_shape.as_ref()
1730    }
1731
1732    pub(super) fn work_shape_arc(&self) -> &Arc<BatchWorkShape> {
1733        &self.work_shape
1734    }
1735
1736    pub fn backing_slices(&self) -> &[LogicalBackingSliceAuthority] {
1737        &self.backing_slices
1738    }
1739
1740    pub fn logical_capacity(&self) -> Option<&LogicalBatchCapacityLease> {
1741        self.logical_capacity.as_ref()
1742    }
1743
1744    pub fn fingerprint(&self) -> &str {
1745        &self.fingerprint
1746    }
1747
1748    pub fn demand(&self) -> &AdmissionDemand {
1749        &self.demand
1750    }
1751
1752    pub fn physical_claim_count(&self) -> usize {
1753        self.physical_claim_count
1754    }
1755
1756    pub fn has_shared_physical_claims(&self) -> bool {
1757        self.has_shared_physical_claims
1758    }
1759
1760    pub fn lane_stable_slot_identity(&self) -> Option<LaneStableArenaSlotIdentity> {
1761        self._lane_slot_lease
1762            .as_ref()
1763            .map(LaneStableArenaSlotLease::identity)
1764    }
1765}
1766
1767/// One physical/logical Invocation backing transaction shared by every node
1768/// in an immutable-plan submission wave. Physical demand is charged once for
1769/// the liveness-derived peak and retained until the wave's terminal fence.
1770#[must_use = "submission wave backing must remain owned through its device fence"]
1771pub struct ClaimedSubmissionWaveBacking {
1772    // Physical extents release before the logical capacity claim.
1773    backing_slices: Vec<LogicalBackingSliceAuthority>,
1774    logical_capacity: Option<LogicalBatchCapacityLease>,
1775    plan_hash: PlanHash,
1776    node_count: usize,
1777    plan_node_count: usize,
1778    work_shape: Arc<BatchWorkShape>,
1779    demand: AdmissionDemand,
1780    fingerprint: String,
1781    physical_claim_count: usize,
1782    program_binding: Option<Arc<ProgramBindingExecutionBinding>>,
1783    // Occupancy releases only after terminal completion readback drops this claim.
1784    _lane_slot_lease: Option<LaneStableArenaSlotLease>,
1785}
1786
1787impl ClaimedSubmissionWaveBacking {
1788    pub(super) fn new(
1789        plan_hash: PlanHash,
1790        node_count: usize,
1791        plan_node_count: usize,
1792        work_shape: Arc<BatchWorkShape>,
1793        demand: AdmissionDemand,
1794        logical_capacity: Option<LogicalBatchCapacityLease>,
1795        committed_backing: CommittedLaneBackingClaim,
1796        program_binding_layout: Option<Arc<ProgramBindingLayout>>,
1797    ) -> Result<Self, VNextError> {
1798        let (backing_slices, certificate, lane_slot_lease) =
1799            committed_backing.into_certified_parts();
1800        let participants = work_shape.participants();
1801        if node_count == 0
1802            || node_count > plan_node_count
1803            || participants.is_empty()
1804            || participants
1805                .windows(2)
1806                .any(|pair| pair[0].canonical_key() >= pair[1].canonical_key())
1807        {
1808            return Err(invalid_resource(
1809                "submission wave backing requires ordered unique nodes and participants",
1810            ));
1811        }
1812        let bound_certificate = certificate.bind(&backing_slices, &demand)?;
1813        if !logical_capacity_matches(&logical_capacity, &demand, participants, &backing_slices) {
1814            return Err(invalid_resource(
1815                "submission wave capacity differs from its participants or evaluated demand",
1816            ));
1817        }
1818        let program_binding = program_binding_layout
1819            .map(|layout| {
1820                let lane_slot_identity = lane_slot_lease
1821                    .as_ref()
1822                    .map(LaneStableArenaSlotLease::identity)
1823                    .ok_or_else(|| {
1824                        invalid_resource(
1825                            "compiled program binding layout has no lane-stable slot authority",
1826                        )
1827                    })?;
1828                ProgramBindingExecutionBinding::bind(
1829                    plan_hash.clone(),
1830                    plan_node_count,
1831                    layout,
1832                    lane_slot_identity,
1833                    &backing_slices,
1834                )
1835            })
1836            .transpose()?;
1837
1838        #[derive(Serialize)]
1839        struct FingerprintInput<'a> {
1840            domain: &'static str,
1841            plan_hash: &'a PlanHash,
1842            node_count: usize,
1843            plan_node_count: usize,
1844            work_fingerprint: &'a str,
1845            demand: &'a AdmissionDemand,
1846            backing_certificate_fingerprint: &'a str,
1847            capacity_parents: Vec<(SequenceAuthorityId, RequestAuthorityId)>,
1848        }
1849        let input = FingerprintInput {
1850            domain: "ferrum.runtime-vnext.claimed-submission-wave-backing.v5",
1851            plan_hash: &plan_hash,
1852            node_count,
1853            plan_node_count,
1854            work_fingerprint: work_shape.fingerprint(),
1855            demand: &demand,
1856            backing_certificate_fingerprint: bound_certificate.fingerprint(),
1857            capacity_parents: logical_capacity
1858                .as_ref()
1859                .map(|capacity| {
1860                    capacity
1861                        .parents()
1862                        .iter()
1863                        .map(|parent| (parent.sequence(), parent.request()))
1864                        .collect()
1865                })
1866                .unwrap_or_default(),
1867        };
1868        let bytes = serde_json::to_vec(&input).map_err(|error| {
1869            invalid_resource(format!(
1870                "submission wave backing fingerprint encode failed: {error}"
1871            ))
1872        })?;
1873        Ok(Self {
1874            backing_slices,
1875            logical_capacity,
1876            plan_hash,
1877            node_count,
1878            plan_node_count,
1879            work_shape,
1880            demand,
1881            fingerprint: format!("{:x}", Sha256::digest(bytes)),
1882            physical_claim_count: bound_certificate.physical_claim_count(),
1883            program_binding,
1884            _lane_slot_lease: lane_slot_lease,
1885        })
1886    }
1887
1888    pub fn backing_slices(&self) -> &[LogicalBackingSliceAuthority] {
1889        &self.backing_slices
1890    }
1891
1892    pub fn logical_capacity(&self) -> Option<&LogicalBatchCapacityLease> {
1893        self.logical_capacity.as_ref()
1894    }
1895
1896    pub fn plan_hash(&self) -> &PlanHash {
1897        &self.plan_hash
1898    }
1899
1900    pub const fn node_count(&self) -> usize {
1901        self.node_count
1902    }
1903
1904    pub const fn plan_node_count(&self) -> usize {
1905        self.plan_node_count
1906    }
1907
1908    pub fn work_shape(&self) -> &BatchWorkShape {
1909        self.work_shape.as_ref()
1910    }
1911
1912    pub fn participants(&self) -> &[BatchParticipantAuthority] {
1913        self.work_shape.participants()
1914    }
1915
1916    pub fn demand(&self) -> &AdmissionDemand {
1917        &self.demand
1918    }
1919
1920    pub fn program_binding_node(&self, node_index: usize) -> Option<ProgramBindingNodeBinding> {
1921        self.program_binding
1922            .as_ref()
1923            .and_then(|binding| binding.node(node_index))
1924    }
1925
1926    pub fn program_binding_layout(&self) -> Option<&ProgramBindingLayout> {
1927        self.program_binding
1928            .as_ref()
1929            .map(|binding| binding.layout())
1930    }
1931
1932    pub fn program_binding_lane_slot_identity(&self) -> Option<&LaneStableArenaSlotIdentity> {
1933        self.program_binding
1934            .as_ref()
1935            .map(|binding| binding.lane_slot_identity())
1936    }
1937
1938    pub fn reusable_execution_bucket_id(&self) -> Option<&ReusableExecutionBucketId> {
1939        self.program_binding_layout()
1940            .map(ProgramBindingLayout::reusable_execution_bucket_id)
1941            .or_else(|| {
1942                self.backing_slices
1943                    .iter()
1944                    .find_map(|slice| slice.evidence().reusable_execution_bucket_id())
1945            })
1946    }
1947
1948    pub fn reusable_execution_program_id(
1949        &self,
1950        runtime_implementation_fingerprint: &str,
1951        lane_id: super::ExecutionLaneId,
1952    ) -> Result<Option<DeviceReusableExecutionProgramId>, VNextError> {
1953        let Some(layout) = self.program_binding_layout() else {
1954            if self.program_binding_lane_slot_identity().is_some() {
1955                return Err(invalid_resource(
1956                    "reusable execution lane slot has no compiled program binding layout",
1957                ));
1958            }
1959            return Ok(None);
1960        };
1961        let lane_slot = self.program_binding_lane_slot_identity().ok_or_else(|| {
1962            invalid_resource("compiled program binding layout has no lane-stable slot identity")
1963        })?;
1964        if lane_slot.lane_id() != lane_id
1965            || lane_slot.reusable_execution_bucket_id() != layout.reusable_execution_bucket_id()
1966        {
1967            return Err(invalid_resource(
1968                "reusable execution program layout differs from its lane-stable slot",
1969            ));
1970        }
1971        DeviceReusableExecutionProgramId::new(
1972            self.plan_hash.clone(),
1973            runtime_implementation_fingerprint.to_owned(),
1974            lane_id,
1975            layout.reusable_execution_bucket_id().clone(),
1976            layout.fingerprint().to_owned(),
1977            lane_slot.layout_fingerprint().to_owned(),
1978            lane_slot.slot_id(),
1979            self.work_shape.immediate_sequences(),
1980            self.work_shape.immediate_tokens(),
1981            self.work_shape.immediate_pages(),
1982        )
1983        .map(Some)
1984    }
1985
1986    pub fn fingerprint(&self) -> &str {
1987        &self.fingerprint
1988    }
1989
1990    pub fn physical_claim_count(&self) -> usize {
1991        self.physical_claim_count
1992    }
1993}
1994
1995#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1996pub struct StepRetirementReceipt {
1997    pub(super) batch_step_id: BatchStepId,
1998    pub(super) participants: Vec<StepParticipantRetirement>,
1999}
2000
2001impl StepRetirementReceipt {
2002    pub const fn batch_step_id(&self) -> BatchStepId {
2003        self.batch_step_id
2004    }
2005
2006    pub fn participants(&self) -> &[StepParticipantRetirement] {
2007        &self.participants
2008    }
2009}
2010
2011#[must_use = "failed step finalization retains the exact step authority"]
2012pub struct StepFinalizationFailure<R>
2013where
2014    R: DeviceRuntime,
2015{
2016    pub(super) step: Arc<StepResourceLease<R>>,
2017    pub(super) error: VNextError,
2018}
2019
2020impl<R> fmt::Debug for StepFinalizationFailure<R>
2021where
2022    R: DeviceRuntime,
2023{
2024    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2025        formatter
2026            .debug_struct("StepFinalizationFailure")
2027            .field("error", &self.error)
2028            .finish_non_exhaustive()
2029    }
2030}
2031
2032impl<R> StepFinalizationFailure<R>
2033where
2034    R: DeviceRuntime,
2035{
2036    pub fn error(&self) -> &VNextError {
2037        &self.error
2038    }
2039
2040    pub fn into_step(self) -> Arc<StepResourceLease<R>> {
2041        self.step
2042    }
2043}