lib

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

commit 5c085d9952a1284ced21911e5930e18e1d82bc7e
parent f7e947c2e5404b14974eef2ed11cae31c6b55061
Author: triesap <tyson@radroots.org>
Date:   Fri,  7 Aug 2026 08:14:46 +0000

feat(mobile): focus phase one social domain

- Replace broad field operations with exact card and Add taxonomies.
- Add validated local context, stable identities, ranking, and cursors.
- Classify admitted events without content or media-dependent drift.
- Expose focused catalogs and context creation through mobile FFI.

Diffstat:
MCargo.lock | 5+++++
Mcrates/mobile_core/Cargo.toml | 5+++++
Mcrates/mobile_core/src/runtime/product_surface.rs | 3692++-----------------------------------------------------------------------------
Acrates/mobile_core/src/runtime/product_surface/context.rs | 338+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/mobile_core/src/runtime/product_surface/cursor.rs | 438+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/mobile_core/src/runtime/product_surface/identity.rs | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/mobile_core/src/runtime/product_surface/model.rs | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/mobile_core/src/runtime/product_surface/projection.rs | 469+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/mobile_core/src/runtime/product_surface/ranking.rs | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/mobile_core/tests/package_boundary.rs | 15+++++++++++++++
Mcrates/mobile_ffi/src/remote.rs | 515++++---------------------------------------------------------------------------
Mcrates/mobile_ffi/src/runtime.rs | 92++++++++++++++++++++++---------------------------------------------------------
Mcrates/mobile_ffi/tests/runtime_delegation.rs | 94++++++++++++++++++++++++++++++++++++++++---------------------------------------
13 files changed, 2084 insertions(+), 4220 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3566,9 +3566,14 @@ name = "radroots_mobile_core" version = "0.1.0-alpha" dependencies = [ "chrono", + "hex", + "nostr 0.44.7", + "radroots_event", + "radroots_event_codec", "radroots_sdk", "serde", "serde_json", + "sha2", "thiserror 1.0.69", "tokio", ] diff --git a/crates/mobile_core/Cargo.toml b/crates/mobile_core/Cargo.toml @@ -28,10 +28,15 @@ mobile-social = [ [dependencies] radroots_sdk = { workspace = true, features = ["memory"] } +radroots_event = { workspace = true, default-features = false, features = ["std"] } +radroots_event_codec = { workspace = true, default-features = false, features = ["json", "std"] } chrono = { workspace = true } +hex = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +sha2 = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +nostr = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/mobile_core/src/runtime/product_surface.rs b/crates/mobile_core/src/runtime/product_surface.rs @@ -1,3640 +1,108 @@ -//! Phase 1 product-surface contracts for native Radroots clients. +//! Focused Phase 1 social-product domain for native Radroots clients. //! -//! These DTOs are the shared Rust vocabulary for the iOS `Today | Add` -//! surface. They deliberately model workflow actors, visibility, authority, -//! Today cards, Add actions, object pages, and outbox state separately from -//! low-level Nostr protocol roles or legacy trade/listing APIs. - -use serde::{Deserialize, Serialize}; +//! This module owns presentation-neutral product semantics. Protocol parsing +//! and admission remain in lower event crates; persistence and live query +//! composition remain in the runtime slices that consume these types. + +mod context; +mod cursor; +mod identity; +mod model; +mod projection; +mod ranking; + +pub use context::{ + ContextAdmission, ContextRank, LocalNetwork, LocalNetworkAdmission, LocalNetworkError, + LocalityEvidence, +}; +pub use cursor::{CursorError, CursorScope, TodayCursor, TodayCursorPosition}; +pub use identity::{CARD_ID_SCHEMA_VERSION, CardId, CardIdError, CardSourceIdentity}; +pub use model::{ + AddCommandType, CANONICAL_ADD_COMMAND_TYPES, CANONICAL_CARD_ADD_PARITY, + CANONICAL_TODAY_CARD_TYPES, CardAddParity, CardLifecycleState, ClassifiedCard, MediaReference, + MediaVerificationState, ProfileSummary, SupportingProfile, ThreadReference, TodayCardType, +}; +pub use projection::{ProductEventClassification, ProductEventExclusion, classify_admitted_event}; +pub use ranking::{RankError, TODAY_RANK_SCHEMA_VERSION, TimeRelevance, TodayRank, TodayRankInput}; use super::RadrootsRuntime; -/// Phase 1 workflow actors are product authority roles. Low-level protocol -/// roles such as Farmer, Buyer, Seller, and Service are compatibility roles and -/// are not sufficient authority for Phase 1 workflows. -pub const WORKFLOW_ACTOR_COMPATIBILITY_NOTE: &str = "Phase 1 workflow actors are product authority roles; low-level protocol \ - roles such as Farmer, Buyer, Seller, and Service are compatibility roles \ - and are not sufficient authority."; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum ContextType { - Regional, - Network, - Farm, - Buyer, - Route, - RoutePartner, - PickupPoint, - TraceRecords, - Hub, - NetworkSteward, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum WorkflowActor { - NetworkMember, - ProducerAdmin, - FarmTeamMember, - HubOperator, - TraceLead, - BuyerSourcingLead, - BuyerReceiver, - PickupPointCoordinator, - RouteCoordinator, - RoutePartner, - NetworkSteward, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum VisibilityClass { - LocalDraft, - FarmPrivate, - WorkspacePrivate, - NetworkVisible, - RouteScoped, - BuyerScoped, - PublicCommunity, - PublicProvenance, - SecretNeverShared, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum AuthorityDomain { - #[serde(rename = "Relay/group access")] - RelayGroupAccess, - #[serde(rename = "Farm/workspace operations authority")] - FarmWorkspaceOperations, - #[serde(rename = "Buyer workspace authority")] - BuyerWorkspace, - #[serde(rename = "Route coordination authority")] - RouteCoordination, - #[serde(rename = "Route execution authority")] - RouteExecution, - #[serde(rename = "Receipt authority")] - Receipt, - #[serde(rename = "Trace/proof authority")] - TraceProof, - #[serde(rename = "Public publishing authority")] - PublicPublishing, - #[serde(rename = "Network stewardship authority")] - NetworkStewardship, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum AuthorityAction { - Submit, - Publish, - Share, - Assign, - Approve, - Correct, - Close, - Retry, - Search, - NavigateRelatedObject, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum ObjectKind { - Region, - Network, - Farm, - BuyerWorkspace, - Route, - RoutePartner, - PickupPoint, - Hub, - Food, - Ask, - Event, - Place, - Task, - Proof, - Exception, - Provenance, - Update, - AccessMembership, - BuyerPacket, - RouteStop, - Draft, - OutboxItem, - MemberInvite, - Correction, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum ObjectPageFamily { - Network, - NetworkRoute, - FarmWorkspace, - FarmPublicProfile, - BuyerWorkspace, - PickupPointPlace, - Food, - Event, - RouteStop, - Proof, - BuyerPacket, - PublicProvenance, - Exception, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum TodayCardType { - Route, - Food, - Ask, - Event, - Place, - Task, - Proof, - Exception, - Provenance, - Update, - AccessMembership, - SyncOutbox, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum AddActionType { - Photo, - Note, - Ask, - Scan, - Food, - Harvest, - BuyerRequest, - BuyerCommitment, - RouteNeed, - RouteStop, - PickupEvent, - Place, - Proof, - Exception, - PublicUpdate, - Provenance, - MemberInvite, - Correction, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum AddFlowState { - NotStarted, - Draft, - Editing, - ValidationFailed, - ReadyToSubmit, - Submitted, - Queued, - Syncing, - NeedsApproval, - Approved, - Published, - Shared, - Confirmed, - Failed, - Conflict, - Discarded, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum OutboxBehavior { - LocalOnly, - QueueWhenOffline, - RequireOnline, - PublishWhenAuthorized, - ShareWhenAuthorized, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum PrototypePathKind { - ProducerFoodToRoute, - BuyerCommitmentToRoute, - RouteCoordinatorAssignment, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum RouteExecutionFlowKind { - RoutePartnerAssignedStops, - BuyerReceiptConfirmation, - ExceptionRecovery, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum RouteExecutionStepKind { - AssignedRoute, - PickupConfirmation, - DropoffConfirmation, - ReceiptConfirmation, - ExceptionReport, - RecoveryAction, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum ProofProvenanceArtifactKind { - ProofCompleteness, - BuyerPacketDraft, - BuyerPacketShared, - PublicProvenancePreview, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum ProofProvenanceReviewState { - MissingProof, - Complete, - Draft, - ReadyToShare, - Shared, - RedactionRequired, - ReadyToPublish, - Published, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum StewardshipAccessItemKind { - AccessRequestReview, - RoleApproval, - RoutePartnerInvite, - RoutePoolMetadata, - PublicModeration, - InviteAcceptance, - RequestAccess, - AccessDenied, - GroupManagementDeferred, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum OutboxState { - NotQueued, - Draft, - Queued, - Syncing, - AwaitingAuthority, - Published, - Shared, - Failed, - Conflict, - Discarded, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "PascalCase")] -pub enum SyncState { - Unknown, - Online, - Offline, - Syncing, - Synced, - Stale, - Failed, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ObjectRef { - pub object_type: ObjectKind, - pub object_id: String, - pub display_label: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct EventRef { - pub event_id: String, - pub relay_url: Option<String>, - pub kind: Option<u32>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ActiveContext { - pub context_type: ContextType, - pub context_ref: ObjectRef, - pub actor: WorkflowActor, - pub display_label: String, - pub visibility_scope: VisibilityClass, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthorityGate { - pub domain: AuthorityDomain, - pub action: AuthorityAction, - pub actor: WorkflowActor, - pub context: ActiveContext, - pub is_required: bool, - pub is_allowed: bool, - pub reason: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ObjectPageSummary { - pub object_ref: ObjectRef, - pub family: ObjectPageFamily, - pub primary_context: ActiveContext, - pub title: String, - pub subtitle: Option<String>, - pub visibility: VisibilityClass, - pub visibility_label: String, - pub required_authority: AuthorityGate, - pub sync_state: SyncState, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TodayCardAction { - pub id: String, - pub label: String, - pub action_type: Option<AddActionType>, - pub target_object: Option<ObjectRef>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TodayCard { - pub id: String, - pub card_type: TodayCardType, - pub source_object_refs: Vec<ObjectRef>, - pub source_event_refs: Vec<EventRef>, - pub primary_context: ActiveContext, - pub actor: WorkflowActor, - pub visibility: VisibilityClass, - pub visibility_label: String, - pub title: String, - pub status_line: String, - pub detail_lines: Vec<String>, - pub pills: Vec<String>, - pub primary_action: TodayCardAction, - pub secondary_action: Option<TodayCardAction>, - pub ranking_reason: String, - pub ranking_features: Vec<String>, - pub sync_state: SyncState, - pub outbox_state: OutboxState, - pub is_stale: bool, - pub is_offline: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RelatedObjectRequirement { - pub object_type: ObjectKind, - pub relationship_label: String, - pub is_required: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ValidationRequirement { - pub id: String, - pub label: String, - pub is_blocking: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AddAction { - pub action_type: AddActionType, - pub display_label: String, - pub allowed_context_types: Vec<ContextType>, - pub required_authority: AuthorityGate, - pub default_visibility: VisibilityClass, - pub allowed_visibility_options: Vec<VisibilityClass>, - pub created_or_updated_object_type: ObjectKind, - pub related_object_requirements: Vec<RelatedObjectRequirement>, - pub validation_requirements: Vec<ValidationRequirement>, - pub supports_offline: bool, - pub supports_draft: bool, - pub outbox_behavior: OutboxBehavior, - pub primary_submit_label: String, - pub completion_state: AddFlowState, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OutboxItem { - pub id: String, - pub action_type: AddActionType, - pub context: ActiveContext, - pub object_refs: Vec<ObjectRef>, - pub event_refs: Vec<EventRef>, - pub visibility: VisibilityClass, - pub authority_gate: AuthorityGate, - pub flow_state: AddFlowState, - pub outbox_state: OutboxState, - pub sync_state: SyncState, - pub queued_at_unix: Option<u64>, - pub last_attempt_at_unix: Option<u64>, - pub retry_count: u32, - pub last_error: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OutboxRetryDecision { - pub item_id: String, - pub is_retryable: bool, - pub authority_gate: AuthorityGate, - pub reason: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SearchResultSummary { - pub id: String, - pub object_ref: ObjectRef, - pub primary_context: ActiveContext, - pub title: String, - pub subtitle: Option<String>, - pub visibility: VisibilityClass, - pub visibility_label: String, - pub required_authority: AuthorityGate, - pub sync_state: SyncState, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PrototypePathStep { - pub id: String, - pub label: String, - pub context: ActiveContext, - pub action_type: Option<AddActionType>, - pub object_ref: Option<ObjectRef>, - pub authority_gate: AuthorityGate, - pub visibility: VisibilityClass, - pub outbox_state: OutboxState, - pub sync_state: SyncState, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PrototypePath { - pub id: String, - pub kind: PrototypePathKind, - pub title: String, - pub actor: WorkflowActor, - pub context: ActiveContext, - pub steps: Vec<PrototypePathStep>, -} - -#[derive(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(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>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProofProvenanceArtifact { - pub id: String, - pub kind: ProofProvenanceArtifactKind, - pub review_state: ProofProvenanceReviewState, - pub title: String, - pub actor: WorkflowActor, - pub context: ActiveContext, - pub object_ref: ObjectRef, - pub source_object_refs: Vec<ObjectRef>, - pub required_authority: AuthorityGate, - pub visibility: VisibilityClass, - pub is_public_preview: bool, - pub requires_redaction_review: bool, - pub can_publish: bool, - pub public_summary_lines: Vec<String>, - pub redacted_field_labels: Vec<String>, - pub outbox_state: OutboxState, - pub sync_state: SyncState, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct StewardshipAccessItem { - pub id: String, - pub kind: StewardshipAccessItemKind, - pub title: String, - pub actor: WorkflowActor, - pub context: ActiveContext, - pub target_ref: ObjectRef, - pub required_authority: AuthorityGate, - pub visibility: VisibilityClass, - pub is_admin_lite: bool, - pub is_phase_2_deferred: bool, - pub grants_private_access: bool, - pub outbox_state: OutboxState, - pub sync_state: SyncState, - pub detail_lines: Vec<String>, -} - -pub const CANONICAL_CONTEXT_TYPES: [ContextType; 10] = [ - ContextType::Regional, - ContextType::Network, - ContextType::Farm, - ContextType::Buyer, - ContextType::Route, - ContextType::RoutePartner, - ContextType::PickupPoint, - ContextType::TraceRecords, - ContextType::Hub, - ContextType::NetworkSteward, -]; - -pub const CANONICAL_WORKFLOW_ACTORS: [WorkflowActor; 11] = [ - WorkflowActor::NetworkMember, - WorkflowActor::ProducerAdmin, - WorkflowActor::FarmTeamMember, - WorkflowActor::HubOperator, - WorkflowActor::TraceLead, - WorkflowActor::BuyerSourcingLead, - WorkflowActor::BuyerReceiver, - WorkflowActor::PickupPointCoordinator, - WorkflowActor::RouteCoordinator, - WorkflowActor::RoutePartner, - WorkflowActor::NetworkSteward, -]; - -pub const CANONICAL_VISIBILITY_CLASSES: [VisibilityClass; 9] = [ - VisibilityClass::LocalDraft, - VisibilityClass::FarmPrivate, - VisibilityClass::WorkspacePrivate, - VisibilityClass::NetworkVisible, - VisibilityClass::RouteScoped, - VisibilityClass::BuyerScoped, - VisibilityClass::PublicCommunity, - VisibilityClass::PublicProvenance, - VisibilityClass::SecretNeverShared, -]; - -pub const CANONICAL_AUTHORITY_DOMAINS: [AuthorityDomain; 9] = [ - AuthorityDomain::RelayGroupAccess, - AuthorityDomain::FarmWorkspaceOperations, - AuthorityDomain::BuyerWorkspace, - AuthorityDomain::RouteCoordination, - AuthorityDomain::RouteExecution, - AuthorityDomain::Receipt, - AuthorityDomain::TraceProof, - AuthorityDomain::PublicPublishing, - AuthorityDomain::NetworkStewardship, -]; - -pub const CANONICAL_TODAY_CARD_TYPES: [TodayCardType; 12] = [ - TodayCardType::Route, - TodayCardType::Food, - TodayCardType::Ask, - TodayCardType::Event, - TodayCardType::Place, - TodayCardType::Task, - TodayCardType::Proof, - TodayCardType::Exception, - TodayCardType::Provenance, - TodayCardType::Update, - TodayCardType::AccessMembership, - TodayCardType::SyncOutbox, -]; - -pub const TODAY_CARD_RANKING_PRIORITY: [TodayCardType; 12] = [ - TodayCardType::Exception, - TodayCardType::SyncOutbox, - TodayCardType::Route, - TodayCardType::Proof, - TodayCardType::Food, - TodayCardType::Task, - TodayCardType::Provenance, - TodayCardType::Ask, - TodayCardType::Event, - TodayCardType::Place, - TodayCardType::Update, - TodayCardType::AccessMembership, -]; - -pub const CANONICAL_ADD_ACTION_TYPES: [AddActionType; 18] = [ - AddActionType::Photo, - AddActionType::Note, - AddActionType::Ask, - AddActionType::Scan, - AddActionType::Food, - AddActionType::Harvest, - AddActionType::BuyerRequest, - AddActionType::BuyerCommitment, - AddActionType::RouteNeed, - AddActionType::RouteStop, - AddActionType::PickupEvent, - AddActionType::Place, - AddActionType::Proof, - AddActionType::Exception, - AddActionType::PublicUpdate, - AddActionType::Provenance, - AddActionType::MemberInvite, - AddActionType::Correction, -]; - -pub const CANONICAL_ADD_FLOW_STATES: [AddFlowState; 16] = [ - AddFlowState::NotStarted, - AddFlowState::Draft, - AddFlowState::Editing, - AddFlowState::ValidationFailed, - AddFlowState::ReadyToSubmit, - AddFlowState::Submitted, - AddFlowState::Queued, - AddFlowState::Syncing, - AddFlowState::NeedsApproval, - AddFlowState::Approved, - AddFlowState::Published, - AddFlowState::Shared, - AddFlowState::Confirmed, - AddFlowState::Failed, - AddFlowState::Conflict, - AddFlowState::Discarded, -]; - -pub const CANONICAL_OBJECT_PAGE_FAMILIES: [ObjectPageFamily; 13] = [ - ObjectPageFamily::Network, - ObjectPageFamily::NetworkRoute, - ObjectPageFamily::FarmWorkspace, - ObjectPageFamily::FarmPublicProfile, - ObjectPageFamily::BuyerWorkspace, - ObjectPageFamily::PickupPointPlace, - ObjectPageFamily::Food, - ObjectPageFamily::Event, - ObjectPageFamily::RouteStop, - ObjectPageFamily::Proof, - ObjectPageFamily::BuyerPacket, - ObjectPageFamily::PublicProvenance, - ObjectPageFamily::Exception, -]; - -pub const CANONICAL_OUTBOX_STATES: [OutboxState; 10] = [ - OutboxState::NotQueued, - OutboxState::Draft, - OutboxState::Queued, - OutboxState::Syncing, - OutboxState::AwaitingAuthority, - OutboxState::Published, - OutboxState::Shared, - OutboxState::Failed, - OutboxState::Conflict, - OutboxState::Discarded, -]; - -pub const CANONICAL_SYNC_STATES: [SyncState; 7] = [ - SyncState::Unknown, - SyncState::Online, - SyncState::Offline, - SyncState::Syncing, - SyncState::Synced, - SyncState::Stale, - 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, -]; - -pub const CANONICAL_PROOF_PROVENANCE_ARTIFACT_KINDS: [ProofProvenanceArtifactKind; 4] = [ - ProofProvenanceArtifactKind::ProofCompleteness, - ProofProvenanceArtifactKind::BuyerPacketDraft, - ProofProvenanceArtifactKind::BuyerPacketShared, - ProofProvenanceArtifactKind::PublicProvenancePreview, -]; - -pub const CANONICAL_PROOF_PROVENANCE_REVIEW_STATES: [ProofProvenanceReviewState; 8] = [ - ProofProvenanceReviewState::MissingProof, - ProofProvenanceReviewState::Complete, - ProofProvenanceReviewState::Draft, - ProofProvenanceReviewState::ReadyToShare, - ProofProvenanceReviewState::Shared, - ProofProvenanceReviewState::RedactionRequired, - ProofProvenanceReviewState::ReadyToPublish, - ProofProvenanceReviewState::Published, -]; - -pub const CANONICAL_STEWARDSHIP_ACCESS_ITEM_KINDS: [StewardshipAccessItemKind; 9] = [ - StewardshipAccessItemKind::AccessRequestReview, - StewardshipAccessItemKind::RoleApproval, - StewardshipAccessItemKind::RoutePartnerInvite, - StewardshipAccessItemKind::RoutePoolMetadata, - StewardshipAccessItemKind::PublicModeration, - StewardshipAccessItemKind::InviteAcceptance, - StewardshipAccessItemKind::RequestAccess, - StewardshipAccessItemKind::AccessDenied, - StewardshipAccessItemKind::GroupManagementDeferred, -]; - -fn object_ref( - object_type: ObjectKind, - object_id: impl Into<String>, - label: impl Into<String>, -) -> ObjectRef { - ObjectRef { - object_type, - object_id: object_id.into(), - display_label: label.into(), - } -} - -fn context_fixture_parts( - context_type: ContextType, -) -> ( - ObjectKind, - &'static str, - &'static str, - WorkflowActor, - VisibilityClass, -) { - match context_type { - ContextType::Regional => ( - ObjectKind::Region, - "region_floripa", - "Floripa regional food network", - WorkflowActor::NetworkMember, - VisibilityClass::PublicCommunity, - ), - ContextType::Network => ( - ObjectKind::Network, - "network_floripa", - "Floripa local food network", - WorkflowActor::NetworkMember, - VisibilityClass::NetworkVisible, - ), - ContextType::Farm => ( - ObjectKind::Farm, - "farm_floripa_001", - "Floripa Farm", - WorkflowActor::ProducerAdmin, - VisibilityClass::FarmPrivate, - ), - ContextType::Buyer => ( - ObjectKind::BuyerWorkspace, - "buyer_workspace_001", - "Kitchen buyer workspace", - WorkflowActor::BuyerSourcingLead, - VisibilityClass::BuyerScoped, - ), - ContextType::Route => ( - ObjectKind::Route, - "route_thursday_001", - "Thursday network loop", - WorkflowActor::RouteCoordinator, - VisibilityClass::RouteScoped, - ), - ContextType::RoutePartner => ( - ObjectKind::RoutePartner, - "route_partner_001", - "Assigned route partner", - WorkflowActor::RoutePartner, - VisibilityClass::RouteScoped, - ), - ContextType::PickupPoint => ( - ObjectKind::PickupPoint, - "pickup_point_001", - "Neighborhood pickup point", - WorkflowActor::PickupPointCoordinator, - VisibilityClass::NetworkVisible, - ), - ContextType::TraceRecords => ( - ObjectKind::Proof, - "trace_records_001", - "Trace and records", - WorkflowActor::TraceLead, - VisibilityClass::WorkspacePrivate, - ), - ContextType::Hub => ( - ObjectKind::Hub, - "hub_001", - "Floripa hub", - WorkflowActor::HubOperator, - VisibilityClass::WorkspacePrivate, - ), - ContextType::NetworkSteward => ( - ObjectKind::AccessMembership, - "network_stewardship_001", - "Network stewardship", - WorkflowActor::NetworkSteward, - VisibilityClass::WorkspacePrivate, - ), - } -} - -fn context_for_type(context_type: ContextType) -> ActiveContext { - let (object_type, object_id, label, actor, visibility_scope) = - context_fixture_parts(context_type); - ActiveContext { - context_type, - context_ref: object_ref(object_type, object_id, label), - actor, - display_label: label.to_string(), - visibility_scope, - } -} - -fn authority_allowed(actor: WorkflowActor, domain: AuthorityDomain) -> bool { - matches!( - (actor, domain), - ( - WorkflowActor::NetworkMember, - AuthorityDomain::RelayGroupAccess - ) | ( - WorkflowActor::ProducerAdmin, - AuthorityDomain::FarmWorkspaceOperations - ) | (WorkflowActor::ProducerAdmin, AuthorityDomain::TraceProof) - | ( - WorkflowActor::ProducerAdmin, - AuthorityDomain::PublicPublishing - ) - | ( - WorkflowActor::FarmTeamMember, - AuthorityDomain::FarmWorkspaceOperations - ) - | ( - WorkflowActor::HubOperator, - AuthorityDomain::FarmWorkspaceOperations - ) - | (WorkflowActor::HubOperator, AuthorityDomain::RouteExecution) - | (WorkflowActor::TraceLead, AuthorityDomain::TraceProof) - | ( - WorkflowActor::BuyerSourcingLead, - AuthorityDomain::BuyerWorkspace - ) - | (WorkflowActor::BuyerReceiver, AuthorityDomain::Receipt) - | ( - WorkflowActor::PickupPointCoordinator, - AuthorityDomain::Receipt - ) - | ( - WorkflowActor::PickupPointCoordinator, - AuthorityDomain::RouteExecution - ) - | ( - WorkflowActor::RouteCoordinator, - AuthorityDomain::RouteCoordination - ) - | (WorkflowActor::RoutePartner, AuthorityDomain::RouteExecution) - | ( - WorkflowActor::NetworkSteward, - AuthorityDomain::RelayGroupAccess - ) - | ( - WorkflowActor::NetworkSteward, - AuthorityDomain::PublicPublishing - ) - | ( - WorkflowActor::NetworkSteward, - AuthorityDomain::NetworkStewardship - ) - ) -} - -pub fn fixture_authority_gate( - actor: WorkflowActor, - context: ActiveContext, - domain: AuthorityDomain, - action: AuthorityAction, -) -> AuthorityGate { - let is_allowed = authority_allowed(actor, domain); - AuthorityGate { - domain, - action, - actor, - context, - is_required: true, - is_allowed, - reason: if is_allowed { - None - } else { - Some("fixture authority denies this actor/domain pair".to_string()) - }, - } -} - -pub fn fixture_active_contexts() -> Vec<ActiveContext> { - CANONICAL_CONTEXT_TYPES - .into_iter() - .map(context_for_type) - .collect() -} - -fn context_by_object_id(context_id: Option<String>) -> Option<ActiveContext> { - let context_id = context_id?; - fixture_active_contexts() - .into_iter() - .find(|context| context.context_ref.object_id == context_id) -} - -fn card_action_for(card_type: TodayCardType) -> Option<AddActionType> { - match card_type { - TodayCardType::Route => Some(AddActionType::RouteStop), - TodayCardType::Food => Some(AddActionType::Food), - TodayCardType::Ask => Some(AddActionType::Ask), - TodayCardType::Event => Some(AddActionType::PickupEvent), - TodayCardType::Place => Some(AddActionType::Place), - TodayCardType::Task => Some(AddActionType::Note), - TodayCardType::Proof => Some(AddActionType::Proof), - TodayCardType::Exception => Some(AddActionType::Exception), - TodayCardType::Provenance => Some(AddActionType::Provenance), - TodayCardType::Update => Some(AddActionType::PublicUpdate), - TodayCardType::AccessMembership => Some(AddActionType::MemberInvite), - TodayCardType::SyncOutbox => None, - } -} - -fn object_kind_for_card(card_type: TodayCardType) -> ObjectKind { - match card_type { - TodayCardType::Route => ObjectKind::Route, - TodayCardType::Food => ObjectKind::Food, - TodayCardType::Ask => ObjectKind::Ask, - TodayCardType::Event => ObjectKind::Event, - TodayCardType::Place => ObjectKind::Place, - TodayCardType::Task => ObjectKind::Task, - TodayCardType::Proof => ObjectKind::Proof, - TodayCardType::Exception => ObjectKind::Exception, - TodayCardType::Provenance => ObjectKind::Provenance, - TodayCardType::Update => ObjectKind::Update, - TodayCardType::AccessMembership => ObjectKind::AccessMembership, - TodayCardType::SyncOutbox => ObjectKind::OutboxItem, - } -} - -fn ranking_reason_for(card_type: TodayCardType) -> &'static str { - match card_type { - TodayCardType::Exception => "blocking exception", - TodayCardType::SyncOutbox => "required sync action", - TodayCardType::Route => "time-window route operation", - TodayCardType::Proof => "route readiness and proof gap", - TodayCardType::Food => "commitment window", - TodayCardType::Task => "assigned task", - TodayCardType::Provenance => "provenance candidate", - TodayCardType::Ask => "food availability and ask", - TodayCardType::Event => "event window", - TodayCardType::Place => "place context", - TodayCardType::Update => "community update", - TodayCardType::AccessMembership => "network access state", - } -} - -fn ranking_features_for(card_type: TodayCardType) -> Vec<String> { - match card_type { - TodayCardType::Exception => vec!["blocking".to_string(), "recovery".to_string()], - TodayCardType::SyncOutbox => vec!["outbox".to_string(), "retry".to_string()], - TodayCardType::Route => vec!["time_window".to_string(), "route".to_string()], - TodayCardType::Proof => vec!["proof_gap".to_string(), "trace".to_string()], - TodayCardType::Food => vec!["commitment".to_string(), "availability".to_string()], - TodayCardType::Task => vec!["assigned".to_string(), "work".to_string()], - TodayCardType::Provenance => vec!["candidate".to_string(), "public_review".to_string()], - TodayCardType::Ask => vec!["ask".to_string(), "network_need".to_string()], - TodayCardType::Event => vec!["event".to_string(), "calendar".to_string()], - TodayCardType::Place => vec!["place".to_string(), "local_context".to_string()], - TodayCardType::Update => vec!["update".to_string(), "community".to_string()], - TodayCardType::AccessMembership => vec!["access".to_string(), "membership".to_string()], - } -} - -fn status_line_for(card_type: TodayCardType) -> &'static str { - match card_type { - TodayCardType::Exception => "Needs review", - TodayCardType::SyncOutbox => "Retry required", - TodayCardType::Route => "Route window open", - TodayCardType::Proof => "Proof gap detected", - TodayCardType::Food => "Commitment window active", - TodayCardType::Task => "Assigned work ready", - TodayCardType::Provenance => "Candidate ready for review", - TodayCardType::Ask => "Network need available", - TodayCardType::Event => "Upcoming gathering", - TodayCardType::Place => "Place context updated", - TodayCardType::Update => "Community update ready", - TodayCardType::AccessMembership => "Membership state available", - } -} - -fn detail_lines_for(card_type: TodayCardType) -> Vec<String> { - match card_type { - TodayCardType::Exception => vec![ - "Resolve the blocker before related route work continues.".to_string(), - "Recovery path remains scoped to the active context.".to_string(), - ], - TodayCardType::SyncOutbox => { - vec!["Queued work will re-check context, visibility, and authority.".to_string()] - } - TodayCardType::Route => { - vec!["Review stops, timing, proof gaps, and assigned route partner state.".to_string()] - } - TodayCardType::Proof => { - vec!["Trace record needs proof completion before publication.".to_string()] - } - TodayCardType::Food => { - vec!["Food availability is connected to commitments and routes.".to_string()] - } - TodayCardType::Task => vec!["Complete the assigned task from this context.".to_string()], - TodayCardType::Provenance => { - vec!["Public provenance preview requires redaction review.".to_string()] - } - TodayCardType::Ask => vec!["Respond to a scoped network ask.".to_string()], - TodayCardType::Event => vec!["Gathering context is visible to the network.".to_string()], - TodayCardType::Place => { - vec!["Place details are ready for local network review.".to_string()] - } - TodayCardType::Update => vec!["Share a context-aware network update.".to_string()], - TodayCardType::AccessMembership => { - vec!["Review member access for this context.".to_string()] - } - } -} - -pub fn fixture_today_cards(context_id: Option<String>) -> Vec<TodayCard> { - let contexts = fixture_active_contexts(); - let filter_context = context_by_object_id(context_id); - TODAY_CARD_RANKING_PRIORITY - .into_iter() - .enumerate() - .map(|(index, card_type)| { - let context = filter_context - .clone() - .unwrap_or_else(|| contexts[index % contexts.len()].clone()); - let object_kind = object_kind_for_card(card_type); - let object_id = format!("phase1_{:?}_001", object_kind).to_lowercase(); - let object = object_ref(object_kind, object_id, format!("{:?} fixture", card_type)); - let action_type = card_action_for(card_type); - TodayCard { - id: format!("today_{:?}_001", card_type).to_lowercase(), - card_type, - source_object_refs: vec![object.clone()], - source_event_refs: Vec::new(), - primary_context: context.clone(), - actor: context.actor, - visibility: context.visibility_scope, - visibility_label: format!("{:?}", context.visibility_scope), - title: format!("{:?} fixture card", card_type), - status_line: status_line_for(card_type).to_string(), - detail_lines: detail_lines_for(card_type), - pills: vec![ - format!("{:?}", card_type), - ranking_reason_for(card_type).to_string(), - ], - primary_action: TodayCardAction { - id: format!("primary_{:?}", card_type).to_lowercase(), - label: action_type - .map(|action| format!("{:?}", action)) - .unwrap_or_else(|| "Review".to_string()), - action_type, - target_object: Some(object), - }, - secondary_action: None, - ranking_reason: ranking_reason_for(card_type).to_string(), - ranking_features: ranking_features_for(card_type), - sync_state: if matches!(card_type, TodayCardType::SyncOutbox) { - SyncState::Failed - } else { - SyncState::Online - }, - outbox_state: if matches!(card_type, TodayCardType::SyncOutbox) { - OutboxState::Failed - } else { - OutboxState::NotQueued - }, - is_stale: matches!(card_type, TodayCardType::Proof), - is_offline: matches!(card_type, TodayCardType::SyncOutbox), - } - }) - .collect() -} - -fn add_action_object_kind(action_type: AddActionType) -> ObjectKind { - match action_type { - AddActionType::Photo | AddActionType::Note | AddActionType::PublicUpdate => { - ObjectKind::Update - } - AddActionType::Ask | AddActionType::BuyerRequest | AddActionType::RouteNeed => { - ObjectKind::Ask - } - AddActionType::Scan | AddActionType::Proof => ObjectKind::Proof, - AddActionType::Food | AddActionType::Harvest | AddActionType::BuyerCommitment => { - ObjectKind::Food - } - AddActionType::RouteStop | AddActionType::PickupEvent => ObjectKind::RouteStop, - AddActionType::Place => ObjectKind::Place, - AddActionType::Exception => ObjectKind::Exception, - AddActionType::Provenance => ObjectKind::Provenance, - AddActionType::MemberInvite => ObjectKind::AccessMembership, - AddActionType::Correction => ObjectKind::Correction, - } -} - -fn add_action_authority(action_type: AddActionType) -> (AuthorityDomain, AuthorityAction) { - match action_type { - AddActionType::PublicUpdate | AddActionType::Provenance => { - (AuthorityDomain::PublicPublishing, AuthorityAction::Publish) - } - AddActionType::BuyerRequest | AddActionType::BuyerCommitment => { - (AuthorityDomain::BuyerWorkspace, AuthorityAction::Submit) - } - AddActionType::RouteNeed | AddActionType::RouteStop | AddActionType::PickupEvent => { - (AuthorityDomain::RouteCoordination, AuthorityAction::Submit) - } - AddActionType::Proof | AddActionType::Scan => { - (AuthorityDomain::TraceProof, AuthorityAction::Submit) - } - AddActionType::MemberInvite => (AuthorityDomain::RelayGroupAccess, AuthorityAction::Share), - AddActionType::Correction => (AuthorityDomain::TraceProof, AuthorityAction::Correct), - _ => ( - AuthorityDomain::FarmWorkspaceOperations, - AuthorityAction::Submit, - ), - } -} - -fn default_visibility_for_action(action_type: AddActionType) -> VisibilityClass { - match action_type { - AddActionType::PublicUpdate => VisibilityClass::PublicCommunity, - AddActionType::Provenance => VisibilityClass::PublicProvenance, - AddActionType::BuyerRequest | AddActionType::BuyerCommitment => { - VisibilityClass::BuyerScoped - } - AddActionType::RouteNeed | AddActionType::RouteStop | AddActionType::PickupEvent => { - VisibilityClass::RouteScoped - } - AddActionType::Photo | AddActionType::Note | AddActionType::Scan => { - VisibilityClass::LocalDraft - } - _ => VisibilityClass::FarmPrivate, - } -} - -pub fn fixture_add_actions(context_id: Option<String>) -> Vec<AddAction> { - let context = - context_by_object_id(context_id).unwrap_or_else(|| context_for_type(ContextType::Farm)); - CANONICAL_ADD_ACTION_TYPES - .into_iter() - .map(|action_type| { - let (domain, action) = add_action_authority(action_type); - AddAction { - action_type, - display_label: format!("{:?}", action_type), - allowed_context_types: vec![context.context_type], - required_authority: fixture_authority_gate( - context.actor, - context.clone(), - domain, - action, - ), - default_visibility: default_visibility_for_action(action_type), - allowed_visibility_options: vec![ - VisibilityClass::LocalDraft, - default_visibility_for_action(action_type), - ], - created_or_updated_object_type: add_action_object_kind(action_type), - related_object_requirements: vec![RelatedObjectRequirement { - object_type: context.context_ref.object_type, - relationship_label: "primary context".to_string(), - is_required: true, - }], - validation_requirements: vec![ValidationRequirement { - id: "fixture_required_fields".to_string(), - label: "Required fields are present".to_string(), - is_blocking: true, - }], - supports_offline: true, - supports_draft: true, - outbox_behavior: OutboxBehavior::QueueWhenOffline, - primary_submit_label: "Submit".to_string(), - completion_state: AddFlowState::ReadyToSubmit, - } - }) - .collect() -} - -fn object_kind_for_page(family: ObjectPageFamily) -> ObjectKind { - match family { - ObjectPageFamily::Network => ObjectKind::Network, - ObjectPageFamily::NetworkRoute => ObjectKind::Route, - ObjectPageFamily::FarmWorkspace | ObjectPageFamily::FarmPublicProfile => ObjectKind::Farm, - ObjectPageFamily::BuyerWorkspace => ObjectKind::BuyerWorkspace, - ObjectPageFamily::PickupPointPlace => ObjectKind::PickupPoint, - ObjectPageFamily::Food => ObjectKind::Food, - ObjectPageFamily::Event => ObjectKind::Event, - ObjectPageFamily::RouteStop => ObjectKind::RouteStop, - ObjectPageFamily::Proof => ObjectKind::Proof, - ObjectPageFamily::BuyerPacket => ObjectKind::BuyerPacket, - ObjectPageFamily::PublicProvenance => ObjectKind::Provenance, - ObjectPageFamily::Exception => ObjectKind::Exception, - } -} - -pub fn fixture_object_page_summaries(context_id: Option<String>) -> Vec<ObjectPageSummary> { - let context = - context_by_object_id(context_id).unwrap_or_else(|| context_for_type(ContextType::Network)); - CANONICAL_OBJECT_PAGE_FAMILIES - .into_iter() - .map(|family| { - let object_kind = object_kind_for_page(family); - ObjectPageSummary { - object_ref: object_ref( - object_kind, - format!("phase1_{:?}_page_001", family).to_lowercase(), - format!("{:?} fixture page", family), - ), - family, - primary_context: context.clone(), - title: format!("{:?} fixture page", family), - subtitle: Some("fixture-backed object summary".to_string()), - visibility: context.visibility_scope, - visibility_label: format!("{:?}", context.visibility_scope), - required_authority: fixture_authority_gate( - context.actor, - context.clone(), - AuthorityDomain::RelayGroupAccess, - AuthorityAction::NavigateRelatedObject, - ), - sync_state: SyncState::Online, - } - }) - .collect() -} - -fn visibility_allows_search_result(visibility: VisibilityClass) -> bool { - matches!( - visibility, - VisibilityClass::NetworkVisible - | VisibilityClass::PublicCommunity - | VisibilityClass::PublicProvenance - ) -} - -fn object_kind_allows_search_result(object_kind: ObjectKind, visibility: VisibilityClass) -> bool { - match object_kind { - ObjectKind::BuyerPacket | ObjectKind::RouteStop => false, - ObjectKind::Proof => visibility == VisibilityClass::PublicProvenance, - _ => true, - } -} - -pub fn fixture_search_results( - query: Option<String>, - context_id: Option<String>, -) -> Vec<SearchResultSummary> { - let normalized_query = query.unwrap_or_default().trim().to_lowercase(); - fixture_object_page_summaries(context_id) - .into_iter() - .filter(|page| page.required_authority.is_allowed) - .filter(|page| visibility_allows_search_result(page.visibility)) - .filter(|page| { - object_kind_allows_search_result(page.object_ref.object_type, page.visibility) - }) - .filter(|page| { - normalized_query.is_empty() - || page.title.to_lowercase().contains(&normalized_query) - || format!("{:?}", page.family) - .to_lowercase() - .contains(&normalized_query) - }) - .map(|page| SearchResultSummary { - id: format!("search_{}", page.object_ref.object_id), - object_ref: page.object_ref, - primary_context: page.primary_context, - title: page.title, - subtitle: page.subtitle, - visibility: page.visibility, - visibility_label: page.visibility_label, - required_authority: page.required_authority, - sync_state: page.sync_state, - }) - .collect() -} - -struct PrototypePathStepFixture { - id: &'static str, - label: &'static str, - context: ActiveContext, - action_type: Option<AddActionType>, - object_ref: Option<ObjectRef>, - domain: AuthorityDomain, - action: AuthorityAction, - visibility: VisibilityClass, - outbox_state: OutboxState, - sync_state: SyncState, -} - -fn prototype_path_step(fixture: PrototypePathStepFixture) -> PrototypePathStep { - let PrototypePathStepFixture { - id, - label, - context, - action_type, - object_ref, - domain, - action, - visibility, - outbox_state, - sync_state, - } = fixture; - PrototypePathStep { - id: id.to_string(), - label: label.to_string(), - context: context.clone(), - action_type, - object_ref, - authority_gate: fixture_authority_gate(context.actor, context, domain, action), - visibility, - outbox_state, - sync_state, - } -} - -pub fn fixture_prototype_paths() -> Vec<PrototypePath> { - let farm = context_for_type(ContextType::Farm); - let buyer = context_for_type(ContextType::Buyer); - let route = context_for_type(ContextType::Route); - let route_partner = context_for_type(ContextType::RoutePartner); - let food_ref = object_ref(ObjectKind::Food, "food_harvest_001", "Summer squash lot"); - let route_ref = object_ref( - ObjectKind::Route, - "route_thursday_001", - "Thursday network loop", - ); - let buyer_request_ref = object_ref( - ObjectKind::BuyerPacket, - "buyer_commitment_001", - "Kitchen commitment packet", - ); - let exception_ref = object_ref( - ObjectKind::Exception, - "route_blocker_001", - "Missing pickup confirmation", - ); - - vec![ - PrototypePath { - id: "producer_food_to_route".to_string(), - kind: PrototypePathKind::ProducerFoodToRoute, - title: "Producer food to route".to_string(), - actor: farm.actor, - context: farm.clone(), - steps: vec![ - prototype_path_step(PrototypePathStepFixture { - id: "producer_today", - label: "Farm Today", - context: farm.clone(), - action_type: None, - object_ref: Some(farm.context_ref.clone()), - domain: AuthorityDomain::FarmWorkspaceOperations, - action: AuthorityAction::Search, - visibility: farm.visibility_scope, - outbox_state: OutboxState::NotQueued, - sync_state: SyncState::Online, - }), - prototype_path_step(PrototypePathStepFixture { - id: "producer_add_food", - label: "Add Food", - context: farm.clone(), - action_type: Some(AddActionType::Food), - object_ref: Some(food_ref.clone()), - domain: AuthorityDomain::FarmWorkspaceOperations, - action: AuthorityAction::Submit, - visibility: VisibilityClass::NetworkVisible, - outbox_state: OutboxState::Draft, - sync_state: SyncState::Offline, - }), - prototype_path_step(PrototypePathStepFixture { - id: "producer_add_to_route", - label: "Add to Route", - context: farm.clone(), - action_type: Some(AddActionType::RouteNeed), - object_ref: Some(route_ref.clone()), - domain: AuthorityDomain::FarmWorkspaceOperations, - action: AuthorityAction::Share, - visibility: VisibilityClass::RouteScoped, - outbox_state: OutboxState::Queued, - sync_state: SyncState::Syncing, - }), - prototype_path_step(PrototypePathStepFixture { - id: "producer_food_page", - label: "Food page", - context: farm.clone(), - action_type: None, - object_ref: Some(food_ref.clone()), - domain: AuthorityDomain::FarmWorkspaceOperations, - action: AuthorityAction::NavigateRelatedObject, - visibility: VisibilityClass::NetworkVisible, - outbox_state: OutboxState::Shared, - sync_state: SyncState::Synced, - }), - prototype_path_step(PrototypePathStepFixture { - id: "producer_route_page", - label: "Route page", - context: route.clone(), - action_type: None, - object_ref: Some(route_ref.clone()), - domain: AuthorityDomain::RouteCoordination, - action: AuthorityAction::NavigateRelatedObject, - visibility: VisibilityClass::RouteScoped, - outbox_state: OutboxState::NotQueued, - sync_state: SyncState::Online, - }), - ], - }, - PrototypePath { - id: "buyer_commitment_to_route".to_string(), - kind: PrototypePathKind::BuyerCommitmentToRoute, - title: "Buyer commitment to route".to_string(), - actor: buyer.actor, - context: buyer.clone(), - steps: vec![ - prototype_path_step(PrototypePathStepFixture { - id: "buyer_today", - label: "Buyer Today", - context: buyer.clone(), - action_type: None, - object_ref: Some(buyer.context_ref.clone()), - domain: AuthorityDomain::BuyerWorkspace, - action: AuthorityAction::Search, - visibility: buyer.visibility_scope, - outbox_state: OutboxState::NotQueued, - sync_state: SyncState::Online, - }), - prototype_path_step(PrototypePathStepFixture { - id: "buyer_request", - label: "Add Buyer Request", - context: buyer.clone(), - action_type: Some(AddActionType::BuyerRequest), - object_ref: Some(buyer_request_ref.clone()), - domain: AuthorityDomain::BuyerWorkspace, - action: AuthorityAction::Submit, - visibility: VisibilityClass::BuyerScoped, - outbox_state: OutboxState::Draft, - sync_state: SyncState::Offline, - }), - prototype_path_step(PrototypePathStepFixture { - id: "buyer_commitment", - label: "Confirm Commitment", - context: buyer.clone(), - action_type: Some(AddActionType::BuyerCommitment), - object_ref: Some(buyer_request_ref), - domain: AuthorityDomain::BuyerWorkspace, - action: AuthorityAction::Approve, - visibility: VisibilityClass::BuyerScoped, - outbox_state: OutboxState::Queued, - sync_state: SyncState::Syncing, - }), - prototype_path_step(PrototypePathStepFixture { - id: "buyer_route", - label: "Route", - context: route.clone(), - action_type: None, - object_ref: Some(route_ref.clone()), - domain: AuthorityDomain::RouteCoordination, - action: AuthorityAction::NavigateRelatedObject, - visibility: VisibilityClass::RouteScoped, - outbox_state: OutboxState::Shared, - sync_state: SyncState::Synced, - }), - ], - }, - PrototypePath { - id: "route_coordinator_assignment".to_string(), - kind: PrototypePathKind::RouteCoordinatorAssignment, - title: "Route coordinator assignment".to_string(), - actor: route.actor, - context: route.clone(), - steps: vec![ - prototype_path_step(PrototypePathStepFixture { - id: "route_today", - label: "Route Today", - context: route.clone(), - action_type: None, - object_ref: Some(route_ref.clone()), - domain: AuthorityDomain::RouteCoordination, - action: AuthorityAction::Search, - visibility: VisibilityClass::RouteScoped, - outbox_state: OutboxState::NotQueued, - sync_state: SyncState::Online, - }), - prototype_path_step(PrototypePathStepFixture { - id: "route_page", - label: "Route page", - context: route.clone(), - action_type: None, - object_ref: Some(route_ref), - domain: AuthorityDomain::RouteCoordination, - action: AuthorityAction::NavigateRelatedObject, - visibility: VisibilityClass::RouteScoped, - outbox_state: OutboxState::NotQueued, - sync_state: SyncState::Online, - }), - prototype_path_step(PrototypePathStepFixture { - id: "route_resolve_blocker", - label: "Resolve blocker", - context: route.clone(), - action_type: Some(AddActionType::Exception), - object_ref: Some(exception_ref), - domain: AuthorityDomain::RouteCoordination, - action: AuthorityAction::Close, - visibility: VisibilityClass::RouteScoped, - outbox_state: OutboxState::Conflict, - sync_state: SyncState::Failed, - }), - prototype_path_step(PrototypePathStepFixture { - id: "route_assign_partner", - label: "Assign RoutePartner", - context: route, - action_type: Some(AddActionType::RouteNeed), - object_ref: Some(route_partner.context_ref.clone()), - domain: AuthorityDomain::RouteCoordination, - action: AuthorityAction::Assign, - visibility: VisibilityClass::RouteScoped, - outbox_state: OutboxState::Queued, - sync_state: SyncState::Syncing, - }), - ], - }, - ] -} - -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 - } -} - -struct ProofProvenanceArtifactFixture { - id: &'static str, - kind: ProofProvenanceArtifactKind, - review_state: ProofProvenanceReviewState, - title: &'static str, - actor: WorkflowActor, - context: ActiveContext, - object_ref: ObjectRef, - source_object_refs: Vec<ObjectRef>, - domain: AuthorityDomain, - action: AuthorityAction, - visibility: VisibilityClass, - is_public_preview: bool, - requires_redaction_review: bool, - can_publish: bool, - public_summary_lines: Vec<&'static str>, - redacted_field_labels: Vec<&'static str>, - outbox_state: OutboxState, - sync_state: SyncState, -} - -fn proof_provenance_artifact(fixture: ProofProvenanceArtifactFixture) -> ProofProvenanceArtifact { - ProofProvenanceArtifact { - id: fixture.id.to_string(), - kind: fixture.kind, - review_state: fixture.review_state, - title: fixture.title.to_string(), - actor: fixture.actor, - context: fixture.context.clone(), - object_ref: fixture.object_ref, - source_object_refs: fixture.source_object_refs, - required_authority: fixture_authority_gate( - fixture.actor, - fixture.context, - fixture.domain, - fixture.action, - ), - visibility: fixture.visibility, - is_public_preview: fixture.is_public_preview, - requires_redaction_review: fixture.requires_redaction_review, - can_publish: fixture.can_publish, - public_summary_lines: fixture - .public_summary_lines - .into_iter() - .map(str::to_string) - .collect(), - redacted_field_labels: fixture - .redacted_field_labels - .into_iter() - .map(str::to_string) - .collect(), - outbox_state: fixture.outbox_state, - sync_state: fixture.sync_state, - } -} - -fn private_provenance_redaction_labels() -> Vec<&'static str> { - vec![ - "private trace JSON", - "private buyer details", - "private evidence", - "private route stops", - "worker notes", - ] -} - -fn all_proof_provenance_artifacts() -> Vec<ProofProvenanceArtifact> { - let trace = context_for_type(ContextType::TraceRecords); - let farm = context_for_type(ContextType::Farm); - let buyer = context_for_type(ContextType::Buyer); - let proof_ref = object_ref( - ObjectKind::Proof, - "proof_route_loop_001", - "Route loop proof set", - ); - let producer_proof_ref = object_ref( - ObjectKind::Proof, - "proof_farm_lot_001", - "Farm lot proof set", - ); - let buyer_packet_ref = object_ref( - ObjectKind::BuyerPacket, - "buyer_packet_kitchen_001", - "Kitchen buyer packet", - ); - let public_provenance_ref = object_ref( - ObjectKind::Provenance, - "public_provenance_squash_001", - "Summer squash provenance preview", - ); - let food_ref = object_ref(ObjectKind::Food, "food_harvest_001", "Summer squash lot"); - let route_ref = object_ref( - ObjectKind::Route, - "route_thursday_001", - "Thursday network loop", - ); - let receipt_ref = object_ref( - ObjectKind::Proof, - "receipt_kitchen_001", - "Kitchen receipt confirmation", - ); - - vec![ - proof_provenance_artifact(ProofProvenanceArtifactFixture { - id: "trace_proof_completeness", - kind: ProofProvenanceArtifactKind::ProofCompleteness, - review_state: ProofProvenanceReviewState::MissingProof, - title: "Trace proof completeness", - actor: WorkflowActor::TraceLead, - context: trace.clone(), - object_ref: proof_ref.clone(), - source_object_refs: vec![route_ref.clone(), receipt_ref.clone()], - domain: AuthorityDomain::TraceProof, - action: AuthorityAction::Approve, - visibility: VisibilityClass::WorkspacePrivate, - is_public_preview: false, - requires_redaction_review: false, - can_publish: false, - public_summary_lines: vec![ - "Internal proof set needs one receipt confirmation before publication review.", - ], - redacted_field_labels: Vec::new(), - outbox_state: OutboxState::NotQueued, - sync_state: SyncState::Online, - }), - proof_provenance_artifact(ProofProvenanceArtifactFixture { - id: "producer_proof_completeness", - kind: ProofProvenanceArtifactKind::ProofCompleteness, - review_state: ProofProvenanceReviewState::Complete, - title: "Producer proof completeness", - actor: WorkflowActor::ProducerAdmin, - context: farm.clone(), - object_ref: producer_proof_ref.clone(), - source_object_refs: vec![food_ref.clone(), route_ref.clone()], - domain: AuthorityDomain::TraceProof, - action: AuthorityAction::Approve, - visibility: VisibilityClass::FarmPrivate, - is_public_preview: false, - requires_redaction_review: false, - can_publish: false, - public_summary_lines: vec![ - "Authorized producer review confirms farm lot proof completeness.", - ], - redacted_field_labels: Vec::new(), - outbox_state: OutboxState::Shared, - sync_state: SyncState::Synced, - }), - proof_provenance_artifact(ProofProvenanceArtifactFixture { - id: "buyer_packet_draft", - kind: ProofProvenanceArtifactKind::BuyerPacketDraft, - review_state: ProofProvenanceReviewState::Draft, - title: "Buyer packet draft", - actor: WorkflowActor::BuyerSourcingLead, - context: buyer.clone(), - object_ref: buyer_packet_ref.clone(), - source_object_refs: vec![food_ref.clone(), receipt_ref.clone()], - domain: AuthorityDomain::BuyerWorkspace, - action: AuthorityAction::Submit, - visibility: VisibilityClass::BuyerScoped, - is_public_preview: false, - requires_redaction_review: false, - can_publish: false, - public_summary_lines: vec!["Buyer packet draft is scoped to the buyer workspace."], - redacted_field_labels: Vec::new(), - outbox_state: OutboxState::Draft, - sync_state: SyncState::Offline, - }), - proof_provenance_artifact(ProofProvenanceArtifactFixture { - id: "buyer_packet_shared", - kind: ProofProvenanceArtifactKind::BuyerPacketShared, - review_state: ProofProvenanceReviewState::Shared, - title: "Buyer packet shared", - actor: WorkflowActor::BuyerSourcingLead, - context: buyer, - object_ref: buyer_packet_ref, - source_object_refs: vec![producer_proof_ref.clone(), receipt_ref.clone()], - domain: AuthorityDomain::BuyerWorkspace, - action: AuthorityAction::Share, - visibility: VisibilityClass::BuyerScoped, - is_public_preview: false, - requires_redaction_review: false, - can_publish: false, - public_summary_lines: vec!["Buyer packet has been shared with authorized receivers."], - redacted_field_labels: Vec::new(), - outbox_state: OutboxState::Shared, - sync_state: SyncState::Synced, - }), - proof_provenance_artifact(ProofProvenanceArtifactFixture { - id: "public_provenance_redaction_review", - kind: ProofProvenanceArtifactKind::PublicProvenancePreview, - review_state: ProofProvenanceReviewState::RedactionRequired, - title: "Public provenance redaction review", - actor: WorkflowActor::ProducerAdmin, - context: farm.clone(), - object_ref: public_provenance_ref.clone(), - source_object_refs: vec![food_ref.clone(), producer_proof_ref.clone()], - domain: AuthorityDomain::PublicPublishing, - action: AuthorityAction::Publish, - visibility: VisibilityClass::PublicProvenance, - is_public_preview: true, - requires_redaction_review: true, - can_publish: false, - public_summary_lines: vec![ - "Summer squash was grown by Floripa Farm for the Thursday network loop.", - "Public preview includes farm, food, harvest window, and network-level route summary.", - ], - redacted_field_labels: private_provenance_redaction_labels(), - outbox_state: OutboxState::AwaitingAuthority, - sync_state: SyncState::Online, - }), - proof_provenance_artifact(ProofProvenanceArtifactFixture { - id: "public_provenance_ready", - kind: ProofProvenanceArtifactKind::PublicProvenancePreview, - review_state: ProofProvenanceReviewState::ReadyToPublish, - title: "Public provenance ready", - actor: WorkflowActor::ProducerAdmin, - context: farm, - object_ref: public_provenance_ref, - source_object_refs: vec![food_ref, producer_proof_ref], - domain: AuthorityDomain::PublicPublishing, - action: AuthorityAction::Publish, - visibility: VisibilityClass::PublicProvenance, - is_public_preview: true, - requires_redaction_review: false, - can_publish: true, - public_summary_lines: vec![ - "Summer squash provenance is ready for public community publication.", - "Preview includes only redacted farm, food, harvest window, and network summary fields.", - ], - redacted_field_labels: private_provenance_redaction_labels(), - outbox_state: OutboxState::Queued, - sync_state: SyncState::Syncing, - }), - ] -} - -pub fn fixture_proof_provenance_artifacts( - context_id: Option<String>, -) -> Vec<ProofProvenanceArtifact> { - let Some(context_id) = context_id else { - return all_proof_provenance_artifacts(); - }; - let matching: Vec<ProofProvenanceArtifact> = all_proof_provenance_artifacts() - .into_iter() - .filter(|artifact| artifact.context.context_ref.object_id == context_id) - .collect(); - if matching.is_empty() { - all_proof_provenance_artifacts() - } else { - matching - } -} - -struct StewardshipAccessItemFixture { - id: &'static str, - kind: StewardshipAccessItemKind, - title: &'static str, - actor: WorkflowActor, - context: ActiveContext, - target_ref: ObjectRef, - domain: AuthorityDomain, - action: AuthorityAction, - visibility: VisibilityClass, - is_admin_lite: bool, - is_phase_2_deferred: bool, - grants_private_access: bool, - outbox_state: OutboxState, - sync_state: SyncState, - detail_lines: Vec<&'static str>, -} - -fn stewardship_access_item(fixture: StewardshipAccessItemFixture) -> StewardshipAccessItem { - StewardshipAccessItem { - id: fixture.id.to_string(), - kind: fixture.kind, - title: fixture.title.to_string(), - actor: fixture.actor, - context: fixture.context.clone(), - target_ref: fixture.target_ref, - required_authority: fixture_authority_gate( - fixture.actor, - fixture.context, - fixture.domain, - fixture.action, - ), - visibility: fixture.visibility, - is_admin_lite: fixture.is_admin_lite, - is_phase_2_deferred: fixture.is_phase_2_deferred, - grants_private_access: fixture.grants_private_access, - outbox_state: fixture.outbox_state, - sync_state: fixture.sync_state, - detail_lines: fixture - .detail_lines - .into_iter() - .map(str::to_string) - .collect(), - } -} - -fn all_stewardship_access_items() -> Vec<StewardshipAccessItem> { - let steward = context_for_type(ContextType::NetworkSteward); - let network = context_for_type(ContextType::Network); - let route = context_for_type(ContextType::Route); - let member = context_for_type(ContextType::Regional); - let access_request_ref = object_ref( - ObjectKind::AccessMembership, - "access_request_farm_team_001", - "Farm team access request", - ); - let role_ref = object_ref( - ObjectKind::AccessMembership, - "role_route_partner_candidate_001", - "Route partner candidate role", - ); - let route_partner_ref = object_ref( - ObjectKind::RoutePartner, - "route_partner_invite_001", - "Thursday route partner invite", - ); - let route_pool_ref = object_ref( - ObjectKind::Route, - "route_pool_metadata_001", - "Route pool metadata", - ); - let public_update_ref = object_ref( - ObjectKind::Update, - "community_update_review_001", - "Community update moderation", - ); - let invite_ref = object_ref( - ObjectKind::MemberInvite, - "member_invite_001", - "Network invite", - ); - let request_ref = object_ref( - ObjectKind::AccessMembership, - "access_request_self_001", - "Request network access", - ); - let denied_ref = object_ref( - ObjectKind::Farm, - "private_farm_denied_001", - "Private farm context", - ); - let group_ref = object_ref( - ObjectKind::AccessMembership, - "phase2_group_management_001", - "Group management", - ); - - vec![ - stewardship_access_item(StewardshipAccessItemFixture { - id: "steward_review_access_request", - kind: StewardshipAccessItemKind::AccessRequestReview, - title: "Review access request", - actor: WorkflowActor::NetworkSteward, - context: steward.clone(), - target_ref: access_request_ref, - domain: AuthorityDomain::RelayGroupAccess, - action: AuthorityAction::Approve, - visibility: VisibilityClass::WorkspacePrivate, - is_admin_lite: true, - is_phase_2_deferred: false, - grants_private_access: false, - outbox_state: OutboxState::Queued, - sync_state: SyncState::Online, - detail_lines: vec![ - "NetworkSteward reviews scoped membership requests.", - "Approval grants role context, not private workspace access.", - ], - }), - stewardship_access_item(StewardshipAccessItemFixture { - id: "steward_approve_role", - kind: StewardshipAccessItemKind::RoleApproval, - title: "Approve role", - actor: WorkflowActor::NetworkSteward, - context: steward.clone(), - target_ref: role_ref, - domain: AuthorityDomain::RelayGroupAccess, - action: AuthorityAction::Approve, - visibility: VisibilityClass::WorkspacePrivate, - is_admin_lite: true, - is_phase_2_deferred: false, - grants_private_access: false, - outbox_state: OutboxState::Shared, - sync_state: SyncState::Synced, - detail_lines: vec![ - "Role approval remains constrained to the requested context.", - "Private farm, buyer, route, proof, and trace data require their own authorities.", - ], - }), - stewardship_access_item(StewardshipAccessItemFixture { - id: "steward_invite_route_partner", - kind: StewardshipAccessItemKind::RoutePartnerInvite, - title: "Invite RoutePartner", - actor: WorkflowActor::NetworkSteward, - context: steward.clone(), - target_ref: route_partner_ref, - domain: AuthorityDomain::RelayGroupAccess, - action: AuthorityAction::Share, - visibility: VisibilityClass::NetworkVisible, - is_admin_lite: true, - is_phase_2_deferred: false, - grants_private_access: false, - outbox_state: OutboxState::Queued, - sync_state: SyncState::Syncing, - detail_lines: vec![ - "RoutePartner invite can be issued without route stop details.", - "Assignment still belongs to route coordination.", - ], - }), - stewardship_access_item(StewardshipAccessItemFixture { - id: "steward_route_pool_metadata", - kind: StewardshipAccessItemKind::RoutePoolMetadata, - title: "Set route pool metadata", - actor: WorkflowActor::NetworkSteward, - context: steward.clone(), - target_ref: route_pool_ref, - domain: AuthorityDomain::NetworkStewardship, - action: AuthorityAction::Assign, - visibility: VisibilityClass::NetworkVisible, - is_admin_lite: true, - is_phase_2_deferred: false, - grants_private_access: false, - outbox_state: OutboxState::Draft, - sync_state: SyncState::Offline, - detail_lines: vec![ - "Stewardship metadata describes route pool availability.", - "Concrete route assignment remains route-coordinator authority.", - ], - }), - stewardship_access_item(StewardshipAccessItemFixture { - id: "steward_public_moderation", - kind: StewardshipAccessItemKind::PublicModeration, - title: "Moderate public/community state", - actor: WorkflowActor::NetworkSteward, - context: steward.clone(), - target_ref: public_update_ref, - domain: AuthorityDomain::PublicPublishing, - action: AuthorityAction::Correct, - visibility: VisibilityClass::PublicCommunity, - is_admin_lite: true, - is_phase_2_deferred: false, - grants_private_access: false, - outbox_state: OutboxState::AwaitingAuthority, - sync_state: SyncState::Online, - detail_lines: vec![ - "Public/community moderation is allowed where publishing authority permits it.", - "Moderation never exposes private proof, buyer, farm, or route-stop data.", - ], - }), - stewardship_access_item(StewardshipAccessItemFixture { - id: "member_accept_invite", - kind: StewardshipAccessItemKind::InviteAcceptance, - title: "Accept invite", - actor: WorkflowActor::NetworkMember, - context: network.clone(), - target_ref: invite_ref, - domain: AuthorityDomain::RelayGroupAccess, - action: AuthorityAction::Submit, - visibility: VisibilityClass::NetworkVisible, - is_admin_lite: false, - is_phase_2_deferred: false, - grants_private_access: false, - outbox_state: OutboxState::Queued, - sync_state: SyncState::Online, - detail_lines: vec![ - "Invite acceptance connects membership without full group management.", - ], - }), - stewardship_access_item(StewardshipAccessItemFixture { - id: "member_request_access", - kind: StewardshipAccessItemKind::RequestAccess, - title: "Request access", - actor: WorkflowActor::NetworkMember, - context: member, - target_ref: request_ref, - domain: AuthorityDomain::RelayGroupAccess, - action: AuthorityAction::Submit, - visibility: VisibilityClass::NetworkVisible, - is_admin_lite: false, - is_phase_2_deferred: false, - grants_private_access: false, - outbox_state: OutboxState::Draft, - sync_state: SyncState::Offline, - detail_lines: vec!["Access requests are queued as explicit membership work."], - }), - stewardship_access_item(StewardshipAccessItemFixture { - id: "member_access_denied", - kind: StewardshipAccessItemKind::AccessDenied, - title: "Access denied", - actor: WorkflowActor::NetworkMember, - context: network, - target_ref: denied_ref, - domain: AuthorityDomain::FarmWorkspaceOperations, - action: AuthorityAction::Search, - visibility: VisibilityClass::FarmPrivate, - is_admin_lite: false, - is_phase_2_deferred: false, - grants_private_access: false, - outbox_state: OutboxState::NotQueued, - sync_state: SyncState::Online, - detail_lines: vec![ - "Denied state preserves the blocked context label without revealing private data.", - ], - }), - stewardship_access_item(StewardshipAccessItemFixture { - id: "phase2_group_management_deferred", - kind: StewardshipAccessItemKind::GroupManagementDeferred, - title: "Group management deferred", - actor: WorkflowActor::NetworkSteward, - context: route, - target_ref: group_ref, - domain: AuthorityDomain::NetworkStewardship, - action: AuthorityAction::Approve, - visibility: VisibilityClass::WorkspacePrivate, - is_admin_lite: true, - is_phase_2_deferred: true, - grants_private_access: false, - outbox_state: OutboxState::NotQueued, - sync_state: SyncState::Unknown, - detail_lines: vec![ - "Full group creation and management are explicitly deferred to Phase 2.", - ], - }), - ] -} - -pub fn fixture_stewardship_access_items(context_id: Option<String>) -> Vec<StewardshipAccessItem> { - let Some(context_id) = context_id else { - return all_stewardship_access_items(); - }; - let matching: Vec<StewardshipAccessItem> = all_stewardship_access_items() - .into_iter() - .filter(|item| item.context.context_ref.object_id == context_id) - .collect(); - if matching.is_empty() { - all_stewardship_access_items() - } else { - matching - } -} - -pub fn fixture_outbox_items() -> Vec<OutboxItem> { - let context = context_for_type(ContextType::Farm); - CANONICAL_OUTBOX_STATES - .into_iter() - .enumerate() - .map(|(index, outbox_state)| OutboxItem { - id: format!("outbox_fixture_{index:02}"), - action_type: AddActionType::PublicUpdate, - context: context.clone(), - object_refs: vec![object_ref( - ObjectKind::Update, - format!("draft_update_{index:02}"), - "Public update draft", - )], - event_refs: Vec::new(), - visibility: VisibilityClass::PublicCommunity, - authority_gate: fixture_authority_gate( - context.actor, - context.clone(), - AuthorityDomain::PublicPublishing, - AuthorityAction::Retry, - ), - flow_state: if matches!(outbox_state, OutboxState::Draft) { - AddFlowState::Draft - } else { - AddFlowState::Queued - }, - outbox_state, - sync_state: CANONICAL_SYNC_STATES[index % CANONICAL_SYNC_STATES.len()], - queued_at_unix: Some(1_799_971_200 + index as u64), - last_attempt_at_unix: None, - retry_count: index as u32, - last_error: if matches!(outbox_state, OutboxState::Failed) { - Some("fixture failure".to_string()) - } else { - None - }, - }) - .collect() -} - -fn outbox_state_allows_retry(state: OutboxState) -> bool { - matches!(state, OutboxState::Failed | OutboxState::Conflict) -} - -fn outbox_visibility_allows_retry(visibility: VisibilityClass) -> bool { - !matches!( - visibility, - VisibilityClass::LocalDraft | VisibilityClass::SecretNeverShared - ) -} - -pub fn fixture_outbox_retry_decision(item: OutboxItem) -> OutboxRetryDecision { - let authority_gate = fixture_authority_gate( - item.context.actor, - item.context.clone(), - item.authority_gate.domain, - AuthorityAction::Retry, - ); - let state_allows_retry = outbox_state_allows_retry(item.outbox_state); - let visibility_allows_retry = outbox_visibility_allows_retry(item.visibility); - let is_retryable = state_allows_retry && visibility_allows_retry && authority_gate.is_allowed; - let reason = if is_retryable { - None - } else if !state_allows_retry { - Some(format!( - "{:?} is not a retryable outbox state", - item.outbox_state - )) - } else if !visibility_allows_retry { - Some(format!( - "{:?} visibility cannot be retried", - item.visibility - )) - } else { - authority_gate.reason.clone() - }; - - OutboxRetryDecision { - item_id: item.id, - is_retryable, - authority_gate, - reason, - } -} - impl RadrootsRuntime { - pub fn phase1_active_contexts(&self) -> Vec<ActiveContext> { - let _ = self; - fixture_active_contexts() + /// Returns the exact five Phase 1 Today card types in contract order. + pub fn phase1_card_types(&self) -> Vec<TodayCardType> { + CANONICAL_TODAY_CARD_TYPES.to_vec() } - pub fn phase1_today_cards(&self, context_id: Option<String>) -> Vec<TodayCard> { - let _ = self; - fixture_today_cards(context_id) + /// Returns the exact five Phase 1 Add commands in card-parity order. + pub fn phase1_add_command_types(&self) -> Vec<AddCommandType> { + CANONICAL_ADD_COMMAND_TYPES.to_vec() } - pub fn phase1_add_actions(&self, context_id: Option<String>) -> Vec<AddAction> { - let _ = self; - fixture_add_actions(context_id) + /// Returns the closed one-to-one Today/Add mapping. + pub fn phase1_card_add_parity(&self) -> Vec<CardAddParity> { + CANONICAL_CARD_ADD_PARITY.to_vec() } - pub fn phase1_object_page_summaries( + /// Constructs a validated local query/composer context. + pub fn phase1_local_network( &self, - context_id: Option<String>, - ) -> Vec<ObjectPageSummary> { - let _ = self; - fixture_object_page_summaries(context_id) - } - - pub fn phase1_outbox_snapshot(&self) -> Vec<OutboxItem> { - let _ = self; - fixture_outbox_items() - } - - pub fn phase1_search_results( - &self, - query: Option<String>, - context_id: Option<String>, - ) -> Vec<SearchResultSummary> { - let _ = self; - fixture_search_results(query, context_id) - } - - pub fn phase1_prototype_paths(&self) -> Vec<PrototypePath> { - let _ = self; - 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_proof_provenance_artifacts( - &self, - context_id: Option<String>, - ) -> Vec<ProofProvenanceArtifact> { - let _ = self; - fixture_proof_provenance_artifacts(context_id) - } - - pub fn phase1_stewardship_access_items( - &self, - context_id: Option<String>, - ) -> Vec<StewardshipAccessItem> { - let _ = self; - fixture_stewardship_access_items(context_id) - } - - pub fn phase1_outbox_retry_decision(&self, item: OutboxItem) -> OutboxRetryDecision { - let _ = self; - fixture_outbox_retry_decision(item) - } - - pub fn phase1_check_authority( - &self, - actor: WorkflowActor, - context: ActiveContext, - domain: AuthorityDomain, - action: AuthorityAction, - ) -> AuthorityGate { - let _ = self; - fixture_authority_gate(actor, context, domain, action) + id: String, + label: String, + relay_urls: Vec<String>, + locality: Option<String>, + followed_authors: Vec<String>, + generation: u64, + ) -> Result<LocalNetwork, crate::RadrootsAppError> { + LocalNetwork::new( + id, + label, + relay_urls, + locality, + followed_authors, + generation, + ) + .map_err(|error| crate::RadrootsAppError::runtime(error.to_string())) } } #[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::*; - fn object_ref(object_type: ObjectKind, object_id: &str, display_label: &str) -> ObjectRef { - ObjectRef { - object_type, - object_id: object_id.to_string(), - display_label: display_label.to_string(), - } - } - - fn active_context() -> ActiveContext { - ActiveContext { - context_type: ContextType::Farm, - context_ref: object_ref(ObjectKind::Farm, "farm_123", "Root & Rad Farm"), - actor: WorkflowActor::ProducerAdmin, - display_label: "Root & Rad Farm".to_string(), - visibility_scope: VisibilityClass::FarmPrivate, - } - } - - fn authority_gate() -> AuthorityGate { - AuthorityGate { - domain: AuthorityDomain::FarmWorkspaceOperations, - action: AuthorityAction::Submit, - actor: WorkflowActor::ProducerAdmin, - context: active_context(), - is_required: true, - is_allowed: true, - reason: None, - } - } - #[test] - fn runtime_wrappers_expose_the_complete_phase_1_surface() { + fn runtime_exposes_only_the_locked_card_and_add_catalogs() { let runtime = RadrootsRuntime::new().expect("runtime"); - let contexts = runtime.phase1_active_contexts(); - let context = contexts.first().expect("context").clone(); - let context_id = Some(context.context_ref.object_id.clone()); - - assert!(!runtime.phase1_today_cards(context_id.clone()).is_empty()); - assert!(!runtime.phase1_add_actions(context_id.clone()).is_empty()); - assert!( - !runtime - .phase1_object_page_summaries(context_id.clone()) - .is_empty() - ); - assert!(!runtime.phase1_outbox_snapshot().is_empty()); - assert!( - !runtime - .phase1_search_results(Some("farm".to_owned()), context_id.clone()) - .is_empty() - ); - assert!(!runtime.phase1_prototype_paths().is_empty()); - assert!( - !runtime - .phase1_route_execution_flows(context_id.clone()) - .is_empty() - ); - assert!( - !runtime - .phase1_proof_provenance_artifacts(context_id.clone()) - .is_empty() - ); - assert!( - !runtime - .phase1_stewardship_access_items(context_id) - .is_empty() - ); - - let item = runtime - .phase1_outbox_snapshot() - .into_iter() - .find(|item| item.outbox_state == OutboxState::Failed) - .expect("failed outbox fixture"); - let decision = runtime.phase1_outbox_retry_decision(item); - assert!(decision.is_retryable); - - let gate = runtime.phase1_check_authority( - context.actor, - context.clone(), - AuthorityDomain::RelayGroupAccess, - AuthorityAction::Search, - ); - assert_eq!(gate.context, context); - - assert!(!runtime.phase1_search_results(None, None).is_empty()); - - let flows = fixture_route_execution_flows(None); - let direct_context = flows[0].context.context_ref.object_id.clone(); - assert!(!fixture_route_execution_flows(Some(direct_context)).is_empty()); - let step_context = flows - .iter() - .flat_map(|flow| { - flow.steps - .iter() - .map(move |step| (&flow.context.context_ref.object_id, step)) - }) - .find(|(flow_context, step)| { - flow_context.as_str() != step.context.context_ref.object_id - }) - .map(|(_, step)| step.context.context_ref.object_id.clone()) - .expect("step-only context"); - assert!(!fixture_route_execution_flows(Some(step_context)).is_empty()); - assert_eq!( - fixture_route_execution_flows(Some("unknown_context".to_owned())).len(), - flows.len() - ); - - let proof_context = fixture_proof_provenance_artifacts(None)[0] - .context - .context_ref - .object_id - .clone(); - assert!(!fixture_proof_provenance_artifacts(Some(proof_context)).is_empty()); - let stewardship_context = fixture_stewardship_access_items(None)[0] - .context - .context_ref - .object_id - .clone(); - assert!(!fixture_stewardship_access_items(Some(stewardship_context)).is_empty()); - } - - #[test] - fn canonical_vocabularies_match_phase_1_spec_counts() { - assert_eq!(CANONICAL_CONTEXT_TYPES.len(), 10); - assert_eq!(CANONICAL_WORKFLOW_ACTORS.len(), 11); - assert_eq!(CANONICAL_VISIBILITY_CLASSES.len(), 9); - assert_eq!(CANONICAL_AUTHORITY_DOMAINS.len(), 9); - assert_eq!(CANONICAL_TODAY_CARD_TYPES.len(), 12); - assert_eq!(TODAY_CARD_RANKING_PRIORITY.len(), 12); - assert_eq!(CANONICAL_ADD_ACTION_TYPES.len(), 18); - assert_eq!(CANONICAL_ADD_FLOW_STATES.len(), 16); - 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); - assert_eq!(CANONICAL_PROOF_PROVENANCE_ARTIFACT_KINDS.len(), 4); - assert_eq!(CANONICAL_PROOF_PROVENANCE_REVIEW_STATES.len(), 8); - assert_eq!(CANONICAL_STEWARDSHIP_ACCESS_ITEM_KINDS.len(), 9); - } - - #[test] - fn today_card_ranking_priority_covers_every_card_type() { - for card_type in CANONICAL_TODAY_CARD_TYPES { - assert!(TODAY_CARD_RANKING_PRIORITY.contains(&card_type)); - } - assert_eq!(TODAY_CARD_RANKING_PRIORITY[0], TodayCardType::Exception); - assert_eq!(TODAY_CARD_RANKING_PRIORITY[1], TodayCardType::SyncOutbox); - assert_eq!(TODAY_CARD_RANKING_PRIORITY[2], TodayCardType::Route); - - let cards = fixture_today_cards(None); - assert_eq!(cards[0].card_type, TodayCardType::Exception); - assert_eq!(cards[0].ranking_reason, "blocking exception"); - assert_eq!(cards[1].card_type, TodayCardType::SyncOutbox); - assert_eq!(cards[1].outbox_state, OutboxState::Failed); - assert!(cards[1].is_offline); - } - - #[test] - fn fixture_backed_projection_apis_cover_required_surface() { - assert_eq!( - fixture_active_contexts().len(), - CANONICAL_CONTEXT_TYPES.len() - ); - assert_eq!( - fixture_today_cards(None).len(), - CANONICAL_TODAY_CARD_TYPES.len() - ); - assert_eq!( - fixture_add_actions(None).len(), - CANONICAL_ADD_ACTION_TYPES.len() - ); - assert_eq!( - fixture_object_page_summaries(None).len(), - 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() - ); - assert_eq!(fixture_proof_provenance_artifacts(None).len(), 6); - assert_eq!(fixture_stewardship_access_items(None).len(), 9); - } - - #[test] - fn object_page_fixtures_carry_routeable_refs_and_navigation_authority() { - let pages = fixture_object_page_summaries(None); - assert_eq!(pages.len(), CANONICAL_OBJECT_PAGE_FAMILIES.len()); - - for (page, family) in pages.iter().zip(CANONICAL_OBJECT_PAGE_FAMILIES) { - assert_eq!(page.family, family); - assert_eq!(page.object_ref.object_type, object_kind_for_page(family)); - assert_eq!( - page.required_authority.action, - AuthorityAction::NavigateRelatedObject - ); - assert_eq!( - page.required_authority.domain, - AuthorityDomain::RelayGroupAccess - ); - assert_eq!( - page.required_authority.context.context_ref.object_id, - page.primary_context.context_ref.object_id - ); - assert!(!page.object_ref.object_id.is_empty()); - assert!(!page.title.is_empty()); - } - } - - #[test] - fn authority_visibility_fixtures_cover_every_actor_and_visibility_class() { - let context = active_context(); - for actor in CANONICAL_WORKFLOW_ACTORS { - let gate = fixture_authority_gate( - actor, - context.clone(), - AuthorityDomain::RelayGroupAccess, - AuthorityAction::Search, - ); - assert_eq!(gate.actor, actor); - assert_eq!(gate.action, AuthorityAction::Search); - } - - for visibility in CANONICAL_VISIBILITY_CLASSES { - let result_allowed = visibility_allows_search_result(visibility); - if matches!( - visibility, - VisibilityClass::LocalDraft - | VisibilityClass::FarmPrivate - | VisibilityClass::WorkspacePrivate - | VisibilityClass::RouteScoped - | VisibilityClass::BuyerScoped - | VisibilityClass::SecretNeverShared - ) { - assert!(!result_allowed); - } - } - } - - #[test] - fn search_results_filter_private_and_unauthorized_surfaces() { - let network = context_for_type(ContextType::Network); - let results = fixture_search_results( - Some("fixture".to_string()), - Some(network.context_ref.object_id), - ); - assert!(!results.is_empty()); - assert!( - results - .iter() - .all(|result| result.required_authority.is_allowed) - ); - assert!( - results - .iter() - .all(|result| visibility_allows_search_result(result.visibility)) - ); - assert!( - results - .iter() - .all(|result| object_kind_allows_search_result( - result.object_ref.object_type, - result.visibility - )) - ); - assert!( - !results - .iter() - .any(|result| result.object_ref.object_type == ObjectKind::BuyerPacket) - ); - assert!( - !results - .iter() - .any(|result| result.object_ref.object_type == ObjectKind::RouteStop) - ); - - let farm = context_for_type(ContextType::Farm); - let denied = fixture_search_results( - Some("fixture".to_string()), - Some(farm.context_ref.object_id), - ); - assert!(denied.is_empty()); - } - - #[test] - fn prototype_paths_cover_required_phase_1_actor_routes() { - let paths = fixture_prototype_paths(); - assert_eq!(paths.len(), 3); - assert!( - paths - .iter() - .any(|path| path.kind == PrototypePathKind::ProducerFoodToRoute - && path.actor == WorkflowActor::ProducerAdmin - && path.steps.iter().any(|step| step.label == "Add Food") - && path.steps.iter().any(|step| step.label == "Add to Route")) - ); - assert!(paths.iter().any(|path| { - path.kind == PrototypePathKind::BuyerCommitmentToRoute - && path.actor == WorkflowActor::BuyerSourcingLead - && path - .steps - .iter() - .any(|step| step.label == "Confirm Commitment") - })); - assert!(paths.iter().any(|path| { - path.kind == PrototypePathKind::RouteCoordinatorAssignment - && path.actor == WorkflowActor::RouteCoordinator - && path - .steps - .iter() - .any(|step| step.label == "Assign RoutePartner") - })); - } - - #[test] - fn prototype_paths_connect_actions_objects_outbox_and_authority() { - let paths = fixture_prototype_paths(); - let steps: Vec<&PrototypePathStep> = - paths.iter().flat_map(|path| path.steps.iter()).collect(); - - assert!( - steps - .iter() - .any(|step| step.action_type == Some(AddActionType::Food)) - ); - assert!(steps.iter().any(|step| { - step.object_ref - .as_ref() - .is_some_and(|object_ref| object_ref.object_type == ObjectKind::Food) - })); - assert!(steps.iter().any(|step| { - step.object_ref - .as_ref() - .is_some_and(|object_ref| object_ref.object_type == ObjectKind::Route) - })); - assert!(steps.iter().any(|step| { - step.object_ref - .as_ref() - .is_some_and(|object_ref| object_ref.object_type == ObjectKind::RoutePartner) - })); - assert!( - steps - .iter() - .any(|step| step.outbox_state == OutboxState::Queued) - ); - assert!( - steps - .iter() - .any(|step| step.outbox_state == OutboxState::Conflict) - ); - assert!(steps.iter().all(|step| step.authority_gate.is_required)); - assert!( - steps - .iter() - .all(|step| step.visibility != VisibilityClass::SecretNeverShared) - ); - } - - #[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!(runtime.phase1_card_types(), CANONICAL_TODAY_CARD_TYPES); assert_eq!( - report.required_authority.domain, - AuthorityDomain::RouteExecution + runtime.phase1_add_command_types(), + CANONICAL_ADD_COMMAND_TYPES ); - assert_eq!(report.outbox_state, OutboxState::Conflict); - assert!(report.supports_offline); - assert_eq!(recovery.actor, WorkflowActor::RouteCoordinator); + assert_eq!(runtime.phase1_card_add_parity(), CANONICAL_CARD_ADD_PARITY); 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 proof_provenance_artifacts_cover_required_kinds_and_states() { - let artifacts = fixture_proof_provenance_artifacts(None); - for kind in CANONICAL_PROOF_PROVENANCE_ARTIFACT_KINDS { - assert!( - artifacts.iter().any(|artifact| artifact.kind == kind), - "missing {kind:?}" - ); - } - - assert!(artifacts.iter().any(|artifact| { - artifact.kind == ProofProvenanceArtifactKind::ProofCompleteness - && artifact.review_state == ProofProvenanceReviewState::MissingProof - })); - assert!(artifacts.iter().any(|artifact| { - artifact.kind == ProofProvenanceArtifactKind::ProofCompleteness - && artifact.review_state == ProofProvenanceReviewState::Complete - })); - assert!(artifacts.iter().any(|artifact| { - artifact.kind == ProofProvenanceArtifactKind::BuyerPacketDraft - && artifact.review_state == ProofProvenanceReviewState::Draft - })); - assert!(artifacts.iter().any(|artifact| { - artifact.kind == ProofProvenanceArtifactKind::BuyerPacketShared - && artifact.review_state == ProofProvenanceReviewState::Shared - })); - assert!(artifacts.iter().any(|artifact| { - artifact.kind == ProofProvenanceArtifactKind::PublicProvenancePreview - && artifact.review_state == ProofProvenanceReviewState::RedactionRequired - })); - assert!(artifacts.iter().any(|artifact| { - artifact.kind == ProofProvenanceArtifactKind::PublicProvenancePreview - && artifact.review_state == ProofProvenanceReviewState::ReadyToPublish - })); - } - - #[test] - fn trace_lead_and_authorized_producer_can_review_proof_completeness() { - let proof_artifacts: Vec<ProofProvenanceArtifact> = - fixture_proof_provenance_artifacts(None) - .into_iter() - .filter(|artifact| artifact.kind == ProofProvenanceArtifactKind::ProofCompleteness) - .collect(); - assert_eq!(proof_artifacts.len(), 2); - assert!(proof_artifacts.iter().any(|artifact| { - artifact.actor == WorkflowActor::TraceLead - && artifact.required_authority.domain == AuthorityDomain::TraceProof - && artifact.required_authority.action == AuthorityAction::Approve - && artifact.required_authority.is_allowed - })); - assert!(proof_artifacts.iter().any(|artifact| { - artifact.actor == WorkflowActor::ProducerAdmin - && artifact.required_authority.domain == AuthorityDomain::TraceProof - && artifact.required_authority.action == AuthorityAction::Approve - && artifact.required_authority.is_allowed - })); - } - - #[test] - fn buyer_packets_are_private_and_public_provenance_is_distinct() { - let artifacts = fixture_proof_provenance_artifacts(None); - let buyer_packets: Vec<&ProofProvenanceArtifact> = artifacts - .iter() - .filter(|artifact| { - matches!( - artifact.kind, - ProofProvenanceArtifactKind::BuyerPacketDraft - | ProofProvenanceArtifactKind::BuyerPacketShared + runtime + .phase1_local_network( + "nearby".into(), + "Near me".into(), + vec!["wss://relay.example".into()], + Some("u10h".into()), + vec!["a".repeat(64)], + 1, ) - }) - .collect(); - assert_eq!(buyer_packets.len(), 2); - assert!(buyer_packets.iter().all(|artifact| { - artifact.object_ref.object_type == ObjectKind::BuyerPacket - && artifact.visibility == VisibilityClass::BuyerScoped - && !artifact.is_public_preview - && !artifact.can_publish - })); - - let public_previews: Vec<&ProofProvenanceArtifact> = artifacts - .iter() - .filter(|artifact| { - artifact.kind == ProofProvenanceArtifactKind::PublicProvenancePreview - }) - .collect(); - assert_eq!(public_previews.len(), 2); - assert!(public_previews.iter().all(|artifact| { - artifact.object_ref.object_type == ObjectKind::Provenance - && artifact.visibility == VisibilityClass::PublicProvenance - && artifact.is_public_preview - && artifact.required_authority.domain == AuthorityDomain::PublicPublishing - })); - } - - #[test] - fn public_provenance_publication_requires_authority_and_redaction_review() { - let artifacts = fixture_proof_provenance_artifacts(None); - let review = artifacts - .iter() - .find(|artifact| { - artifact.id == "public_provenance_redaction_review" - && artifact.kind == ProofProvenanceArtifactKind::PublicProvenancePreview - }) - .expect("redaction review artifact"); - assert!(review.required_authority.is_allowed); - assert_eq!(review.required_authority.action, AuthorityAction::Publish); - assert!(review.requires_redaction_review); - assert!(!review.can_publish); - assert_eq!(review.outbox_state, OutboxState::AwaitingAuthority); - - let ready = artifacts - .iter() - .find(|artifact| artifact.id == "public_provenance_ready") - .expect("ready artifact"); - assert!(ready.required_authority.is_allowed); - assert!(!ready.requires_redaction_review); - assert!(ready.can_publish); - assert_eq!( - ready.review_state, - ProofProvenanceReviewState::ReadyToPublish - ); - } - - #[test] - fn public_provenance_never_leaks_private_trace_or_buyer_fields() { - let public_previews: Vec<ProofProvenanceArtifact> = - fixture_proof_provenance_artifacts(None) - .into_iter() - .filter(|artifact| { - artifact.kind == ProofProvenanceArtifactKind::PublicProvenancePreview - }) - .collect(); - assert!(!public_previews.is_empty()); - - let blocked = private_provenance_redaction_labels(); - for artifact in public_previews { - for label in &blocked { - assert!( - artifact - .redacted_field_labels - .iter() - .any(|redacted| redacted == label) - ); - } - - let public_text = artifact.public_summary_lines.join(" ").to_lowercase(); - for label in &blocked { - assert!( - !public_text.contains(&label.to_lowercase()), - "{label} leaked into public summary" - ); - } - assert!( - !artifact - .source_object_refs - .iter() - .any(|object_ref| object_ref.object_type == ObjectKind::BuyerPacket) - ); - assert!( - !artifact - .source_object_refs - .iter() - .any(|object_ref| object_ref.object_type == ObjectKind::RouteStop) - ); - } - } - - #[test] - fn stewardship_access_items_cover_admin_lite_and_member_access_states() { - let items = fixture_stewardship_access_items(None); - for kind in CANONICAL_STEWARDSHIP_ACCESS_ITEM_KINDS { - assert!( - items.iter().any(|item| item.kind == kind), - "missing {kind:?}" - ); - } - - assert!(items.iter().any(|item| { - item.kind == StewardshipAccessItemKind::InviteAcceptance - && item.actor == WorkflowActor::NetworkMember - })); - assert!(items.iter().any(|item| { - item.kind == StewardshipAccessItemKind::RequestAccess - && item.actor == WorkflowActor::NetworkMember - })); - assert!(items.iter().any(|item| { - item.kind == StewardshipAccessItemKind::AccessDenied - && !item.required_authority.is_allowed - && item.visibility == VisibilityClass::FarmPrivate - })); - assert!(items.iter().any(|item| { - item.kind == StewardshipAccessItemKind::GroupManagementDeferred - && item.is_phase_2_deferred - })); - } - - #[test] - fn network_steward_can_perform_admin_lite_actions() { - let items: Vec<StewardshipAccessItem> = fixture_stewardship_access_items(None) - .into_iter() - .filter(|item| item.actor == WorkflowActor::NetworkSteward && item.is_admin_lite) - .collect(); - assert!(items.len() >= 6); - - for kind in [ - StewardshipAccessItemKind::AccessRequestReview, - StewardshipAccessItemKind::RoleApproval, - StewardshipAccessItemKind::RoutePartnerInvite, - StewardshipAccessItemKind::RoutePoolMetadata, - StewardshipAccessItemKind::PublicModeration, - ] { - let item = items - .iter() - .find(|item| item.kind == kind) - .unwrap_or_else(|| panic!("missing {kind:?}")); - assert!(item.required_authority.is_allowed); - assert!(!item.grants_private_access); - assert!(!item.is_phase_2_deferred); - } - - let route_pool = items - .iter() - .find(|item| item.kind == StewardshipAccessItemKind::RoutePoolMetadata) - .expect("route pool item"); - assert_eq!( - route_pool.required_authority.domain, - AuthorityDomain::NetworkStewardship - ); - assert_eq!( - route_pool.required_authority.action, - AuthorityAction::Assign - ); - - let moderation = items - .iter() - .find(|item| item.kind == StewardshipAccessItemKind::PublicModeration) - .expect("public moderation item"); - assert_eq!( - moderation.required_authority.domain, - AuthorityDomain::PublicPublishing - ); - } - - #[test] - fn phase_2_group_management_remains_deferred() { - let deferred = fixture_stewardship_access_items(None) - .into_iter() - .find(|item| item.kind == StewardshipAccessItemKind::GroupManagementDeferred) - .expect("deferred group management item"); - assert!(deferred.is_phase_2_deferred); - assert!(deferred.is_admin_lite); - assert!(!deferred.grants_private_access); - assert_eq!(deferred.outbox_state, OutboxState::NotQueued); - } - - #[test] - fn network_steward_does_not_automatically_gain_private_workspace_access() { - let steward = context_for_type(ContextType::NetworkSteward); - for (domain, context_type) in [ - (AuthorityDomain::FarmWorkspaceOperations, ContextType::Farm), - (AuthorityDomain::BuyerWorkspace, ContextType::Buyer), - (AuthorityDomain::RouteCoordination, ContextType::Route), - (AuthorityDomain::RouteExecution, ContextType::RoutePartner), - (AuthorityDomain::TraceProof, ContextType::TraceRecords), - (AuthorityDomain::Receipt, ContextType::PickupPoint), - ] { - let context = context_for_type(context_type); - let gate = fixture_authority_gate( - WorkflowActor::NetworkSteward, - context, - domain, - AuthorityAction::Search, - ); - assert!( - !gate.is_allowed, - "NetworkSteward unexpectedly gained {domain:?}" - ); - } - - assert!( - fixture_authority_gate( - WorkflowActor::NetworkSteward, - steward.clone(), - AuthorityDomain::RelayGroupAccess, - AuthorityAction::Approve, - ) - .is_allowed - ); - assert!( - fixture_authority_gate( - WorkflowActor::NetworkSteward, - steward, - AuthorityDomain::NetworkStewardship, - AuthorityAction::Assign, - ) - .is_allowed - ); - } - - #[test] - fn serde_names_preserve_product_vocabulary() { - assert_eq!( - serde_json::to_value(WorkflowActor::NetworkMember).expect("serialize actor"), - "NetworkMember" - ); - assert_eq!( - serde_json::to_value(VisibilityClass::PublicProvenance).expect("serialize visibility"), - "PublicProvenance" - ); - assert_eq!( - serde_json::to_value(AuthorityDomain::RelayGroupAccess).expect("serialize authority"), - "Relay/group access" - ); - assert_eq!( - serde_json::to_value(AddActionType::BuyerCommitment).expect("serialize action"), - "BuyerCommitment" - ); - } - - #[test] - fn product_surface_records_round_trip_through_json() { - let card = TodayCard { - id: "card_route_gap_001".to_string(), - card_type: TodayCardType::Proof, - source_object_refs: vec![object_ref(ObjectKind::Route, "route_123", "Thursday loop")], - source_event_refs: vec![EventRef { - event_id: "event_abc".to_string(), - relay_url: Some("wss://relay.example".to_string()), - kind: Some(1), - }], - primary_context: active_context(), - actor: WorkflowActor::ProducerAdmin, - visibility: VisibilityClass::RouteScoped, - visibility_label: "route crew".to_string(), - title: "Proof needed for Thursday loop".to_string(), - status_line: "missing pickup confirmation".to_string(), - detail_lines: vec!["2 stops need proof".to_string()], - pills: vec!["blocking".to_string(), "route".to_string()], - primary_action: TodayCardAction { - id: "add_proof".to_string(), - label: "Add proof".to_string(), - action_type: Some(AddActionType::Proof), - target_object: Some(object_ref(ObjectKind::Route, "route_123", "Thursday loop")), - }, - secondary_action: None, - ranking_reason: "blocking exception".to_string(), - ranking_features: vec!["proof_gap".to_string()], - sync_state: SyncState::Online, - outbox_state: OutboxState::NotQueued, - is_stale: false, - is_offline: false, - }; - - let json = serde_json::to_string(&card).expect("serialize card"); - let decoded: TodayCard = serde_json::from_str(&json).expect("decode card"); - assert_eq!(decoded, card); - assert!(json.contains("\"cardType\":\"Proof\"")); - assert!(json.contains("\"visibility\":\"RouteScoped\"")); - } - - #[test] - fn add_action_and_outbox_contract_carry_authority_and_visibility() { - let add_action = AddAction { - action_type: AddActionType::PublicUpdate, - display_label: "Public update".to_string(), - allowed_context_types: vec![ContextType::Network, ContextType::Farm], - required_authority: authority_gate(), - default_visibility: VisibilityClass::PublicCommunity, - allowed_visibility_options: vec![ - VisibilityClass::NetworkVisible, - VisibilityClass::PublicCommunity, - ], - created_or_updated_object_type: ObjectKind::Update, - related_object_requirements: vec![RelatedObjectRequirement { - object_type: ObjectKind::Farm, - relationship_label: "posted by".to_string(), - is_required: true, - }], - validation_requirements: vec![ValidationRequirement { - id: "non_empty_body".to_string(), - label: "Body is required".to_string(), - is_blocking: true, - }], - supports_offline: true, - supports_draft: true, - outbox_behavior: OutboxBehavior::PublishWhenAuthorized, - primary_submit_label: "Publish".to_string(), - completion_state: AddFlowState::ReadyToSubmit, - }; - let outbox_item = OutboxItem { - id: "outbox_001".to_string(), - action_type: add_action.action_type, - context: active_context(), - object_refs: vec![object_ref( - ObjectKind::Update, - "draft_001", - "Public update draft", - )], - event_refs: Vec::new(), - visibility: add_action.default_visibility, - authority_gate: add_action.required_authority.clone(), - flow_state: AddFlowState::Queued, - outbox_state: OutboxState::AwaitingAuthority, - sync_state: SyncState::Offline, - queued_at_unix: Some(1_799_971_200), - last_attempt_at_unix: None, - retry_count: 0, - last_error: None, - }; - - assert_eq!( - outbox_item.authority_gate.domain, - AuthorityDomain::FarmWorkspaceOperations - ); - assert_eq!(outbox_item.visibility, VisibilityClass::PublicCommunity); - assert_eq!(outbox_item.flow_state, AddFlowState::Queued); - assert_eq!(outbox_item.sync_state, SyncState::Offline); - } - - #[test] - fn outbox_retry_decision_rechecks_state_visibility_and_authority() { - let failed = fixture_outbox_items() - .into_iter() - .find(|item| item.outbox_state == OutboxState::Failed) - .expect("failed outbox fixture"); - - let retryable = fixture_outbox_retry_decision(failed.clone()); - assert!(retryable.is_retryable); - assert_eq!(retryable.item_id, failed.id); - assert_eq!(retryable.authority_gate.action, AuthorityAction::Retry); - assert!(retryable.reason.is_none()); - - let mut queued = failed.clone(); - queued.outbox_state = OutboxState::Queued; - let queued_decision = fixture_outbox_retry_decision(queued); - assert!(!queued_decision.is_retryable); - assert!( - queued_decision - .reason - .as_deref() - .expect("queued reason") - .contains("not a retryable outbox state") - ); - - let mut secret = failed.clone(); - secret.visibility = VisibilityClass::SecretNeverShared; - let secret_decision = fixture_outbox_retry_decision(secret); - assert!(!secret_decision.is_retryable); - assert!( - secret_decision - .reason - .as_deref() - .expect("secret reason") - .contains("visibility cannot be retried") - ); - - let mut denied = failed; - denied.authority_gate.domain = AuthorityDomain::BuyerWorkspace; - let denied_decision = fixture_outbox_retry_decision(denied); - assert!(!denied_decision.is_retryable); - assert!(!denied_decision.authority_gate.is_allowed); - assert!(denied_decision.reason.is_some()); - } - - #[test] - fn compatibility_note_quarantines_low_level_roles() { - assert!(WORKFLOW_ACTOR_COMPATIBILITY_NOTE.contains("Farmer")); - assert!(WORKFLOW_ACTOR_COMPATIBILITY_NOTE.contains("Buyer")); - assert!(WORKFLOW_ACTOR_COMPATIBILITY_NOTE.contains("not sufficient authority")); - } - - #[test] - fn authority_fixture_allows_and_denies_by_actor_domain_pair() { - let context = active_context(); - let allowed = fixture_authority_gate( - WorkflowActor::ProducerAdmin, - context.clone(), - AuthorityDomain::FarmWorkspaceOperations, - AuthorityAction::Submit, - ); - assert!(allowed.is_allowed); - assert_eq!(allowed.reason, None); - - let denied = fixture_authority_gate( - WorkflowActor::NetworkMember, - context, - AuthorityDomain::FarmWorkspaceOperations, - AuthorityAction::Submit, + .expect("network") + .id, + "nearby" + ); + assert!( + runtime + .phase1_local_network( + "nearby".into(), + "Near me".into(), + Vec::new(), + None, + Vec::new(), + 1, + ) + .is_err() ); - assert!(!denied.is_allowed); - assert!(denied.reason.is_some()); } } diff --git a/crates/mobile_core/src/runtime/product_surface/context.rs b/crates/mobile_core/src/runtime/product_surface/context.rs @@ -0,0 +1,338 @@ +use std::collections::BTreeSet; + +use radroots_event::id::RelayUrl; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +const CONTEXT_TEXT_MAX_BYTES: usize = 256; +const RELAY_URL_MAX_BYTES: usize = 2_048; + +/// A validated local query/composer context. It has no Nostr event identity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalNetwork { + pub id: String, + pub label: String, + pub relay_urls: Vec<String>, + pub locality: Option<String>, + pub followed_authors: Vec<String>, + pub generation: u64, +} + +#[derive(Clone, Debug, Error, Eq, PartialEq)] +pub enum LocalNetworkError { + #[error("local network {field} is invalid")] + InvalidText { field: &'static str }, + #[error("local network requires at least one relay")] + MissingRelay, + #[error("local network relay URL is invalid")] + InvalidRelay, + #[error("local network relay URLs must be unique")] + DuplicateRelay, + #[error("local network followed author is invalid")] + InvalidAuthor, + #[error("local network followed authors must be unique")] + DuplicateAuthor, +} + +impl LocalNetwork { + pub fn new( + id: String, + label: String, + relay_urls: Vec<String>, + locality: Option<String>, + followed_authors: Vec<String>, + generation: u64, + ) -> Result<Self, LocalNetworkError> { + validate_text(&id, "id")?; + validate_text(&label, "label")?; + if let Some(locality) = locality.as_deref() { + validate_text(locality, "locality")?; + } + if relay_urls.is_empty() { + return Err(LocalNetworkError::MissingRelay); + } + let mut relays = BTreeSet::new(); + for relay in &relay_urls { + if relay.is_empty() + || relay.len() > RELAY_URL_MAX_BYTES + || !relay.starts_with("wss://") + || RelayUrl::parse(relay).is_err() + { + return Err(LocalNetworkError::InvalidRelay); + } + if !relays.insert(relay) { + return Err(LocalNetworkError::DuplicateRelay); + } + } + let mut authors = BTreeSet::new(); + for author in &followed_authors { + if author.len() != 64 + || !author + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(LocalNetworkError::InvalidAuthor); + } + if !authors.insert(author) { + return Err(LocalNetworkError::DuplicateAuthor); + } + } + Ok(Self { + id, + label, + relay_urls, + locality, + followed_authors, + generation, + }) + } + + /// Applies the locked locality policy to the selected local context. + pub const fn admit(&self, evidence: LocalityEvidence) -> LocalNetworkAdmission { + match evidence { + LocalityEvidence::Match => LocalNetworkAdmission::Included(ContextAdmission { + rank: ContextRank::LocalityMatch, + reason: "locality_match", + }), + LocalityEvidence::Missing => LocalNetworkAdmission::Included(ContextAdmission { + rank: ContextRank::MissingLocalityFallback, + reason: "locality_missing_fallback", + }), + LocalityEvidence::Nonmatch => LocalNetworkAdmission::Excluded { + reason: "locality_nonmatch", + }, + } + } +} + +fn validate_text(value: &str, field: &'static str) -> Result<(), LocalNetworkError> { + if value.is_empty() + || value.trim() != value + || value.len() > CONTEXT_TEXT_MAX_BYTES + || value.chars().any(char::is_control) + { + return Err(LocalNetworkError::InvalidText { field }); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum LocalityEvidence { + Match, + Missing, + Nonmatch, +} + +/// The only admitted context-rank values. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum ContextRank { + MissingLocalityFallback = 1, + LocalityMatch = 2, +} + +impl ContextRank { + pub const fn value(self) -> u8 { + self as u8 + } + + pub const fn from_value(value: u8) -> Option<Self> { + match value { + 1 => Some(Self::MissingLocalityFallback), + 2 => Some(Self::LocalityMatch), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ContextAdmission { + pub rank: ContextRank, + pub reason: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LocalNetworkAdmission { + Included(ContextAdmission), + Excluded { reason: &'static str }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn network() -> LocalNetwork { + LocalNetwork::new( + "local-network".into(), + "Near me".into(), + vec!["wss://relay.example".into()], + Some("u10h".into()), + vec!["a".repeat(64)], + 7, + ) + .expect("network") + } + + #[test] + fn locality_policy_has_exact_rank_and_exclusion_outcomes() { + assert_eq!( + network().admit(LocalityEvidence::Match), + LocalNetworkAdmission::Included(ContextAdmission { + rank: ContextRank::LocalityMatch, + reason: "locality_match", + }) + ); + assert_eq!( + network().admit(LocalityEvidence::Missing), + LocalNetworkAdmission::Included(ContextAdmission { + rank: ContextRank::MissingLocalityFallback, + reason: "locality_missing_fallback", + }) + ); + assert!(matches!( + network().admit(LocalityEvidence::Nonmatch), + LocalNetworkAdmission::Excluded { .. } + )); + } + + #[test] + fn local_network_fields_are_bounded_and_unique() { + assert_eq!(network().generation, 7); + for invalid in [ + LocalNetwork::new( + "".into(), + "label".into(), + vec!["wss://r".into()], + None, + vec![], + 0, + ), + LocalNetwork::new("id".into(), "label".into(), vec![], None, vec![], 0), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec![format!("wss://{}", "r".repeat(RELAY_URL_MAX_BYTES))], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://relay example".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://relay\u{7f}".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["https://r".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://user@relay.example".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://relay.example#fragment".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://r".into(), "wss://r".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://r".into()], + None, + vec!["A".repeat(64)], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://r".into()], + None, + vec!["a".repeat(63)], + 0, + ), + LocalNetwork::new( + "id".into(), + "label".into(), + vec!["wss://r".into()], + None, + vec!["a".repeat(64), "a".repeat(64)], + 0, + ), + LocalNetwork::new( + " id ".into(), + "label".into(), + vec!["wss://r".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "i".repeat(CONTEXT_TEXT_MAX_BYTES + 1), + "label".into(), + vec!["wss://r".into()], + None, + vec![], + 0, + ), + LocalNetwork::new( + "id".into(), + "la\u{7f}bel".into(), + vec!["wss://r".into()], + None, + vec![], + 0, + ), + ] { + assert!(invalid.is_err()); + } + } +} diff --git a/crates/mobile_core/src/runtime/product_surface/cursor.rs b/crates/mobile_core/src/runtime/product_surface/cursor.rs @@ -0,0 +1,438 @@ +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use super::{CardId, ContextRank, TODAY_RANK_SCHEMA_VERSION, TodayRank}; +use crate::runtime::product_surface::ranking::TODAY_RANK_ALGORITHM_VERSION; + +const CURSOR_PREFIX: &str = "rrtc1:"; +const CURSOR_DOMAIN: &[u8] = b"radroots.today-cursor.v1\0"; +const CURSOR_SCHEMA_VERSION: u16 = 1; +const MAX_CONTEXT_ID_BYTES: usize = 256; +const FIXED_PAYLOAD_BYTES: usize = 2 + 2 + 2 + 2 + 8 + 8 + 32 + 8 + 1 + 1 + 8 + 32; +const DIGEST_BYTES: usize = 32; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CursorScope { + pub context_id: String, + pub context_generation: u64, + pub as_of: u64, + pub store_generation: [u8; 32], + pub projection_generation: u64, +} + +impl CursorScope { + pub fn new( + context_id: String, + context_generation: u64, + as_of: u64, + store_generation: [u8; 32], + projection_generation: u64, + ) -> Result<Self, CursorError> { + validate_context_id(&context_id)?; + Ok(Self { + context_id, + context_generation, + as_of, + store_generation, + projection_generation, + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TodayCursorPosition { + pub rank: TodayRank, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TodayCursor(String); + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum CursorError { + #[error("today cursor context id is invalid")] + InvalidContext, + #[error("today cursor encoding is malformed")] + Malformed, + #[error("today cursor integrity check failed")] + Integrity, + #[error("today cursor version is unsupported")] + Version, + #[error("today cursor belongs to another context")] + ContextMismatch, + #[error("today cursor belongs to another frozen snapshot")] + SnapshotMismatch, + #[error("today cursor belongs to a retired store or projection generation")] + Stale, + #[error("today cursor position is invalid")] + InvalidPosition, +} + +impl TodayCursor { + pub fn encode(scope: &CursorScope, position: TodayCursorPosition) -> Result<Self, CursorError> { + if position.rank.schema_version != TODAY_RANK_SCHEMA_VERSION + || position.rank.algorithm_version != TODAY_RANK_ALGORITHM_VERSION + { + return Err(CursorError::Version); + } + if position.rank.time_relevance_rank > 4 { + return Err(CursorError::InvalidPosition); + } + let context_bytes = scope.context_id.as_bytes(); + let mut payload = Vec::with_capacity(FIXED_PAYLOAD_BYTES + context_bytes.len()); + payload.extend_from_slice(&CURSOR_SCHEMA_VERSION.to_be_bytes()); + payload.extend_from_slice(&TODAY_RANK_SCHEMA_VERSION.to_be_bytes()); + payload.extend_from_slice(&TODAY_RANK_ALGORITHM_VERSION.to_be_bytes()); + payload.extend_from_slice( + &u16::try_from(context_bytes.len()) + .expect("validated context length fits u16") + .to_be_bytes(), + ); + payload.extend_from_slice(context_bytes); + payload.extend_from_slice(&scope.context_generation.to_be_bytes()); + payload.extend_from_slice(&scope.as_of.to_be_bytes()); + payload.extend_from_slice(&scope.store_generation); + payload.extend_from_slice(&scope.projection_generation.to_be_bytes()); + payload.push(position.rank.context_rank.value()); + payload.push(position.rank.time_relevance_rank); + payload.extend_from_slice(&position.rank.effective_at.to_be_bytes()); + payload.extend_from_slice(position.rank.card_id.as_bytes()); + let digest = cursor_digest(&payload); + payload.extend_from_slice(&digest); + Ok(Self(format!("{CURSOR_PREFIX}{}", hex::encode(payload)))) + } + + pub fn decode(value: &str, expected: &CursorScope) -> Result<TodayCursorPosition, CursorError> { + let encoded = value + .strip_prefix(CURSOR_PREFIX) + .ok_or(CursorError::Malformed)?; + if encoded.len() % 2 != 0 + || !encoded + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(CursorError::Malformed); + } + let bytes = hex::decode(encoded).map_err(|_| CursorError::Malformed)?; + if bytes.len() < FIXED_PAYLOAD_BYTES + DIGEST_BYTES { + return Err(CursorError::Malformed); + } + let (payload, observed_digest) = bytes.split_at(bytes.len() - DIGEST_BYTES); + if cursor_digest(payload).as_slice() != observed_digest { + return Err(CursorError::Integrity); + } + decode_payload(payload, expected) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn decode_payload( + payload: &[u8], + expected: &CursorScope, +) -> Result<TodayCursorPosition, CursorError> { + let mut decoder = Decoder::new(payload); + let cursor_version = decoder.u16()?; + let rank_schema_version = decoder.u16()?; + let rank_algorithm_version = decoder.u16()?; + if cursor_version != CURSOR_SCHEMA_VERSION + || rank_schema_version != TODAY_RANK_SCHEMA_VERSION + || rank_algorithm_version != TODAY_RANK_ALGORITHM_VERSION + { + return Err(CursorError::Version); + } + let context_len = usize::from(decoder.u16()?); + let context_id = + core::str::from_utf8(decoder.bytes(context_len)?).map_err(|_| CursorError::Malformed)?; + validate_context_id(context_id)?; + let context_generation = decoder.u64()?; + let as_of = decoder.u64()?; + let store_generation = decoder.array_32()?; + let projection_generation = decoder.u64()?; + let context_rank = ContextRank::from_value(decoder.u8()?).ok_or(CursorError::Malformed)?; + let time_relevance_rank = decoder.u8()?; + if time_relevance_rank > 4 { + return Err(CursorError::Malformed); + } + let effective_at = decoder.u64()?; + let card_id = + CardId::parse(&hex::encode(decoder.array_32()?)).map_err(|_| CursorError::Malformed)?; + if !decoder.is_finished() { + return Err(CursorError::Malformed); + } + if context_id != expected.context_id || context_generation != expected.context_generation { + return Err(CursorError::ContextMismatch); + } + if as_of != expected.as_of { + return Err(CursorError::SnapshotMismatch); + } + if store_generation != expected.store_generation + || projection_generation != expected.projection_generation + { + return Err(CursorError::Stale); + } + Ok(TodayCursorPosition { + rank: TodayRank { + schema_version: rank_schema_version, + algorithm_version: rank_algorithm_version, + context_rank, + time_relevance_rank, + effective_at, + card_id, + }, + }) +} + +fn validate_context_id(value: &str) -> Result<(), CursorError> { + if value.is_empty() + || value.len() > MAX_CONTEXT_ID_BYTES + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(CursorError::InvalidContext); + } + Ok(()) +} + +fn cursor_digest(payload: &[u8]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(CURSOR_DOMAIN); + digest.update(payload); + digest.finalize().into() +} + +struct Decoder<'a> { + remaining: &'a [u8], +} + +impl<'a> Decoder<'a> { + const fn new(value: &'a [u8]) -> Self { + Self { remaining: value } + } + + fn bytes(&mut self, length: usize) -> Result<&'a [u8], CursorError> { + if self.remaining.len() < length { + return Err(CursorError::Malformed); + } + let (value, remaining) = self.remaining.split_at(length); + self.remaining = remaining; + Ok(value) + } + + fn u8(&mut self) -> Result<u8, CursorError> { + Ok(self.bytes(1)?[0]) + } + + fn u16(&mut self) -> Result<u16, CursorError> { + Ok(u16::from_be_bytes( + self.bytes(2)?.try_into().expect("exact length"), + )) + } + + fn u64(&mut self) -> Result<u64, CursorError> { + Ok(u64::from_be_bytes( + self.bytes(8)?.try_into().expect("exact length"), + )) + } + + fn array_32(&mut self) -> Result<[u8; 32], CursorError> { + Ok(self.bytes(32)?.try_into().expect("exact length")) + } + + const fn is_finished(&self) -> bool { + self.remaining.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scope() -> CursorScope { + CursorScope::new("nearby".into(), 4, 2_000_000_000, [7; 32], 9).expect("scope") + } + + fn position() -> TodayCursorPosition { + TodayCursorPosition { + rank: TodayRank { + schema_version: TODAY_RANK_SCHEMA_VERSION, + algorithm_version: TODAY_RANK_ALGORITHM_VERSION, + context_rank: ContextRank::LocalityMatch, + time_relevance_rank: 3, + effective_at: 1_999_999_000, + card_id: CardId::parse(&"a".repeat(64)).expect("card"), + }, + } + } + + fn payload(cursor: &TodayCursor) -> Vec<u8> { + let bytes = + hex::decode(cursor.as_str().strip_prefix(CURSOR_PREFIX).expect("prefix")).expect("hex"); + bytes[..bytes.len() - DIGEST_BYTES].to_vec() + } + + fn signed_payload(mut payload: Vec<u8>) -> String { + payload.extend_from_slice(&cursor_digest(&payload)); + format!("{CURSOR_PREFIX}{}", hex::encode(payload)) + } + + #[test] + fn cursor_vector_round_trips_and_is_fixed() { + let cursor = TodayCursor::encode(&scope(), position()).expect("cursor"); + assert_eq!( + cursor.as_str(), + "rrtc1:00010001000100066e6561726279000000000000000400000000773594000707070707070707070707070707070707070707070707070707070707070707000000000000000902030000000077359018aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaedf305be41633dfc2f7d621e067c3d33a71c3548c6a1fcf68a6707a1d8664b11" + ); + assert_eq!( + TodayCursor::decode(cursor.as_str(), &scope()).expect("decode"), + position() + ); + } + + #[test] + fn cursor_rejects_tamper_context_snapshot_and_stale_generations() { + let cursor = TodayCursor::encode(&scope(), position()).expect("cursor"); + let mut tampered = cursor.as_str().as_bytes().to_vec(); + *tampered.last_mut().expect("byte") = b'0'; + assert_eq!( + TodayCursor::decode(core::str::from_utf8(&tampered).expect("utf8"), &scope()), + Err(CursorError::Integrity) + ); + let other_context = + CursorScope::new("other".into(), 4, 2_000_000_000, [7; 32], 9).expect("scope"); + assert_eq!( + TodayCursor::decode(cursor.as_str(), &other_context), + Err(CursorError::ContextMismatch) + ); + let other_context_generation = + CursorScope::new("nearby".into(), 5, 2_000_000_000, [7; 32], 9).expect("scope"); + assert_eq!( + TodayCursor::decode(cursor.as_str(), &other_context_generation), + Err(CursorError::ContextMismatch) + ); + let other_snapshot = + CursorScope::new("nearby".into(), 4, 2_000_000_001, [7; 32], 9).expect("scope"); + assert_eq!( + TodayCursor::decode(cursor.as_str(), &other_snapshot), + Err(CursorError::SnapshotMismatch) + ); + let stale = CursorScope::new("nearby".into(), 4, 2_000_000_000, [8; 32], 9).expect("scope"); + assert_eq!( + TodayCursor::decode(cursor.as_str(), &stale), + Err(CursorError::Stale) + ); + let stale_projection = + CursorScope::new("nearby".into(), 4, 2_000_000_000, [7; 32], 10).expect("scope"); + assert_eq!( + TodayCursor::decode(cursor.as_str(), &stale_projection), + Err(CursorError::Stale) + ); + } + + #[test] + fn malformed_and_versioned_cursor_inputs_fail_closed() { + assert_eq!( + TodayCursor::decode("nope", &scope()), + Err(CursorError::Malformed) + ); + for malformed in ["rrtc1:0", "rrtc1:GG", "rrtc1:00"] { + assert_eq!( + TodayCursor::decode(malformed, &scope()), + Err(CursorError::Malformed) + ); + } + assert_eq!( + TodayCursor::decode( + &TodayCursor::encode(&scope(), position()) + .expect("cursor") + .as_str() + .to_uppercase(), + &scope() + ), + Err(CursorError::Malformed) + ); + assert!(CursorScope::new("".into(), 0, 0, [0; 32], 0).is_err()); + assert!(CursorScope::new("x".repeat(257), 0, 0, [0; 32], 0).is_err()); + assert!(CursorScope::new(" nearby ".into(), 0, 0, [0; 32], 0).is_err()); + assert!(CursorScope::new("near\u{7f}by".into(), 0, 0, [0; 32], 0).is_err()); + let cursor = TodayCursor::encode(&scope(), position()).expect("cursor"); + for version_offset in [1, 3, 5] { + let mut unsupported = payload(&cursor); + unsupported[version_offset] = 2; + assert_eq!( + TodayCursor::decode(&signed_payload(unsupported), &scope()), + Err(CursorError::Version) + ); + } + let mut invalid_utf8 = payload(&cursor); + invalid_utf8[8] = 0xff; + assert_eq!( + TodayCursor::decode(&signed_payload(invalid_utf8), &scope()), + Err(CursorError::Malformed) + ); + let mut invalid_context = payload(&cursor); + invalid_context[8] = b' '; + assert_eq!( + TodayCursor::decode(&signed_payload(invalid_context), &scope()), + Err(CursorError::InvalidContext) + ); + let mut trailing = payload(&cursor); + trailing.push(0); + assert_eq!( + TodayCursor::decode(&signed_payload(trailing), &scope()), + Err(CursorError::Malformed) + ); + let mut invalid_context_rank = payload(&cursor); + invalid_context_rank[70] = 3; + assert_eq!( + TodayCursor::decode(&signed_payload(invalid_context_rank), &scope()), + Err(CursorError::Malformed) + ); + let mut invalid_time_rank = payload(&cursor); + invalid_time_rank[71] = 5; + assert_eq!( + TodayCursor::decode(&signed_payload(invalid_time_rank), &scope()), + Err(CursorError::Malformed) + ); + let mut truncated_field = vec![0; FIXED_PAYLOAD_BYTES]; + truncated_field[1] = 1; + truncated_field[3] = 1; + truncated_field[5] = 1; + truncated_field[6] = 1; + assert_eq!( + TodayCursor::decode(&signed_payload(truncated_field), &scope()), + Err(CursorError::Malformed) + ); + let invalid_version = TodayCursorPosition { + rank: TodayRank { + schema_version: 2, + ..position().rank + }, + }; + assert_eq!( + TodayCursor::encode(&scope(), invalid_version), + Err(CursorError::Version) + ); + let invalid_algorithm = TodayCursorPosition { + rank: TodayRank { + algorithm_version: 2, + ..position().rank + }, + }; + assert_eq!( + TodayCursor::encode(&scope(), invalid_algorithm), + Err(CursorError::Version) + ); + let invalid_rank = TodayCursorPosition { + rank: TodayRank { + time_relevance_rank: 5, + ..position().rank + }, + }; + assert_eq!( + TodayCursor::encode(&scope(), invalid_rank), + Err(CursorError::InvalidPosition) + ); + } +} diff --git a/crates/mobile_core/src/runtime/product_surface/identity.rs b/crates/mobile_core/src/runtime/product_surface/identity.rs @@ -0,0 +1,185 @@ +use core::fmt; + +use radroots_event::EventId; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use super::TodayCardType; + +pub const CARD_ID_SCHEMA_VERSION: u16 = 1; +const CARD_ID_DOMAIN: &[u8] = b"radroots.today-card.v1\0"; + +/// Canonical source identity used to derive a stable card identifier. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CardSourceIdentity { + Event(EventId), + Address { + kind: u32, + author_pubkey: String, + identifier: String, + }, +} + +#[derive(Clone, Debug, Error, Eq, PartialEq)] +pub enum CardIdError { + #[error("card address kind must be parameterized replaceable")] + InvalidAddressKind, + #[error("card address author must be canonical lowercase hexadecimal")] + InvalidAuthor, + #[error("card address identifier is invalid")] + InvalidIdentifier, + #[error("card identifier must be 64 lowercase hexadecimal characters")] + InvalidCardId, +} + +impl CardSourceIdentity { + pub fn address( + kind: u32, + author_pubkey: impl Into<String>, + identifier: impl Into<String>, + ) -> Result<Self, CardIdError> { + if !(30_000..40_000).contains(&kind) { + return Err(CardIdError::InvalidAddressKind); + } + let author_pubkey = author_pubkey.into(); + if author_pubkey.len() != 64 + || !author_pubkey + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(CardIdError::InvalidAuthor); + } + let identifier = identifier.into(); + if identifier.is_empty() + || identifier.len() > 512 + || identifier.chars().any(char::is_control) + { + return Err(CardIdError::InvalidIdentifier); + } + Ok(Self::Address { + kind, + author_pubkey, + identifier, + }) + } + + pub fn canonical_string(&self) -> String { + match self { + Self::Event(event_id) => format!("event:{}", event_id.to_hex()), + Self::Address { + kind, + author_pubkey, + identifier, + } => format!("address:{kind}:{author_pubkey}:{identifier}"), + } + } +} + +/// Lowercase SHA-256 stable identity for one top-level Today card. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct CardId([u8; 32]); + +impl CardId { + pub fn derive(card_type: TodayCardType, source: &CardSourceIdentity) -> Self { + let mut digest = Sha256::new(); + digest.update(CARD_ID_DOMAIN); + digest.update(card_type.label().as_bytes()); + digest.update(b"\0"); + digest.update(source.canonical_string().as_bytes()); + Self(digest.finalize().into()) + } + + pub fn parse(value: &str) -> Result<Self, CardIdError> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(CardIdError::InvalidCardId); + } + let mut bytes = [0; 32]; + hex::decode_to_slice(value, &mut bytes).map_err(|_| CardIdError::InvalidCardId)?; + Ok(Self(bytes)) + } + + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn to_hex(self) -> String { + hex::encode(self.0) + } +} + +impl fmt::Display for CardId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&hex::encode(self.0)) + } +} + +impl Serialize for CardId { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_hex()) + } +} + +impl<'de> Deserialize<'de> for CardId { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_card_id_vectors_cover_regular_and_addressable_sources() { + let event = EventId::parse("a".repeat(64)).expect("event"); + assert_eq!( + CardId::derive(TodayCardType::Update, &CardSourceIdentity::Event(event)).to_hex(), + "36bf89dc7a6759143986b1f339870ec792f7bee865c9738b0adb50ac9c5197be" + ); + let address = CardSourceIdentity::address(31_923, "b".repeat(64), "farmers-market-2026") + .expect("address"); + assert_eq!( + CardId::derive(TodayCardType::Event, &address).to_hex(), + "75f6127161783c583368b6c76ed779ae5e02cd7bc9f71ef55ff39e32a59274fc" + ); + let replacement = CardId::derive(TodayCardType::Event, &address); + assert_eq!(replacement, CardId::derive(TodayCardType::Event, &address)); + } + + #[test] + fn card_identity_rejects_noncanonical_addresses_and_ids() { + assert!(CardSourceIdentity::address(1, "a".repeat(64), "id").is_err()); + assert!(CardSourceIdentity::address(40_000, "a".repeat(64), "id").is_err()); + assert!(CardSourceIdentity::address(30_402, "a".repeat(63), "id").is_err()); + assert!(CardSourceIdentity::address(30_402, "A".repeat(64), "id").is_err()); + assert!(CardSourceIdentity::address(30_402, "a".repeat(64), "").is_err()); + assert!(CardSourceIdentity::address(30_402, "a".repeat(64), "i".repeat(513)).is_err()); + assert!(CardSourceIdentity::address(30_402, "a".repeat(64), "bad\nid").is_err()); + let opaque = CardSourceIdentity::address(31_923, "a".repeat(64), " market day ") + .expect("opaque d value"); + assert!(opaque.canonical_string().ends_with(": market day ")); + assert!(CardId::parse(&"a".repeat(63)).is_err()); + assert!(CardId::parse(&"A".repeat(64)).is_err()); + + let card = CardId::parse(&"c".repeat(64)).expect("card"); + assert_eq!(card.to_string(), "c".repeat(64)); + let encoded = serde_json::to_string(&card).expect("serialize"); + assert_eq!( + serde_json::from_str::<CardId>(&encoded).expect("deserialize"), + card + ); + assert!(serde_json::from_str::<CardId>(&format!("\"{}\"", "G".repeat(64))).is_err()); + } +} diff --git a/crates/mobile_core/src/runtime/product_surface/model.rs b/crates/mobile_core/src/runtime/product_surface/model.rs @@ -0,0 +1,197 @@ +use serde::{Deserialize, Serialize}; + +use super::{CardId, ContextRank, TodayRank}; + +/// The closed Phase 1 top-level Today taxonomy. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum TodayCardType { + Update, + PhotoUpdate, + Ask, + Event, + FoodAvailability, +} + +impl TodayCardType { + pub const fn label(self) -> &'static str { + match self { + Self::Update => "Update", + Self::PhotoUpdate => "PhotoUpdate", + Self::Ask => "Ask", + Self::Event => "Event", + Self::FoodAvailability => "FoodAvailability", + } + } +} + +/// The closed Phase 1 Add command taxonomy. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum AddCommandType { + CreateUpdate, + CreatePhotoUpdate, + CreateAsk, + CreateEvent, + CreateFoodAvailability, +} + +/// One exact top-level card to Add-command mapping. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CardAddParity { + pub card_type: TodayCardType, + pub add_command_type: AddCommandType, +} + +pub const CANONICAL_TODAY_CARD_TYPES: [TodayCardType; 5] = [ + TodayCardType::Update, + TodayCardType::PhotoUpdate, + TodayCardType::Ask, + TodayCardType::Event, + TodayCardType::FoodAvailability, +]; + +pub const CANONICAL_ADD_COMMAND_TYPES: [AddCommandType; 5] = [ + AddCommandType::CreateUpdate, + AddCommandType::CreatePhotoUpdate, + AddCommandType::CreateAsk, + AddCommandType::CreateEvent, + AddCommandType::CreateFoodAvailability, +]; + +pub const CANONICAL_CARD_ADD_PARITY: [CardAddParity; 5] = [ + CardAddParity { + card_type: TodayCardType::Update, + add_command_type: AddCommandType::CreateUpdate, + }, + CardAddParity { + card_type: TodayCardType::PhotoUpdate, + add_command_type: AddCommandType::CreatePhotoUpdate, + }, + CardAddParity { + card_type: TodayCardType::Ask, + add_command_type: AddCommandType::CreateAsk, + }, + CardAddParity { + card_type: TodayCardType::Event, + add_command_type: AddCommandType::CreateEvent, + }, + CardAddParity { + card_type: TodayCardType::FoodAvailability, + add_command_type: AddCommandType::CreateFoodAvailability, + }, +]; + +/// Supporting standard profiles that enrich the product without creating cards. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum SupportingProfile { + Profile, + Reply, + Comment, + Deletion, +} + +/// Local media verification never changes the canonical card type. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum MediaVerificationState { + Pending, + Verified, + Failed, + Unavailable, +} + +/// Structural media metadata plus its separate local verification state. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaReference { + pub url: String, + pub sha256: Option<String>, + pub media_type: Option<String>, + pub width: Option<u32>, + pub height: Option<u32>, + pub byte_size: Option<u64>, + pub alt: Option<String>, + pub verification: MediaVerificationState, +} + +/// Tolerant profile attribution attached to cards and Me results. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileSummary { + pub author_pubkey: String, + pub name: Option<String>, + pub display_name: Option<String>, + pub picture: Option<MediaReference>, +} + +/// Thread enrichment identity; replies and comments never become top-level cards. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ThreadReference { + pub profile: SupportingProfile, + pub root: String, + pub parent_event_id: String, +} + +/// Current rendering state derived from standard event semantics. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum CardLifecycleState { + Active, + Sold, + Past, +} + +/// Verified, visible, context-admitted source facts for one top-level card. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClassifiedCard { + pub schema_version: u16, + pub card_id: CardId, + pub card_type: TodayCardType, + pub source_event_id: String, + pub source_address: Option<String>, + pub author_pubkey: String, + pub contract_id: String, + pub content: String, + pub authored_at: u64, + pub context_rank: ContextRank, + pub inclusion_reason: String, + pub media: Vec<MediaReference>, + pub lifecycle: CardLifecycleState, + pub rank: Option<TodayRank>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn card_and_add_taxonomies_are_exact_and_serialized_stably() { + assert_eq!(CANONICAL_TODAY_CARD_TYPES.len(), 5); + assert_eq!(CANONICAL_ADD_COMMAND_TYPES.len(), 5); + for (index, parity) in CANONICAL_CARD_ADD_PARITY.iter().enumerate() { + assert_eq!(parity.card_type, CANONICAL_TODAY_CARD_TYPES[index]); + assert_eq!(parity.add_command_type, CANONICAL_ADD_COMMAND_TYPES[index]); + } + assert_eq!( + serde_json::to_string(&CANONICAL_TODAY_CARD_TYPES).expect("cards"), + r#"["Update","PhotoUpdate","Ask","Event","FoodAvailability"]"# + ); + } + + #[test] + fn media_state_is_independent_from_card_type() { + for state in [ + MediaVerificationState::Pending, + MediaVerificationState::Verified, + MediaVerificationState::Failed, + MediaVerificationState::Unavailable, + ] { + assert!(!serde_json::to_string(&state).expect("state").is_empty()); + } + } +} diff --git a/crates/mobile_core/src/runtime/product_surface/projection.rs b/crates/mobile_core/src/runtime/product_surface/projection.rs @@ -0,0 +1,469 @@ +use radroots_event::food::availability::FoodAvailabilityStatus; +use radroots_event_codec::{ + admission::RadrootsAdmittedEvent, decode::post::RadrootsPostClassification, +}; +use serde::{Deserialize, Serialize}; + +use super::{ + CardId, CardLifecycleState, CardSourceIdentity, ClassifiedCard, ContextAdmission, + LocalNetworkAdmission, MediaReference, MediaVerificationState, SupportingProfile, + TodayCardType, +}; + +const CLASSIFIED_CARD_SCHEMA_VERSION: u16 = 1; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ProductEventClassification { + Card(Box<ClassifiedCard>), + Supporting(SupportingProfile), + Excluded(ProductEventExclusion), +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum ProductEventExclusion { + LocalityNonmatch, + UnsupportedProfile, + InvalidSourceIdentity, +} + +/// Classifies an already signature/id-verified and standard-profile-admitted event. +/// +/// The caller must supply the result of the selected LocalNetwork admission. +/// Replacement and deletion are applied by storage before the event reaches +/// this boundary. No content prose or remote media retrieval influences type. +pub fn classify_admitted_event( + admitted: &RadrootsAdmittedEvent, + context: LocalNetworkAdmission, +) -> ProductEventClassification { + let context = match context { + LocalNetworkAdmission::Included(context) => context, + LocalNetworkAdmission::Excluded { .. } => { + return ProductEventClassification::Excluded(ProductEventExclusion::LocalityNonmatch); + } + }; + + match admitted { + RadrootsAdmittedEvent::Profile(_) => supporting(SupportingProfile::Profile), + RadrootsAdmittedEvent::Reply(_) => supporting(SupportingProfile::Reply), + RadrootsAdmittedEvent::Comment(_) => supporting(SupportingProfile::Comment), + RadrootsAdmittedEvent::DeletionRequest(_) => supporting(SupportingProfile::Deletion), + RadrootsAdmittedEvent::RootPost(event) => { + let card_type = match event.projection().classification() { + RadrootsPostClassification::Update => TodayCardType::Update, + RadrootsPostClassification::PhotoUpdate => TodayCardType::PhotoUpdate, + RadrootsPostClassification::Ask => TodayCardType::Ask, + RadrootsPostClassification::ThreadExcluded => { + return ProductEventClassification::Excluded( + ProductEventExclusion::UnsupportedProfile, + ); + } + _ => { + return ProductEventClassification::Excluded( + ProductEventExclusion::UnsupportedProfile, + ); + } + }; + card( + admitted, + card_type, + context, + post_media(event.projection()), + CardLifecycleState::Active, + ) + } + RadrootsAdmittedEvent::FoodAvailability(event) => { + let lifecycle = match event.projection().status() { + FoodAvailabilityStatus::Active => CardLifecycleState::Active, + FoodAvailabilityStatus::Sold => CardLifecycleState::Sold, + }; + card( + admitted, + TodayCardType::FoodAvailability, + context, + food_media(event.projection()), + lifecycle, + ) + } + RadrootsAdmittedEvent::ContractValidated(event) => match event.contract_id() { + "radroots.calendar.date_event.v1" | "radroots.calendar.time_event.v1" => card( + admitted, + TodayCardType::Event, + context, + calendar_media(event.event().tags_as_vec()), + CardLifecycleState::Active, + ), + _ => ProductEventClassification::Excluded(ProductEventExclusion::UnsupportedProfile), + }, + _ => ProductEventClassification::Excluded(ProductEventExclusion::UnsupportedProfile), + } +} + +const fn supporting(profile: SupportingProfile) -> ProductEventClassification { + ProductEventClassification::Supporting(profile) +} + +fn card( + admitted: &RadrootsAdmittedEvent, + card_type: TodayCardType, + context: ContextAdmission, + media: Vec<MediaReference>, + lifecycle: CardLifecycleState, +) -> ProductEventClassification { + let event = admitted.event(); + let source = match card_type { + TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask => { + CardSourceIdentity::Event(*event.id()) + } + TodayCardType::Event | TodayCardType::FoodAvailability => { + let identifier = event + .tags_as_vec() + .into_iter() + .find(|tag| tag.first().map(String::as_str) == Some("d")) + .and_then(|tag| tag.get(1).cloned()); + let Some(identifier) = identifier else { + return ProductEventClassification::Excluded( + ProductEventExclusion::InvalidSourceIdentity, + ); + }; + let Ok(source) = + CardSourceIdentity::address(event.kind_u32(), event.author().to_hex(), identifier) + else { + return ProductEventClassification::Excluded( + ProductEventExclusion::InvalidSourceIdentity, + ); + }; + source + } + }; + let source_address = match &source { + CardSourceIdentity::Event(_) => None, + CardSourceIdentity::Address { + kind, + author_pubkey, + identifier, + } => Some(format!("{kind}:{author_pubkey}:{identifier}")), + }; + ProductEventClassification::Card(Box::new(ClassifiedCard { + schema_version: CLASSIFIED_CARD_SCHEMA_VERSION, + card_id: CardId::derive(card_type, &source), + card_type, + source_event_id: event.id_hex(), + source_address, + author_pubkey: event.author().to_hex(), + contract_id: admitted.contract_id().to_owned(), + content: event.content().to_owned(), + authored_at: event.created_at_u64(), + context_rank: context.rank, + inclusion_reason: context.reason.to_owned(), + media, + lifecycle, + rank: None, + })) +} + +fn post_media( + projection: &radroots_event_codec::decode::post::RadrootsInboundPostProjection, +) -> Vec<MediaReference> { + projection + .imeta() + .iter() + .filter_map(|media| { + let dimensions = media.dimensions(); + Some(MediaReference { + url: media.url()?.to_owned(), + sha256: media.sha256().map(str::to_owned), + media_type: media.media_type().map(str::to_owned), + width: dimensions.map(|value| value.width()), + height: dimensions.map(|value| value.height()), + byte_size: media.size(), + alt: media.alt().map(str::to_owned), + verification: MediaVerificationState::Unavailable, + }) + }) + .collect() +} + +fn food_media( + projection: &radroots_event_codec::decode::food_availability::RadrootsInboundFoodAvailabilityProjection, +) -> Vec<MediaReference> { + projection + .images() + .iter() + .filter_map(|media| { + let dimensions = media.dimensions(); + Some(MediaReference { + url: media.url()?.to_owned(), + sha256: blossom_digest(media.url()?), + media_type: None, + width: dimensions.map(|value| value.width()), + height: dimensions.map(|value| value.height()), + byte_size: None, + alt: None, + verification: MediaVerificationState::Unavailable, + }) + }) + .collect() +} + +fn calendar_media(tags: Vec<Vec<String>>) -> Vec<MediaReference> { + tags.into_iter() + .find(|tag| tag.first().map(String::as_str) == Some("image")) + .and_then(|tag| tag.get(1).cloned()) + .map(|url| MediaReference { + sha256: blossom_digest(&url), + url, + media_type: None, + width: None, + height: None, + byte_size: None, + alt: None, + verification: MediaVerificationState::Unavailable, + }) + .into_iter() + .collect() +} + +fn blossom_digest(url: &str) -> Option<String> { + let path = url.split_once("://")?.1.split_once('/')?.1; + let candidate = path.split(['.', '/', '?', '#']).next()?; + (candidate.len() == 64 + && candidate + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))) + .then(|| candidate.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::secp256k1::Message; + use nostr::{Keys, SECP256K1}; + use radroots_event::{ + Event, envelope::EventEnvelopeParts, wire::compute_canonical_nip01_event_id, + }; + use radroots_event_codec::{admission::admit_verified_event, verify::verify_nip01_event}; + + const SECRET: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; + + fn admitted(kind: u32, tags: Vec<Vec<&str>>, content: &str) -> RadrootsAdmittedEvent { + admitted_owned( + kind, + tags.into_iter() + .map(|tag| tag.into_iter().map(str::to_owned).collect()) + .collect(), + content, + ) + } + + fn admitted_owned(kind: u32, tags: Vec<Vec<String>>, content: &str) -> RadrootsAdmittedEvent { + let keys = Keys::parse(SECRET).expect("key"); + let author = keys.public_key().to_string(); + let created_at = 2_000_000_000; + let id = compute_canonical_nip01_event_id(&author, created_at, kind, &tags, content) + .expect("id"); + let message = Message::from_digest(*id.as_bytes()); + let signature = SECP256K1.sign_schnorr_no_aux_rand( + &message, + &nostr::secp256k1::Keypair::from_secret_key(SECP256K1, keys.secret_key()), + ); + let event = Event::new(EventEnvelopeParts { + id: id.to_hex(), + author, + created_at, + kind, + tags, + content: content.into(), + sig: signature.to_string(), + }) + .expect("event"); + let verified = verify_nip01_event(event).expect("verified"); + admit_verified_event(verified).expect("admitted") + } + + const fn context() -> LocalNetworkAdmission { + LocalNetworkAdmission::Included(ContextAdmission { + rank: super::super::ContextRank::MissingLocalityFallback, + reason: "locality_missing_fallback", + }) + } + + fn card_type(event: RadrootsAdmittedEvent) -> TodayCardType { + match classify_admitted_event(&event, context()) { + ProductEventClassification::Card(card) => card.card_type, + other => panic!("expected card, got {other:?}"), + } + } + + #[test] + fn exact_five_card_classifier_precedence_is_protocol_structural() { + assert_eq!( + card_type(admitted(1, vec![], "ordinary note")), + TodayCardType::Update + ); + let digest_tag = format!("x {}", "a".repeat(64)); + assert_eq!( + card_type(admitted( + 1, + vec![vec![ + "imeta", + "url https://media.example/a.jpg", + &digest_tag, + "m image/jpeg", + "dim 10x20", + "size 123", + "alt field photo" + ]], + "photo https://media.example/a.jpg", + )), + TodayCardType::PhotoUpdate + ); + assert_eq!( + card_type(admitted( + 1, + vec![vec!["t", " RADROOTS-ASK "], vec!["imeta", "broken"]], + "Anyone have carrots", + )), + TodayCardType::Ask + ); + assert_eq!( + card_type(admitted( + 31_923, + vec![ + vec!["d", "market-2026"], + vec!["title", "Saturday market"], + vec!["start", "2000000100"], + vec!["end", "2000000200"], + vec!["D", "23148"], + ], + "Farm market", + )), + TodayCardType::Event + ); + assert_eq!( + card_type(admitted( + 30_402, + vec![ + vec!["d", "carrots"], + vec!["title", "Carrots"], + vec!["summary", "Fresh bunches"], + vec!["published_at", "1999999999"], + vec!["location", "Saanich"], + vec!["price", "3", "CAD"], + vec!["radroots:price_unit", "lb"], + vec!["status", "active"], + ], + "Carrots available", + )), + TodayCardType::FoodAvailability + ); + } + + #[test] + fn ordinary_standard_kind_one_needs_no_product_marker() { + let event = admitted(1, vec![vec!["t", "gardening"]], "Seedlings are ready"); + let ProductEventClassification::Card(card) = classify_admitted_event(&event, context()) + else { + panic!("standard kind-1 must remain admitted"); + }; + assert_eq!(card.card_type, TodayCardType::Update); + assert_eq!( + card.context_rank, + super::super::ContextRank::MissingLocalityFallback + ); + } + + #[test] + fn supporting_profiles_never_become_cards_and_nonmatches_are_excluded() { + let profile = admitted(0, vec![], r#"{"name":"Farm"}"#); + let reply = admitted(1, vec![vec!["e", &"a".repeat(64), "", "root"]], "Reply"); + let author = Keys::parse(SECRET).expect("key").public_key().to_string(); + let root_id = "a".repeat(64); + let comment = admitted_owned( + 1_111, + vec![ + vec!["E".into(), root_id.clone(), String::new(), author.clone()], + vec!["K".into(), "30402".into()], + vec!["P".into(), author.clone()], + vec!["e".into(), root_id.clone(), String::new(), author.clone()], + vec!["k".into(), "30402".into()], + vec!["p".into(), author], + ], + "Comment", + ); + let deletion = admitted(5, vec![vec!["e", &root_id]], "Superseded"); + for (event, expected) in [ + (profile, SupportingProfile::Profile), + (reply, SupportingProfile::Reply), + (comment, SupportingProfile::Comment), + (deletion, SupportingProfile::Deletion), + ] { + assert_eq!( + classify_admitted_event(&event, context()), + ProductEventClassification::Supporting(expected) + ); + } + + let nip98 = admitted( + 27_235, + vec![ + vec!["u", "https://media.example/upload"], + vec!["method", "GET"], + ], + "{}", + ); + assert_eq!( + classify_admitted_event(&nip98, context()), + ProductEventClassification::Excluded(ProductEventExclusion::UnsupportedProfile) + ); + + let update = admitted(1, vec![], "ordinary note"); + assert_eq!( + classify_admitted_event( + &update, + LocalNetworkAdmission::Excluded { + reason: "locality_nonmatch" + } + ), + ProductEventClassification::Excluded(ProductEventExclusion::LocalityNonmatch) + ); + } + + #[test] + fn addressable_replacements_keep_stable_card_identity() { + let first = admitted( + 30_402, + vec![ + vec!["d", "carrots"], + vec!["title", "Carrots"], + vec!["summary", "Fresh"], + vec!["published_at", "1999999999"], + vec!["location", "Saanich"], + vec!["price", "3", "CAD"], + vec!["radroots:price_unit", "lb"], + vec!["status", "active"], + ], + "First", + ); + let second = admitted( + 30_402, + vec![ + vec!["d", "carrots"], + vec!["title", "Carrots"], + vec!["summary", "Fresh"], + vec!["published_at", "1999999999"], + vec!["location", "Saanich"], + vec!["price", "4", "CAD"], + vec!["radroots:price_unit", "lb"], + vec!["status", "active"], + ], + "Second", + ); + let ids = [first, second].map(|event| match classify_admitted_event(&event, context()) { + ProductEventClassification::Card(card) => card.card_id, + other => panic!("expected card, got {other:?}"), + }); + assert_eq!(ids[0], ids[1]); + } +} diff --git a/crates/mobile_core/src/runtime/product_surface/ranking.rs b/crates/mobile_core/src/runtime/product_surface/ranking.rs @@ -0,0 +1,259 @@ +use core::cmp::Ordering; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use super::{CardId, ContextRank, TodayCardType}; + +pub const TODAY_RANK_SCHEMA_VERSION: u16 = 1; +pub const TODAY_RANK_ALGORITHM_VERSION: u16 = 1; +const RANK_DIGEST_DOMAIN: &[u8] = b"radroots.today-rank.v1\0"; +const UPCOMING_EVENT_WINDOW_SECONDS: u64 = 7 * 24 * 60 * 60; + +/// Exact time inputs used by the deliberately small Phase 1 ranking function. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TimeRelevance { + Published, + Event { start: u64, end: Option<u64> }, + FoodAvailability { active: bool }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TodayRankInput { + pub card_type: TodayCardType, + pub context_rank: ContextRank, + pub as_of: u64, + pub effective_at: u64, + pub time: TimeRelevance, + pub card_id: CardId, +} + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum RankError { + #[error("card type and time-relevance input do not match")] + MismatchedTimeProfile, + #[error("event end must be later than its start")] + InvalidEventRange, +} + +/// Lexicographic Today order key. +/// +/// Its [`Ord`] implementation sorts directly into feed order: higher context, +/// time relevance, and effective time first, then lower card ID. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TodayRank { + pub schema_version: u16, + pub algorithm_version: u16, + pub context_rank: ContextRank, + pub time_relevance_rank: u8, + pub effective_at: u64, + pub card_id: CardId, +} + +impl TodayRank { + pub fn derive(input: TodayRankInput) -> Result<Self, RankError> { + let time_relevance_rank = time_relevance_rank(input)?; + Ok(Self { + schema_version: TODAY_RANK_SCHEMA_VERSION, + algorithm_version: TODAY_RANK_ALGORITHM_VERSION, + context_rank: input.context_rank, + time_relevance_rank, + effective_at: input.effective_at, + card_id: input.card_id, + }) + } + + pub fn digest(self) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(RANK_DIGEST_DOMAIN); + digest.update(self.schema_version.to_be_bytes()); + digest.update(self.algorithm_version.to_be_bytes()); + digest.update([self.context_rank.value()]); + digest.update([self.time_relevance_rank]); + digest.update(self.effective_at.to_be_bytes()); + digest.update(self.card_id.as_bytes()); + digest.finalize().into() + } + + pub fn digest_hex(self) -> String { + hex::encode(self.digest()) + } +} + +impl Ord for TodayRank { + fn cmp(&self, other: &Self) -> Ordering { + other + .context_rank + .cmp(&self.context_rank) + .then_with(|| other.time_relevance_rank.cmp(&self.time_relevance_rank)) + .then_with(|| other.effective_at.cmp(&self.effective_at)) + .then_with(|| self.card_id.cmp(&other.card_id)) + } +} + +impl PartialOrd for TodayRank { + fn partial_cmp(&self, other: &Self) -> Option<Ordering> { + Some(self.cmp(other)) + } +} + +fn time_relevance_rank(input: TodayRankInput) -> Result<u8, RankError> { + match (input.card_type, input.time) { + ( + TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask, + TimeRelevance::Published, + ) => Ok(1), + (TodayCardType::FoodAvailability, TimeRelevance::FoodAvailability { active }) => { + Ok(if active { 3 } else { 0 }) + } + (TodayCardType::Event, TimeRelevance::Event { start, end }) => { + if end.is_some_and(|end| end <= start) { + return Err(RankError::InvalidEventRange); + } + if end.is_some_and(|end| input.as_of >= end) { + return Ok(0); + } + if input.as_of >= start { + return Ok(4); + } + if start.saturating_sub(input.as_of) <= UPCOMING_EVENT_WINDOW_SECONDS { + Ok(3) + } else { + Ok(2) + } + } + _ => Err(RankError::MismatchedTimeProfile), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(value: char) -> CardId { + CardId::parse(&value.to_string().repeat(64)).expect("card id") + } + + fn input(card_type: TodayCardType, time: TimeRelevance) -> TodayRankInput { + TodayRankInput { + card_type, + context_rank: ContextRank::LocalityMatch, + as_of: 2_000_000_000, + effective_at: 1_999_999_900, + time, + card_id: id('a'), + } + } + + #[test] + fn time_relevance_boundaries_are_exact() { + assert_eq!( + TodayRank::derive(input(TodayCardType::Update, TimeRelevance::Published)) + .expect("update") + .time_relevance_rank, + 1 + ); + assert_eq!( + TodayRank::derive(input( + TodayCardType::FoodAvailability, + TimeRelevance::FoodAvailability { active: true } + )) + .expect("food") + .time_relevance_rank, + 3 + ); + assert_eq!( + TodayRank::derive(input( + TodayCardType::FoodAvailability, + TimeRelevance::FoodAvailability { active: false } + )) + .expect("sold food") + .time_relevance_rank, + 0 + ); + for (start, end, expected) in [ + (1_999_999_900, Some(2_000_000_100), 4), + (1_999_999_900, None, 4), + (2_000_604_800, Some(2_000_604_900), 3), + (2_000_604_801, None, 2), + (1_999_999_000, Some(2_000_000_000), 0), + ] { + assert_eq!( + TodayRank::derive(input( + TodayCardType::Event, + TimeRelevance::Event { start, end } + )) + .expect("event") + .time_relevance_rank, + expected + ); + } + } + + #[test] + fn tuple_sorts_in_locked_feed_order_and_has_a_fixed_digest() { + let exact = TodayRank::derive(input(TodayCardType::Update, TimeRelevance::Published)) + .expect("rank"); + assert_eq!( + exact.digest_hex(), + "c7792876c8177f6f5420cc0f9aa84fb3c478f0bc6555c94ea5a7288502d6e4db" + ); + let fallback = TodayRank { + context_rank: ContextRank::MissingLocalityFallback, + time_relevance_rank: 4, + effective_at: exact.effective_at + 100, + card_id: id('e'), + ..exact + }; + let lower_time = TodayRank { + time_relevance_rank: 0, + effective_at: exact.effective_at + 200, + card_id: id('d'), + ..exact + }; + let older = TodayRank { + effective_at: exact.effective_at - 1, + card_id: id('c'), + ..exact + }; + let tie_high_id = TodayRank { + effective_at: exact.effective_at + 1, + card_id: id('b'), + ..exact + }; + let tie_low_id = TodayRank { + effective_at: exact.effective_at + 1, + card_id: id('a'), + ..exact + }; + let mut values = vec![fallback, lower_time, older, tie_high_id, tie_low_id]; + values.sort(); + assert_eq!( + values, + vec![tie_low_id, tie_high_id, older, lower_time, fallback] + ); + } + + #[test] + fn mismatched_and_invalid_time_inputs_fail_closed() { + assert_eq!( + TodayRank::derive(input( + TodayCardType::Update, + TimeRelevance::FoodAvailability { active: true } + )), + Err(RankError::MismatchedTimeProfile) + ); + assert_eq!( + TodayRank::derive(input( + TodayCardType::Event, + TimeRelevance::Event { + start: 10, + end: Some(10), + } + )), + Err(RankError::InvalidEventRange) + ); + } +} diff --git a/crates/mobile_core/tests/package_boundary.rs b/crates/mobile_core/tests/package_boundary.rs @@ -7,6 +7,12 @@ const INFO: &str = include_str!("../src/runtime/info.rs"); const KEY_MANAGEMENT: &str = include_str!("../src/runtime/key_management.rs"); const NOSTR: &str = include_str!("../src/runtime/nostr.rs"); const PRODUCT_SURFACE: &str = include_str!("../src/runtime/product_surface.rs"); +const PRODUCT_CONTEXT: &str = include_str!("../src/runtime/product_surface/context.rs"); +const PRODUCT_CURSOR: &str = include_str!("../src/runtime/product_surface/cursor.rs"); +const PRODUCT_IDENTITY: &str = include_str!("../src/runtime/product_surface/identity.rs"); +const PRODUCT_MODEL: &str = include_str!("../src/runtime/product_surface/model.rs"); +const PRODUCT_PROJECTION: &str = include_str!("../src/runtime/product_surface/projection.rs"); +const PRODUCT_RANKING: &str = include_str!("../src/runtime/product_surface/ranking.rs"); const SDK: &str = include_str!("../src/runtime/sdk.rs"); #[test] @@ -21,6 +27,15 @@ fn core_owns_no_uniffi_or_process_global_logging_policy() { ("src/runtime/key_management.rs", KEY_MANAGEMENT), ("src/runtime/nostr.rs", NOSTR), ("src/runtime/product_surface.rs", PRODUCT_SURFACE), + ("src/runtime/product_surface/context.rs", PRODUCT_CONTEXT), + ("src/runtime/product_surface/cursor.rs", PRODUCT_CURSOR), + ("src/runtime/product_surface/identity.rs", PRODUCT_IDENTITY), + ("src/runtime/product_surface/model.rs", PRODUCT_MODEL), + ( + "src/runtime/product_surface/projection.rs", + PRODUCT_PROJECTION, + ), + ("src/runtime/product_surface/ranking.rs", PRODUCT_RANKING), ("src/runtime/sdk.rs", SDK), ] { assert!( diff --git a/crates/mobile_ffi/src/remote.rs b/crates/mobile_ffi/src/remote.rs @@ -28,6 +28,7 @@ pub struct AppInfoPlatform { pub build_number: Option<String>, pub build_sha: Option<String>, } + #[uniffi::remote(Record)] pub struct RuntimeBuildInfo { pub crate_name: String, @@ -54,6 +55,7 @@ pub struct RuntimeInfo { pub sdk: RuntimeBuildInfo, pub sdk_closed: bool, } + #[uniffi::remote(Record)] pub struct SdkCapabilityRecord { pub id: String, @@ -76,6 +78,7 @@ pub struct SdkShutdownRecord { pub state: String, pub already_closed: bool, } + #[uniffi::remote(Record)] pub struct NostrIdentityRecord { pub id: String, @@ -99,6 +102,7 @@ pub struct NostrHostCustodyIdentity { pub public_key_hex: String, pub public_key_npub: String, } + #[uniffi::remote(Enum)] pub enum NostrLight { Red, @@ -149,516 +153,37 @@ pub struct NostrPostEventMetadata { pub published_at: u64, pub post: NostrPost, } -#[uniffi::remote(Enum)] -pub enum ContextType { - Regional, - Network, - Farm, - Buyer, - Route, - RoutePartner, - PickupPoint, - TraceRecords, - Hub, - NetworkSteward, -} - -#[uniffi::remote(Enum)] -pub enum WorkflowActor { - NetworkMember, - ProducerAdmin, - FarmTeamMember, - HubOperator, - TraceLead, - BuyerSourcingLead, - BuyerReceiver, - PickupPointCoordinator, - RouteCoordinator, - RoutePartner, - NetworkSteward, -} - -#[uniffi::remote(Enum)] -pub enum VisibilityClass { - LocalDraft, - FarmPrivate, - WorkspacePrivate, - NetworkVisible, - RouteScoped, - BuyerScoped, - PublicCommunity, - PublicProvenance, - SecretNeverShared, -} - -#[uniffi::remote(Enum)] -pub enum AuthorityDomain { - #[serde(rename = "Relay/group access")] - RelayGroupAccess, - #[serde(rename = "Farm/workspace operations authority")] - FarmWorkspaceOperations, - #[serde(rename = "Buyer workspace authority")] - BuyerWorkspace, - #[serde(rename = "Route coordination authority")] - RouteCoordination, - #[serde(rename = "Route execution authority")] - RouteExecution, - #[serde(rename = "Receipt authority")] - Receipt, - #[serde(rename = "Trace/proof authority")] - TraceProof, - #[serde(rename = "Public publishing authority")] - PublicPublishing, - #[serde(rename = "Network stewardship authority")] - NetworkStewardship, -} - -#[uniffi::remote(Enum)] -pub enum AuthorityAction { - Submit, - Publish, - Share, - Assign, - Approve, - Correct, - Close, - Retry, - Search, - NavigateRelatedObject, -} - -#[uniffi::remote(Enum)] -pub enum ObjectKind { - Region, - Network, - Farm, - BuyerWorkspace, - Route, - RoutePartner, - PickupPoint, - Hub, - Food, - Ask, - Event, - Place, - Task, - Proof, - Exception, - Provenance, - Update, - AccessMembership, - BuyerPacket, - RouteStop, - Draft, - OutboxItem, - MemberInvite, - Correction, -} - -#[uniffi::remote(Enum)] -pub enum ObjectPageFamily { - Network, - NetworkRoute, - FarmWorkspace, - FarmPublicProfile, - BuyerWorkspace, - PickupPointPlace, - Food, - Event, - RouteStop, - Proof, - BuyerPacket, - PublicProvenance, - Exception, -} #[uniffi::remote(Enum)] pub enum TodayCardType { - Route, - Food, - Ask, - Event, - Place, - Task, - Proof, - Exception, - Provenance, Update, - AccessMembership, - SyncOutbox, -} - -#[uniffi::remote(Enum)] -pub enum AddActionType { - Photo, - Note, + PhotoUpdate, Ask, - Scan, - Food, - Harvest, - BuyerRequest, - BuyerCommitment, - RouteNeed, - RouteStop, - PickupEvent, - Place, - Proof, - Exception, - PublicUpdate, - Provenance, - MemberInvite, - Correction, -} - -#[uniffi::remote(Enum)] -pub enum AddFlowState { - NotStarted, - Draft, - Editing, - ValidationFailed, - ReadyToSubmit, - Submitted, - Queued, - Syncing, - NeedsApproval, - Approved, - Published, - Shared, - Confirmed, - Failed, - Conflict, - Discarded, -} - -#[uniffi::remote(Enum)] -pub enum OutboxBehavior { - LocalOnly, - QueueWhenOffline, - RequireOnline, - PublishWhenAuthorized, - ShareWhenAuthorized, -} - -#[uniffi::remote(Enum)] -pub enum PrototypePathKind { - ProducerFoodToRoute, - BuyerCommitmentToRoute, - RouteCoordinatorAssignment, -} - -#[uniffi::remote(Enum)] -pub enum RouteExecutionFlowKind { - RoutePartnerAssignedStops, - BuyerReceiptConfirmation, - ExceptionRecovery, -} - -#[uniffi::remote(Enum)] -pub enum RouteExecutionStepKind { - AssignedRoute, - PickupConfirmation, - DropoffConfirmation, - ReceiptConfirmation, - ExceptionReport, - RecoveryAction, -} - -#[uniffi::remote(Enum)] -pub enum ProofProvenanceArtifactKind { - ProofCompleteness, - BuyerPacketDraft, - BuyerPacketShared, - PublicProvenancePreview, -} - -#[uniffi::remote(Enum)] -pub enum ProofProvenanceReviewState { - MissingProof, - Complete, - Draft, - ReadyToShare, - Shared, - RedactionRequired, - ReadyToPublish, - Published, -} - -#[uniffi::remote(Enum)] -pub enum StewardshipAccessItemKind { - AccessRequestReview, - RoleApproval, - RoutePartnerInvite, - RoutePoolMetadata, - PublicModeration, - InviteAcceptance, - RequestAccess, - AccessDenied, - GroupManagementDeferred, -} - -#[uniffi::remote(Enum)] -pub enum OutboxState { - NotQueued, - Draft, - Queued, - Syncing, - AwaitingAuthority, - Published, - Shared, - Failed, - Conflict, - Discarded, + Event, + FoodAvailability, } #[uniffi::remote(Enum)] -pub enum SyncState { - Unknown, - Online, - Offline, - Syncing, - Synced, - Stale, - Failed, -} - -#[uniffi::remote(Record)] -pub struct ObjectRef { - pub object_type: ObjectKind, - pub object_id: String, - pub display_label: String, -} - -#[uniffi::remote(Record)] -pub struct EventRef { - pub event_id: String, - pub relay_url: Option<String>, - pub kind: Option<u32>, -} - -#[uniffi::remote(Record)] -pub struct ActiveContext { - pub context_type: ContextType, - pub context_ref: ObjectRef, - pub actor: WorkflowActor, - pub display_label: String, - pub visibility_scope: VisibilityClass, -} - -#[uniffi::remote(Record)] -pub struct AuthorityGate { - pub domain: AuthorityDomain, - pub action: AuthorityAction, - pub actor: WorkflowActor, - pub context: ActiveContext, - pub is_required: bool, - pub is_allowed: bool, - pub reason: Option<String>, -} - -#[uniffi::remote(Record)] -pub struct ObjectPageSummary { - pub object_ref: ObjectRef, - pub family: ObjectPageFamily, - pub primary_context: ActiveContext, - pub title: String, - pub subtitle: Option<String>, - pub visibility: VisibilityClass, - pub visibility_label: String, - pub required_authority: AuthorityGate, - pub sync_state: SyncState, -} - -#[uniffi::remote(Record)] -pub struct TodayCardAction { - pub id: String, - pub label: String, - pub action_type: Option<AddActionType>, - pub target_object: Option<ObjectRef>, +pub enum AddCommandType { + CreateUpdate, + CreatePhotoUpdate, + CreateAsk, + CreateEvent, + CreateFoodAvailability, } #[uniffi::remote(Record)] -pub struct TodayCard { - pub id: String, +pub struct CardAddParity { pub card_type: TodayCardType, - pub source_object_refs: Vec<ObjectRef>, - pub source_event_refs: Vec<EventRef>, - pub primary_context: ActiveContext, - pub actor: WorkflowActor, - pub visibility: VisibilityClass, - pub visibility_label: String, - pub title: String, - pub status_line: String, - pub detail_lines: Vec<String>, - pub pills: Vec<String>, - pub primary_action: TodayCardAction, - pub secondary_action: Option<TodayCardAction>, - pub ranking_reason: String, - pub ranking_features: Vec<String>, - pub sync_state: SyncState, - pub outbox_state: OutboxState, - pub is_stale: bool, - pub is_offline: bool, -} - -#[uniffi::remote(Record)] -pub struct RelatedObjectRequirement { - pub object_type: ObjectKind, - pub relationship_label: String, - pub is_required: bool, + pub add_command_type: AddCommandType, } #[uniffi::remote(Record)] -pub struct ValidationRequirement { +pub struct LocalNetwork { pub id: String, pub label: String, - pub is_blocking: bool, -} - -#[uniffi::remote(Record)] -pub struct AddAction { - pub action_type: AddActionType, - pub display_label: String, - pub allowed_context_types: Vec<ContextType>, - pub required_authority: AuthorityGate, - pub default_visibility: VisibilityClass, - pub allowed_visibility_options: Vec<VisibilityClass>, - pub created_or_updated_object_type: ObjectKind, - pub related_object_requirements: Vec<RelatedObjectRequirement>, - pub validation_requirements: Vec<ValidationRequirement>, - pub supports_offline: bool, - pub supports_draft: bool, - pub outbox_behavior: OutboxBehavior, - pub primary_submit_label: String, - pub completion_state: AddFlowState, -} - -#[uniffi::remote(Record)] -pub struct OutboxItem { - pub id: String, - pub action_type: AddActionType, - pub context: ActiveContext, - pub object_refs: Vec<ObjectRef>, - pub event_refs: Vec<EventRef>, - pub visibility: VisibilityClass, - pub authority_gate: AuthorityGate, - pub flow_state: AddFlowState, - pub outbox_state: OutboxState, - pub sync_state: SyncState, - pub queued_at_unix: Option<u64>, - pub last_attempt_at_unix: Option<u64>, - pub retry_count: u32, - pub last_error: Option<String>, -} - -#[uniffi::remote(Record)] -pub struct OutboxRetryDecision { - pub item_id: String, - pub is_retryable: bool, - pub authority_gate: AuthorityGate, - pub reason: Option<String>, -} - -#[uniffi::remote(Record)] -pub struct SearchResultSummary { - pub id: String, - pub object_ref: ObjectRef, - pub primary_context: ActiveContext, - pub title: String, - pub subtitle: Option<String>, - pub visibility: VisibilityClass, - pub visibility_label: String, - pub required_authority: AuthorityGate, - pub sync_state: SyncState, -} - -#[uniffi::remote(Record)] -pub struct PrototypePathStep { - pub id: String, - pub label: String, - pub context: ActiveContext, - pub action_type: Option<AddActionType>, - pub object_ref: Option<ObjectRef>, - pub authority_gate: AuthorityGate, - pub visibility: VisibilityClass, - pub outbox_state: OutboxState, - pub sync_state: SyncState, -} - -#[uniffi::remote(Record)] -pub struct PrototypePath { - pub id: String, - pub kind: PrototypePathKind, - pub title: String, - pub actor: WorkflowActor, - pub context: ActiveContext, - pub steps: Vec<PrototypePathStep>, -} - -#[uniffi::remote(Record)] -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>, -} - -#[uniffi::remote(Record)] -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>, -} - -#[uniffi::remote(Record)] -pub struct ProofProvenanceArtifact { - pub id: String, - pub kind: ProofProvenanceArtifactKind, - pub review_state: ProofProvenanceReviewState, - pub title: String, - pub actor: WorkflowActor, - pub context: ActiveContext, - pub object_ref: ObjectRef, - pub source_object_refs: Vec<ObjectRef>, - pub required_authority: AuthorityGate, - pub visibility: VisibilityClass, - pub is_public_preview: bool, - pub requires_redaction_review: bool, - pub can_publish: bool, - pub public_summary_lines: Vec<String>, - pub redacted_field_labels: Vec<String>, - pub outbox_state: OutboxState, - pub sync_state: SyncState, -} - -#[uniffi::remote(Record)] -pub struct StewardshipAccessItem { - pub id: String, - pub kind: StewardshipAccessItemKind, - pub title: String, - pub actor: WorkflowActor, - pub context: ActiveContext, - pub target_ref: ObjectRef, - pub required_authority: AuthorityGate, - pub visibility: VisibilityClass, - pub is_admin_lite: bool, - pub is_phase_2_deferred: bool, - pub grants_private_access: bool, - pub outbox_state: OutboxState, - pub sync_state: SyncState, - pub detail_lines: Vec<String>, + pub relay_urls: Vec<String>, + pub locality: Option<String>, + pub followed_authors: Vec<String>, + pub generation: u64, } diff --git a/crates/mobile_ffi/src/runtime.rs b/crates/mobile_ffi/src/runtime.rs @@ -2,11 +2,7 @@ use radroots_mobile_core::runtime::{ info::RuntimeInfo, key_management::{NostrHostCustodyIdentity, NostrIdentityRecord, NostrIdentitySnapshot}, nostr::{NostrConnectionStatus, NostrPostEventMetadata, NostrProfileEventMetadata}, - product_surface::{ - ActiveContext, AddAction, AuthorityAction, AuthorityDomain, AuthorityGate, - ObjectPageSummary, OutboxItem, OutboxRetryDecision, ProofProvenanceArtifact, PrototypePath, - RouteExecutionFlow, SearchResultSummary, StewardshipAccessItem, TodayCard, WorkflowActor, - }, + product_surface::{AddCommandType, CardAddParity, LocalNetwork, TodayCardType}, sdk::{SdkCapabilityRecord, SdkShutdownRecord, SdkStorageStatusRecord}, }; @@ -204,74 +200,36 @@ impl RadrootsRuntime { .map_err(Into::into) } - pub fn phase1_active_contexts(&self) -> Vec<ActiveContext> { - self.inner.phase1_active_contexts() + pub fn phase1_card_types(&self) -> Vec<TodayCardType> { + self.inner.phase1_card_types() } - pub fn phase1_today_cards(&self, context_id: Option<String>) -> Vec<TodayCard> { - self.inner.phase1_today_cards(context_id) + pub fn phase1_add_command_types(&self) -> Vec<AddCommandType> { + self.inner.phase1_add_command_types() } - pub fn phase1_add_actions(&self, context_id: Option<String>) -> Vec<AddAction> { - self.inner.phase1_add_actions(context_id) + pub fn phase1_card_add_parity(&self) -> Vec<CardAddParity> { + self.inner.phase1_card_add_parity() } - pub fn phase1_object_page_summaries( + pub fn phase1_local_network( &self, - context_id: Option<String>, - ) -> Vec<ObjectPageSummary> { - self.inner.phase1_object_page_summaries(context_id) - } - - pub fn phase1_outbox_snapshot(&self) -> Vec<OutboxItem> { - self.inner.phase1_outbox_snapshot() - } - - pub fn phase1_search_results( - &self, - query: Option<String>, - context_id: Option<String>, - ) -> Vec<SearchResultSummary> { - self.inner.phase1_search_results(query, context_id) - } - - pub fn phase1_prototype_paths(&self) -> Vec<PrototypePath> { - self.inner.phase1_prototype_paths() - } - - pub fn phase1_route_execution_flows( - &self, - context_id: Option<String>, - ) -> Vec<RouteExecutionFlow> { - self.inner.phase1_route_execution_flows(context_id) - } - - pub fn phase1_proof_provenance_artifacts( - &self, - context_id: Option<String>, - ) -> Vec<ProofProvenanceArtifact> { - self.inner.phase1_proof_provenance_artifacts(context_id) - } - - pub fn phase1_stewardship_access_items( - &self, - context_id: Option<String>, - ) -> Vec<StewardshipAccessItem> { - self.inner.phase1_stewardship_access_items(context_id) - } - - pub fn phase1_outbox_retry_decision(&self, item: OutboxItem) -> OutboxRetryDecision { - self.inner.phase1_outbox_retry_decision(item) - } - - pub fn phase1_check_authority( - &self, - actor: WorkflowActor, - context: ActiveContext, - domain: AuthorityDomain, - action: AuthorityAction, - ) -> AuthorityGate { - self.inner - .phase1_check_authority(actor, context, domain, action) + id: String, + label: String, + relay_urls: Vec<String>, + locality: Option<String>, + followed_authors: Vec<String>, + generation: u64, + ) -> Result<LocalNetwork, RadrootsAppError> { + self.inner + .phase1_local_network( + id, + label, + relay_urls, + locality, + followed_authors, + generation, + ) + .map_err(Into::into) } } diff --git a/crates/mobile_ffi/tests/runtime_delegation.rs b/crates/mobile_ffi/tests/runtime_delegation.rs @@ -1,6 +1,4 @@ -use radroots_mobile_core::runtime::product_surface::{ - AuthorityAction, AuthorityDomain, OutboxState, -}; +use radroots_mobile_core::runtime::product_surface::{AddCommandType, TodayCardType}; use radroots_mobile_ffi::{RadrootsAppError, RadrootsRuntime}; const SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001"; @@ -120,53 +118,57 @@ async fn native_boundary_delegates_the_complete_core_surface() { Err(RadrootsAppError::Unsupported(_)) )); - let contexts = runtime.phase1_active_contexts(); - let context = contexts.first().expect("context").clone(); - let context_id = Some(context.context_ref.object_id.clone()); - assert!(!runtime.phase1_today_cards(context_id.clone()).is_empty()); - assert!(!runtime.phase1_add_actions(context_id.clone()).is_empty()); - assert!( - !runtime - .phase1_object_page_summaries(context_id.clone()) - .is_empty() - ); - let outbox = runtime.phase1_outbox_snapshot(); - let failed = outbox - .into_iter() - .find(|item| item.outbox_state == OutboxState::Failed) - .expect("failed outbox item"); - assert!(runtime.phase1_outbox_retry_decision(failed).is_retryable); - assert!( - !runtime - .phase1_search_results(Some("farm".to_owned()), context_id.clone()) - .is_empty() - ); - assert!(!runtime.phase1_prototype_paths().is_empty()); - assert!( - !runtime - .phase1_route_execution_flows(context_id.clone()) - .is_empty() - ); - assert!( - !runtime - .phase1_proof_provenance_artifacts(context_id.clone()) - .is_empty() - ); - assert!( - !runtime - .phase1_stewardship_access_items(context_id) - .is_empty() + assert_eq!( + runtime.phase1_card_types(), + vec![ + TodayCardType::Update, + TodayCardType::PhotoUpdate, + TodayCardType::Ask, + TodayCardType::Event, + TodayCardType::FoodAvailability, + ] ); assert_eq!( + runtime.phase1_add_command_types(), + vec![ + AddCommandType::CreateUpdate, + AddCommandType::CreatePhotoUpdate, + AddCommandType::CreateAsk, + AddCommandType::CreateEvent, + AddCommandType::CreateFoodAvailability, + ] + ); + let parity = runtime.phase1_card_add_parity(); + assert_eq!(parity.len(), 5); + for (index, item) in parity.iter().enumerate() { + assert_eq!(item.card_type, runtime.phase1_card_types()[index]); + assert_eq!( + item.add_command_type, + runtime.phase1_add_command_types()[index] + ); + } + let local_network = runtime + .phase1_local_network( + "nearby".to_owned(), + "Near me".to_owned(), + vec!["wss://relay.example".to_owned()], + Some("u10h".to_owned()), + vec!["a".repeat(64)], + 1, + ) + .expect("valid local network"); + assert_eq!(local_network.id, "nearby"); + assert!( runtime - .phase1_check_authority( - context.actor, - context.clone(), - AuthorityDomain::RelayGroupAccess, - AuthorityAction::Search, + .phase1_local_network( + "nearby".to_owned(), + "Near me".to_owned(), + Vec::new(), + None, + Vec::new(), + 1, ) - .context, - context + .is_err() ); runtime.shutdown().await.expect("shutdown");