lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit c1428bb006ca570c429b2d7ff0ce5f8d21360657
parent 47addd903de6f67989dfaf53afaba148e6449721
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Jul 2026 09:04:07 +0000

runtime: add Phase 1 route execution flows

- model route partner assigned stops, buyer receipt, and exception recovery
- connect route execution steps to authority, visibility, outbox, and sync state
- cover receipt token, partial receipt, and least-privilege boundaries in tests

Diffstat:
Mcrates/mobile_core/src/runtime/product_surface.rs | 550+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 550 insertions(+), 0 deletions(-)

diff --git a/crates/mobile_core/src/runtime/product_surface.rs b/crates/mobile_core/src/runtime/product_surface.rs @@ -226,6 +226,25 @@ pub enum PrototypePathKind { #[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] +pub enum RouteExecutionFlowKind { + RoutePartnerAssignedStops, + BuyerReceiptConfirmation, + ExceptionRecovery, +} + +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum RouteExecutionStepKind { + AssignedRoute, + PickupConfirmation, + DropoffConfirmation, + ReceiptConfirmation, + ExceptionReport, + RecoveryAction, +} + +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] pub enum OutboxState { NotQueued, Draft, @@ -439,6 +458,38 @@ pub struct PrototypePath { pub steps: Vec<PrototypePathStep>, } +#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteExecutionStep { + pub id: String, + pub kind: RouteExecutionStepKind, + pub label: String, + pub actor: WorkflowActor, + pub context: ActiveContext, + pub route_ref: ObjectRef, + pub object_ref: Option<ObjectRef>, + pub required_authority: AuthorityGate, + pub visibility: VisibilityClass, + pub supports_offline: bool, + pub supports_partial_receipt: bool, + pub uses_receipt_token: bool, + pub outbox_state: OutboxState, + pub sync_state: SyncState, + pub detail_lines: Vec<String>, +} + +#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteExecutionFlow { + pub id: String, + pub kind: RouteExecutionFlowKind, + pub title: String, + pub actor: WorkflowActor, + pub context: ActiveContext, + pub route_ref: ObjectRef, + pub steps: Vec<RouteExecutionStep>, +} + pub const CANONICAL_CONTEXT_TYPES: [ContextType; 10] = [ ContextType::Regional, ContextType::Network, @@ -599,6 +650,21 @@ pub const CANONICAL_SYNC_STATES: [SyncState; 7] = [ SyncState::Failed, ]; +pub const CANONICAL_ROUTE_EXECUTION_FLOW_KINDS: [RouteExecutionFlowKind; 3] = [ + RouteExecutionFlowKind::RoutePartnerAssignedStops, + RouteExecutionFlowKind::BuyerReceiptConfirmation, + RouteExecutionFlowKind::ExceptionRecovery, +]; + +pub const CANONICAL_ROUTE_EXECUTION_STEP_KINDS: [RouteExecutionStepKind; 6] = [ + RouteExecutionStepKind::AssignedRoute, + RouteExecutionStepKind::PickupConfirmation, + RouteExecutionStepKind::DropoffConfirmation, + RouteExecutionStepKind::ReceiptConfirmation, + RouteExecutionStepKind::ExceptionReport, + RouteExecutionStepKind::RecoveryAction, +]; + fn object_ref( object_type: ObjectKind, object_id: impl Into<String>, @@ -1413,6 +1479,337 @@ pub fn fixture_prototype_paths() -> Vec<PrototypePath> { ] } +struct RouteExecutionStepFixture { + id: &'static str, + kind: RouteExecutionStepKind, + label: &'static str, + actor: WorkflowActor, + context: ActiveContext, + object_ref: Option<ObjectRef>, + domain: AuthorityDomain, + action: AuthorityAction, + visibility: VisibilityClass, + supports_offline: bool, + supports_partial_receipt: bool, + uses_receipt_token: bool, + outbox_state: OutboxState, + sync_state: SyncState, + detail_lines: Vec<&'static str>, +} + +fn route_execution_step( + route_ref: ObjectRef, + fixture: RouteExecutionStepFixture, +) -> RouteExecutionStep { + RouteExecutionStep { + id: fixture.id.to_string(), + kind: fixture.kind, + label: fixture.label.to_string(), + actor: fixture.actor, + context: fixture.context.clone(), + route_ref, + object_ref: fixture.object_ref, + required_authority: fixture_authority_gate( + fixture.actor, + fixture.context, + fixture.domain, + fixture.action, + ), + visibility: fixture.visibility, + supports_offline: fixture.supports_offline, + supports_partial_receipt: fixture.supports_partial_receipt, + uses_receipt_token: fixture.uses_receipt_token, + outbox_state: fixture.outbox_state, + sync_state: fixture.sync_state, + detail_lines: fixture + .detail_lines + .into_iter() + .map(str::to_string) + .collect(), + } +} + +fn all_route_execution_flows() -> Vec<RouteExecutionFlow> { + let route = context_for_type(ContextType::Route); + let route_partner = context_for_type(ContextType::RoutePartner); + let buyer_receiver = ActiveContext { + context_type: ContextType::Buyer, + context_ref: object_ref( + ObjectKind::BuyerWorkspace, + "buyer_receiver_workspace_001", + "Kitchen receiving workspace", + ), + actor: WorkflowActor::BuyerReceiver, + display_label: "Kitchen receiving workspace".to_string(), + visibility_scope: VisibilityClass::BuyerScoped, + }; + let route_ref = object_ref( + ObjectKind::Route, + "route_thursday_001", + "Thursday network loop", + ); + let stop_pickup_ref = object_ref( + ObjectKind::RouteStop, + "route_stop_pickup_001", + "Floripa Farm pickup", + ); + let stop_dropoff_ref = object_ref( + ObjectKind::RouteStop, + "route_stop_dropoff_001", + "Kitchen drop-off", + ); + let pickup_proof_ref = object_ref( + ObjectKind::Proof, + "proof_pickup_001", + "Pickup confirmation proof", + ); + let dropoff_proof_ref = object_ref( + ObjectKind::Proof, + "proof_dropoff_001", + "Drop-off confirmation proof", + ); + let receipt_ref = object_ref( + ObjectKind::Proof, + "receipt_kitchen_001", + "Kitchen receipt confirmation", + ); + let exception_ref = object_ref( + ObjectKind::Exception, + "exception_short_case_001", + "Short case divergence", + ); + + vec![ + RouteExecutionFlow { + id: "route_partner_assigned_stops".to_string(), + kind: RouteExecutionFlowKind::RoutePartnerAssignedStops, + title: "Assigned route stops".to_string(), + actor: WorkflowActor::RoutePartner, + context: route_partner.clone(), + route_ref: route_ref.clone(), + steps: vec![ + route_execution_step( + route_ref.clone(), + RouteExecutionStepFixture { + id: "assigned_route", + kind: RouteExecutionStepKind::AssignedRoute, + label: "Assigned route", + actor: WorkflowActor::RoutePartner, + context: route_partner.clone(), + object_ref: Some(route_ref.clone()), + domain: AuthorityDomain::RouteExecution, + action: AuthorityAction::Search, + visibility: VisibilityClass::RouteScoped, + supports_offline: true, + supports_partial_receipt: false, + uses_receipt_token: false, + outbox_state: OutboxState::NotQueued, + sync_state: SyncState::Online, + detail_lines: vec![ + "RoutePartner sees the assigned route and assigned stops only.", + "Buyer packet and private buyer workspace data are not exposed.", + ], + }, + ), + route_execution_step( + route_ref.clone(), + RouteExecutionStepFixture { + id: "pickup_confirmation", + kind: RouteExecutionStepKind::PickupConfirmation, + label: "Confirm pickup", + actor: WorkflowActor::RoutePartner, + context: route_partner.clone(), + object_ref: Some(pickup_proof_ref), + domain: AuthorityDomain::RouteExecution, + action: AuthorityAction::Submit, + visibility: VisibilityClass::RouteScoped, + supports_offline: true, + supports_partial_receipt: false, + uses_receipt_token: false, + outbox_state: OutboxState::Queued, + sync_state: SyncState::Offline, + detail_lines: vec![ + "Photo, note, scan, or signature proof can queue offline.", + "The stop remains scoped to the assigned route.", + ], + }, + ), + route_execution_step( + route_ref.clone(), + RouteExecutionStepFixture { + id: "dropoff_confirmation", + kind: RouteExecutionStepKind::DropoffConfirmation, + label: "Confirm drop-off", + actor: WorkflowActor::RoutePartner, + context: route_partner, + object_ref: Some(dropoff_proof_ref), + domain: AuthorityDomain::RouteExecution, + action: AuthorityAction::Submit, + visibility: VisibilityClass::RouteScoped, + supports_offline: true, + supports_partial_receipt: false, + uses_receipt_token: false, + outbox_state: OutboxState::Syncing, + sync_state: SyncState::Syncing, + detail_lines: vec![ + "Drop-off proof syncs when relay connectivity returns.", + "Receipt confirmation remains separate from route execution.", + ], + }, + ), + route_execution_step( + route_ref.clone(), + RouteExecutionStepFixture { + id: "assigned_pickup_stop", + kind: RouteExecutionStepKind::AssignedRoute, + label: "Pickup stop", + actor: WorkflowActor::RoutePartner, + context: context_for_type(ContextType::RoutePartner), + object_ref: Some(stop_pickup_ref), + domain: AuthorityDomain::RouteExecution, + action: AuthorityAction::NavigateRelatedObject, + visibility: VisibilityClass::RouteScoped, + supports_offline: true, + supports_partial_receipt: false, + uses_receipt_token: false, + outbox_state: OutboxState::NotQueued, + sync_state: SyncState::Synced, + detail_lines: vec!["Assigned stop detail is available offline."], + }, + ), + route_execution_step( + route_ref.clone(), + RouteExecutionStepFixture { + id: "assigned_dropoff_stop", + kind: RouteExecutionStepKind::AssignedRoute, + label: "Drop-off stop", + actor: WorkflowActor::RoutePartner, + context: context_for_type(ContextType::RoutePartner), + object_ref: Some(stop_dropoff_ref), + domain: AuthorityDomain::RouteExecution, + action: AuthorityAction::NavigateRelatedObject, + visibility: VisibilityClass::RouteScoped, + supports_offline: true, + supports_partial_receipt: false, + uses_receipt_token: false, + outbox_state: OutboxState::NotQueued, + sync_state: SyncState::Synced, + detail_lines: vec!["Assigned stop detail is available offline."], + }, + ), + ], + }, + RouteExecutionFlow { + id: "buyer_receipt_confirmation".to_string(), + kind: RouteExecutionFlowKind::BuyerReceiptConfirmation, + title: "Buyer receipt confirmation".to_string(), + actor: WorkflowActor::BuyerReceiver, + context: buyer_receiver.clone(), + route_ref: route_ref.clone(), + steps: vec![route_execution_step( + route_ref.clone(), + RouteExecutionStepFixture { + id: "receiver_receipt", + kind: RouteExecutionStepKind::ReceiptConfirmation, + label: "Confirm full or partial receipt", + actor: WorkflowActor::BuyerReceiver, + context: buyer_receiver, + object_ref: Some(receipt_ref), + domain: AuthorityDomain::Receipt, + action: AuthorityAction::Submit, + visibility: VisibilityClass::BuyerScoped, + supports_offline: true, + supports_partial_receipt: true, + uses_receipt_token: true, + outbox_state: OutboxState::Queued, + sync_state: SyncState::Offline, + detail_lines: vec![ + "BuyerReceiver can confirm full or partial receipt.", + "A scoped receipt token can be used without exposing buyer workspace data.", + ], + }, + )], + }, + RouteExecutionFlow { + id: "route_exception_recovery".to_string(), + kind: RouteExecutionFlowKind::ExceptionRecovery, + title: "Exception recovery".to_string(), + actor: WorkflowActor::RoutePartner, + context: context_for_type(ContextType::RoutePartner), + route_ref: route_ref.clone(), + steps: vec![ + route_execution_step( + route_ref.clone(), + RouteExecutionStepFixture { + id: "report_exception", + kind: RouteExecutionStepKind::ExceptionReport, + label: "Report divergence", + actor: WorkflowActor::RoutePartner, + context: context_for_type(ContextType::RoutePartner), + object_ref: Some(exception_ref.clone()), + domain: AuthorityDomain::RouteExecution, + action: AuthorityAction::Submit, + visibility: VisibilityClass::RouteScoped, + supports_offline: true, + supports_partial_receipt: false, + uses_receipt_token: false, + outbox_state: OutboxState::Conflict, + sync_state: SyncState::Failed, + detail_lines: vec![ + "Short, damaged, late, or missing-item divergence becomes an exception card.", + "The exception stays route-scoped until resolved or escalated.", + ], + }, + ), + route_execution_step( + route_ref, + RouteExecutionStepFixture { + id: "resolve_exception", + kind: RouteExecutionStepKind::RecoveryAction, + label: "Resolve recovery path", + actor: WorkflowActor::RouteCoordinator, + context: route, + object_ref: Some(exception_ref), + domain: AuthorityDomain::RouteCoordination, + action: AuthorityAction::Close, + visibility: VisibilityClass::RouteScoped, + supports_offline: false, + supports_partial_receipt: true, + uses_receipt_token: false, + outbox_state: OutboxState::AwaitingAuthority, + sync_state: SyncState::Online, + detail_lines: vec![ + "RouteCoordinator chooses correction, partial receipt, replacement, or closure.", + "Recovery keeps route execution, receipt, and buyer data boundaries separate.", + ], + }, + ), + ], + }, + ] +} + +pub fn fixture_route_execution_flows(context_id: Option<String>) -> Vec<RouteExecutionFlow> { + let Some(context_id) = context_id else { + return all_route_execution_flows(); + }; + let matching: Vec<RouteExecutionFlow> = all_route_execution_flows() + .into_iter() + .filter(|flow| { + flow.context.context_ref.object_id == context_id + || flow + .steps + .iter() + .any(|step| step.context.context_ref.object_id == context_id) + }) + .collect(); + if matching.is_empty() { + all_route_execution_flows() + } else { + matching + } +} + pub fn fixture_outbox_items() -> Vec<OutboxItem> { let context = context_for_type(ContextType::Farm); CANONICAL_OUTBOX_STATES @@ -1543,6 +1940,14 @@ impl RadrootsRuntime { fixture_prototype_paths() } + pub fn phase1_route_execution_flows( + &self, + context_id: Option<String>, + ) -> Vec<RouteExecutionFlow> { + let _ = self; + fixture_route_execution_flows(context_id) + } + pub fn phase1_outbox_retry_decision(&self, item: OutboxItem) -> OutboxRetryDecision { let _ = self; fixture_outbox_retry_decision(item) @@ -1607,6 +2012,8 @@ mod tests { assert_eq!(CANONICAL_OBJECT_PAGE_FAMILIES.len(), 13); assert_eq!(CANONICAL_OUTBOX_STATES.len(), 10); assert_eq!(CANONICAL_SYNC_STATES.len(), 7); + assert_eq!(CANONICAL_ROUTE_EXECUTION_FLOW_KINDS.len(), 3); + assert_eq!(CANONICAL_ROUTE_EXECUTION_STEP_KINDS.len(), 6); } #[test] @@ -1645,6 +2052,10 @@ mod tests { CANONICAL_OBJECT_PAGE_FAMILIES.len() ); assert_eq!(fixture_outbox_items().len(), CANONICAL_OUTBOX_STATES.len()); + assert_eq!( + fixture_route_execution_flows(None).len(), + CANONICAL_ROUTE_EXECUTION_FLOW_KINDS.len() + ); } #[test] @@ -1822,6 +2233,145 @@ mod tests { } #[test] + fn route_execution_flows_cover_partner_receipt_and_exception_paths() { + let flows = fixture_route_execution_flows(None); + assert_eq!(flows.len(), 3); + + for kind in CANONICAL_ROUTE_EXECUTION_FLOW_KINDS { + assert!( + flows.iter().any(|flow| flow.kind == kind), + "missing {kind:?}" + ); + } + + let steps: Vec<&RouteExecutionStep> = + flows.iter().flat_map(|flow| flow.steps.iter()).collect(); + for kind in CANONICAL_ROUTE_EXECUTION_STEP_KINDS { + assert!( + steps.iter().any(|step| step.kind == kind), + "missing {kind:?}" + ); + } + + assert!(steps.iter().any(|step| { + step.kind == RouteExecutionStepKind::PickupConfirmation + && step.supports_offline + && step + .object_ref + .as_ref() + .is_some_and(|object_ref| object_ref.object_type == ObjectKind::Proof) + })); + assert!(steps.iter().any(|step| { + step.kind == RouteExecutionStepKind::DropoffConfirmation + && step.supports_offline + && step + .object_ref + .as_ref() + .is_some_and(|object_ref| object_ref.object_type == ObjectKind::Proof) + })); + assert!( + steps + .iter() + .any(|step| step.kind == RouteExecutionStepKind::ExceptionReport + && step.outbox_state == OutboxState::Conflict) + ); + assert!( + fixture_today_cards(None) + .iter() + .any(|card| card.card_type == TodayCardType::Exception) + ); + } + + #[test] + fn route_partner_execution_flow_is_assigned_route_scoped_only() { + let flow = fixture_route_execution_flows(None) + .into_iter() + .find(|flow| flow.kind == RouteExecutionFlowKind::RoutePartnerAssignedStops) + .expect("route partner flow"); + assert_eq!(flow.actor, WorkflowActor::RoutePartner); + assert_eq!(flow.context.actor, WorkflowActor::RoutePartner); + + for step in &flow.steps { + assert_eq!(step.actor, WorkflowActor::RoutePartner); + assert_eq!(step.visibility, VisibilityClass::RouteScoped); + assert_eq!( + step.required_authority.domain, + AuthorityDomain::RouteExecution + ); + assert!(step.required_authority.is_allowed); + assert_ne!(step.required_authority.domain, AuthorityDomain::Receipt); + assert_ne!( + step.required_authority.domain, + AuthorityDomain::BuyerWorkspace + ); + assert!(matches!( + step.object_ref + .as_ref() + .map(|object_ref| object_ref.object_type), + Some(ObjectKind::Route | ObjectKind::RouteStop | ObjectKind::Proof) + )); + } + } + + #[test] + fn buyer_receipt_flow_supports_partial_receipt_token_without_route_execution() { + let flow = fixture_route_execution_flows(None) + .into_iter() + .find(|flow| flow.kind == RouteExecutionFlowKind::BuyerReceiptConfirmation) + .expect("buyer receipt flow"); + assert_eq!(flow.actor, WorkflowActor::BuyerReceiver); + assert_eq!(flow.context.actor, WorkflowActor::BuyerReceiver); + assert_eq!(flow.steps.len(), 1); + + let receipt = &flow.steps[0]; + assert_eq!(receipt.kind, RouteExecutionStepKind::ReceiptConfirmation); + assert_eq!(receipt.required_authority.domain, AuthorityDomain::Receipt); + assert_eq!(receipt.required_authority.action, AuthorityAction::Submit); + assert!(receipt.required_authority.is_allowed); + assert!(receipt.supports_partial_receipt); + assert!(receipt.uses_receipt_token); + assert_ne!( + receipt.required_authority.domain, + AuthorityDomain::RouteExecution + ); + assert_eq!(receipt.visibility, VisibilityClass::BuyerScoped); + } + + #[test] + fn route_exception_recovery_separates_reporting_from_coordination() { + let flow = fixture_route_execution_flows(None) + .into_iter() + .find(|flow| flow.kind == RouteExecutionFlowKind::ExceptionRecovery) + .expect("exception recovery flow"); + let report = flow + .steps + .iter() + .find(|step| step.kind == RouteExecutionStepKind::ExceptionReport) + .expect("report step"); + let recovery = flow + .steps + .iter() + .find(|step| step.kind == RouteExecutionStepKind::RecoveryAction) + .expect("recovery step"); + + assert_eq!(report.actor, WorkflowActor::RoutePartner); + assert_eq!( + report.required_authority.domain, + AuthorityDomain::RouteExecution + ); + assert_eq!(report.outbox_state, OutboxState::Conflict); + assert!(report.supports_offline); + assert_eq!(recovery.actor, WorkflowActor::RouteCoordinator); + assert_eq!( + recovery.required_authority.domain, + AuthorityDomain::RouteCoordination + ); + assert_eq!(recovery.required_authority.action, AuthorityAction::Close); + assert!(recovery.supports_partial_receipt); + assert!(recovery.required_authority.is_allowed); + } + + #[test] fn serde_names_preserve_product_vocabulary() { assert_eq!( serde_json::to_value(WorkflowActor::NetworkMember).expect("serialize actor"),