Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## 1.2.0

### 2026-09-03
- **Feature: provider-native hosted routing modes (#538)**
- Requires an explicit backend-provided mode choice for every fresh online route, independently of manual Transport Profiles
- Preserves accepted Segment geometry and retained-route priority, with explicit network-free Direct routing
- Fences capability, requests, publication, and new retained provenance to the selected mode and provider authority
- Treats older servers without additive mode discovery as bounded fresh-route unavailability without inventing modes

### 2026-08-31
- **Feature: bounded offline Wayfarer routing (#261)**
- Retains only fully validated routes whose backend storage authority is exactly `persistent`
Expand Down
4 changes: 4 additions & 0 deletions docs/07-Troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,10 @@ marked `persistent`. Old routes do not expire automatically, but a logout, serve
configuration change makes another partition or authority ineligible. With an exact match, choose retained guidance,
an explicit one-time Wayfarer refresh, or Direct. A failed, cancelled, or invalid refresh preserves the prior complete
retained route as active guidance. If no eligible row exists, Direct remains the explicit fallback.
Fresh online routing requires an explicit choice from the active provider's modes. These choices are independent of
the Segment's manual-planning Transport Profile. An older server that does not return the additive mode catalog cannot
provide a fresh route to this Mobile version, but saved geometry and retained routes remain available without discovery,
and Direct remains an explicit network-free choice.

### Off-Route Constantly

Expand Down
19 changes: 12 additions & 7 deletions docs/12-Services.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,16 +554,19 @@ public NavigationRoute? CalculateRouteToPlace(
```

Hosted routes are authenticated, provider-neutral results. Provider credentials and provider selection remain on
Wayfarer. The active route retains linked attribution plus safe provenance: selected transport
profile and authority identities, provider and provider-configuration identities, mapping identity, storage mode, and
Wayfarer. Every fresh online route first presents exactly the active provider's discovered native modes, with no
preselection or inference from the Segment's independent manual-planning Transport Profile. Choosing Direct cancels
the online path without capability or route contact. The active route retains linked attribution plus safe provenance:
selected transport profile, provider mode, and authority identities, provider and provider-configuration identities, mapping identity, storage mode, and
the normalized backend generation timestamp. It contains no bearer token, credentials, or provider endpoint and
clears through normal replacement or stop. Old servers, disabled routing, rejected requests, cancellation,
malformed/stale responses, and provider
unavailability remain routing-local and retain Direct guidance without affecting authentication or synchronization.
Valid saved Segment geometry is never replaced automatically. Only a completely validated response whose exact backend
storage authority is `persistent` can be retained. Transient and unknown modes remain active-route-only.

`RetainedWayfarerRouteRepository` owns the schema-10 route table and a single narrow mutation gate. Lookup plus a
`RetainedWayfarerRouteRepository` owns the schema-11 route table and a single narrow mutation gate. Schema 11 adds
nullable provider-mode provenance without rewriting or invalidating older retained routes. Lookup plus a
successful recency update, complete insert/replacement, cap eviction, and explicit clear are transactional. Immediately
before a write transaction it revalidates #260's live generation/current-state authority; stale work performs no write.
Storage/eviction failure rolls back the complete replacement, so a displayed fresh route can succeed while the prior
Expand All @@ -585,10 +588,12 @@ offers **Use retained route**, **Refresh with Wayfarer**, and **Direct**. Refres
interaction while keeping retained guidance active; failure preserves it, success may replace it atomically, and the
choice is not persisted.

Chooser entries are scoped to the exact discovery catalog displayed. Mobile submits that catalog identity with the
chosen profile; a `catalog-changed` capability response makes no route request, rediscovers once, and requires a
fresh choice from the refreshed labels or retains Direct when dismissed. Once capability succeeds, unrelated later
catalog changes do not invalidate the confirmed route.
Chooser entries are scoped to the exact discovery catalog displayed. Mobile submits that catalog identity, the exact
chosen provider-mode key, and the Segment's unchanged planning `TransportProfileId`; a `catalog-changed` capability
response makes no route request, rediscovers once, and requires a fresh explicit choice from the refreshed labels.
It never substitutes another mode. A server without the additive mode catalog produces bounded fresh-route
unavailability while saved geometry, retained routes, and Direct remain usable. Once capability succeeds, the echoed
mode and selected-provider authority fence the request, candidate, publication, and retained provenance.

`TransportProfileId` is the Segment's current planning profile identity. Current hosted selection state remains
separate from the immutable provenance retained on a successfully published route; neither rewrites the Segment nor
Expand Down
12 changes: 9 additions & 3 deletions src/WayfarerMobile.Core/Interfaces/ITripNavigationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ public interface ITripNavigationService
/// <param name="currentLon">Current longitude.</param>
/// <param name="destinationPlaceId">Destination place ID.</param>
/// <returns>The calculated route or null if no route found.</returns>
NavigationRoute? CalculateRouteToPlace(double currentLat, double currentLon, string destinationPlaceId);
NavigationRoute? CalculateRouteToPlace(double currentLat, double currentLon, string destinationPlaceId,
bool activate = true);

/// <summary>
/// Calculates a route to a specific place using saved Segment geometry or Direct guidance.
Expand Down Expand Up @@ -95,20 +96,25 @@ public interface ITripNavigationService
/// <param name="destLon">Destination longitude.</param>
/// <param name="destName">Destination name for display.</param>
/// <param name="profile">Routing profile (foot, car, bike). Default is foot.</param>
/// <param name="activate">Whether to replace the active navigation route.</param>
/// <returns>The Direct route.</returns>
Task<NavigationRoute> CalculateRouteToCoordinatesAsync(
double currentLat, double currentLon,
double destLat, double destLon,
string destName,
string profile = "foot");
string profile = "foot",
bool activate = true);

/// <summary>Installs a route selected by a coordinator.</summary>
void ActivateRoute(NavigationRoute route);

/// <summary>
/// Calculates a route to the next place in sequence.
/// </summary>
/// <param name="currentLat">Current latitude.</param>
/// <param name="currentLon">Current longitude.</param>
/// <returns>The calculated route or null if no next place.</returns>
NavigationRoute? CalculateRouteToNextPlace(double currentLat, double currentLon);
NavigationRoute? CalculateRouteToNextPlace(double currentLat, double currentLon, bool activate = true);

/// <summary>
/// Updates navigation state with current location.
Expand Down
3 changes: 2 additions & 1 deletion src/WayfarerMobile.Core/Models/NavigationRoute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ public sealed record HostedRouteProvenance(
Guid ProviderConfigurationId,
string MappingIdentity,
string StorageMode,
DateTimeOffset GeneratedAt)
DateTimeOffset GeneratedAt,
string? ProviderMode = null)
{
/// <summary>Whether this route was selected from bounded local retained storage.</summary>
public bool IsRetained { get; init; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public sealed class RetainedWayfarerRouteEntity
[Indexed]
public string TransportProfileId { get; set; } = string.Empty;
public string SelectedProfileAuthorityIdentity { get; set; } = string.Empty;
public string? ProviderMode { get; set; }
public string ModeKey { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public int OriginLongitude { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ private static bool TryPrepare(HostedRouteCandidate candidate, Guid partition,
|| candidate.SelectedProfileId == Guid.Empty
|| candidate.Metadata.ProviderConfigurationId == Guid.Empty
|| !HostedOpaqueIdentity.IsValid(candidate.SelectedProfileAuthorityIdentity)
|| !Bounded(candidate.SelectedProviderMode, 100)
|| !Bounded(candidate.Metadata.Provider, 100) || !Bounded(candidate.Metadata.MappingIdentity, 200)
|| !Bounded(candidate.Context.ModeKey, 100) || !Bounded(candidate.Context.Category, 100)
|| !Bounded(candidate.Context.NormalizedServer, MaximumServerLength)
Expand All @@ -272,6 +273,7 @@ private static bool TryPrepare(HostedRouteCandidate candidate, Guid partition,
MappingIdentity = candidate.Metadata.MappingIdentity,
TransportProfileId = candidate.SelectedProfileId.ToString("D"),
SelectedProfileAuthorityIdentity = candidate.SelectedProfileAuthorityIdentity,
ProviderMode = candidate.SelectedProviderMode,
ModeKey = candidate.Context.ModeKey!,
Category = candidate.Context.Category!,
OriginLongitude = canonical[0], OriginLatitude = canonical[1],
Expand Down Expand Up @@ -364,7 +366,7 @@ private static bool TryBuildRoute(RetainedWayfarerRouteEntity row, string destin
EstimatedDuration = TimeSpan.FromSeconds(row.DurationSeconds), IsDirectRoute = false,
Attribution = attribution.ToList(),
HostedProvenance = new(profileId, row.SelectedProfileAuthorityIdentity, row.Provider,
configurationId, row.MappingIdentity, row.StorageAuthority, generated)
configurationId, row.MappingIdentity, row.StorageAuthority, generated, row.ProviderMode)
{ IsRetained = true, Age = age }
};
return true;
Expand All @@ -388,6 +390,7 @@ private static bool TryValidateInstalledRow(RetainedWayfarerRouteEntity row,
|| !Bounded(row.Provider, 100) || !CanonicalGuid(row.ProviderConfigurationId, out configurationId)
|| !Bounded(row.MappingIdentity, 200) || !CanonicalGuid(row.TransportProfileId, out profileId)
|| !HostedOpaqueIdentity.IsValid(row.SelectedProfileAuthorityIdentity)
|| row.ProviderMode is not null && !Bounded(row.ProviderMode, 100)
|| !Bounded(row.ModeKey, 100) || !Bounded(row.Category, 100)
|| row.StorageAuthority != "persistent" || !row.IsCurrentAuthority
|| !ValidCanonicalCoordinate(row.OriginLongitude, row.OriginLatitude)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ namespace WayfarerMobile.Data.Services;

public static class RetainedWayfarerRouteMigration
{
public const int SchemaVersion = 10;
public const int SchemaVersion = 11;

public static async Task ApplyApplicationUpgradeAsync(SQLiteAsyncConnection connection,
int installedVersion, Func<int, Task> recordSchemaVersion,
CancellationToken cancellationToken)
{
if (installedVersion >= SchemaVersion) return;
await ApplyAsync(connection, cancellationToken);
if (installedVersion >= 10)
await EnsureProviderModeColumnAsync(connection, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
await recordSchemaVersion(SchemaVersion);
}
Expand All @@ -31,4 +33,13 @@ await connection.ExecuteAsync(@"
CREATE INDEX IF NOT EXISTS IX_RetainedWayfarerRoutes_Eviction
ON RetainedWayfarerRoutes (LastUsedAtUnixMilliseconds, StoredAtUnixMilliseconds, Id)");
}

private static async Task EnsureProviderModeColumnAsync(SQLiteAsyncConnection connection,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var columns = await connection.GetTableInfoAsync("RetainedWayfarerRoutes");
if (columns.All(column => column.Name != nameof(RetainedWayfarerRouteEntity.ProviderMode)))
await connection.ExecuteAsync("ALTER TABLE RetainedWayfarerRoutes ADD COLUMN ProviderMode TEXT NULL");
}
}
13 changes: 7 additions & 6 deletions src/WayfarerMobile/Services/HostedRoutingApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ public async Task<HostedRoutingCatalog> DiscoverAsync(CancellationToken cancella
?? new(null, "invalid-response", []);
}

public async Task<HostedRoutingCapability> GetCapabilityAsync(Guid profileId,
public async Task<HostedRoutingCapability> GetCapabilityAsync(Guid profileId, string providerMode,
string discoveryCatalogIdentity, CancellationToken cancellationToken)
{
var endpoint = $"/api/mobile/routing/capability/{profileId:D}?discoveryCatalogIdentity={Uri.EscapeDataString(discoveryCatalogIdentity)}";
var endpoint = $"/api/mobile/routing/capability/{profileId:D}?discoveryCatalogIdentity={Uri.EscapeDataString(discoveryCatalogIdentity)}"
+ $"&providerMode={Uri.EscapeDataString(providerMode)}";
using var response = await SendAsync(HttpMethod.Get, endpoint, null, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return new("unavailable", profileId, null, null, null, null, null, null, null);
Expand Down Expand Up @@ -89,24 +90,24 @@ private static HostedRouteResponse Failure(string outcome) =>
private sealed record CapabilityDto(string Outcome, Guid TransportProfileId, string? Provider,
Guid? ProviderConfigurationId, string? MappingIdentity, string? StorageMode,
IReadOnlyList<HostedRouteAttribution>? Attribution, string? DiscoveryCatalogIdentity,
string? SelectedProfileAuthorityIdentity)
string? SelectedProfileAuthorityIdentity, string? ProviderMode)
{
public HostedRoutingCapability ToModel() => new(Outcome, TransportProfileId, Provider,
ProviderConfigurationId, MappingIdentity, StorageMode, Attribution, DiscoveryCatalogIdentity,
SelectedProfileAuthorityIdentity);
SelectedProfileAuthorityIdentity, ProviderMode);
}

private sealed record RouteResponseDto(bool Succeeded, string Outcome, IReadOnlyList<HostedRouteCoordinate>? Geometry,
double? DistanceMetres, double? DurationSeconds, IReadOnlyList<HostedRouteInstruction>? Instructions,
string? GeneratedAt, string? Provider, Guid? ProviderConfigurationId, string? MappingIdentity,
Guid? TransportProfileId, IReadOnlyList<HostedRouteCoordinate>? MatchPoints,
IReadOnlyList<HostedRouteAttribution>? Attribution, string? StorageMode,
string? SelectedProfileAuthorityIdentity)
string? SelectedProfileAuthorityIdentity, string? ProviderMode)
{
public HostedRouteResponse ToModel() => new(Succeeded, Outcome, Geometry, DistanceMetres, DurationSeconds,
Instructions, ParseGeneratedAt(GeneratedAt), Provider, ProviderConfigurationId, MappingIdentity,
TransportProfileId, MatchPoints, Attribution, StorageMode,
SelectedProfileAuthorityIdentity);
SelectedProfileAuthorityIdentity, ProviderMode);

private static DateTimeOffset? ParseGeneratedAt(string? value)
{
Expand Down
Loading
Loading