ADR-051: Multi-source Events, OpenAgenda, and the Temporal Lifecycle
- Status: Accepted — events become a multi-source layer with a relevance-filtered, deduplicated, distance-ranked read. OpenAgenda is the second source (delivered separately, #984); the weekly temporal refresh is delivered by #985 and its mechanism is recorded in §4 below. This ADR records the shared decision the three issues build on.
- Date: 2026-08-10
- Depends on: ADR-040 (Local-first reference data — single PostGIS source), ADR-026 (external data sources and attribution), ADR-049 (Zone opening and import-time completeness).
- Supersedes / relates to: ADR-044 (removal of the data.gouv markets source) — the multi-source shape this ADR generalises is the one ADR-044 removed for markets; events reintroduce it deliberately, with the deduplication and relevance the markets experiment lacked.
- Numbering note: ADR-048 is reserved by #936, ADR-049/050 are taken. The next free number is 051.
Context and Problem Statement
Events were the last reference layer wired to a single source in a way no other layer still is. Concretely, before this decision:
tourism.eventshad nosourcecolumn; the runtime stampedsource: 'datatourisme'as a hard-coded constant inScanEventsHandler.- There was no deduplication: had a second source existed, the same festival would have appeared twice.
EventRepository::findActiveNearordered bystart_date, capped atLIMIT 100, with no link filter and no relevance filter — it returned up to a hundred events a day, earliest-starting first, including linkless entries a rider cannot act on and categories (a generic fallback, young-audience activities) a bikepacker does not plan a detour around.
Every other curated layer already had the multi-source shape this one lacked: POIs, food POIs, cultural POIs and accommodations each expose a …SourceInterface + …SourceRegistry tagged-iterator, and the OSM/DataTourisme overlap is collapsed by NearbyNameDeduplicator (ADR-040). Events were the exception, and adding OpenAgenda as a second events source (#984) is impossible without first giving events the same shape.
Two further properties of events, absent from the other layers, shape this decision:
- Events are dated and perishable. A POI is there next year; an event that ended last week is dead weight. The append-only, obsolescence-assumed storage model of ADR-040 is correct for places but wrong for events, which need a temporal lifecycle — a periodic refresh that adds what is upcoming and drops what has passed (#985).
- Relevance is about the rider's detour, not the catalogue. A curated catalogue lists everything; a stage roadbook should surface the handful of events near where the day ends that a rider would actually reroute for.
Decision
1. source becomes a per-row column, not a runtime constant
tourism.events gains source text NOT NULL DEFAULT 'datatourisme' (forward migration Version20260810120000, on top of the collapsed baseline of #972). The default backfills the rows the flux already imported and lets a DataTourisme promotion run without spelling the origin out on every COPY line. The provisioner writes it explicitly all the same (DataTourismeImporter::TABLE_COLUMNS, staging DDL), so a second source that writes a different value slots in with no schema change. The runtime reads the column instead of the constant.
2. The multi-source runtime mirrors the POI pattern exactly
A tagged-iterator EventSourceInterface (app.event_source) + EventSourceRegistry, copied from App\Poi\PoiSource*. DataTourismeEventSource adapts the existing EventRepository; OpenAgenda joins by implementing the same interface, tagged automatically. The registry merges every source and collapses the same event reported twice through the shared NearbyNameDeduplicator (name + 75 m proximity, the curated DataTourisme entry winning on a tie — the same rule already used for places). Reusing the existing deduplicator, rather than inventing an event-specific key, is deliberate: events carry no stable cross-source id, so name-and-proximity is the only identity available, and it is exactly what ADR-040 already settled for the OSM/DataTourisme place overlap.
3. Relevance, link and cap are decided once, across sources
The registry applies, after the merge and dedup, the rules that must hold identically whatever the origin:
- A link is mandatory.
EventRepositoryfiltersurl IS NOT NULL AND url <> ''in SQL (efficient, before the per-source cap); the registry re-checks it so a source that forgets cannot leak a linkless event into a stage. An event a rider cannot open is noise. - Relevance is a category whitelist. Only
festival,concert,exhibition,sports,fair,showsurvive — the app-normalised event vocabulary the DataTourisme mapper already produces. The genericeventfallback and any young-audience / children category a source normalises outside this set are dropped. Because each source normalises its own taxonomy onto this one vocabulary before the registry sees it, this single whitelist is the shared audience filter across sources: there is no per-source relevance code to keep in sync. - Ranked by distance to the stage end point, capped at 20. A rider cares about what is near where the day ends, not about the earliest start date; and a roadbook shows a handful of events, not a hundred. The repository orders by distance and bounds the per-source read; the registry re-ranks the merged set by distance and caps it.
4. The temporal lifecycle is a periodic refresh, not the append-only place model
Events are perishable, so the append-only / obsolescence-assumed storage of ADR-040 does not carry over unchanged. This is the one place the reference-data storage model is intentionally not append-only, and it is scoped to events precisely because only events have an expiry. Places keep the ADR-040 model untouched.
The refresh is delivered by #985 as a dedicated provisioner command, events-refresh (provisioner/src/EventsRefreshCommand.php, its own bin/events-refresh binary):
- Upsert, not append-only insert. For every open zone (discovered from
osm.zones, the same registryprovisionwrites) the national feeds are re-downloaded once and each zone's events are promoted throughEventsPromotion:INSERT … ON CONFLICT (id) DO UPDATEof the mutable fields (name, start/end date, url, description, price, category, tags, geom, source,last_seen_at). An event whose dates moved is updated in place, never duplicated.zoneis not in the update set — it is provenance, recording which zone first imported the row. This replaces the append-onlyZonePromotionpath for events; places still go through it unchanged. - Purge in the same transaction. After the upsert, and in the same transaction,
DELETE FROM tourism.events WHERE end_date < :todaydrops what has passed (a separate statement after the upsert, so it also removes a just-imported event that has already ended). The purge is global, not zone-scoped: a passed event is dead weight everywhere.:todayis a calendar date computed once in the command with an explicitEurope/Paristimezone and spliced as a literal — nevernow()/CURRENT_DATEin SQL, whose day boundary would drift with the server timezone. - Called both ways.
provision <zone>delegates its events promotion to the sameEventsPromotion(the DataTourisme finish and the OpenAgenda run both upsert-and-purge instead of the old append-only events insert), so opening a zone and refreshing it agree byte-for-byte.events-refreshruns the same promotion for every open zone from one download per feed. - Scheduling: a Coolify scheduled task, not a resurrected
osm-cron. The cron container of ADR-033 was removed (superseded by ADR-036) for the Docker socket it needed and the Compose coupling it carried.events-refreshneeds neither: it writes onlytourism.events— no schema swap, no Valhalla restart, no socket — so it is scheduled out of band as a weekly Coolify scheduled task (default Sunday 03:00 UTC, configurable), running the provisioner image with theevents-refreshentrypoint. This is a manual production-configuration step, documented in docs/runbooks/events-refresh.md and docs/deployment.md, not automated by this repository.
Consequences
Positive
- Events reach parity with every other curated layer: adding OpenAgenda (#984) is now "implement
EventSourceInterfaceand tag it", with dedup, relevance, link filtering and the cap already handled centrally. - The stage roadbook shows relevant, openable, nearby events instead of up to a hundred date-sorted entries including noise.
- The relevance and audience rules live in exactly one place (
EventSourceRegistry), so they cannot drift between sources. - DataTourisme is non-regressed: the column defaults to
datatourisme, the importer still writes it, and the existing read path returns the same events minus the linkless and irrelevant ones.
Negative
- The distance cap is applied per source then re-applied on the merge, so a source's nearest-20 is what feeds the union — a globally-21st-nearest event a source did not return in its own top-20 cannot reappear. Accepted: the per-source bound is generous relative to how many relevant, dated, in-radius events a 20 km disc holds on a given day.
- The relevance whitelist is a fixed list in code. A new event category worth surfacing needs both the source's mapping and this whitelist updated. Accepted as the price of deciding relevance once rather than per source.
Neutral
- The 20 km radius and the 20-event cap are constants this ADR introduces but does not sanctify; they can be tuned without reopening the decision.
- The temporal refresh mechanism (the
events-refreshcommand, the upsert + purge SQL, the weekly Coolify scheduled task) is specified in §4 and delivered by #985. The scheduling itself is a manual production-configuration step, not automated by this repository.
Sources
- ADR-040 — local-first PostGIS reference index,
NearbyNameDeduplicator, and the append-only / obsolescence-assumed storage model this ADR narrows for the perishable events layer. - ADR-026 — external data sources and attribution; the parent of the multi-source data model.
- ADR-044 — the earlier multi-source events experiment (markets), removed; the shape reintroduced here with dedup and relevance.
- ADR-049 — per-zone promotion and import-time completeness, the pipeline the events column and COPY order slot into.
- Issue #975 — multi-source events, relevance filtering and cap (this ADR's origin).
- Issue #984 — OpenAgenda as the second events source.
- Issue #985 — weekly temporal refresh and purge.