@team-agent/installer 0.5.59 → 0.5.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +44 -1
  4. package/crates/team-agent/src/cli/diagnose.rs +183 -0
  5. package/crates/team-agent/src/cli/emit.rs +63 -14
  6. package/crates/team-agent/src/cli/leader.rs +8 -1
  7. package/crates/team-agent/src/cli/mod.rs +263 -5
  8. package/crates/team-agent/src/cli/named_address.rs +18 -1
  9. package/crates/team-agent/src/cli/send/presentation.rs +2 -0
  10. package/crates/team-agent/src/cli/spec.rs +4 -1
  11. package/crates/team-agent/src/cli/status_port/store.rs +7 -3
  12. package/crates/team-agent/src/cli/tests/named_address.rs +65 -0
  13. package/crates/team-agent/src/cli/types.rs +33 -0
  14. package/crates/team-agent/src/communication_mode/mod.rs +53 -0
  15. package/crates/team-agent/src/compiler/tests.rs +5 -5
  16. package/crates/team-agent/src/compiler.rs +53 -1
  17. package/crates/team-agent/src/coordinator/tick.rs +21 -8
  18. package/crates/team-agent/src/db/message_store.rs +45 -1
  19. package/crates/team-agent/src/fake_worker.rs +21 -1
  20. package/crates/team-agent/src/leader/start.rs +732 -94
  21. package/crates/team-agent/src/leader/tests/identity.rs +64 -57
  22. package/crates/team-agent/src/leader/tests/identity_session_names.rs +42 -0
  23. package/crates/team-agent/src/lib.rs +4 -0
  24. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +58 -0
  25. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +57 -51
  26. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +148 -8
  27. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +5 -0
  28. package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/launch.rs +1 -1
  30. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
  31. package/crates/team-agent/src/lifecycle/restart/common.rs +1 -1
  32. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +35 -1
  33. package/crates/team-agent/src/lifecycle/types.rs +1 -0
  34. package/crates/team-agent/src/lifecycle/worker_command_context.rs +29 -11
  35. package/crates/team-agent/src/mcp_server/normalize.rs +1 -0
  36. package/crates/team-agent/src/mcp_server/tests/send.rs +26 -0
  37. package/crates/team-agent/src/mcp_server/tests/wire.rs +1 -1
  38. package/crates/team-agent/src/mcp_server/tools.rs +100 -66
  39. package/crates/team-agent/src/mcp_server/types.rs +5 -0
  40. package/crates/team-agent/src/mcp_server/wire.rs +27 -21
  41. package/crates/team-agent/src/messaging/delivery.rs +105 -38
  42. package/crates/team-agent/src/messaging/leader_channel.rs +33 -10
  43. package/crates/team-agent/src/messaging/leader_receiver.rs +40 -23
  44. package/crates/team-agent/src/messaging/presentation.rs +109 -0
  45. package/crates/team-agent/src/messaging/results.rs +165 -17
  46. package/crates/team-agent/src/messaging/send.rs +94 -81
  47. package/crates/team-agent/src/messaging/tests/leader_channel.rs +6 -6
  48. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +187 -0
  49. package/crates/team-agent/src/messaging/tests/runtime.rs +13 -6
  50. package/crates/team-agent/src/messaging/types.rs +5 -0
  51. package/crates/team-agent/src/messaging/watchers.rs +5 -0
  52. package/crates/team-agent/src/model/mod.rs +1 -0
  53. package/crates/team-agent/src/model/pane_authority_refusal.rs +358 -0
  54. package/crates/team-agent/src/model/spec.rs +16 -0
  55. package/crates/team-agent/src/provider/adapter.rs +57 -0
  56. package/crates/team-agent/src/provider/adapters/claude_fork.rs +10 -2
  57. package/crates/team-agent/src/provider/session/capture.rs +69 -34
  58. package/crates/team-agent/src/provider/session/context_fork/codex.rs +386 -9
  59. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +3 -14
  60. package/crates/team-agent/src/provider/session/context_fork.rs +7 -2
  61. package/crates/team-agent/src/provider/session/mod.rs +3 -2
  62. package/crates/team-agent/src/provider/session_scan/claude.rs +92 -3
  63. package/crates/team-agent/src/provider/session_scan/codex.rs +28 -0
  64. package/crates/team-agent/src/provider/session_scan/common.rs +34 -8
  65. package/crates/team-agent/src/provider/session_scan.rs +8 -0
  66. package/crates/team-agent/src/topology.rs +3 -0
  67. package/package.json +4 -4
  68. package/skills/team-agent/command-coverage.json +379 -0
@@ -9,7 +9,6 @@ use crate::event_log::EventLog;
9
9
  use crate::messaging::MessageTarget;
10
10
 
11
11
  use super::helpers::{json_dumps_default, object_fields};
12
- use super::normalize::normalize_result_status;
13
12
  use super::tools::TeamOrchestratorTools;
14
13
  use super::types::{
15
14
  McpError, McpTool, RpcError, RpcId, RpcMethod, RpcResponse, Scope, SendOutcome,
@@ -369,7 +368,7 @@ fn python_repr(value: &str) -> String {
369
368
  fn tool_contract(tool: McpTool) -> Value {
370
369
  let (description, required) = match tool {
371
370
  McpTool::SendMessage => (
372
- "Send a message to a teammate, the leader, or '*' for all other team members. Team Agent fills identity and delivery metadata; optional presentation routing is durable and never drops the message.",
371
+ "Send a message to a teammate, the leader, or '*' for all other team members. mailbox=true stores durably without live injection; the default is live delivery.",
373
372
  vec!["to", "content"],
374
373
  ),
375
374
  McpTool::AssignTask => ("Assign or update a task in the team graph and deliver it to its assignee.", vec!["task"]),
@@ -404,11 +403,7 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
404
403
  let mut properties = serde_json::Map::new();
405
404
  match tool {
406
405
  McpTool::AssignTask => {
407
- insert_property(
408
- &mut properties,
409
- "task",
410
- object_property("Task object to add or update."),
411
- );
406
+ insert_property(&mut properties, "task", task_property());
412
407
  insert_property(
413
408
  &mut properties,
414
409
  "message",
@@ -424,8 +419,10 @@ fn tool_properties(tool: McpTool) -> serde_json::Map<String, Value> {
424
419
  insert_property(&mut properties, "content", string_property("Message body."));
425
420
  insert_property(
426
421
  &mut properties,
427
- "presentation",
428
- presentation_property("Optional durable presentation routing."),
422
+ "mailbox",
423
+ boolean_property(
424
+ "Set true to store durably without live injection; omit for default live delivery.",
425
+ ),
429
426
  );
430
427
  }
431
428
  McpTool::ReportResult => {
@@ -603,6 +600,25 @@ fn object_property(description: &str) -> Value {
603
600
  serde_json::json!({"type": "object", "description": description, "additionalProperties": true})
604
601
  }
605
602
 
603
+ fn task_property() -> Value {
604
+ serde_json::json!({
605
+ "type": "object",
606
+ "description": "Task object to add or update.",
607
+ "properties": {
608
+ "result_route": {
609
+ "type": "string",
610
+ "enum": crate::messaging::results::ResultRoute::ALL
611
+ .iter()
612
+ .map(|route| route.as_str())
613
+ .collect::<Vec<_>>(),
614
+ "default": crate::messaging::results::ResultRoute::default().as_str(),
615
+ "description": "Result destination chosen by the task assigner."
616
+ }
617
+ },
618
+ "additionalProperties": true
619
+ })
620
+ }
621
+
606
622
  fn presentation_property(description: &str) -> Value {
607
623
  serde_json::json!({
608
624
  "type": "object",
@@ -646,6 +662,7 @@ pub(crate) fn dispatch_tool(
646
662
  None,
647
663
  None,
648
664
  None,
665
+ args.get("mailbox"),
649
666
  args.get("presentation"),
650
667
  )?;
651
668
  match outcome {
@@ -658,18 +675,7 @@ pub(crate) fn dispatch_tool(
658
675
  McpTool::ReportResult => tools.report_result_with_presentation(
659
676
  args.get("envelope"),
660
677
  args.get("summary").and_then(Value::as_str),
661
- // cr verdict (T3-1 refined): an unknown status literal normalizes to
662
- // Partial and is OBSERVABLE at this ingestion boundary, never silent.
663
- {
664
- let (status, unknown) =
665
- crate::mcp_server::normalize::normalize_result_status_observed(
666
- args.get("status").and_then(Value::as_str),
667
- );
668
- if let Some(raw) = unknown {
669
- tools.note_unknown_result_status(&raw);
670
- }
671
- status
672
- },
678
+ args.get("status").and_then(Value::as_str),
673
679
  args.get("changes")
674
680
  .and_then(Value::as_array)
675
681
  .map(Vec::as_slice),
@@ -220,7 +220,12 @@ pub fn deliver_pending_message(
220
220
  }
221
221
  _ => state,
222
222
  };
223
- if message.recipient == "leader" && message.status == "submitted_pending_acceptance" {
223
+ if message.recipient == "leader"
224
+ && matches!(
225
+ message.status.as_str(),
226
+ "submitted_pending_acceptance" | "injected_awaiting_receipt"
227
+ )
228
+ {
224
229
  return observe_pending_leader_acceptance(store, event_log, state, message_id);
225
230
  }
226
231
  if message.recipient == "leader" && message.status == "queued_until_leader_attach" {
@@ -341,6 +346,12 @@ pub fn deliver_pending_message(
341
346
  .get("status")
342
347
  .and_then(serde_json::Value::as_str)
343
348
  .unwrap_or("unbound");
349
+ let refusal = match &reason {
350
+ crate::messaging::LeaderChannelUnbound::PaneWorkspaceMismatch(_) => {
351
+ DeliveryRefusal::PaneWorkspaceMismatch
352
+ }
353
+ _ => DeliveryRefusal::LeaderNotAttached,
354
+ };
344
355
  store.mark(message_id, "failed", Some("leader_not_attached"))?;
345
356
  event_log.write(
346
357
  "leader_receiver.delivery_blocked",
@@ -349,7 +360,7 @@ pub fn deliver_pending_message(
349
360
  "sender": message.sender,
350
361
  "reason": "leader_receiver_not_attached",
351
362
  "error": "leader_not_attached",
352
- "channel_reason": format!("{reason:?}"),
363
+ "channel_reason": reason.reason_code(),
353
364
  "channel": "rebind_required",
354
365
  "action": "run team-agent attach-leader or team-agent takeover",
355
366
  "status": receiver_status,
@@ -364,7 +375,7 @@ pub fn deliver_pending_message(
364
375
  "run team-agent attach-leader or team-agent takeover".to_string(),
365
376
  ),
366
377
  stage: None,
367
- reason: Some(DeliveryRefusal::LeaderNotAttached),
378
+ reason: Some(refusal),
368
379
  channel: Some("rebind_required".to_string()),
369
380
  });
370
381
  }
@@ -711,19 +722,30 @@ pub fn deliver_pending_message(
711
722
  }
712
723
  if is_leader_recipient {
713
724
  store.record_delivery_submission(message_id, readback_verified)?;
714
- if !leader_transcript_has_token(state, message_id) {
715
- store.mark(message_id, "submitted_pending_acceptance", None)?;
716
- event_log.write(
717
- "leader_receiver.acceptance_pending",
718
- serde_json::json!({
719
- "message_id": message_id,
720
- "reason": "provider_receipt_not_observed",
721
- }),
722
- )?;
723
- return Ok(leader_acceptance_pending_outcome(
724
- message_id,
725
- "submitted_pending_acceptance",
726
- ));
725
+ match observe_leader_receipt(state, message_id) {
726
+ LeaderReceiptObservation::SourceUnavailable => {
727
+ store.mark_injected_awaiting_receipt(message_id)?;
728
+ event_log.write(
729
+ "leader_receiver.receipt_source_unavailable",
730
+ serde_json::json!({"message_id": message_id}),
731
+ )?;
732
+ return Ok(leader_receipt_source_unavailable_outcome(message_id));
733
+ }
734
+ LeaderReceiptObservation::TokenAbsent => {
735
+ store.mark(message_id, "submitted_pending_acceptance", None)?;
736
+ event_log.write(
737
+ "leader_receiver.acceptance_pending",
738
+ serde_json::json!({
739
+ "message_id": message_id,
740
+ "reason": "provider_receipt_not_observed",
741
+ }),
742
+ )?;
743
+ return Ok(leader_acceptance_pending_outcome(
744
+ message_id,
745
+ "submitted_pending_acceptance",
746
+ ));
747
+ }
748
+ LeaderReceiptObservation::TokenObserved => {}
727
749
  }
728
750
  store.mark_delivered_with_receipt(message_id)?;
729
751
  } else {
@@ -2041,15 +2063,36 @@ fn leader_rollout_path(state: &serde_json::Value) -> Option<std::path::PathBuf>
2041
2063
  .map(std::path::PathBuf::from)
2042
2064
  }
2043
2065
 
2044
- pub(crate) fn leader_transcript_has_token(state: &serde_json::Value, message_id: &str) -> bool {
2066
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2067
+ pub(crate) enum LeaderReceiptObservation {
2068
+ SourceUnavailable,
2069
+ TokenAbsent,
2070
+ TokenObserved,
2071
+ }
2072
+
2073
+ pub(crate) fn observe_leader_receipt(
2074
+ state: &serde_json::Value,
2075
+ message_id: &str,
2076
+ ) -> LeaderReceiptObservation {
2045
2077
  let Some(path) = leader_rollout_path(state) else {
2046
- return false;
2078
+ return LeaderReceiptObservation::SourceUnavailable;
2047
2079
  };
2048
- rollout_tail_contains(
2080
+ let Ok(observed) = rollout_tail_contains_result(
2049
2081
  &path,
2050
2082
  &format!("[team-agent-token:{message_id}]"),
2051
2083
  64 * 1024,
2052
- )
2084
+ ) else {
2085
+ return LeaderReceiptObservation::SourceUnavailable;
2086
+ };
2087
+ if observed {
2088
+ LeaderReceiptObservation::TokenObserved
2089
+ } else {
2090
+ LeaderReceiptObservation::TokenAbsent
2091
+ }
2092
+ }
2093
+
2094
+ pub(crate) fn leader_transcript_has_token(state: &serde_json::Value, message_id: &str) -> bool {
2095
+ observe_leader_receipt(state, message_id) == LeaderReceiptObservation::TokenObserved
2053
2096
  }
2054
2097
 
2055
2098
  fn leader_acceptance_pending_outcome(message_id: &str, status: &str) -> DeliveryOutcome {
@@ -2065,17 +2108,41 @@ fn leader_acceptance_pending_outcome(message_id: &str, status: &str) -> Delivery
2065
2108
  }
2066
2109
  }
2067
2110
 
2111
+ pub(crate) fn leader_receipt_source_unavailable_outcome(message_id: &str) -> DeliveryOutcome {
2112
+ DeliveryOutcome {
2113
+ ok: true,
2114
+ status: DeliveryStatus::Blocked,
2115
+ message_status: MessageStatusShadow("injected_awaiting_receipt".to_string()),
2116
+ message_id: Some(message_id.to_string()),
2117
+ verification: Some("leader_receipt_source_unavailable".to_string()),
2118
+ stage: Some(DeliveryStage::Submit),
2119
+ reason: None,
2120
+ channel: Some("leader_receipt_source_unavailable".to_string()),
2121
+ }
2122
+ }
2123
+
2068
2124
  fn observe_pending_leader_acceptance(
2069
2125
  store: &MessageStore,
2070
2126
  event_log: &EventLog,
2071
2127
  state: &serde_json::Value,
2072
2128
  message_id: &str,
2073
2129
  ) -> Result<DeliveryOutcome, MessagingError> {
2074
- if !leader_transcript_has_token(state, message_id) {
2075
- return Ok(leader_acceptance_pending_outcome(
2076
- message_id,
2077
- "submitted_pending_acceptance",
2078
- ));
2130
+ match observe_leader_receipt(state, message_id) {
2131
+ LeaderReceiptObservation::SourceUnavailable => {
2132
+ store.mark_injected_awaiting_receipt(message_id)?;
2133
+ event_log.write(
2134
+ "leader_receiver.receipt_source_unavailable",
2135
+ serde_json::json!({"message_id": message_id}),
2136
+ )?;
2137
+ return Ok(leader_receipt_source_unavailable_outcome(message_id));
2138
+ }
2139
+ LeaderReceiptObservation::TokenAbsent => {
2140
+ return Ok(leader_acceptance_pending_outcome(
2141
+ message_id,
2142
+ "submitted_pending_acceptance",
2143
+ ));
2144
+ }
2145
+ LeaderReceiptObservation::TokenObserved => {}
2079
2146
  }
2080
2147
  store.mark_delivered_with_receipt(message_id)?;
2081
2148
  event_log.write(
@@ -2744,22 +2811,22 @@ fn claude_recipient_rollout(
2744
2811
  /// Silent on read errors — callers treat missing/unreadable as "token not
2745
2812
  /// present" which forces the unverified path.
2746
2813
  fn rollout_tail_contains(path: &std::path::Path, needle: &str, tail_bytes: u64) -> bool {
2814
+ rollout_tail_contains_result(path, needle, tail_bytes).unwrap_or(false)
2815
+ }
2816
+
2817
+ fn rollout_tail_contains_result(
2818
+ path: &std::path::Path,
2819
+ needle: &str,
2820
+ tail_bytes: u64,
2821
+ ) -> std::io::Result<bool> {
2747
2822
  use std::io::{Read, Seek, SeekFrom};
2748
- let Ok(mut file) = std::fs::File::open(path) else {
2749
- return false;
2750
- };
2751
- let Ok(metadata) = file.metadata() else {
2752
- return false;
2753
- };
2823
+ let mut file = std::fs::File::open(path)?;
2824
+ let metadata = file.metadata()?;
2754
2825
  let len = metadata.len();
2755
2826
  let start = len.saturating_sub(tail_bytes);
2756
- if file.seek(SeekFrom::Start(start)).is_err() {
2757
- return false;
2758
- }
2827
+ file.seek(SeekFrom::Start(start))?;
2759
2828
  let mut buf = Vec::with_capacity(tail_bytes.min(len) as usize);
2760
- if file.take(tail_bytes).read_to_end(&mut buf).is_err() {
2761
- return false;
2762
- }
2829
+ file.take(tail_bytes).read_to_end(&mut buf)?;
2763
2830
  let haystack = String::from_utf8_lossy(&buf);
2764
- haystack.contains(needle)
2831
+ Ok(haystack.contains(needle))
2765
2832
  }
@@ -3,6 +3,7 @@ use std::path::Path;
3
3
  use serde_json::Value;
4
4
 
5
5
  use crate::codex_app_server::AppServerBinding;
6
+ use crate::model::pane_authority_refusal::PaneWorkspaceMismatchFacts;
6
7
  use crate::transport::{PaneInfo, Transport};
7
8
 
8
9
  #[derive(Debug, Clone, PartialEq, Eq)]
@@ -26,7 +27,7 @@ pub enum LeaderChannelResolution {
26
27
  ProbeFailed(String),
27
28
  }
28
29
 
29
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
30
+ #[derive(Debug, Clone, PartialEq, Eq)]
30
31
  pub enum LeaderChannelUnbound {
31
32
  ReceiverNotAttached,
32
33
  TransportConflict,
@@ -34,10 +35,25 @@ pub enum LeaderChannelUnbound {
34
35
  NonCanonicalTmuxSocket,
35
36
  EndpointMismatch,
36
37
  PaneNotLive,
37
- PaneWorkspaceMismatch,
38
+ PaneWorkspaceMismatch(PaneWorkspaceMismatchFacts),
38
39
  AppServerBindingInvalid,
39
40
  }
40
41
 
42
+ impl LeaderChannelUnbound {
43
+ pub const fn reason_code(&self) -> &'static str {
44
+ match self {
45
+ Self::ReceiverNotAttached => "ReceiverNotAttached",
46
+ Self::TransportConflict => "TransportConflict",
47
+ Self::MissingPaneId => "MissingPaneId",
48
+ Self::NonCanonicalTmuxSocket => "NonCanonicalTmuxSocket",
49
+ Self::EndpointMismatch => "EndpointMismatch",
50
+ Self::PaneNotLive => "PaneNotLive",
51
+ Self::PaneWorkspaceMismatch(_) => "PaneWorkspaceMismatch",
52
+ Self::AppServerBindingInvalid => "AppServerBindingInvalid",
53
+ }
54
+ }
55
+ }
56
+
41
57
  /// Resolve the canonical receiver's live physical channel without mutating
42
58
  /// ownership. For direct tmux, `(absolute socket, pane id)` plus an available
43
59
  /// pane cwd inside the target workspace is the authority. Session/window/tty/
@@ -79,11 +95,12 @@ pub fn resolve_live_leader_channel(
79
95
  .get("tmux_socket")
80
96
  .and_then(Value::as_str)
81
97
  .filter(|socket| !socket.is_empty());
98
+ let transport_endpoint = transport.tmux_endpoint();
82
99
  if tmux_socket.is_some_and(|socket| !std::path::Path::new(socket).is_absolute()) {
83
100
  return LeaderChannelResolution::Unbound(LeaderChannelUnbound::NonCanonicalTmuxSocket);
84
101
  }
85
102
  if let Some(expected) = tmux_socket {
86
- if transport.tmux_endpoint().as_deref() != Some(expected) {
103
+ if transport_endpoint.as_deref() != Some(expected) {
87
104
  return LeaderChannelResolution::Unbound(LeaderChannelUnbound::EndpointMismatch);
88
105
  }
89
106
  }
@@ -97,13 +114,19 @@ pub fn resolve_live_leader_channel(
97
114
  else {
98
115
  return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneNotLive);
99
116
  };
100
- if observed
101
- .current_path
102
- .as_deref()
103
- .is_some_and(|path| !path_is_in_workspace(path, workspace))
104
- && !explicit_claim_authority_matches(workspace, receiver, &observed)
105
- {
106
- return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch);
117
+ if let Some(observed_pane_workspace) = observed.current_path.clone() {
118
+ if !path_is_in_workspace(&observed_pane_workspace, workspace)
119
+ && !explicit_claim_authority_matches(workspace, receiver, &observed)
120
+ {
121
+ return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch(
122
+ PaneWorkspaceMismatchFacts {
123
+ requested_workspace: canonical_path(workspace),
124
+ observed_pane_id: observed.pane_id.as_str().to_string(),
125
+ observed_pane_workspace,
126
+ endpoint: transport_endpoint.unwrap_or_else(|| "default".to_string()),
127
+ },
128
+ ));
129
+ }
107
130
  }
108
131
  let metadata_drift = receiver_metadata_drift(receiver, &observed);
109
132
  LeaderChannelResolution::Live(LiveLeaderChannel::DirectTmux(DirectTmuxLeaderChannel {
@@ -397,29 +397,46 @@ pub fn deliver_to_leader_fallback_pane(
397
397
  let readback_ok = super::delivery::pane_readback_verified(&report);
398
398
  if submit_ok {
399
399
  store.record_delivery_submission(message_id, readback_ok)?;
400
- if !super::delivery::leader_transcript_has_token(state, message_id) {
401
- store.mark(message_id, "submitted_pending_acceptance", None)?;
402
- event_log.write(
403
- "leader_receiver.acceptance_pending",
404
- serde_json::json!({
405
- "message_id": message_id,
406
- "result_id": result_id,
407
- "reason": "provider_receipt_not_observed",
408
- "channel": "fallback_pane",
409
- }),
410
- )?;
411
- return Ok(DeliveryOutcome {
412
- ok: true,
413
- status: DeliveryStatus::RetryScheduled,
414
- message_status: MessageStatusShadow(
415
- "submitted_pending_acceptance".to_string(),
416
- ),
417
- message_id: Some(message_id.to_string()),
418
- verification: Some("provider_receipt_not_observed".to_string()),
419
- stage: Some(DeliveryStage::Submit),
420
- reason: None,
421
- channel: Some("leader_acceptance_pending".to_string()),
422
- });
400
+ match super::delivery::observe_leader_receipt(state, message_id) {
401
+ super::delivery::LeaderReceiptObservation::SourceUnavailable => {
402
+ store.mark_injected_awaiting_receipt(message_id)?;
403
+ event_log.write(
404
+ "leader_receiver.receipt_source_unavailable",
405
+ serde_json::json!({
406
+ "message_id": message_id,
407
+ "result_id": result_id,
408
+ "channel": "fallback_pane",
409
+ }),
410
+ )?;
411
+ return Ok(super::delivery::leader_receipt_source_unavailable_outcome(
412
+ message_id,
413
+ ));
414
+ }
415
+ super::delivery::LeaderReceiptObservation::TokenAbsent => {
416
+ store.mark(message_id, "submitted_pending_acceptance", None)?;
417
+ event_log.write(
418
+ "leader_receiver.acceptance_pending",
419
+ serde_json::json!({
420
+ "message_id": message_id,
421
+ "result_id": result_id,
422
+ "reason": "provider_receipt_not_observed",
423
+ "channel": "fallback_pane",
424
+ }),
425
+ )?;
426
+ return Ok(DeliveryOutcome {
427
+ ok: true,
428
+ status: DeliveryStatus::RetryScheduled,
429
+ message_status: MessageStatusShadow(
430
+ "submitted_pending_acceptance".to_string(),
431
+ ),
432
+ message_id: Some(message_id.to_string()),
433
+ verification: Some("provider_receipt_not_observed".to_string()),
434
+ stage: Some(DeliveryStage::Submit),
435
+ reason: None,
436
+ channel: Some("leader_acceptance_pending".to_string()),
437
+ });
438
+ }
439
+ super::delivery::LeaderReceiptObservation::TokenObserved => {}
423
440
  }
424
441
  store.mark_delivered_with_receipt(message_id)?;
425
442
  event_log.write(
@@ -98,6 +98,12 @@ pub struct PresentationDecision {
98
98
  pub policy_version: String,
99
99
  }
100
100
 
101
+ #[derive(Debug, Clone, PartialEq, Eq)]
102
+ pub struct SendPresentationInput {
103
+ pub request: PresentationRequest,
104
+ pub deprecation: Option<String>,
105
+ }
106
+
101
107
  impl Default for PresentationRequest {
102
108
  fn default() -> Self {
103
109
  Self {
@@ -177,6 +183,79 @@ pub fn normalize_report_presentation(
177
183
  (request, None)
178
184
  }
179
185
 
186
+ /// Normalize the one-bit send surface and the temporary S-011 compatibility
187
+ /// input into the existing presentation disposition primitive.
188
+ ///
189
+ /// S-011 sensitive: if the compatibility period is ended by a hard-cut
190
+ /// decision, replace this table with one uniform legacy-input refusal.
191
+ pub fn normalize_send_presentation(
192
+ mailbox: Option<&Value>,
193
+ legacy_presentation: Option<&Value>,
194
+ ) -> Result<SendPresentationInput, String> {
195
+ if mailbox.is_some() && legacy_presentation.is_some() {
196
+ return Err("mailbox_conflicts_with_deprecated_presentation".to_string());
197
+ }
198
+ if let Some(mailbox) = mailbox {
199
+ let Some(mailbox) = mailbox.as_bool() else {
200
+ return Err("mailbox_must_be_boolean".to_string());
201
+ };
202
+ return Ok(SendPresentationInput {
203
+ request: PresentationRequest {
204
+ sink: if mailbox {
205
+ PresentationSink::Casefile
206
+ } else {
207
+ PresentationSink::Leader
208
+ },
209
+ class: PresentationClass::Message,
210
+ case_id: None,
211
+ },
212
+ deprecation: None,
213
+ });
214
+ }
215
+ let Some(legacy) = legacy_presentation else {
216
+ return Ok(SendPresentationInput {
217
+ request: PresentationRequest::default(),
218
+ deprecation: None,
219
+ });
220
+ };
221
+ let Some(object) = legacy.as_object() else {
222
+ return Err("malformed_presentation".to_string());
223
+ };
224
+ let Some(class) = object.get("class").and_then(Value::as_str) else {
225
+ return Err("missing_class".to_string());
226
+ };
227
+ let Some(class) = PresentationClass::parse(class) else {
228
+ return Err(format!("unknown_class:{class}"));
229
+ };
230
+ let sink = match class {
231
+ PresentationClass::Message | PresentationClass::Blocking | PresentationClass::Timeout => {
232
+ PresentationSink::Leader
233
+ }
234
+ PresentationClass::Progress => PresentationSink::Casefile,
235
+ PresentationClass::StageResult
236
+ | PresentationClass::StagePass
237
+ | PresentationClass::Bounce
238
+ | PresentationClass::FinalReview => {
239
+ return Err(format!(
240
+ "deprecated_message_class:{}_requires_result_route",
241
+ class.as_str()
242
+ ));
243
+ }
244
+ };
245
+ Ok(SendPresentationInput {
246
+ request: PresentationRequest {
247
+ sink,
248
+ class: PresentationClass::Message,
249
+ case_id: None,
250
+ },
251
+ deprecation: Some(format!(
252
+ "message-class={} is deprecated; use mailbox={} instead",
253
+ class.as_str(),
254
+ sink != PresentationSink::Leader
255
+ )),
256
+ })
257
+ }
258
+
180
259
  pub fn normalize_presentation(value: Option<&Value>) -> (PresentationRequest, Option<String>) {
181
260
  let Some(value) = value else {
182
261
  return (PresentationRequest::default(), None);
@@ -304,4 +383,34 @@ mod tests {
304
383
  "send normalization remains unchanged"
305
384
  );
306
385
  }
386
+
387
+ #[test]
388
+ fn send_mailbox_and_s011_compatibility_map_to_one_bit() {
389
+ let mailbox = normalize_send_presentation(Some(&json!(true)), None).unwrap();
390
+ assert_eq!(mailbox.request.sink, PresentationSink::Casefile);
391
+ assert_eq!(mailbox.deprecation, None);
392
+
393
+ for (class, sink) in [
394
+ ("message", PresentationSink::Leader),
395
+ ("progress", PresentationSink::Casefile),
396
+ ("blocking", PresentationSink::Leader),
397
+ ("timeout", PresentationSink::Leader),
398
+ ] {
399
+ let legacy = json!({"sink": "silent", "class": class});
400
+ let mapped = normalize_send_presentation(None, Some(&legacy)).unwrap();
401
+ assert_eq!(mapped.request.sink, sink, "{class}");
402
+ assert!(mapped.deprecation.is_some(), "{class}");
403
+ }
404
+ }
405
+
406
+ #[test]
407
+ fn s011_stage_classes_require_result_route() {
408
+ for class in ["stage_result", "stage_pass", "bounce", "final_review"] {
409
+ let legacy = json!({"sink": "leader", "class": class});
410
+ assert_eq!(
411
+ normalize_send_presentation(None, Some(&legacy)).unwrap_err(),
412
+ format!("deprecated_message_class:{class}_requires_result_route")
413
+ );
414
+ }
415
+ }
307
416
  }