ADR-024 — Mission URL sharing
Status · Accepted Date · 2026-04-29 Closes · RFC-004 (mission URL sharing) TA anchor · §components/router · §contracts/url-params
Context
RFC-004 raised the question of how to surface mission state in URLs so a user can share a launch window or a specific mission's flight: /fly?mission=curiosity, /missions?dest=MARS, /missions?dest=MOON&status=ACTIVE. The open questions were:
- Param vocabulary. Which fields make it into the URL — just
mission, or alsodest,status,vehicle,tof,dep_day? - State serialisation. Is the URL the source of truth, or a one-way display? If the user changes a filter, do we update the URL?
- Back-button behaviour. Should every filter toggle land in the back-button history?
- Default state. When
?missionisn't present, what loads on/fly?
Closure was originally scheduled for the Slice 4 gate; we close it here in 4a-6 alongside the e2e tests that validate the contract end-to-end.
Decision
Param vocabulary (locked)
Only the parameters that materially change what the user sees ship in the URL.
| Route | Param | Values | Default | Source of truth |
|---|---|---|---|---|
/fly | mission | mission id (e.g. curiosity, apollo11, omitted) | omitted → ORRERY-1 | URL |
/missions | dest | MARS | MOON | omitted | omitted → ALL | URL |
/missions | status | ACTIVE | FLOWN | PLANNED | omitted | omitted → ALL | URL |
Vehicle, transit time, and other porkchop-cell parameters intentionally do not ship in the URL. They're surfaced by the porkchop interaction itself — sharing a /plan URL takes the recipient to the same plot, not the same selected cell. RFC-004's trade-off discussion concluded that "share an exact cell" is a v2 feature once the porkchop UX is more mature; for now the colour pattern is the educational surface.
State serialisation — URL is the source of truth on entry
Each route reads $page.url.searchParams in onMount (and on subsequent URL changes via $effect) and applies the params to component state. Filter / mission changes write back to the URL via goto(target, { replaceState: true }) so the back-button history isn't littered with every toggle.
// missions/+page.svelte
function pushFiltersToUrl() {
const params = new URLSearchParams();
if (destFilter !== 'ALL') params.set('dest', destFilter);
if (statusFilter !== 'ALL') params.set('status', statusFilter);
goto(`${base}/missions?${params}`, { replaceState: true, keepFocus: true, noScroll: true });
}replaceState was chosen deliberately. Without it, three filter clicks produce three back-button entries; the user's mental model is "one screen, refining" so we collapse the history.
Back-button behaviour — page-level, not filter-level
Browser back returns the user to the previous page, not the previous filter state. This matches the prevailing convention for SaaS apps and tested as more intuitive in the prototype than per-filter history.
Default state
/fly with no ?mission= param shows the canonical ORRERY-1 free-return scenario. We chose this over "last visited mission" (would require localStorage which CLAUDE.md forbids) and "first mission alphabetically" (arbitrary and confusing). A user landing on /fly cold gets the educational scenario the rest of the app references.
If ?mission=id is present but the mission doesn't exist (typo, deleted overlay), a top-banner alert surfaces the failure and the screen falls back to ORRERY-1. The banner is role="alert" so screen readers announce the fallback.
Cross-route navigation (FLY THIS MISSION)
The MissionPanel's FLY CTA constructs the URL via goto(${base}/fly?mission=${id}) rather than passing state through Svelte stores or session storage. This makes the deep link self-contained — copy the resulting URL into a chat or email and it works.
Rationale
URL-as-source-of-truth on entry + replaceState on update gives us:
- Shareability: any state the user cares about lives in the URL they can copy.
- Back-button correctness: page-level history without per-toggle noise.
- No localStorage: complies with CLAUDE.md's prohibition; works fully offline; nothing to migrate when the user clears site data.
The vocabulary stays small. Adding params is a one-line schema change in the consuming route — but every new param is a maintenance cost (URL-readers, URL-writers, e2e coverage), so we explicitly defer porkchop-cell parameters until there's a user case for them.
Alternatives considered
- State in localStorage — rejected per CLAUDE.md.
- Hash routing for state — rejected: ADR-013 already locked History API routing.
- Push every filter toggle to history — rejected: clutters the back-button stack with no user value (tested informally on the prototype).
- Server-side state via /api endpoints — rejected: ADR-014 locks GitHub Pages static deploy; no backend.
Consequences
Positive: deep-link sharing works for the two highest-value cases (a mission, a destination filter); back-button matches user expectation; state is debuggable at a glance because it's in the address bar.
Negative: porkchop-cell sharing is unimplemented. If a user wants to share "this exact launch window for ∆v=6.2 km/s on day 412", they currently can't — they have to share /plan and verbally describe the cell. Tracked as a v2 issue.
Validation
Closing evidence: 7 of the 20 e2e tests added in 4a-6 (tests/e2e/fly.spec.ts) and 6 of 6 in 4a-2 (tests/e2e/missions.spec.ts) cover the URL contract end-to-end. Specifically:
/missions?dest=MARSpre-applies the filter on cold load- MARS / MOON filter toggles update the URL with
replaceState /fly?mission=curiositypopulates the identity HUD with Curiosity data/fly?mission=apollo11loads the Moon-dest fallback/fly?mission=does-not-existsurfaces the load-failed banner- Mission library → click card → click FLY navigates to
/fly?mission=idand the fly screen loads the correct mission