Skip to content

ADR-052 — Spaceflight Fleet schema + bidirectional cross-reference contract

Status · Accepted (retrospective; shipped v0.6.0) Date · 2026-05-15 Closes · RFC-016 OQ-1, OQ-2, OQ-9, OQ-10, OQ-15, OQ-16 TA anchor · §components/fleet · §contracts/fleet-entry · §constraints/cross-reference symmetry Related ADRs · ADR-006 (JSON files for mission data), ADR-017 (locale overlay merge), ADR-020 (canonical mission schema) Scope · static/data/fleet/, static/data/schemas/fleet-{entry,index,overlay}.schema.json, scripts/validate-data.ts (fleet symmetric-link check), src/types/fleet.ts

Context

PRD-012 / RFC-016 promised a curated ~110-entry inventory of every machine used in spaceflight — launchers, crewed spacecraft, cargo spacecraft, stations, rovers, landers, orbiters, observatories, and space-suits — each cross-linked bidirectionally to the rest of the corpus (missions/, iss-modules.json, tiangong-modules.json, earth-objects.json, moon-sites.json, mars-sites.json).

Three structural questions had to be resolved before any entry could ship:

  1. Storage shape — one giant fleet.json or per-category files?
  2. Cross-reference contract — how does Apollo 11's mission panel point to "Saturn V on /fleet", and how does Saturn V's /fleet entry point back to all Apollo missions?
  3. Granularity — family (one Soyuz entry) or generation (Soyuz-7K-OK, Soyuz-T, Soyuz-TM, Soyuz-MS as four entries)?

Without bidirectional symmetry the cross-links rot silently: a curator adds Apollo 11 → Saturn V but forgets the reverse pointer, and Saturn V's panel never lists Apollo 11. Without symmetric validation that drift accumulates until the most important panels (Soyuz, Saturn V, Ariane 5) show fewer flights than the corpus actually contains.

Decision

Storage shape (closes OQ-1)

Per-category folders + a lightweight index manifest. Files live at:

static/data/fleet/{category}/{id}.json     # one detail file per entry
static/data/fleet/index.json               # ordered manifest for the card grid

Category set is locked at 9 values (closes RFC-016 OQ-1):

launcher · crewed-spacecraft · cargo-spacecraft · station · rover · lander ·
orbiter · observatory · space-suit

The detail file is the source of truth (full agency/era/specs/flights/links). The index is a generated digest with only the fields the card grid needs (id, name, category, agency, country, year, status, era, epoch, hero_path, best_known_for). The index is regenerated by scripts/build-fleet-index.ts during npm run fetch; it is checked into source so /fleet renders without a build step at PR review time, and the integrity check fails closed if it drifts from the detail files.

At v0.6.0 the inventory ships 137 entries (PRD-012 inflated from the original ~110 target after per-generation granularity locked).

Detail file schema (closes OQ-10, OQ-15)

Locked by static/data/schemas/fleet-entry.schema.json. Language-neutral fields only (per ADR-017 overlay pattern):

json
{
  "id": "saturn-v",
  "name": "Saturn V",
  "category": "launcher",
  "agency": "NASA",
  "country": "USA",
  "manufacturer": "Boeing / North American / Douglas",
  "first_flight": "1967-11-09",
  "last_flight": "1973-05-14",
  "status": "RETIRED",
  "era": "1969-1981",
  "epoch": "lunar-era",
  "best_known_for": "Carried every crewed Apollo lunar mission.",
  "specs": { "height_m": 110.6, "payload_lEO_kg": 140000, "stages": 3 },
  "linked_missions": ["apollo11", "apollo12", "apollo13", "apollo14", "apollo15", "apollo16", "apollo17", "skylab1"],
  "linked_sites": [{ "type": "moon", "site_id": "apollo11" }, ...],
  "flights": [
    {
      "mission_id": "apollo11",
      "flight_designation": "AS-506",
      "patch_path": "/images/missions/apollo11-patch.png",
      "crew": [{ "name": "Neil Armstrong", "role": "Commander", "agency": "NASA", "country": "USA", "portrait_path": "..." }]
    }
  ],
  "explorer_route": "/iss",
  "credit": "© NASA — ...",
  "links": [{ "l": "...", "u": "https://...", "t": "intro" }]
}

Editorial strings (name, best_known_for, flights[].crew[].name, etc.) merge from static/data/i18n/<locale>/fleet/{category}/{id}.json per ADR-017 and ADR-054.

Granularity rule (closes OQ-10)

Per-generation. Soyuz ships as four entries (soyuz-7k-ok, soyuz-t, soyuz-tm, soyuz-ms), not one. Two reasons: (a) the engineering deltas between Soyuz-7K-OK and Soyuz-MS are larger than between two unrelated capsules in the corpus; (b) each generation has its own crew list and own notable-flights set, so the per-flight cross-reference table (below) becomes unreadable when collapsed into a single "Soyuz" entry.

Bidirectional cross-reference contract (closes OQ-2, OQ-9, OQ-16)

The contract has three directions, each enforced symmetrically at build:

  1. Mission → Fleet — every static/data/missions/<dest>/<id>.json gains a fleet_refs array of { id, role } objects. Roles are spacecraft, launcher, lander, rover, or surface-suit. Example: apollo11.fleet_refs = [{ id: "saturn-v", role: "launcher" }, { id: "apollo-csm-block-ii", role: "spacecraft" }, { id: "apollo-lm", role: "lander" }, { id: "a7l", role: "surface-suit" }].

  2. Surface site / earth-object → Fleetmoon-sites.json, mars-sites.json, and earth-objects.json entries gain the same fleet_refs field. Example: the Tranquillity Base entry on /moon lists [{ id: "apollo-lm", role: "spacecraft" }], and Hubble on /earth lists [{ id: "hubble", role: "spacecraft" }].

  3. Fleet → everything — every fleet entry carries linked_missions[] (mission ids) and linked_sites[] ({ type: "moon" | "mars" | "earth-object", site_id }).

scripts/validate-data.ts runs a symmetric-link check before every build: for every mission M and every fleet_refs[i] = { id: F }, the fleet entry F must list M in linked_missions[], and vice versa. Same check for sites. Asymmetry fails the build with a structured diff so the curator sees exactly which side is missing the pointer. This is the only way to keep the bidirectional contract honest across ~1,500 cross-references.

Failure entries (closes OQ-15)

FAILED is a first-class status value alongside FLOWN/ACTIVE/RETIRED/PLANNED. Apollo 1, Apollo 13 (partial loss), Challenger STS-51L, Columbia STS-107, N1, Crew Dragon Demo-1 in-flight abort, and other failure-class flights ship as either standalone entries (where the vehicle didn't survive to fly again) or as flights[] entries on a parent entry with their flight_designation annotated. PRD-012 v0.2: "not all about success — the failures are how spaceflight learned."

Era + epoch (closes OQ-17, partial)

Two parallel categorical axes:

  • era — date range bucket: 1957-1969 · 1969-1981 · 1981-2011 · 2011-now · planned.
  • epoch — named historical period: first-steps · space-race · lunar-era · first-stations · shuttle-and-mir · iss-assembly · commercial-era · lunar-return · mars-era.

Both ship as filter-chip axes on /fleet. The named-epoch axis is the editorial primary; the date-range axis is a back-up for users who think in decades.

Rationale

  • Per-category folders scale better than one mega-file: PRs touch one or two files per change, not the entire fleet.
  • Generated index manifest keeps the card grid fast (no need to fetch 137 detail files on first paint) without manual sync — the build derives it.
  • Per-generation granularity matches how the user thinks ("I want to see Soyuz-MS, not 'Soyuz'") and gives every notable flight a real home.
  • Symmetric-link fail-closed validation is the only durable answer to bidirectional drift. Trusting curator discipline failed within the first ~20 entries during fleet content authoring (v0.6 Phase D); the validator caught 60+ asymmetries on its first run.
  • FAILED as a first-class status lets the UI render failure entries with the same depth as flown entries instead of relegating them to footnotes — preserving the editorial honesty that the rest of the corpus already commits to.

Alternatives considered

  • Single fleet.json file (rejected) — would have made PRs noisy and made it impossible to lazy-load detail panels without an extra cache layer.
  • Family-level granularity (rejected) — collapsing Soyuz-7K-OK through Soyuz-MS into one entry would have lost ~70 % of the notable-flights table and made the page editorially shallower than the same-era Apollo entries.
  • Mission → Fleet only (one-way) (rejected v0.2; was the v0.1 plan) — would have left every /fleet panel with an empty or stale flight list; bidirectional with symmetric validation was the only path that survived contact with real curation.
  • Embedded launcher data in missions/ (rejected) — would have duplicated Saturn V's specs across eight Apollo files. Reference-by-id is the only sane shape.

Consequences

Positive

  • One source-of-truth file per entry; index manifest derives mechanically.
  • Bidirectional cross-references are machine-checked; drift fails the build.
  • 137 entries × 9 categories renders as a single coherent grid with per-axis filters that map directly to how users think about the dataset.
  • Failure entries get the same editorial weight as flown entries.

Negative

  • Bidirectional contract doubles the curation surface (every new mission must update fleet, every new fleet entry must update its missions). Mitigated by the symmetric-link validator.
  • Per-generation granularity inflates entry count: 110 in PRD-012 v0.1 → 137 at v0.6.0 ship. Acceptable; the page reads denser, not slower.
  • The flights[] block on crewed-spacecraft entries is the largest curation cost (3–6 notable flights × portrait + patch lookup per entry) — addressed in ADR-053.

Implementation notes

  • Schemas: static/data/schemas/fleet-{entry,index,overlay}.schema.json. The overlay schema permits the locale-specific subset of fields.
  • Symmetric-link check: scripts/validate-data.ts (function checkFleetCrossRefs), runs in the same fail-closed pipeline as validate-link-provenance and validate-image-provenance.
  • linked_missions[] / linked_sites[] are the canonical reverse pointers — they must agree with whatever the forward pointers say. If they disagree, the validator names the offending entry pair and exits non-zero.
  • scripts/scaffold-fleet-entries.ts was used once to seed the initial 110 entries from cross-references already present in the missions and surface-site corpus; subsequent entries are authored by hand.
  • One-time migrations live in scripts/migrate-fleet-*.ts (5 scripts: crew flights, overlays, learn links, site refs, mission refs); these are archival — do not run again.
  • ADR-053 — mission badge + crew portrait sourcing (agency-first pipeline for fleet assets).
  • ADR-054 — fleet i18n strategy (1,918 overlay files across 14 locales).
  • RFC-016 — Spaceflight Fleet · architecture, schema, and dataset boundaries.
  • PRD-012 — Spaceflight Fleet product spec.

Orrery — architecture documentation · MIT · No tracking