Skip to content

RFC-023 · Launches Calendar — multi-source agency-first pipeline + /missions calendar architecture

Status: Closed (v0.7) · 2026-05-19 · Closes: PRD-020

Why this is an RFC. The architecture binds: the LaunchSource provider abstraction (so adding NASA / ESA / JAXA / SpaceX scrapers later is a config + a new file, not a rewrite), the priority-ordered merge that determines whether a launch row's primary provenance is agency-direct or aggregator-fallback (this is the editorial-posture lever from PRD-020 §why), the manifest schema that carries the per-entry provenance_chain and threads through every consuming surface (banner, calendar, fleet widgets), the GCAT-TSV ingest pipeline + release-version pinning (CC-BY compliance + schema-bump safety), the curation override file shape (mirrored later in PRD-018's image-curation per Marko's directive), the per-source cadence in the build-cron workflow, and the validate-data.ts fail-closed gates for missing primary provenance + missing citation strings. Get any of these cuts wrong and we either silently regress to "Orrery is a UI for LL2" or we ship a CC-BY violation. Both are project-defining outcomes.


1 · Architecture overview

┌────────────────────────────────────────────────────────────┐
│  Provider layer  —  src/lib/launches/sources/              │
│                                                            │
│   NasaSource     EsaSource     JaxaSource    SpaceXSource  │ ← agency-direct (M1 priority 1)
│   RoscosmosSource  IsroSource  CnsaSource                  │
│                                                            │
│   GcatSource           ← Jonathan McDowell, CC-BY (M1 p.2) │
│   LL2Source            ← gap-fill only (M1 priority 3)     │
│                                                            │
│   each implements `LaunchSource` interface (§3)            │
└──────────────────────────┬─────────────────────────────────┘


┌────────────────────────────────────────────────────────────┐
│  scripts/fetch-launches.ts (orchestrator)                  │
│   Phase 1 — pull each provider in declared priority order  │
│   Phase 2 — dedupe by stable Orrery-internal id (§4.2)     │
│   Phase 3 — merge entries, record provenance_chain         │
│   Phase 4 — apply heuristic tier (§5)                      │
│   Phase 5 — apply launches-curation.json overrides         │
│   Phase 6 — write launches.json + launches-historic.*.json │
│   Phase 7 — write audit-report.html (operator UI)          │
└──────────────────────────┬─────────────────────────────────┘


┌────────────────────────────────────────────────────────────┐
│  validate-data.ts  (new gates per PRD-020 M13)             │
│   • ajv schema validation on launches + curation manifests │
│   • every entry has ≥1 primary OR fallback-primary source  │
│   • required citation strings present in text-sources.json │
│   • CC-BY GCAT version pinned + cited                      │
└──────────────────────────┬─────────────────────────────────┘


┌────────────────────────────────────────────────────────────┐
│  Frontend  —  src/routes/missions/launches/+page.svelte    │
│   + src/lib/components/LaunchesBanner.svelte               │
│   + src/lib/components/launches/MonthStrip.svelte          │
│   + src/lib/components/launches/Timeline.svelte            │
│   + src/lib/components/launches/ProvenanceChip.svelte      │
│   • static import of launches.json + per-decade historic   │
│   • lazy-load decade pages on year-range filter            │
│   • cross-integration: fleet launcher pages read same data │
└────────────────────────────────────────────────────────────┘

Eight new files (orchestrator + 7 providers worst-case) on the pipeline side, four new components + one route on the frontend side. Zero modifications to any existing manifest schema. Zero runtime fetches to any external source from the deployed app. All side effects are file I/O + outbound HTTPS at build time.


2 · Two new manifest files + one curation file

2.1 · static/data/launches.json (upcoming)

Generated by fetch-launches.ts. Keyed by Orrery-internal stable launch id.

jsonc
{
  "version": 1,
  "generated_at": "2026-05-19T14:32:11Z",
  "sources_active": ["nasa-direct", "esa-direct", "spacex-direct", "gcat", "ll2"],
  "gcat_release": "1.8.0",
  "gcat_data_update": "2026-05-20",
  "entries": {
    "2026-05-26-falcon9-starlink-10-31": {
      "id": "2026-05-26-falcon9-starlink-10-31",
      "net": "2026-05-26T18:42:00Z",
      "net_precision": "minute",
      "window_start": "2026-05-26T18:42:00Z",
      "window_end": "2026-05-26T22:12:00Z",
      "status": { "code": "GO", "label": "Go for Launch" },
      "name": "Falcon 9 Block 5 | Starlink Group 10-31",
      "mission_name": "Starlink Group 10-31",
      "mission_type": "Communications",
      "orbit_abbrev": "LEO",
      "orbit_name": "Low Earth Orbit",
      "agency_id": "spacex",
      "agency_name": "SpaceX",
      "agency_type": "Commercial",
      "country": "US",
      "rocket_config_name": "Falcon 9 Block 5",
      "rocket_family": "Falcon 9",
      "orrery_launcher_ref": "falcon-9",
      "pad_name": "SLC-40",
      "pad_location": "Cape Canaveral SFS, Florida, USA",
      "image_url": "/static/images/launches/falcon9-block5.jpg",
      "image_credit": "SpaceX (Public Domain)",
      "tier": "T4",
      "tier_reason": "routine-constellation",
      "provenance_chain": [
        { "source": "spacex-direct", "source_url": "https://www.spacex.com/launches/mission/?missionId=…", "fetched_at": "2026-05-19T14:30:00Z", "role": "primary" },
        { "source": "ll2",           "source_url": "https://ll.thespacedevs.com/2.3.0/launches/ef65c43b-…/", "fetched_at": "2026-05-19T14:30:42Z", "role": "augmented-with" }
      ],
      "editorial_note": null,
      "webcast_live": false,
      "fetched_at": "2026-05-19T14:30:42Z"
    },
    "2026-09-22-sls-artemis-2": { /* T1 crewed lunar — editorial_note set */ }
  }
}

Invariants:

  • entries[*].id is the Orrery-internal stable id (§4.2). Not LL2's UUID; not GCAT's LAUNCH_TAG. Built from net-date + rocket-family-slug + mission-slug.
  • provenance_chain is ordered: first entry is the highest-priority source that contributed; subsequent entries note confirmed-via, augmented-with, or fallback-primary roles.
  • Every entry has at least one primary OR fallback-primary role (validate-data fail-closed).
  • tier_reason is a machine-readable string from a finite set (§5.4) so the audit report can group by reason.

2.2 · static/data/launches-historic.<decade>.json

Same entry schema, paged per-decade for git friendliness:

static/data/launches-historic/
  1957-1969.json   ~150 entries, ~150 KB gzip
  1970-1979.json   ~450 entries, ~450 KB
  1980-1989.json   ~600 entries
  1990-1999.json   ~700 entries
  2000-2009.json   ~800 entries
  2010-2019.json   ~1000 entries
  2020-2026.json   ~1500 entries (and growing)

Lazy-loaded by the frontend when the year-range filter touches a decade. Default load on /missions/launches first paint = upcoming + the current decade only (~1.7 MB raw / ~400 KB gzip combined).

2.3 · static/data/launches-curation.json (committed, hand-edited)

jsonc
{
  "version": 1,
  "featured": [
    {
      "launch_id": "2026-09-22-sls-artemis-2",
      "reason": "First crewed lunar flyby since Apollo 17",
      "editorial_note": "Four astronauts on a free-return loop around the Moon — the first crewed beyond-LEO flight in 53 years.",
      "added_by": "marko",
      "added_at": "2026-05-19T15:00:00Z"
    }
  ],
  "demoted": [
    {
      "launch_id": "2026-06-04-falcon9-starlink-10-44",
      "reason": "Routine — heuristic over-promoted because of new-pad first-flight false positive",
      "added_by": "marko",
      "added_at": "2026-05-19T15:02:00Z"
    }
  ]
}

editorial_note (optional) flows through to the corresponding manifest entry (PRD-020 M5/M6). Same pattern PRD-018's image-curation will adopt.

2.4 · static/data/launches-rocket-mapping.json (committed, hand-edited)

Maps source-provided rocket family/config strings to our fleet launcher ids:

jsonc
{
  "version": 1,
  "families": {
    "Falcon 9": "falcon-9",
    "Falcon Heavy": "falcon-heavy",
    "SLS": "sls-block-1",
    "Atlas V": "atlas-v",
    "Soyuz-2": "soyuz-fg",
    "Long March 5": "long-march-5",
    "Ariane 6": "ariane-6",
    "H3": "h3",
    "LVM3": "lvm3"
    // … all 23 fleet launchers
  },
  "config_exceptions": {
    "Falcon 9 Block 4": "falcon-9",
    "Falcon 9 v1.1": "falcon-9"
  }
}

Unmapped source strings → orrery_launcher_ref: null + a warn line in the audit report.


3 · LaunchSource provider abstraction

Same pattern as VisionProvider (RFC-022) and TtsProvider (RFC-019).

typescript
// src/lib/launches/sources/provider.ts
export type SourceRole = 'primary' | 'confirmed-via' | 'augmented-with' | 'fallback-primary';

export interface LaunchSource {
  readonly name: string;                    // 'nasa-direct' | 'esa-direct' | 'gcat' | 'll2' | …
  readonly priority: number;                // lower = higher priority. NASA-direct=10, GCAT=20, LL2=90.
  readonly defaultRole: SourceRole;         // what role this source claims by default

  /** Fetch all entries this provider can offer in the given window. */
  fetchWindow(input: {
    mode: 'upcoming' | 'historic';
    fromIso: string;                        // ISO 8601 inclusive
    toIso: string;                          // ISO 8601 exclusive
  }): Promise<RawLaunchEntry[]>;

  /** Optional per-id lookup for confirmation/augmentation passes. */
  lookup?(input: { orreryId: string; hint?: object }): Promise<RawLaunchEntry | null>;

  /** Attribution metadata for /credits + /library + per-row chip. */
  attribution(): {
    citation: string;                       // canonical citation string (e.g. McDowell GCAT)
    url: string;                            // homepage / canonical citation URL
    license: string;                        // 'CC-BY-4.0' | 'permissive' | 'public-domain' | 'agency-no-restriction'
    citation_id: string;                    // key into text-sources.json
  };
}

RawLaunchEntry is a normalised shape each provider returns; the orchestrator merges these into the manifest entry shape from §2.1. Each provider is responsible for normalising source-specific weirdness (GCAT fixed-width parsing, LL2's pagination, NASA's RSS quirks) into the common shape.

v0.1 ships these providers:

ProviderPriorityModeImplementation shapeStatus v0.1
NasaSource10upcomingRSS + KSC media-schedule scrapeYES (v0.1)
SpaceXSource11upcomingspacex.com/launches scrape (their schema is stable)YES (v0.1)
EsaSource12upcomingesa.int press calendar parseYES (v0.1)
JaxaSource13upcomingglobal.jaxa.jp press calendarDEFERRED to v0.2 (falls back to LL2)
RoscosmosSource14upcomingen.roscosmos.ru pressDEFERRED to v0.2
IsroSource15upcomingisro.gov.in pressDEFERRED to v0.2
CnsaSource16upcoming(limited public schedule)DEFERRED to v0.2
GcatSource20historicTSV download + parseYES (v0.1) — primary for ALL historic
LL2Source90bothREST + ETag cacheYES (v0.1) — fill-in only

Deferred providers fall back to LL2 for upcoming + GCAT for historic. The audit report flags every entry whose provenance_chain first role is ll2 so we can see the gap.


4 · Orchestrator merge algorithm

4.1 · Pull order

typescript
const providers: LaunchSource[] = [
  new NasaSource(), new SpaceXSource(), new EsaSource(),
  // deferred: new JaxaSource(), new RoscosmosSource(), new IsroSource(), new CnsaSource(),
  new GcatSource(),
  new LL2Source(),
].sort((a, b) => a.priority - b.priority);

for (const p of providers) {
  const raws = await p.fetchWindow({ mode, fromIso, toIso });
  // dedupe + merge (§4.2 + §4.3)
}

4.2 · Stable Orrery-internal id

id = `${YYYY-MM-DD}-${rocket-family-slug}-${mission-slug}`
   = `2026-09-22-sls-artemis-2`

rocket-family-slug lower-cased + hyphenated from the normalised rocket family. mission-slug slugified from the mission name with the agency stripped if redundant. Date is the net date in UTC. Different sources for the same launch produce the same id (deterministic by design).

Collision case (two distinct launches on the same date with the same vehicle + mission name): append a counter (-2, -3). Audit report flags any non-trivial counter use so we catch genuine collisions vs source misalignment.

4.3 · Merge rule

For each id:

  1. First seen wins for primary fields — fields that the highest-priority provider sets (net, status, name, mission_name, agency_*, rocket_*) come from the first provider that included them.
  2. Subsequent providers augment — any field still null after the primary pass is filled from the next provider in priority order. The augmenting provider gets a role: "augmented-with" entry in the chain.
  3. Disagreement is logged, not silently overwritten — if Provider A reports status: GO and Provider B reports status: HOLD, the higher-priority value wins for the manifest BUT the conflict is recorded in the audit report. Operator reviews; may add the launch to launches-curation.json demoted or flag a provider bug.
  4. GCAT-only entries (historic) — get role: "primary" (GCAT is primary for historic).
  5. LL2-only entries (upcoming, no agency-direct match) — get role: "fallback-primary". These are the rows the audit report nags about so we know which agency-direct providers to prioritise adding next.

4.4 · Citation registration

After the merge, the orchestrator collects attribution() from every provider that contributed at least one entry + writes:

  • An entry per active source into static/data/text-sources.json (creating if absent — ADR-047 requirement).
  • An entry per active source into static/data/link-provenance.json so /library surfaces them (ADR-051).

This is idempotent: re-running adds no duplicates; re-running with a provider dropped (e.g. GCAT temporarily unavailable) removes its citation cleanly.


5 · Importance tier algorithm (heuristic + override)

5.1 · Heuristic order (first match wins)

if launch_id in curation.featured           → T1 (reason: featured-override)
else if launch_id in curation.demoted       → T4 (reason: demoted-override)
else if mission flagged crewed              → T1 (reason: crewed)
else if orbit ∈ {beyond-LEO destinations}   → T1 (reason: interplanetary | lunar | cislunar | …)
else if rocket_config first flight ever     → T1 (reason: first-flight-vehicle)
else if pad first launch ever               → T2 (reason: first-flight-pad)
else if reusable-stage milestone            → T2 (reason: reusable-milestone)
else if agency first orbital launch ever    → T2 (reason: agency-debut)
else if mission constellation_batch         → T4 (reason: routine-constellation)
else                                        → T3 (reason: standard)

Curated override wins over heuristic — both directions. This is the operator-control lever PRD-020 M4 promised.

5.2 · "First flight" detection

For each rocket_config_id: the entry with the lowest net in the combined upcoming + historic manifest gets first-flight-vehicle reason. Edge: if GCAT records a retired-then-resurrected vehicle as a single config (e.g. Soyuz variants), the simple definition can produce false positives. Accepted for v0.1; operator can demote via curation.

5.3 · "Beyond LEO" detection

Orbit-abbrev set: { GTO, GEO, MEO, HEO, TLI, TMI, GTO+, SSO-beyond, L1, L2, L4, L5, HCO, SOL, MARS, MOON, VENUS, JUPITER, SATURN, KUIPER, INTERPLANETARY, ESCAPE }. Captured in a constants file src/lib/launches/beyond-leo.ts; auditable; edits land in PRs.

5.4 · tier_reason finite set

typescript
type TierReason =
  | 'featured-override' | 'demoted-override'
  | 'crewed' | 'interplanetary' | 'lunar' | 'cislunar' | 'heliocentric' | 'mars' | 'venus' | 'moon'
  | 'first-flight-vehicle' | 'first-flight-pad' | 'reusable-milestone' | 'agency-debut'
  | 'routine-constellation' | 'standard';

Audit report groups by reason. Useful when calibrating: "show me everything tagged routine-constellation that the operator promoted via curation" reveals where the heuristic under-fires.


6 · Cache strategy

6.1 · Per-source cache layout

.launches-cache/
  nasa-direct/         (ETags or content-hash per scraped page)
  spacex-direct/       (content-hash of /launches HTML)
  esa-direct/          (content-hash of press calendar HTML)
  gcat/
    gcat-1.8.0-launch-log.tsv   ← committed-revision-pinned download
    gcat-1.8.0-launch-log.parsed.json   ← cached parse output
  ll2/
    upcoming-page-0.json   (LL2 ETag honoured)
    upcoming-page-1.json
    previous-window-2020-2026.json

6.2 · Invalidation triggers (explicit + finite)

TriggerEffect
Source page content hash changes (NASA / SpaceX / ESA scraped page)That source's entries re-fetched + re-merged
GCAT release-version bump (gcat_release constant edited in build config)All historic re-parsed; full manifest regen
LL2 ETag changes per pageThat page re-fetched; merge re-runs
launches-curation.json modifiedHeuristic + override re-applied; affected entries re-tiered (no re-fetch)
launches-rocket-mapping.json modifiedorrery_launcher_ref re-resolved for every entry (no re-fetch)
scripts/fetch-launches.ts orchestrator version bumpedFull merge re-runs (no per-source re-fetch unless cache misses)
Time-based invalidationNEVER — only the 6h build-cron + explicit edits trigger re-runs. Mirrors RFC-022 §5.4 discipline.

6.3 · Cost-per-iteration table

OperationNetwork callsParse workWall clock
Cache-warm rebuild (nothing changed)0merge only~15 s
6 h cron tick — typical (LL2 ETag change + maybe one scrape diff)~15 (LL2 pagination + scrape)small~90 s
GCAT major-version bump (e.g. 1.8.0 → 1.9.0)1 (TSV)50–80 MB parse~2 min
Cold rebuild (first time, no cache)~30 (paginated LL2 + GCAT TSV + ~6 agency pages)full parse~5–8 min
--source nasa-direct only (operator debugging)1small~10 s

Mirrors PRD-020 §11 budget (≤ 8 min cold rebuild).


7 · CLI scope flags (mirrors RFC-022 §6)

bash
# DEFAULT — incremental, all sources, respect cache. Routine workflow.
npm run fetch:launches

# Force-refresh a single source (operator debugging when a scraper acts up)
npm run fetch:launches -- --source nasa-direct --force

# Pin GCAT to a specific release (CI: lock the historic data version)
npm run fetch:launches -- --gcat-release 1.8.0

# Operator-triggered: re-run tier algorithm only (no fetches)
npm run fetch:launches -- --retier-only

# Audit report only (no manifest writes; useful pre-commit review)
npm run fetch:launches -- --audit-only --no-write

# Operator-triggered: pull only a year range from GCAT (debugging historic)
npm run fetch:launches -- --source gcat --year-from 1965 --year-to 1969

# Catch-all — drop all caches + rebuild
npm run fetch:launches -- --all --no-cache

Default behaviour is incremental + cache-respecting. --no-cache is the explicit "nuke and pave" gesture, opt-in only.


8 · Frontend integration

8.1 · Manifest import

typescript
// src/lib/launches/manifest.ts
import upcoming from '$lib/../static/data/launches.json';

export type ManifestEntry = (typeof upcoming)['entries'][string];

// Decade lazy-loader for historic
export async function loadDecade(decade: string): Promise<Record<string, ManifestEntry>> {
  const res = await fetch(`/data/launches-historic/${decade}.json`);
  return (await res.json()).entries;
}

export function getNextFeatured(n = 3): ManifestEntry[] {
  return Object.values(upcoming.entries)
    .filter(e => e.tier === 'T1')
    .sort((a, b) => a.net.localeCompare(b.net))
    .slice(0, n);
}

8.2 · Components

ComponentWhereResponsibility
LaunchesBanner.sveltetop of /missions/+page.sveltenext 3 T1 cards + T-0 countdown
+page.svelte (route)src/routes/missions/launches/+page.svelteroute shell + filter strip + URL sync
MonthStrip.svelteinside the routehorizontal scrolling month strip w/ per-month counts
Timeline.svelteinside the routevertical chronological list, grouped by day
LaunchRow.svelteinside Timelineper-launch row + tier chip + status + provenance trigger
ProvenanceChip.svelteinside LaunchRowhover/long-press surface showing provenance_chain
EditorialNoteCallout.svelteinside LaunchRowoptional one-line italic Orrery-authored framing
LauncherFlightsWidget.sveltetop of /fleet/launcher/<id>NEXT FLIGHT + RECENT FLIGHTS filtered by orrery_launcher_ref
LaunchCitationsStrip.sveltefooter of /missions/launchesrequired citation surface (GCAT McDowell + any other primary used)

8.3 · URL filter state

Follows the existing untrack() pattern (per the feedback_svelte5_effect_untrack memory) for the $effect that re-applies URL params:

typescript
// avoid effect_update_depth_exceeded on `?id=…` deep-links
$effect(() => {
  untrack(() => applyUrlFilters(page.url));
});

URL params: mode (upcoming|historic), agency, vehicle, orbit, outcome, year_from, year_to, tier, id (deep-link to a specific row).

8.4 · Readiness signals (ADR-056)

/missions/launches exposes data-route-ready once the upcoming manifest is hydrated + the timeline has measured. No canvas → window.__pickAt not applicable. data-loading covers the lazy-loaded decade fetch.

8.5 · i18n

UI chrome strings into messages/en-US/launches.json:

jsonc
{
  "launches.banner.next_label": "Next Launch",
  "launches.tier.featured": "FEATURED",
  "launches.tier.notable": "NOTABLE",
  "launches.tier.routine": "ROUTINE",
  "launches.toggle.upcoming": "UPCOMING",
  "launches.toggle.historic": "HISTORIC",
  "launches.filter.agency": "Agency",
  "launches.filter.vehicle": "Vehicle",
  "launches.filter.orbit": "Destination",
  "launches.filter.outcome": "Outcome",
  "launches.status.go": "Go",
  "launches.status.hold": "Hold",
  "launches.status.scrubbed": "Scrubbed",
  "launches.status.success": "Success",
  "launches.status.failure": "Failure",
  "launches.status.partial": "Partial",
  "launches.orbit.leo": "Low Earth Orbit",
  "launches.orbit.gto": "Geostationary Transfer",
  "launches.orbit.interplanetary": "Interplanetary",
  "launches.orbit.lunar": "Lunar",
  "launches.citation.gcat": "Launch data sourced in part from Jonathan McDowell's GCAT (CC-BY)",
  "launches.refresh.label": "Last refreshed {n} hours ago"
}

~60 strings v0.1. wave23/catalog.ts extracts; wave23/maps.ts argos-translates to the 13 other locales; wave23/apply-translations.ts writes per-locale overlay files.

Source-data strings (mission/payload/vehicle variant names) stay English. Validated by a checker that warns (not fails) when a locale overlay tries to translate a key in the launches.data.* namespace (which we don't populate v0.1).


9 · validate-data.ts integration (fail-closed gates)

New checks added; existing ADR-047 / ADR-051 / ADR-052 gates unchanged.

  1. Manifest schema validation. launches.json + each launches-historic.<decade>.json validate against schemas/launch.schema.json via ajv. Malformed = fail.
  2. Required primary provenance. Every entry's provenance_chain must contain ≥ 1 role ∈ { "primary", "fallback-primary" }. Missing = fail. Catches the case where a future provider bug strips the role.
  3. Citation strings present. Every distinct source value present in any entry's provenance_chain must have a corresponding entry in text-sources.json AND link-provenance.json with a matching citation_id. Missing = fail. This is the CC-BY compliance gate for GCAT and the attribution gate for every other source.
  4. GCAT release pin recorded. launches.json.gcat_release non-empty when any entry's chain has source: "gcat". Missing = fail.
  5. Curation file schema. launches-curation.json validates against schemas/launches-curation.schema.json. Referenced launch ids that don't exist in any current manifest = WARN (per PRD-020 M13 reasoning).
  6. Rocket mapping schema. launches-rocket-mapping.json validates. Mapping target ids must exist as static/data/fleet/launcher/<id>.json files. Missing target = fail.
  7. No provenance_chain missing fetched_at. Each chain entry has a timestamp. Missing = fail. Catches stale entries.

A Playwright spec (tests/e2e/launches-citations.spec.ts) asserts the McDowell GCAT citation string is visible on /missions/launches, /credits, and /library. Mirrors PRD-020 success criterion #4.


10 · Audit report HTML

Generated by Phase 7 of the orchestrator: static/audit-report-launches.html (gitignored, dev-only artefact, mirrors RFC-022 §8).

Sections:

  • Provenance gaps. Every entry whose provenance_chain[0].source === 'll2' (i.e. the agency-direct chain returned nothing). Sorted by net. Acts as the prioritisation list for which agency-direct providers to add next.
  • Disagreements. Every entry where two providers' field values disagreed during merge. Operator reviews; may file a provider bug or add a curation override.
  • Unmapped rocket families. Every source-side rocket-family string with no entry in launches-rocket-mapping.json. Quick-fix queue.
  • Curation candidates. Entries the heuristic tiered T1 (FEATURED) — operator sanity-checks; demotes false positives. Also: entries tiered T3 (STANDARD) but flagged with agency-debut reason — operator considers promoting.
  • Cost ledger (LL2 calls). Per-build LL2 request count + remaining quota (15/hour anonymous tier). v0.1 expects ~30 calls per cold build, ~0–5 per warm. If we ever bump to Patreon tier, ledger surfaces consumption.
  • Citation surface check. Confirms text-sources.json + link-provenance.json + the calendar-page footer all carry every required citation.

Marko opens locally: open static/audit-report-launches.html after a build.


11 · Build-cron workflow

yaml
# .github/workflows/refresh-launches.yml
name: Refresh launches manifest
on:
  schedule:
    - cron: '17 */6 * * *'   # every 6h at minute 17 (avoid round-minute thundering herd)
  workflow_dispatch:
jobs:
  refresh:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npm run fetch:launches
        env:
          LL2_PATREON_KEY: ${‌{ secrets.LL2_PATREON_KEY }}   # optional; anon if absent
      - name: Commit if changed
        run: |
          git config user.name 'orrery-launches-bot'
          git config user.email 'launches-bot@orrery.local'
          if [[ -n $(git status --porcelain static/data/launches.json static/data/launches-historic) ]]; then
            git add static/data/launches.json static/data/launches-historic static/data/text-sources.json static/data/link-provenance.json
            git commit -m "chore(launches): refresh — $(date -u +%Y-%m-%dT%H:%MZ)"
            git push
          fi

Commit messages: chore(launches): refresh — <ISO timestamp> · N added · M updated · K removed. The orchestrator emits these stats from the merge diff. No preflight (the workflow doesn't gate on typecheck / lint / e2e — it commits raw data; the next push-to-main fires the standard CI chain which validates).


12 · Provider implementation notes

12.1 · GcatSource — historic primary

GCAT publishes catalogs as fixed-width text + TSV. The relevant file for our use case: Orbital Launch Log (derived catalog) which combines orbital launches + payloads in a column-described TSV.

typescript
// src/lib/launches/sources/gcat.ts
const GCAT_BASE = 'https://planet4589.org/space/gcat/tsv/';
const ORBITAL_LAUNCH_LOG = `${GCAT_BASE}derived/launch.tsv`;   // exact path verified at impl time
const RELEASE_PIN = '1.8.0';                                    // bumped explicitly

export class GcatSource implements LaunchSource {
  readonly name = 'gcat';
  readonly priority = 20;
  readonly defaultRole: SourceRole = 'primary';

  async fetchWindow(input) {
    const tsv = await this.fetchTsvCached();
    return this.parseLaunchLog(tsv, input);
  }

  attribution() {
    return {
      citation: `McDowell, J. C. — General Catalog of Artificial Space Objects, Release ${RELEASE_PIN}`,
      url: 'https://planet4589.org/space/gcat/',
      license: 'CC-BY-4.0',
      citation_id: 'gcat-mcdowell',
    };
  }
}

Schema-bump safety: when RELEASE_PIN is bumped, the build fails CI if any GCAT column-name expectations changed (parser asserts header row matches a checked-in fixture). Operator runs npm run fetch:launches -- --gcat-release X.Y.Z --no-cache, reviews the diff manually, updates the fixture, re-commits.

12.2 · NasaSource / SpaceXSource / EsaSource — agency-direct upcoming

Each is HTML scraping or RSS parsing. Implementation discipline:

  • Selectors / parsing logic isolated to one file per provider.
  • Snapshot tests (Vitest) with a checked-in __fixtures__/<provider>.html so a future scraper-break is caught locally before CI.
  • Graceful degradation: provider throws → orchestrator catches, logs warning, continues with the remaining providers. Audit report flags every launch that fell back to LL2 because of an agency-provider failure.

12.3 · LL2Source — fallback aggregator

typescript
// src/lib/launches/sources/ll2.ts
const LL2_BASE = 'https://ll.thespacedevs.com/2.3.0';

export class LL2Source implements LaunchSource {
  readonly name = 'll2';
  readonly priority = 90;
  readonly defaultRole: SourceRole = 'fallback-primary';

  async fetchWindow(input) {
    if (input.mode === 'historic') {
      // Historic comes from GCAT — LL2 only contributes augmentation for entries GCAT already has.
      return this.fetchHistoricAugmentationOnly(input);
    }
    return this.fetchUpcomingPaginated(input);
  }

  attribution() {
    return {
      citation: 'Launch Library 2 — The Space Devs',
      url: 'https://thespacedevs.com/llapi',
      license: 'permissive',
      citation_id: 'll2-thespacedevs',
    };
  }
}

Pagination respects Retry-After on 429s; ETag headers cached. Anonymous tier (15 req/hour) is fine for our cadence; Patreon key is honoured if present in env but never required.


13 · Failure modes + handling

FailureDetectionHandling
Agency-direct scraper breaks (HTML restructure)Provider throws / snapshot test failsOrchestrator logs, falls back to LL2 for affected window. Audit report flags. CI fails IF snapshot test detects the break (caught local).
GCAT TSV download failsHTTP error in GcatSourceUse last cached TSV from .launches-cache/gcat/. Build continues. Audit report flags staleness. If cache is empty (cold + outage), build fails closed (historic mode requires GCAT — no LL2 fallback for the historic primary).
LL2 rate-limitHTTP 429Honour Retry-After; sleep + resume. If still 429 after one retry cycle, log + skip; LL2 fallback entries flagged in audit.
Merge conflict (two providers disagree on status)Field-by-field merge inspectionHigher-priority wins for manifest; both values + sources recorded in audit-report disagreements section.
Curation file references unknown launch idValidate-data warn (not fail)Override is no-op; warning surfaces in audit. Most common cause: a curated upcoming launch slipped to historic between fetches; the override re-binds once the launch appears in the historic manifest.
Citation string missing from text-sources.jsonValidate-data fail-closedBuild fails. Operator adds the citation entry; re-runs. CC-BY non-negotiable.
GCAT major-version bump (1.x → 2.x) with column changesParser fixture-mismatch assertionBuild fails. Operator manually reviews diff, updates fixture, decides whether downstream schema needs migration.
launches-rocket-mapping.json missing a target launcher idValidate-data fail-closedBuild fails. Operator either adds the missing fleet launcher entry OR removes the mapping line.
Conflicting Orrery-internal id (genuine same-day same-vehicle same-mission collision)Orchestrator id-collision checkAppend counter (-2). Audit report flags counter-use as a likely sign of a genuine collision OR a source misalignment worth investigating.

Build never fails closed because of editorial signal quality (a bad scrape, a weird LL2 entry, a contested status). It DOES fail closed on structural validation failures (schema, missing primary provenance, missing citation, missing rocket-mapping target).


14 · Resolved decisions

Resolved 2026-05-19:

  1. Multi-source priority-ordered architecture — RESOLVED. LaunchSource provider abstraction, declared priority per source, orchestrator merges in order. Mirrors the agency-first pattern of ADR-046 (imagery) and ADR-051 (LEARN links).
  2. Historic primary source — RESOLVED: GCAT direct. Bypasses LL2 entirely for historic; cites McDowell (CC-BY); pinned to a specific release version.
  3. Upcoming agency-direct providers in v0.1 — RESOLVED: NASA + SpaceX + ESA. JAXA + Roscosmos + ISRO + CNSA fall back to LL2 in v0.1; agency-direct providers added incrementally v0.2+.
  4. Stable Orrery-internal id — RESOLVED: {YYYY-MM-DD}-{rocket-family-slug}-{mission-slug}. Deterministic across providers; collision counter for genuine same-day same-vehicle collisions.
  5. Merge rule — RESOLVED: First-seen-wins-for-primary-fields + augment-the-rest (§4.3). Disagreements logged not silently overwritten.
  6. Tier algorithm — RESOLVED: Heuristic + curated override (launches-curation.json). Override wins both directions. Editorial-note field on T1 featured launches carries Orrery-authored framing.
  7. /missions/launches UI — RESOLVED: Hybrid month-strip + timeline. Mobile-first 375 px. URL-encoded filter state. untrack() for URL→state effect (per Svelte 5 memory).
  8. Per-decade historic paging — RESOLVED. ~7 files at ~150KB–1MB gzipped each. Lazy-loaded by year-range filter.
  9. CC-BY compliance gate — RESOLVED: ship-gate, not a follow-up. Validate-data fail-closed on missing citation strings in text-sources.json + link-provenance.json. Playwright spec asserts citation visible on /missions/launches, /credits, /library.
  10. Build-cron cadence — RESOLVED: every 6 h (17 */6 * * * to avoid round-minute herd). GCAT pulled weekly via the same workflow's release-version-bump gate. Bot commits diff if changed; standard CI re-validates.
  11. Failure posture — RESOLVED: degrade gracefully on editorial signal failures, fail-closed on structural validation failures (§13).
  12. LL2 access tier — RESOLVED: anonymous (15 req/hour) sufficient v0.1. Patreon key honoured if present (env var) but never required.

15 · Open follow-ups

  1. Verify GCAT Orbital Launch Log TSV path + column set at implementation start. Doc says derived/launch.tsv — confirm at first script run. Build the parser fixture against the real file.
  2. NASA / SpaceX / ESA scraper concrete selectors. Implementation-time work; each provider needs ~½–1 day + ongoing snapshot maintenance.
  3. launches-rocket-mapping.json seed. Author the 23-entry mapping for current fleet launchers as part of v0.1.
  4. Curation file initial seed. Marko adds initial featured overrides for the upcoming Artemis II, any upcoming crewed Starships, any imminent first flights — to validate the loop end-to-end on v0.1 release.
  5. PRD-018 alignment. Migrate PRD-018's image-curation.json to the same version + featured + demoted shape as launches-curation.json (per Marko's directive). Captured as task #5 in this scoping session.
  6. Editorial-note bandwidth. Confirm operator-side: how many editorial_note strings per quarter realistically maintained? If < 5, the field stays on featured launches only. If > 20, consider an editorial-overlay file separate from the curation override file.
  7. Sourcing posture for Roscosmos / CNSA / ISRO upcoming launches. v0.1 uses LL2 fallback; the audit report flags these. v0.2 priority for agency-direct providers in this list — depends on stability of each agency's public press feed.
  8. Path forward when LL2 changes its terms. v0.1 architectural posture (agency-first; LL2 is the leaf, not the trunk) means a hostile LL2 ToS change degrades us gracefully — agency-direct providers + GCAT cover the editorial spine; we'd lose only the small-commercial fill-in. Worth noting because it's exactly the reason for the architectural choice.

RFC-023 · Orrery · Launches Calendar — multi-source agency-first pipeline · Drafted 2026-05-19 · Closes-into-PRD-020

Orrery — architecture documentation · MIT · No tracking