{"schemaVersion":"mappls.journey-workshop.v1","slug":"ios-direction-planning-handoff","journeySlug":"ios-direction-planning-handoff","title":"Build iOS direction planning and navigation handoff","summary":"An eight-lab, source-bounded workshop for the complete route planning session lifecycle: exact commands and events, durable records, replay, concurrency, unknown outcomes, hostile fixtures, a maintained capstone, and production exit evidence.","duration":"2 hr 10 min","level":"Advanced","productSlug":"routes-navigation","stateModel":"hybrid","aggregate":"route planning session","actorCount":4,"stateCount":8,"transitionCount":8,"eventCount":8,"sourceGuideSlugs":["mappls-direction-ui-ios-distribution","mappls-direction-ui-ios-distribution-base"],"contractSlugs":[],"relatedTutorialSlugs":["ios-direction-geofence-handoffs"],"sample":{"slug":"deep-link-journey-host","name":"Deep-link & Native UI Journey Host","downloadPath":"/downloads/deep-link-journey-host.zip","checksumPath":"/downloads/deep-link-journey-host.zip.sha256","verifiedTestCount":11,"runCommand":"npm test --workspace @mappls-example/deep-link-journey-host"},"labs":[{"slug":"model-lifecycle","title":"Model the lifecycle before the UI","duration":"15 min","objective":"Turn the route planning session blueprint into an explicit aggregate boundary owned by the application.","build":["draft: The host owns one route intent, traveler context, and revision before presenting provider UI.","editing: One presented controller generation owns source, destination, via points, options, delegates, dismissal, and accessibility focus.","calculating: The provider surface is resolving route alternatives for the current immutable location and option revision.","candidates_ready: One or more provider route objects are visible for comparison but remain controller-scoped candidates.","selected: The traveler selected an in-range route index and the adapter copied a bounded route handoff value plus the exact location revision.","handoff_pending: The documented start-navigation callback requested a host-owned navigation action, but no target navigator acknowledgement exists yet.","handed_off: The configured navigation adapter accepted the normalized route request and returned its own attributable session identity or acknowledgement.","cancelled: The traveler dismissed or backed out and the host recorded one terminal cancellation without a route handoff."],"prove":["Every persisted state exists in the reviewed blueprint.","Terminal states reject ordinary forward commands.","Recovery text is operational guidance, not another hidden state."]},{"slug":"command-event-contract","title":"Implement every command and event pair","duration":"20 min","objective":"Make intent, actor authority, allowed source state, committed state, and emitted fact reviewable together.","build":["create_plan by Host application: new aggregate → draft; emit route_plan.created.","open_direction_ui by Traveler: draft | selected → editing; emit route_plan.editor_opened.","request_routes by Traveler: editing → calculating; emit route_plan.calculation_requested.","receive_routes by MapplsDirectionUI: calculating → candidates_ready; emit route_plan.candidates_received.","select_route by Traveler: candidates_ready → selected; emit route_plan.route_selected.","request_navigation by Traveler: selected | candidates_ready → handoff_pending; emit route_plan.navigation_requested.","confirm_handoff by Navigation adapter: handoff_pending → handed_off; emit route_plan.navigation_handed_off.","cancel_plan by Traveler: draft | editing | calculating | candidates_ready | selected | handoff_pending → cancelled; emit route_plan.cancelled."],"prove":["create_plan resolves to ios-direction-planning-handoff-route-plan-created without claiming a provider webhook payload.","open_direction_ui resolves to ios-direction-planning-handoff-route-plan-editor-opened without claiming a provider webhook payload.","request_routes resolves to ios-direction-planning-handoff-route-plan-calculation-requested without claiming a provider webhook payload.","receive_routes resolves to ios-direction-planning-handoff-route-plan-candidates-received without claiming a provider webhook payload.","select_route resolves to ios-direction-planning-handoff-route-plan-route-selected without claiming a provider webhook payload.","request_navigation resolves to ios-direction-planning-handoff-route-plan-navigation-requested without claiming a provider webhook payload.","confirm_handoff resolves to ios-direction-planning-handoff-route-plan-navigation-handed-off without claiming a provider webhook payload.","cancel_plan resolves to ios-direction-planning-handoff-route-plan-cancelled without claiming a provider webhook payload."]},{"slug":"durable-records","title":"Persist restart-safe records","duration":"15 min","objective":"Separate business identity, provider evidence, command receipts, immutable facts, audit, and downstream delivery.","build":["Route plan: Host-owned normalized stops, options, revision, lifecycle state, and optimistic version. Keys: planId, externalId, state, routeRevision, version, owner.","Route handoff candidate: Bounded portable value copied from the active route selection without retaining provider UI objects. Keys: candidateId, routeRevision, selectedIndex, locationDigest, optionDigest, createdAt.","Handoff attempt: Immutable request and target acknowledgement separating planning from navigation runtime. Keys: attemptId, candidateId, target, status, targetSessionRef, requestedAt.","Audit and outbox: Attributable transitions and exactly-once-in-effect downstream notification. Keys: eventId, aggregateVersion, actor, idempotencyKey, outboxStatus."],"prove":["Process restart restores the same aggregate version and command result.","Opaque SDK or native UI objects are not durable records.","Provider evidence and application decisions remain distinguishable."]},{"slug":"concurrency-replay","title":"Make concurrency and replay deterministic","duration":"15 min","objective":"Apply optimistic expected versions and aggregate-scoped idempotency before executing effects.","build":["Stops, options, route candidates, and selection share one explicit revision.","A selected index is validated before dereferencing its Route candidate.","The provider controller and opaque Route objects never become durable application records.","The start-navigation callback expresses intent, not proof that navigation started or completed.","One presentation generation produces at most one terminal handoff or cancellation.","Credentials and unrestricted location histories never enter route-planning audit events."],"prove":["An exact replay returns the first result without another event or version.","A reused key with different intent conflicts.","A stale expected version changes no durable truth."]},{"slug":"effects-reconciliation","title":"Control effects and unknown outcomes","duration":"15 min","objective":"Commit outbox intent atomically, execute effects outside the transaction, and reconcile ambiguous results.","build":["Route calculation fails or returns no alternatives: detect with The active controller reports an error or has no valid selected route for the current revision. Recover with Keep the editable draft, show a safe error, and allow option or stop revision before retry.","A delegate callback arrives from an old controller: detect with The callback presentation generation differs from the aggregate's active generation. Recover with Ignore it, dispose its resources, and leave the current route revision unchanged.","Selected route index is stale or invalid: detect with The index is outside the current route collection or belongs to a superseded calculation revision. Recover with Reject the handoff and require visible reselection from current candidates.","Navigation target rejects or times out: detect with No target acknowledgement exists for the handoff identity inside the bounded deadline. Recover with Remain handoff pending, expose retry or return-to-selection, and do not claim an active navigation session."],"prove":["A timeout remains an unknown outcome until identity-based reconciliation completes.","Retries are bounded and preserve the original business and command identities.","Dead-letter or manual review retains the entire attempt history."]},{"slug":"hostile-scenarios","title":"Run all hostile fixture scenarios","duration":"15 min","objective":"Exercise the success path plus replay, concurrency, state, and response-loss failures without an account.","build":["Complete journey: Commit the shortest reviewed success path to the journey-specific operating target.","Idempotent replay: Repeat one command identity and prove that version, event identity, and side effects do not duplicate.","Stale version: Reject a command based on an outdated aggregate version without changing durable truth.","Invalid transition: Reject a known command when the current state does not permit it.","Unknown outcome recovery: Reconcile after a lost response, then replay the original command identity safely."],"prove":["All fixture checks pass for all five scenarios.","Rejected commands emit no event and do not increment version.","The fixture makes zero provider calls and exposes no write tool."]},{"slug":"maintained-capstone","title":"Trace the Deep-link & Native UI Journey Host capstone","duration":"20 min","objective":"Follow the maintained source through domain rules, adapter seam, repository transaction, HTTP boundary, UI evidence, and restart test.","build":["Run the app's declared test suite (11 tests).","Run fixture mode without a credential.","Inspect audit and outbox evidence after each transition.","Restart the process and continue the same aggregate."],"prove":["The downloadable archive checksum verifies before execution.","The capstone covers the journey target without inventing provider completion.","Browser and HTTP surfaces report the same durable version."]},{"slug":"production-exit","title":"Qualify the real integration boundary","duration":"15 min","objective":"Replace only reviewed adapter seams and collect independent production evidence without weakening application invariants.","build":["Editor presentation, dismissal, and terminal result by released component version","Calculation latency, failure, and zero-alternative rate","Stop and option revision count before selection","Candidate-to-selection and selection-to-handoff conversion","Invalid index, stale generation, duplicate callback, and late callback rejection","Handoff acknowledgement latency and target rejection rate","Idempotency replay and optimistic version conflict rate"],"prove":["Exact product entitlement and regional behavior are validated separately.","Provider contract tests cover success, rejection, throttling, timeout, and unknown outcome.","Security, privacy, operations, rollback, and product owners approve exact evidence.","Fixture completion is never presented as provider or production completion."]}],"codeSamples":[{"language":"typescript","label":"TypeScript aggregate boundary","code":"type State = \"draft\" | \"editing\" | \"calculating\" | \"candidates_ready\" | \"selected\" | \"handoff_pending\" | \"handed_off\" | \"cancelled\";\ntype CommandName = \"create_plan\" | \"open_direction_ui\" | \"request_routes\" | \"receive_routes\" | \"select_route\" | \"request_navigation\" | \"confirm_handoff\" | \"cancel_plan\";\n\ntype Command = {\n  name: CommandName;\n  aggregateId: string;\n  expectedVersion: number;\n  idempotencyKey: string;\n};\n\nconst transitions = {\n  \"create_plan\": { from: [null], to: \"draft\", event: \"route_plan.created\" },\n  \"open_direction_ui\": { from: [\"draft\", \"selected\"], to: \"editing\", event: \"route_plan.editor_opened\" },\n  \"request_routes\": { from: [\"editing\"], to: \"calculating\", event: \"route_plan.calculation_requested\" },\n  \"receive_routes\": { from: [\"calculating\"], to: \"candidates_ready\", event: \"route_plan.candidates_received\" },\n  \"select_route\": { from: [\"candidates_ready\"], to: \"selected\", event: \"route_plan.route_selected\" },\n  \"request_navigation\": { from: [\"selected\", \"candidates_ready\"], to: \"handoff_pending\", event: \"route_plan.navigation_requested\" },\n  \"confirm_handoff\": { from: [\"handoff_pending\"], to: \"handed_off\", event: \"route_plan.navigation_handed_off\" },\n  \"cancel_plan\": { from: [\"draft\", \"editing\", \"calculating\", \"candidates_ready\", \"selected\", \"handoff_pending\"], to: \"cancelled\", event: \"route_plan.cancelled\" },\n} as const;\n\nexport function decide(current: { state: State | null; version: number }, command: Command) {\n  const rule = transitions[command.name];\n  if (command.expectedVersion !== current.version) throw new Error(\"version_conflict\");\n  if (!rule.from.includes(current.state as never)) throw new Error(\"invalid_transition\");\n  return {\n    state: rule.to as State,\n    version: current.version + 1,\n    event: rule.event,\n    idempotencyKey: command.idempotencyKey,\n  };\n}\n\n// Persist the result, immutable event, audit row, and outbox intent atomically.\n// Store the first result by idempotencyKey before executing another effect."},{"language":"sql","label":"SQL durability skeleton","code":"CREATE TABLE journey_ios_direction_planning_handoff (\n  aggregate_id text PRIMARY KEY,\n  state text NOT NULL,\n  version bigint NOT NULL CHECK (version > 0),\n  updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE TABLE journey_ios_direction_planning_handoff_commands (\n  aggregate_id text NOT NULL REFERENCES journey_ios_direction_planning_handoff(aggregate_id),\n  idempotency_key text NOT NULL,\n  request_hash text NOT NULL CHECK (length(request_hash) = 64),\n  committed_version bigint NOT NULL,\n  result_json jsonb NOT NULL,\n  PRIMARY KEY (aggregate_id, idempotency_key)\n);\n\nCREATE TABLE journey_ios_direction_planning_handoff_outbox (\n  event_id text PRIMARY KEY,\n  aggregate_id text NOT NULL,\n  aggregate_version bigint NOT NULL,\n  event_type text NOT NULL,\n  payload jsonb NOT NULL,\n  published_at timestamptz\n);\n\n-- In one transaction: lock aggregate, compare version, decide, append audit/event,\n-- insert the outbox row, and remember the exact command result."},{"language":"curl","label":"Complete fixture journey","code":"curl --request POST 'https://developer.mappls.com/api/journey-simulator' \\\n+  --header 'content-type: application/json' \\\n+  --data '{\"journey\":\"ios-direction-planning-handoff\",\"scenario\":\"complete-journey\"}'"},{"language":"curl","label":"Unknown-outcome drill","code":"curl --request POST 'https://developer.mappls.com/api/journey-simulator' \\\n+  --header 'content-type: application/json' \\\n+  --data '{\"journey\":\"ios-direction-planning-handoff\",\"scenario\":\"unknown-outcome\"}'\n\n# Reconcile routeplanningsession identity and the original idempotency key.\n# Never mint a replacement key merely because the response was lost."},{"language":"json","label":"First command envelope","code":"{\n  \"command\": \"create_plan\",\n  \"aggregateId\": \"fixture-ios-direction-planning-handoff-001\",\n  \"expectedVersion\": 0,\n  \"idempotencyKey\": \"cmd_ios-direction-planning-handoff_001\",\n  \"evidenceBoundary\": \"application-owned-workshop\"\n}"}],"simulationScenarios":[{"slug":"complete-journey","title":"Complete journey","outcome":"Commit the shortest reviewed success path to the journey-specific operating target.","href":"/tools/journey-lab?journey=ios-direction-planning-handoff&scenario=complete-journey#lab"},{"slug":"idempotent-replay","title":"Idempotent replay","outcome":"Repeat one command identity and prove that version, event identity, and side effects do not duplicate.","href":"/tools/journey-lab?journey=ios-direction-planning-handoff&scenario=idempotent-replay#lab"},{"slug":"stale-version","title":"Stale version","outcome":"Reject a command based on an outdated aggregate version without changing durable truth.","href":"/tools/journey-lab?journey=ios-direction-planning-handoff&scenario=stale-version#lab"},{"slug":"invalid-transition","title":"Invalid transition","outcome":"Reject a known command when the current state does not permit it.","href":"/tools/journey-lab?journey=ios-direction-planning-handoff&scenario=invalid-transition#lab"},{"slug":"unknown-outcome","title":"Unknown outcome recovery","outcome":"Reconcile after a lost response, then replay the original command identity safely.","href":"/tools/journey-lab?journey=ios-direction-planning-handoff&scenario=unknown-outcome#lab"}],"acceptance":["All 8 reviewed transitions are implemented with actor and source-state checks.","All 8 application event identities are immutable and versioned.","Exact replay, idempotency conflict, stale version, invalid transition, and unknown outcome are tested.","Aggregate, event, audit, command result, and outbox intent commit atomically.","The Deep-link & Native UI Journey Host capstone passes 11 declared tests after archive checksum verification.","Provider entitlement, payload, callback, completion, and production behavior remain independently evidenced."],"sourceBoundary":["The journey blueprint and application event contracts are implementation guidance, not Mappls provider payload specifications.","Only linked normalized contracts and source guides may define provider request syntax; empty evidence is never backfilled.","The simulator and maintained capstone operate in explicit fixture mode and make no entitlement claim.","Credentials, precise production payloads, opaque native objects, and provider secrets stay outside workshop inputs and durable examples."],"releaseBoundary":"Workshop completion proves an application-owned reliability design only. Production still requires issued entitlement, exact adapter contract tests, regional and quota validation, security/privacy review, operational drills, and independent release approval.","websitePath":"/journeys/ios-direction-planning-handoff/workshop","apiPath":"/api/journey-workshops?journey=ios-direction-planning-handoff","providerCalls":0,"writesExposed":false}