1use std::sync::Arc;
4
5use bamboo_domain::{Session, SessionAuthorityIdentity, SessionKind, Storage};
6use bamboo_tickets::{
7 canonical_bytes, content_hash, AssignmentState, Authority, DispatchSpec, Error, Operation,
8 Principal, Result, RuntimeReceipt, ScopeBinding, TicketService, WorkState,
9};
10use serde::{Deserialize, Serialize};
11
12use crate::execution::spawn::SessionInboxRuntimeBinding;
13
14pub const TICKET_DISPATCH_KEY: &str = "ticket.runtime.dispatch.v1";
15mod result;
16pub use result::{
17 checkpoint_owned_task_stop, checkpoint_owned_ticket_stop, checkpoint_ticket_result,
18 question_terminal_call_id,
19};
20
21#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct TicketDispatchBinding {
24 pub dispatch_key: String,
25 pub spec_hash: String,
26 pub assignment_id: String,
27 pub binding: ScopeBinding,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub max_rounds: Option<u32>,
31 pub receipt: Option<RuntimeReceipt>,
32 pub receipt_created_at: Option<chrono::DateTime<chrono::Utc>>,
33}
34
35#[derive(Clone, Debug, Serialize)]
36#[serde(tag = "status", rename_all = "snake_case")]
37pub enum DispatchObservation {
38 Missing,
39 Pending {
40 session_id: String,
41 },
42 Admitted {
43 receipt: RuntimeReceipt,
44 },
45 Terminal {
46 receipt: RuntimeReceipt,
47 terminal_status: String,
48 },
49 OutcomeUnknown {
50 receipt: Option<RuntimeReceipt>,
51 reason: String,
52 },
53}
54
55pub fn ticket_child_id(dispatch_key: &str) -> String {
56 format!("ticket-{}", &content_hash(dispatch_key.as_bytes())[..32])
57}
58
59pub async fn verified_scope_binding(
62 storage: &dyn Storage,
63 supervisor_id: &str,
64) -> Result<(ScopeBinding, Session)> {
65 let supervisor = storage
66 .load_root_authority(supervisor_id)
67 .await?
68 .ok_or_else(|| Error::ScopeDenied("Supervisor missing".into()))?;
69 let SessionAuthorityIdentity::Supervisor { incarnation_id } = supervisor.authority_identity
70 else {
71 return Err(Error::ScopeDenied("canonical Supervisor required".into()));
72 };
73 let Some(orchestration_revision) = supervisor.supervisor_orchestration_revision() else {
74 return Err(Error::ScopeDenied(
75 "Supervisor orchestration authority required".into(),
76 ));
77 };
78 let binding = ScopeBinding {
79 scope_id: format!("supervisor/{incarnation_id}"),
80 supervisor_session_id: supervisor.id.clone(),
81 binding_revision: orchestration_revision,
82 };
83 Ok((binding, supervisor))
84}
85
86pub async fn verified_scope_binding_for_service(
90 storage: &dyn Storage,
91 service: &TicketService,
92 supervisor_id: &str,
93) -> Result<(ScopeBinding, Session)> {
94 let (expected, supervisor) = verified_scope_binding(storage, supervisor_id).await?;
95 let actual = service.published()?.1.binding;
96 if actual.scope_id != expected.scope_id
97 || actual.supervisor_session_id != expected.supervisor_session_id
98 || actual.binding_revision == 0
99 {
100 return Err(Error::ScopeDenied(
101 "Supervisor scope binding changed".into(),
102 ));
103 }
104 Ok((actual, supervisor))
105}
106
107pub fn dispatch_binding(
108 service: &TicketService,
109 key: &str,
110 spec: &DispatchSpec,
111) -> Result<TicketDispatchBinding> {
112 let (_, snapshot) = service.published()?;
113 let intent = snapshot
114 .intents
115 .get(key)
116 .ok_or_else(|| Error::InvalidTransition("dispatch intent missing".into()))?;
117 let spec_hash = content_hash(&canonical_bytes(spec)?);
118 if snapshot.binding != spec.binding || intent.spec_hash != spec_hash {
119 return Err(Error::IdempotencyConflict);
120 }
121 Ok(TicketDispatchBinding {
122 dispatch_key: key.into(),
123 spec_hash,
124 assignment_id: spec.assignment_id.clone(),
125 binding: snapshot.binding,
126 max_rounds: None,
127 receipt: None,
128 receipt_created_at: None,
129 })
130}
131
132pub fn require_dispatch_permission(
133 service: &TicketService,
134 dispatch: &TicketDispatchBinding,
135) -> Result<()> {
136 if service.health() != bamboo_tickets::Health::Writable {
137 return Err(Error::AuthorityUnavailable(
138 "dispatch requires writable authority".into(),
139 ));
140 }
141 let (_, snapshot) = service.published()?;
142 let assignment = snapshot
143 .assignments
144 .get(&dispatch.assignment_id)
145 .ok_or_else(|| Error::ScopeDenied("Assignment missing".into()))?;
146 let work = &snapshot.tickets[&assignment.work_id];
147 if assignment.process_stopped
148 || snapshot.binding != dispatch.binding
149 || snapshot
150 .intents
151 .get(&dispatch.dispatch_key)
152 .is_none_or(|i| i.spec_hash != dispatch.spec_hash)
153 || assignment.dispatch_key != dispatch.dispatch_key
154 || assignment.authority_epoch != snapshot.authority_epoch
155 || work.generation != assignment.generation
156 || work.contract_revision != assignment.contract_revision
157 || work.active_assignment.as_deref() != Some(&assignment.id)
158 || work.state != WorkState::Active
159 || !matches!(
160 assignment.state,
161 AssignmentState::DispatchPending | AssignmentState::Admitted | AssignmentState::Running
162 )
163 {
164 return Err(Error::ScopeDenied(
165 "dispatch permission is stale or inactive".into(),
166 ));
167 }
168 Ok(())
169}
170
171pub fn read_dispatch(session: &Session) -> Result<Option<TicketDispatchBinding>> {
172 session
173 .metadata
174 .get(TICKET_DISPATCH_KEY)
175 .map(|raw| {
176 if raw.len() > 4096 {
177 return Err(Error::ScopeDenied("dispatch binding exceeds limit".into()));
178 }
179 Ok(serde_json::from_str(raw)?)
180 })
181 .transpose()
182}
183
184pub(crate) async fn is_independent_ticket_child(
192 storage: &dyn Storage,
193 parent: &Session,
194 child_id: &str,
195) -> bool {
196 let Ok((binding, canonical_parent)) = verified_scope_binding(storage, &parent.id).await else {
197 return false;
198 };
199 if parent.kind != SessionKind::Root
200 || parent.parent_session_id.is_some()
201 || parent.root_session_id != parent.id
202 || canonical_parent.created_at != parent.created_at
203 || canonical_parent.authority_identity != parent.authority_identity
204 {
205 return false;
206 }
207 let Ok(Some(child)) = storage.load_runtime_control_plane(child_id).await else {
208 return false;
209 };
210 let Ok(Some(dispatch)) = read_dispatch(&child) else {
211 return false;
212 };
213 child.kind == SessionKind::Child
214 && child.parent_session_id.as_deref() == Some(parent.id.as_str())
215 && child.parent_created_at == Some(parent.created_at)
216 && child.root_session_id == parent.id
217 && child.id == ticket_child_id(&dispatch.dispatch_key)
218 && !dispatch.dispatch_key.is_empty()
219 && !dispatch.assignment_id.is_empty()
220 && dispatch.binding.scope_id == binding.scope_id
221 && dispatch.binding.supervisor_session_id == binding.supervisor_session_id
222 && dispatch.binding.binding_revision > 0
223 && child
224 .metadata
225 .get(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
226 == Some(&dispatch.assignment_id)
227}
228
229pub async fn query_dispatch(
230 storage: &dyn Storage,
231 service: &TicketService,
232 key: &str,
233 spec: &DispatchSpec,
234) -> Result<DispatchObservation> {
235 let expected = dispatch_binding(service, key, spec)?;
236 let (binding, _) = verified_scope_binding_for_service(
237 storage,
238 service,
239 &expected.binding.supervisor_session_id,
240 )
241 .await?;
242 if binding != expected.binding {
243 return Err(Error::ScopeDenied("Supervisor binding changed".into()));
244 }
245 let child = storage
246 .load_runtime_control_plane(&ticket_child_id(key))
247 .await?;
248 let Some(child) = child else {
249 return Ok(
250 if require_dispatch_permission(service, &expected).is_ok()
251 && service.published()?.1.assignments[&spec.assignment_id]
252 .runtime
253 .is_none()
254 {
255 DispatchObservation::Missing
256 } else {
257 DispatchObservation::OutcomeUnknown {
258 receipt: None,
259 reason: "canonical dispatch evidence unavailable".into(),
260 }
261 },
262 );
263 };
264 let actual = read_dispatch(&child)?
265 .ok_or_else(|| Error::ScopeDenied("Child lacks dispatch binding".into()))?;
266 let mut comparable = actual.clone();
267 comparable.receipt = None;
268 comparable.receipt_created_at = None;
269 comparable.max_rounds = None;
270 if comparable != expected
271 || child.kind != SessionKind::Child
272 || child.parent_session_id.as_deref() != Some(&binding.supervisor_session_id)
273 || child
274 .metadata
275 .get(crate::ticket_worker_plan::TICKET_LOCAL_PLAN_KEY)
276 != Some(&expected.assignment_id)
277 {
278 return Err(Error::ScopeDenied(
279 "canonical Child dispatch changed".into(),
280 ));
281 }
282 if let Some(receipt) = actual.receipt {
283 if receipt.dispatch_key != key
284 || receipt.spec_hash != actual.spec_hash
285 || receipt.session_id != child.id
286 || receipt.run_id.is_empty()
287 || actual.receipt_created_at != Some(child.created_at)
288 {
289 return Err(Error::ScopeDenied(
290 "canonical receipt identity changed".into(),
291 ));
292 }
293 let snapshot = service.published()?.1;
294 if snapshot.assignments[&spec.assignment_id].runtime.as_ref() != Some(&receipt) {
295 return Ok(DispatchObservation::OutcomeUnknown {
296 receipt: Some(receipt),
297 reason: "Runtime receipt prepared but Ticket admission not confirmed".into(),
298 });
299 }
300 if snapshot.assignments[&spec.assignment_id].state == AssignmentState::OutcomeUnknown {
301 return Ok(DispatchObservation::OutcomeUnknown {
305 receipt: Some(receipt),
306 reason: "Ticket attempt requires Runtime stop/result reconciliation".into(),
307 });
308 }
309 let status = child.last_run_status();
310 if let Some(status @ ("completed" | "error" | "timeout" | "cancelled" | "skipped")) =
311 status.as_deref()
312 {
313 return Ok(DispatchObservation::Terminal {
314 receipt,
315 terminal_status: status.into(),
316 });
317 }
318 if require_dispatch_permission(service, &expected).is_err() {
319 return Ok(DispatchObservation::OutcomeUnknown {
320 receipt: Some(receipt),
321 reason: "old writer or revoked execution; reconciliation required".into(),
322 });
323 }
324 Ok(DispatchObservation::Admitted { receipt })
325 } else if require_dispatch_permission(service, &expected).is_ok()
326 && child.last_run_status().as_deref() == Some("pending")
327 {
328 Ok(DispatchObservation::Pending {
329 session_id: child.id,
330 })
331 } else {
332 Ok(DispatchObservation::OutcomeUnknown {
333 receipt: None,
334 reason: "dispatch has no confirmed run receipt".into(),
335 })
336 }
337}
338
339fn actual_with_receipt(
340 expected: &TicketDispatchBinding,
341 receipt: &RuntimeReceipt,
342 created_at: chrono::DateTime<chrono::Utc>,
343) -> TicketDispatchBinding {
344 TicketDispatchBinding {
345 receipt: Some(receipt.clone()),
346 receipt_created_at: Some(created_at),
347 ..expected.clone()
348 }
349}
350
351pub async fn admit_ticket_run(
355 service: &Arc<TicketService>,
356 runtime: &SessionInboxRuntimeBinding,
357 session: &mut Session,
358 run_id: &str,
359) -> Result<()> {
360 let dispatch = read_dispatch(session)?
361 .ok_or_else(|| Error::ScopeDenied("dispatch binding missing".into()))?;
362 require_dispatch_permission(service, &dispatch)?;
363 let (binding, _) = verified_scope_binding_for_service(
364 runtime.storage.as_ref(),
365 service,
366 &dispatch.binding.supervisor_session_id,
367 )
368 .await?;
369 if binding != dispatch.binding || !runtime.router.owns_run(&session.id, run_id).await {
370 return Err(Error::ScopeDenied(
371 "canonical activation ownership changed".into(),
372 ));
373 }
374 let store = runtime.parent_question_lock.as_ref().ok_or_else(|| {
375 Error::AuthorityUnavailable("canonical write coordinator unavailable".into())
376 })?;
377 let receipt = RuntimeReceipt {
378 dispatch_key: dispatch.dispatch_key.clone(),
379 spec_hash: dispatch.spec_hash.clone(),
380 session_id: session.id.clone(),
381 run_id: run_id.into(),
382 };
383 let saved = store
384 .mutate_runtime_session_and_publish(
385 &session.id,
386 || None,
387 |latest| {
388 let current = read_dispatch(latest)?
389 .ok_or_else(|| Error::ScopeDenied("canonical dispatch missing".into()))?;
390 let mut comparable = current.clone();
391 comparable.receipt = None;
392 comparable.receipt_created_at = None;
393 let mut expected = dispatch.clone();
394 expected.receipt = None;
395 expected.receipt_created_at = None;
396 if latest.created_at != session.created_at
397 || latest.parent_session_id != session.parent_session_id
398 || latest.kind != SessionKind::Child
399 || comparable != expected
400 || current.receipt.as_ref().is_some_and(|r| r != &receipt)
401 || current.receipt.is_some()
402 && current.receipt_created_at != Some(latest.created_at)
403 {
404 return Err(Error::ScopeDenied(
405 "dispatch birth or receipt changed".into(),
406 ));
407 }
408 if current.receipt.is_none() {
409 latest.metadata.insert(
410 TICKET_DISPATCH_KEY.into(),
411 serde_json::to_string(&actual_with_receipt(
412 &dispatch,
413 &receipt,
414 latest.created_at,
415 ))?,
416 );
417 latest.metadata_version =
418 latest.metadata_version.checked_add(1).ok_or_else(|| {
419 Error::AuthorityUnavailable("metadata revision overflow".into())
420 })?;
421 }
422 Ok::<_, Error>(())
423 },
424 |_| {},
425 )
426 .await??
427 .ok_or_else(|| Error::ScopeDenied("canonical Child missing".into()))?;
428 session.metadata.insert(
430 TICKET_DISPATCH_KEY.into(),
431 saved.metadata[TICKET_DISPATCH_KEY].clone(),
432 );
433 session.metadata_version = saved.metadata_version;
434 let authority = Authority::from_verified_host(dispatch.binding, Principal::Runtime);
435 execute_runtime_command(
436 service,
437 &authority,
438 &format!("runtime-admit/{}", dispatch.dispatch_key),
439 vec![
440 Operation::Admitted {
441 assignment_id: dispatch.assignment_id.clone(),
442 receipt,
443 },
444 Operation::Running {
445 assignment_id: dispatch.assignment_id.clone(),
446 },
447 ],
448 )?;
449 crate::ticket_worker_plan::TicketWorkerPlan::from_runtime_receipt(
452 service.clone(),
453 &dispatch.assignment_id,
454 )?;
455 Ok(())
456}
457
458pub(crate) fn execute_runtime_command(
459 service: &TicketService,
460 authority: &Authority,
461 id: &str,
462 operations: Vec<Operation>,
463) -> Result<bamboo_tickets::OperationReceipt> {
464 for attempt in 0..4 {
465 let command = service.prepare_command(authority, id, operations.clone())?;
466 match service.execute(authority, &command) {
467 Err(Error::RevisionConflict) if attempt < 3 => continue,
468 result => return result,
469 }
470 }
471 unreachable!()
472}
473
474#[cfg(test)]
475mod tests;