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
LaunchSourceprovider 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-entryprovenance_chainand 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 thevalidate-data.tsfail-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.
{
"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[*].idis the Orrery-internal stable id (§4.2). Not LL2's UUID; not GCAT'sLAUNCH_TAG. Built fromnet-date + rocket-family-slug + mission-slug.provenance_chainis ordered: first entry is the highest-priority source that contributed; subsequent entries noteconfirmed-via,augmented-with, orfallback-primaryroles.- Every entry has at least one
primaryORfallback-primaryrole (validate-data fail-closed). tier_reasonis 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)
{
"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:
{
"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).
// 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:
| Provider | Priority | Mode | Implementation shape | Status v0.1 |
|---|---|---|---|---|
NasaSource | 10 | upcoming | RSS + KSC media-schedule scrape | YES (v0.1) |
SpaceXSource | 11 | upcoming | spacex.com/launches scrape (their schema is stable) | YES (v0.1) |
EsaSource | 12 | upcoming | esa.int press calendar parse | YES (v0.1) |
JaxaSource | 13 | upcoming | global.jaxa.jp press calendar | DEFERRED to v0.2 (falls back to LL2) |
RoscosmosSource | 14 | upcoming | en.roscosmos.ru press | DEFERRED to v0.2 |
IsroSource | 15 | upcoming | isro.gov.in press | DEFERRED to v0.2 |
CnsaSource | 16 | upcoming | (limited public schedule) | DEFERRED to v0.2 |
GcatSource | 20 | historic | TSV download + parse | YES (v0.1) — primary for ALL historic |
LL2Source | 90 | both | REST + ETag cache | YES (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
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:
- First seen wins for
primaryfields — fields that the highest-priority provider sets (net,status,name,mission_name,agency_*,rocket_*) come from the first provider that included them. - 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. - Disagreement is logged, not silently overwritten — if Provider A reports
status: GOand Provider B reportsstatus: HOLD, the higher-priority value wins for the manifest BUT the conflict is recorded in the audit report. Operator reviews; may add the launch tolaunches-curation.jsondemotedor flag a provider bug. - GCAT-only entries (historic) — get
role: "primary"(GCAT is primary for historic). - 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.jsonso/librarysurfaces 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
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.json6.2 · Invalidation triggers (explicit + finite)
| Trigger | Effect |
|---|---|
| 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 page | That page re-fetched; merge re-runs |
launches-curation.json modified | Heuristic + override re-applied; affected entries re-tiered (no re-fetch) |
launches-rocket-mapping.json modified | orrery_launcher_ref re-resolved for every entry (no re-fetch) |
scripts/fetch-launches.ts orchestrator version bumped | Full merge re-runs (no per-source re-fetch unless cache misses) |
| Time-based invalidation | NEVER — only the 6h build-cron + explicit edits trigger re-runs. Mirrors RFC-022 §5.4 discipline. |
6.3 · Cost-per-iteration table
| Operation | Network calls | Parse work | Wall clock |
|---|---|---|---|
| Cache-warm rebuild (nothing changed) | 0 | merge 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) | 1 | small | ~10 s |
Mirrors PRD-020 §11 budget (≤ 8 min cold rebuild).
7 · CLI scope flags (mirrors RFC-022 §6)
# 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-cacheDefault behaviour is incremental + cache-respecting. --no-cache is the explicit "nuke and pave" gesture, opt-in only.
8 · Frontend integration
8.1 · Manifest import
// 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
| Component | Where | Responsibility |
|---|---|---|
LaunchesBanner.svelte | top of /missions/+page.svelte | next 3 T1 cards + T-0 countdown |
+page.svelte (route) | src/routes/missions/launches/+page.svelte | route shell + filter strip + URL sync |
MonthStrip.svelte | inside the route | horizontal scrolling month strip w/ per-month counts |
Timeline.svelte | inside the route | vertical chronological list, grouped by day |
LaunchRow.svelte | inside Timeline | per-launch row + tier chip + status + provenance trigger |
ProvenanceChip.svelte | inside LaunchRow | hover/long-press surface showing provenance_chain |
EditorialNoteCallout.svelte | inside LaunchRow | optional one-line italic Orrery-authored framing |
LauncherFlightsWidget.svelte | top of /fleet/launcher/<id> | NEXT FLIGHT + RECENT FLIGHTS filtered by orrery_launcher_ref |
LaunchCitationsStrip.svelte | footer of /missions/launches | required 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:
// 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:
{
"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.
- Manifest schema validation.
launches.json+ eachlaunches-historic.<decade>.jsonvalidate againstschemas/launch.schema.jsonvia ajv. Malformed = fail. - Required primary provenance. Every entry's
provenance_chainmust contain ≥ 1role ∈ { "primary", "fallback-primary" }. Missing = fail. Catches the case where a future provider bug strips the role. - Citation strings present. Every distinct
sourcevalue present in any entry'sprovenance_chainmust have a corresponding entry intext-sources.jsonANDlink-provenance.jsonwith a matchingcitation_id. Missing = fail. This is the CC-BY compliance gate for GCAT and the attribution gate for every other source. - GCAT release pin recorded.
launches.json.gcat_releasenon-empty when any entry's chain hassource: "gcat". Missing = fail. - Curation file schema.
launches-curation.jsonvalidates againstschemas/launches-curation.schema.json. Referenced launch ids that don't exist in any current manifest = WARN (per PRD-020 M13 reasoning). - Rocket mapping schema.
launches-rocket-mapping.jsonvalidates. Mapping target ids must exist asstatic/data/fleet/launcher/<id>.jsonfiles. Missing target = fail. - No
provenance_chainmissingfetched_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 bynet. 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-debutreason — 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
# .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
fiCommit 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.
// 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>.htmlso 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
// 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
| Failure | Detection | Handling |
|---|---|---|
| Agency-direct scraper breaks (HTML restructure) | Provider throws / snapshot test fails | Orchestrator logs, falls back to LL2 for affected window. Audit report flags. CI fails IF snapshot test detects the break (caught local). |
| GCAT TSV download fails | HTTP error in GcatSource | Use 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-limit | HTTP 429 | Honour 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 inspection | Higher-priority wins for manifest; both values + sources recorded in audit-report disagreements section. |
| Curation file references unknown launch id | Validate-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.json | Validate-data fail-closed | Build fails. Operator adds the citation entry; re-runs. CC-BY non-negotiable. |
| GCAT major-version bump (1.x → 2.x) with column changes | Parser fixture-mismatch assertion | Build fails. Operator manually reviews diff, updates fixture, decides whether downstream schema needs migration. |
launches-rocket-mapping.json missing a target launcher id | Validate-data fail-closed | Build 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 check | Append 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:
- Multi-source priority-ordered architecture — RESOLVED.
LaunchSourceprovider abstraction, declaredpriorityper source, orchestrator merges in order. Mirrors the agency-first pattern of ADR-046 (imagery) and ADR-051 (LEARN links). - Historic primary source — RESOLVED: GCAT direct. Bypasses LL2 entirely for historic; cites McDowell (CC-BY); pinned to a specific release version.
- 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+.
- 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. - Merge rule — RESOLVED: First-seen-wins-for-primary-fields + augment-the-rest (§4.3). Disagreements logged not silently overwritten.
- Tier algorithm — RESOLVED: Heuristic + curated override (
launches-curation.json). Override wins both directions. Editorial-note field on T1 featured launches carries Orrery-authored framing. /missions/launchesUI — RESOLVED: Hybrid month-strip + timeline. Mobile-first 375 px. URL-encoded filter state.untrack()for URL→state effect (per Svelte 5 memory).- Per-decade historic paging — RESOLVED. ~7 files at ~150KB–1MB gzipped each. Lazy-loaded by year-range filter.
- 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. - 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. - Failure posture — RESOLVED: degrade gracefully on editorial signal failures, fail-closed on structural validation failures (§13).
- 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
- 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. - NASA / SpaceX / ESA scraper concrete selectors. Implementation-time work; each provider needs ~½–1 day + ongoing snapshot maintenance.
launches-rocket-mapping.jsonseed. Author the 23-entry mapping for current fleet launchers as part of v0.1.- Curation file initial seed. Marko adds initial
featuredoverrides for the upcoming Artemis II, any upcoming crewed Starships, any imminent first flights — to validate the loop end-to-end on v0.1 release. - PRD-018 alignment. Migrate PRD-018's
image-curation.jsonto the sameversion + featured + demotedshape aslaunches-curation.json(per Marko's directive). Captured as task #5 in this scoping session. - Editorial-note bandwidth. Confirm operator-side: how many
editorial_notestrings 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. - 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.
- 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