ADR-039 — Bidirectional cross-link contract: site panels ↔ mission cards
Status · Accepted (closes RFC-012 OQ-4) Date · 2026-05-09 (back-filled from v0.4.0 implementation) Closes · RFC-012 OQ-4 ("/mars ↔ /missions cross-link UX") TA anchor · §components/cross-routing Related ADRs · ADR-013 (History API routing), ADR-024 (Mission URL sharing), ADR-037 (shared surface-site type)
Context
/mars (and /moon) site panels show landing-site detail; /missions cards show full mission detail. Many entries appear in both — Curiosity is a Mars surface site AND a mission. RFC-012 OQ-4 asked: how do the two views cross-link without drifting?
Three failure modes worth designing against:
- Drift — Curiosity's launch year is 2011 in
/missions, but/marsaccidentally says 2012 because two different overlay files describe the same thing. Schema should reject this. - Lost context — user reads about Curiosity on
/mars, clicks through to/missions, has no way back. URL contract should round-trip cleanly. - Broken link —
/mars?site=curiosityopens a panel; that panel says "FULL MISSION CARD" linking to/missions?id=curiosity; the mission then says "ON THE SURFACE" linking back to/mars?site=curiosity. Both directions must work; either-direction rot is invisible without explicit tests.
Decision
Field name: mission_id
Every /mars and /moon site that corresponds to a /missions catalog entry carries a mission_id field referencing the mission's primary id. Validate-data enforces:
- If
mission_idis set, it MUST exist instatic/data/missions/index.json(validate-data treats unknown ids as a fail-closed error per ADR-019). - Editorial fields that appear in BOTH the site panel and the mission panel (mission name, launch date, agency) SHOULD be sourced from the mission entry first, with the site overlay only adding surface-specific details (landing coordinates, surface time, samples returned, capability unlocked).
Forward cross-link — site → mission
The site panel renders a chip / cross-link block titled "FULL MISSION CARD" (per UXS) when mission_id is set. Click navigates to /missions?id=<mission_id>. The mission panel opens pre-selected.
Reverse cross-link — mission → surface
The mission panel renders a "ON THE SURFACE" chip when the corresponding mission id appears in either mars-sites.json or moon-sites.json. Click navigates to /mars?site=<id> or /moon?site=<id> (the resolver picks the body the site is on). The site panel opens pre-selected.
URL contract
Both directions are URL-state-driven:
/mars?site=curiosity
/moon?site=apollo11
/missions?id=curiosityURL is the source of truth — back/forward in the browser navigates exactly through the cross-link history. Bookmarking either deep link reopens the right panel.
Drift guard
E2e test /mars round-trip per RFC-012 §Decision criteria #3:
/mars?site=curiosity → click "FULL MISSION CARD"
→ /missions?id=curiosity (mission panel open + named "Curiosity")
→ click "ON THE SURFACE"
→ /mars?site=curiosity (site panel open + named "Curiosity")Same test exists for /moon round-trip with Apollo 11.
Rationale
mission_idfield keeps the cross-link explicit + machine-checkable (validate-data can verify every link before ship).- URL state matches the existing routing contract (ADR-013 / ADR-024); no new mechanism needed.
- One-way display rule ("editorial fields sourced from mission entry first") keeps the data flow obvious — mission JSON is canonical for shared fields; site overlay adds surface-only fields.
- E2e round-trip test catches drift early — if either chip stops rendering or routes wrong, CI breaks.
Alternatives considered
- Embed the mission record in the site overlay (one big merged JSON per site) — rejected because mission updates would have to fan out to two places. Schema-validated
mission_idreference is cheaper. - Canonical mission JSON only, with site data as
surface: {…}sub-object on missions — rejected because not every mission has a surface (orbiters, flybys); the surface-site catalogue would have nullablesurfaceeverywhere. - No automatic reverse link (forward-only) — rejected because mission users want to know "where on the surface did this land?" without guessing the site id.
Consequences
Positive:
- One canonical place for shared fields (mission entry); site overlays add surface-only details.
- URL contract round-trips cleanly; back/forward works.
- E2e test catches cross-link rot.
- Validate-data fails closed on unknown
mission_idreferences.
Negative:
- Site authors must remember to set
mission_idfor sites that have a corresponding mission — tracked by the e2e test (any new site without amission_idfor an existing mission would fail the round-trip if the cross-link is expected).
Implementation notes
src/types/surface-site.ts—SurfaceSite.mission_id?: string(optional).static/data/schemas/surface-site.schema.json—mission_idas string, validated byscripts/validate-data.tsagainstmissions/index.jsonids.src/lib/components/MissionCard.svelte(or equivalent on/missionsMissionPanel) — renders "ON THE SURFACE" chip when site found.src/routes/mars/+page.svelte,src/routes/moon/+page.svelte— render "FULL MISSION CARD" chip whenmission_idset.- E2e:
tests/e2e/mars.spec.tscovers Curiosity round-trip;tests/e2e/moon.spec.tscovers Apollo 11 round-trip.