1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4pub const BINARY_OUTCOME_CONTRACT_VERSION: &str = "binary-outcome-v1";
5pub const USER_FACING_OUTCOME_CONTRACT_VERSION: &str = "ui-outcome-v1";
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum BinaryOutcomeClass {
10 Ok,
11 Recoverable,
12 Blocked,
13 Operator,
14}
15
16impl BinaryOutcomeClass {
17 pub const fn as_str(self) -> &'static str {
18 match self {
19 Self::Ok => "ok",
20 Self::Recoverable => "recoverable",
21 Self::Blocked => "blocked",
22 Self::Operator => "operator",
23 }
24 }
25}
26
27impl fmt::Display for BinaryOutcomeClass {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 f.write_str(self.as_str())
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum UserFacingOutcomeKind {
36 QueuedBehindOwner,
37 RecoveredAndRetried,
38 DeferredForOperatorProof,
39 DeferredForSupervisorDrain,
45 DeferredForRecycle,
52 NoDrainableWork,
53 RealComponentConflict,
54 BlockedWithExactUnblocker,
55}
56
57impl UserFacingOutcomeKind {
58 pub const fn as_str(self) -> &'static str {
59 match self {
60 Self::QueuedBehindOwner => "queued_behind_owner",
61 Self::RecoveredAndRetried => "recovered_and_retried",
62 Self::DeferredForOperatorProof => "deferred_for_operator_proof",
63 Self::DeferredForSupervisorDrain => "deferred_for_supervisor_drain",
64 Self::DeferredForRecycle => "deferred_for_recycle",
65 Self::NoDrainableWork => "no_drainable_work",
66 Self::RealComponentConflict => "real_component_conflict",
67 Self::BlockedWithExactUnblocker => "blocked_with_exact_unblocker",
68 }
69 }
70
71 pub const fn class(self) -> BinaryOutcomeClass {
72 match self {
73 Self::QueuedBehindOwner
74 | Self::NoDrainableWork
75 | Self::DeferredForSupervisorDrain
76 | Self::DeferredForRecycle => BinaryOutcomeClass::Ok,
77 Self::RecoveredAndRetried => BinaryOutcomeClass::Recoverable,
78 Self::DeferredForOperatorProof => BinaryOutcomeClass::Operator,
79 Self::RealComponentConflict | Self::BlockedWithExactUnblocker => {
80 BinaryOutcomeClass::Blocked
81 }
82 }
83 }
84
85 pub const fn next_action(self) -> &'static str {
86 match self {
87 Self::QueuedBehindOwner => "wait_for_owner_turn_to_drain",
88 Self::RecoveredAndRetried => "continue_after_recovery_retry",
89 Self::DeferredForOperatorProof => "operator_proof_required",
90 Self::DeferredForSupervisorDrain => "yield_to_supervisor_clear_and_continue",
91 Self::DeferredForRecycle => "yield_for_supervisor_recycle",
92 Self::NoDrainableWork => "no_agent_action",
93 Self::RealComponentConflict => "resolve_component_conflict",
94 Self::BlockedWithExactUnblocker => "follow_unblocker",
95 }
96 }
97}
98
99impl fmt::Display for UserFacingOutcomeKind {
100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101 f.write_str(self.as_str())
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct UserFacingOutcome {
107 pub contract_version: String,
108 pub outcome: UserFacingOutcomeKind,
109 pub class: BinaryOutcomeClass,
110 pub next_action: String,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub unblocker: Option<String>,
113}
114
115impl UserFacingOutcome {
116 pub fn new(outcome: UserFacingOutcomeKind) -> Result<Self, BinaryOutcomeError> {
117 if outcome == UserFacingOutcomeKind::BlockedWithExactUnblocker {
118 return Err(BinaryOutcomeError::EmptyField { field: "unblocker" });
119 }
120 Ok(Self {
121 contract_version: USER_FACING_OUTCOME_CONTRACT_VERSION.to_string(),
122 outcome,
123 class: outcome.class(),
124 next_action: outcome.next_action().to_string(),
125 unblocker: None,
126 })
127 }
128
129 pub fn with_unblocker(
130 outcome: UserFacingOutcomeKind,
131 unblocker: impl Into<String>,
132 ) -> Result<Self, BinaryOutcomeError> {
133 let unblocker = validate_token("unblocker", unblocker.into())?;
134 Ok(Self {
135 contract_version: USER_FACING_OUTCOME_CONTRACT_VERSION.to_string(),
136 outcome,
137 class: outcome.class(),
138 next_action: outcome.next_action().to_string(),
139 unblocker: Some(unblocker),
140 })
141 }
142
143 pub fn log_fields(&self) -> String {
144 let fields = format!(
145 "ui_outcome_contract={} ui_outcome={} ui_outcome_class={} next_action={}",
146 self.contract_version,
147 self.outcome.as_str(),
148 self.class.as_str(),
149 self.next_action
150 );
151 match self.unblocker.as_deref() {
152 Some(unblocker) => format!("{fields} unblocker={unblocker}"),
153 None => fields,
154 }
155 }
156}
157
158pub fn user_outcome_fields(kind: UserFacingOutcomeKind) -> String {
159 UserFacingOutcome::new(kind)
160 .expect("static user-facing outcome is valid")
161 .log_fields()
162}
163
164pub fn blocked_with_exact_unblocker_fields(unblocker: &str) -> String {
165 UserFacingOutcome::with_unblocker(UserFacingOutcomeKind::BlockedWithExactUnblocker, unblocker)
166 .expect("static user-facing unblocker is valid")
167 .log_fields()
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct BinaryOutcome {
172 pub contract_version: String,
173 pub class: BinaryOutcomeClass,
174 pub invariant_id: String,
175 pub proof_marker: String,
176 pub next_action: String,
177}
178
179impl BinaryOutcome {
180 pub fn new(
181 class: BinaryOutcomeClass,
182 invariant_id: impl Into<String>,
183 proof_marker: impl Into<String>,
184 next_action: impl Into<String>,
185 ) -> Result<Self, BinaryOutcomeError> {
186 let invariant_id = validate_token("invariant_id", invariant_id.into())?;
187 let proof_marker = validate_token("proof_marker", proof_marker.into())?;
188 let next_action = validate_token("next_action", next_action.into())?;
189 Ok(Self {
190 contract_version: BINARY_OUTCOME_CONTRACT_VERSION.to_string(),
191 class,
192 invariant_id,
193 proof_marker,
194 next_action,
195 })
196 }
197
198 pub fn ok(
199 invariant_id: impl Into<String>,
200 proof_marker: impl Into<String>,
201 next_action: impl Into<String>,
202 ) -> Result<Self, BinaryOutcomeError> {
203 Self::new(
204 BinaryOutcomeClass::Ok,
205 invariant_id,
206 proof_marker,
207 next_action,
208 )
209 }
210
211 pub fn recoverable(
212 invariant_id: impl Into<String>,
213 proof_marker: impl Into<String>,
214 next_action: impl Into<String>,
215 ) -> Result<Self, BinaryOutcomeError> {
216 Self::new(
217 BinaryOutcomeClass::Recoverable,
218 invariant_id,
219 proof_marker,
220 next_action,
221 )
222 }
223
224 pub fn blocked(
225 invariant_id: impl Into<String>,
226 proof_marker: impl Into<String>,
227 next_action: impl Into<String>,
228 ) -> Result<Self, BinaryOutcomeError> {
229 Self::new(
230 BinaryOutcomeClass::Blocked,
231 invariant_id,
232 proof_marker,
233 next_action,
234 )
235 }
236
237 pub fn operator(
238 invariant_id: impl Into<String>,
239 proof_marker: impl Into<String>,
240 next_action: impl Into<String>,
241 ) -> Result<Self, BinaryOutcomeError> {
242 Self::new(
243 BinaryOutcomeClass::Operator,
244 invariant_id,
245 proof_marker,
246 next_action,
247 )
248 }
249
250 pub fn log_fields(&self) -> String {
251 format!(
252 "binary_outcome={} invariant={} proof_marker={} next_action={}",
253 self.class.as_str(),
254 self.invariant_id,
255 self.proof_marker,
256 self.next_action
257 )
258 }
259}
260
261pub fn supervisor_stale_self_recycled_outcome() -> BinaryOutcome {
267 BinaryOutcome::recoverable(
268 "supervisor_freshness",
269 "supervisor_binary_stale_self_recycled",
270 "restart_supervisor_once_and_retry",
271 )
272 .expect("static supervisor_freshness outcome tokens are contract-valid")
273}
274
275pub fn deferred_for_recycle_outcome() -> UserFacingOutcome {
278 UserFacingOutcome::new(UserFacingOutcomeKind::DeferredForRecycle)
279 .expect("deferred_for_recycle requires no unblocker")
280}
281
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub enum BinaryOutcomeError {
284 EmptyField { field: &'static str },
285 InvalidToken { field: &'static str, value: String },
286}
287
288impl fmt::Display for BinaryOutcomeError {
289 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290 match self {
291 Self::EmptyField { field } => write!(f, "{field} must not be empty"),
292 Self::InvalidToken { field, value } => {
293 write!(f, "{field} must be a single field-safe token: {value}")
294 }
295 }
296 }
297}
298
299impl std::error::Error for BinaryOutcomeError {}
300
301fn validate_token(field: &'static str, value: String) -> Result<String, BinaryOutcomeError> {
302 if value.trim().is_empty() {
303 return Err(BinaryOutcomeError::EmptyField { field });
304 }
305 if value.trim() != value || !value.chars().all(is_token_char) {
306 return Err(BinaryOutcomeError::InvalidToken { field, value });
307 }
308 Ok(value)
309}
310
311fn is_token_char(ch: char) -> bool {
312 ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | ':' | '/')
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn binary_outcome_records_required_contract_fields() {
321 let outcome = BinaryOutcome::recoverable(
322 "supervisor_freshness",
323 "supervisor_binary_stale_self_recycled",
324 "restart_supervisor_once_and_retry",
325 )
326 .unwrap();
327
328 assert_eq!(outcome.contract_version, "binary-outcome-v1");
329 assert_eq!(outcome.class, BinaryOutcomeClass::Recoverable);
330 assert_eq!(outcome.invariant_id, "supervisor_freshness");
331 assert_eq!(
332 outcome.log_fields(),
333 "binary_outcome=recoverable invariant=supervisor_freshness proof_marker=supervisor_binary_stale_self_recycled next_action=restart_supervisor_once_and_retry"
334 );
335 }
336
337 #[test]
338 fn supervisor_stale_self_recycled_and_deferred_for_recycle_emitters_are_contract_valid() {
339 let binary = supervisor_stale_self_recycled_outcome();
343 assert_eq!(binary.class, BinaryOutcomeClass::Recoverable);
344 assert_eq!(binary.invariant_id, "supervisor_freshness");
345 assert_eq!(binary.proof_marker, "supervisor_binary_stale_self_recycled");
346 assert_eq!(binary.next_action, "restart_supervisor_once_and_retry");
347
348 let ui = deferred_for_recycle_outcome();
349 assert_eq!(ui.outcome, UserFacingOutcomeKind::DeferredForRecycle);
350 assert_eq!(ui.class, BinaryOutcomeClass::Ok);
351 assert_eq!(ui.next_action, "yield_for_supervisor_recycle");
352 }
353
354 #[test]
355 fn binary_outcome_classes_are_stable_snake_case() {
356 assert_eq!(BinaryOutcomeClass::Ok.as_str(), "ok");
357 assert_eq!(BinaryOutcomeClass::Recoverable.as_str(), "recoverable");
358 assert_eq!(BinaryOutcomeClass::Blocked.as_str(), "blocked");
359 assert_eq!(BinaryOutcomeClass::Operator.as_str(), "operator");
360
361 let json = serde_json::to_value(
362 BinaryOutcome::operator(
363 "two_editor_convergence",
364 "missing_live_vscode_ack",
365 "request_operator_live_proof",
366 )
367 .unwrap(),
368 )
369 .unwrap();
370
371 assert_eq!(json["class"], "operator");
372 assert_eq!(json["next_action"], "request_operator_live_proof");
373 }
374
375 #[test]
376 fn user_facing_outcome_vocabulary_is_stable_and_typed() {
377 use UserFacingOutcomeKind as Kind;
378
379 let cases = [
380 (
381 Kind::QueuedBehindOwner,
382 "queued_behind_owner",
383 BinaryOutcomeClass::Ok,
384 "wait_for_owner_turn_to_drain",
385 ),
386 (
387 Kind::RecoveredAndRetried,
388 "recovered_and_retried",
389 BinaryOutcomeClass::Recoverable,
390 "continue_after_recovery_retry",
391 ),
392 (
393 Kind::DeferredForOperatorProof,
394 "deferred_for_operator_proof",
395 BinaryOutcomeClass::Operator,
396 "operator_proof_required",
397 ),
398 (
399 Kind::DeferredForSupervisorDrain,
400 "deferred_for_supervisor_drain",
401 BinaryOutcomeClass::Ok,
402 "yield_to_supervisor_clear_and_continue",
403 ),
404 (
405 Kind::DeferredForRecycle,
406 "deferred_for_recycle",
407 BinaryOutcomeClass::Ok,
408 "yield_for_supervisor_recycle",
409 ),
410 (
411 Kind::NoDrainableWork,
412 "no_drainable_work",
413 BinaryOutcomeClass::Ok,
414 "no_agent_action",
415 ),
416 (
417 Kind::RealComponentConflict,
418 "real_component_conflict",
419 BinaryOutcomeClass::Blocked,
420 "resolve_component_conflict",
421 ),
422 (
423 Kind::BlockedWithExactUnblocker,
424 "blocked_with_exact_unblocker",
425 BinaryOutcomeClass::Blocked,
426 "follow_unblocker",
427 ),
428 ];
429
430 for (kind, token, class, next_action) in cases {
431 assert_eq!(kind.as_str(), token);
432 assert_eq!(kind.class(), class);
433 assert_eq!(kind.next_action(), next_action);
434 }
435
436 let json = serde_json::to_value(
437 UserFacingOutcome::new(Kind::QueuedBehindOwner)
438 .expect("queued outcome does not require an unblocker"),
439 )
440 .unwrap();
441 assert_eq!(json["contract_version"], "ui-outcome-v1");
442 assert_eq!(json["outcome"], "queued_behind_owner");
443 assert_eq!(json["class"], "ok");
444 assert_eq!(json["next_action"], "wait_for_owner_turn_to_drain");
445 }
446
447 #[test]
448 fn blocked_user_facing_outcome_requires_exact_unblocker() {
449 use UserFacingOutcomeKind as Kind;
450
451 assert_eq!(
452 UserFacingOutcome::new(Kind::BlockedWithExactUnblocker).unwrap_err(),
453 BinaryOutcomeError::EmptyField { field: "unblocker" }
454 );
455
456 let outcome = UserFacingOutcome::with_unblocker(
457 Kind::BlockedWithExactUnblocker,
458 "restore_idle_prompt",
459 )
460 .unwrap();
461 assert_eq!(
462 outcome.log_fields(),
463 "ui_outcome_contract=ui-outcome-v1 ui_outcome=blocked_with_exact_unblocker ui_outcome_class=blocked next_action=follow_unblocker unblocker=restore_idle_prompt"
464 );
465 assert!(matches!(
466 UserFacingOutcome::with_unblocker(
467 Kind::BlockedWithExactUnblocker,
468 "restore idle prompt"
469 )
470 .unwrap_err(),
471 BinaryOutcomeError::InvalidToken {
472 field: "unblocker",
473 ..
474 }
475 ));
476 }
477
478 #[test]
479 fn user_outcome_field_helpers_emit_contract_fields() {
480 use UserFacingOutcomeKind as Kind;
481
482 assert_eq!(
483 user_outcome_fields(Kind::QueuedBehindOwner),
484 "ui_outcome_contract=ui-outcome-v1 ui_outcome=queued_behind_owner ui_outcome_class=ok next_action=wait_for_owner_turn_to_drain"
485 );
486 assert_eq!(
487 blocked_with_exact_unblocker_fields("run_recovery_command"),
488 "ui_outcome_contract=ui-outcome-v1 ui_outcome=blocked_with_exact_unblocker ui_outcome_class=blocked next_action=follow_unblocker unblocker=run_recovery_command"
489 );
490 }
491
492 #[test]
493 fn binary_outcome_rejects_ambiguous_or_multi_action_fields() {
494 assert_eq!(
495 BinaryOutcome::ok("", "proof", "continue").unwrap_err(),
496 BinaryOutcomeError::EmptyField {
497 field: "invariant_id"
498 }
499 );
500
501 assert!(matches!(
502 BinaryOutcome::blocked("queue head", "proof", "stop").unwrap_err(),
503 BinaryOutcomeError::InvalidToken {
504 field: "invariant_id",
505 ..
506 }
507 ));
508
509 assert!(matches!(
510 BinaryOutcome::recoverable("queue_head", "proof", "retry,clear").unwrap_err(),
511 BinaryOutcomeError::InvalidToken {
512 field: "next_action",
513 ..
514 }
515 ));
516 }
517}