Lage.Bonn Problem melden

OpenSpec

Spezifikationsgetriebene Entwicklung: Changes sind vorgeschlagene Vorhaben (Proposal, Design, Tasks, Delta-Specs); Specifications sind die daraus abgeleiteten aktiven Anforderungen. Diese Seite ist statisch aus dem openspec/-Verzeichnis generiert — die Inhalte liegen auf Codeberg.

01 Changes (proposed) 17

Change 10/10 Tasks

Add Civic Kpi Tier1 ↗

add-civic-kpi-tier1

Lage has two KPI families today and neither measures the mission. The 49 Prometheus metrics (cycles, errors, latency, freshness — `apps/web/src/lib/metrics.json`) prove *the machine runs*. The Spec Quality Index (`docs/kpi/spec-quality-index.md`) proves *specs implement cheaply*. Nothing answers the question VISION.md actually poses: **is Lage delivering the three civic moments?**

  • Add read-only KPI accessors to the store that compute Tier-1 metrics over a time
  • Add a GET /api/kpi/product endpoint returning the computed Tier-1 KPI snapshot as
  • Add a prerendered SvelteKit page at apps/web/src/routes/kpi/product/ that renders the
  • Document the exact definitions in docs/kpi/product-kpis.md.
Change 16/17 Tasks

Add Client API Cache ↗

add-client-api-cache

Every JSON GET endpoint in `src/lage/api.py` responds with `Cache-Control: no-cache` and no validator (no `ETag`, no `Last-Modified`). `no-cache` allows the browser to store a response but forces revalidation before reuse — yet with nothing to revalidate against, every request is a full `200` with the full payload. The dashboard refetches `/api/panels` on **every** SSE `update` event and on every reconnect (`apps/web/src/lib/live.svelte.ts`), so a no-op reconnect catch-up re-downloads the full payload even when nothing changed. (When any panel actually changed, a full `200` is correct and unavoidable — `/api/panels` is one aggregate body; the win is scoped to no-op refetches.)

  • status_panel (src/lage/panels.py) becomes time-stable: absolute ISO timestamps (last_fetch, last_ok) replace _age() relative strings; overdue and the now-windowed last_24h/last_72h counts move out of the body. The web status-panel component computes relative labels and overdue flags client-side.
  • The API adds a strong ETag (content hash of the serialized body) to JSON GET responses that currently ship Cache-Control: no-cache, via a **raw ASGI** middleware (never BaseHTTPMiddleware — it would buffer SSE).
  • The API honors If-None-Match on those endpoints and answers 304 Not Modified with an empty body when the ETag matches.
  • Cache-Control: no-cache stays — it is the correct policy for live data (store, but always revalidate). The change adds the missing validator, which makes the browser's HTTP cache effective as the client-side cache.
  • No client JavaScript changes required: fetch('/api/panels') uses the browser HTTP cache by default; on a 304 revalidation the browser transparently serves the cached body as a 200 to the caller.
+1 weitere →
Change 0/19 Tasks

Add Enricher Chain ↗

add-enricher-chain

Item enrichment/normalization (geo tagging, dateline geo/date parsing, press event_at, Nominatim Blaulicht fallback, news point attachment, bonn_only filtering) is duplicated with drift across three call sites: `pipeline.run()` inline closures (`src/lage/pipeline.py:243-329` — the *retiring*, non-deployed store-writer, invoked only via manual `lage pipeline`, `cli.py:58`; `feed-kafka-pipeline` already mandates its retirement), the feed-collector producer (`src/lage/collectors/feed.py:95-133`), and the Kafka consumer (`src/lage/consumers/base.py` per-message `geo.tag_item` + `geocode_blaulicht_fallback`). The load-bearing drift is feed-collector vs. intended behavior: the producer is missing `geocode_blaulicht_fallback` and `attach_news_point`, has **no Open311 branch at all** (Open311Source falls to "unknown source kind" and produces nothing), uses plain `tag_item` for Radzählung where `pipeline.run` is coord-first (`tag_from_coord`), and runs `enrich_dateline_event_at` for all source kinds where `pipeline.run` scopes it to RSS. Every new enricher must be wired into each site by hand and drift is invisible until data quality suffers. Replay is piecemeal: `store.backfill_event_at` / `store.backfill_dateline` exist as surgical one-off methods for specific fields, but there is no *general* mechanism to re-run an arbitrary enricher over stored history — larger corrections still mean full rebuilds (lage-fn93).

  • New module src/lage/enrichment/ defining enrichers as **pure, idempotent stage
  • All three call sites (pipeline.run(), feed-collector _fetch_source, Kafka
  • **Drift fix**: the feed-collector producer catches up to pipeline.run()'s per-kind
  • **Per-stage provenance**: each item records which enricher stages (name + version)
  • **Backfill CLI**: re-run one named enricher over stored history (optionally
+1 weitere →
Change 14/14 Tasks

Add Event Graph Kpis ↗

add-event-graph-kpis

The only shipped correlation KPI (`cross_source_binding` in `GET /api/kpi/product`, `src/lage/store.py:709-910`) is computed over `items.cluster_id` — the **dedup** key with a 36h pairing window — not over the Event graph the Correlator actually builds (`events.item_guids` / `items.event_id`). It reported 0.96% on 2026-07-18 while the real event graph showed 61/1234 confirmed events (4.9%) binding ≥2 sources. We are about to invest in correlation-lift work (epic lage-3dh5: geo enrichment, per-category windows, open311 clustering); without KPIs on the real graph and without any correlator instrumentation (it emits **zero** Prometheus metrics today), lift cannot be told apart from regression.

  • Add an event_graph section to the GET /api/kpi/product response, computed over events + items.event_id:
  • multi_source_event_ratio — confirmed events with ≥2 distinct member source_id / confirmed events (baseline 2026-07-18: 61/1234 = 0.049)
  • item_coverage_ratio — items that are members of a confirmed or candidate event / all items in window (baseline: 1941/5674 = 0.34 all-time)
  • avg_members_per_confirmed_event (baseline: 1.58)
  • uncorrelated_ratio — items with event_id IS NULL and not curation_locked / all items in window
+5 weitere →
Change SQI·B 0/15 Tasks

Add Event Reject Retraction ↗

add-event-reject-retraction

There is no supported way to **retract a confirmed Event**. `POST /api/curation/events/{id}/reject` was written for candidate noise, and `event update` cannot touch `review_status` or `public_urn`. When an Event is confirmed in error, its public URN is already outward-facing (`/api/events`, SSE, `/ngsi-ld/v1/entities/{urn}`) with no way to withdraw it. This blocks `lage-y45z`: 7 pre-gate non-Bonn WDR Lokalzeit Bonn Events were auto-confirmed with leaked public URNs and must be pulled back.

  • **Retraction path.** POST /api/curation/events/{id}/reject extends to **confirmed** Events: it unconfirms them, defaulting review_status back to candidate (optionally rejected), and clears events.public_urn. Retracting is idempotent.
  • **Tombstone in the ledger, not the row.** The retracted URN's row in event_resolution is **retained** — that append-only ledger becomes the permanent tombstone. events.public_urn is set to NULL, preserving the invariant **events.public_urn IS NOT NULL ⟺ review_status = 'confirmed'**.
  • **URN sequence never reused.** URN minting derives its per-year sequence from event_resolution.public_urn (the ledger) rather than the mutable events.public_urn, so a retracted sequence number can never be reassigned to a different Event.
  • **410 Gone for a withdrawn URN.** GET /api/events/urn/{slug} and GET /ngsi-ld/v1/entities/{urn} return **410 Gone** for a URN that exists in event_resolution but has no currently-confirmed Event (retracted/withdrawn), and **404** for a URN that was never minted. Live confirmed URNs are unaffected.
  • **CLI.** New lage event reject <id|urn> [--to candidate|rejected], curator-token guarded, with the dual --api / direct-store path used by event update and event auto-approve.
Change 0/22 Tasks

Add Falsch Gruppiert Feedback ↗

add-falsch-gruppiert-feedback

The correlator sometimes merges unrelated items into one event or scatters one incident across several events, and today only operators notice. Readers who spot a wrong grouping have no way to say so, so mis-groupings persist until an operator stumbles over them — and we have no measure of how often grouping fails in the eyes of users.

  • Add a "Falsch gruppiert?" feedback button to each public event detail page (/ereignisse/[slug]) that lets an anonymous reader report a wrongly grouped event, optionally pointing at the specific member item(s) that don't belong.
  • Add an anonymous public write endpoint POST /api/events/urn/{urn}/misgroup-report — the first unauthenticated write path in the system — with built-in abuse controls (per-IP rate limit, dedup per event+client, payload allowlist).
  • Persist reports append-only in a new misgroup_reports table (Alembic migration), keyed to the event's public_urn and optional item fingerprints.
  • Add a "Falsch gruppierte Ereignisse" KPI: open-report rate over confirmed events, computed in product_kpis() and surfaced on /kpi/product and documented in docs/kpi/product-kpis.md.
  • Add a "Falsch gruppiert" section to /admin: a curator-token-gated queue listing reported events with report counts, linking into the existing membership-editing tools (detach/move/split), with resolve/dismiss actions.
Change 0/16 Tasks

Add Fragdenstaat Source ↗

add-fragdenstaat-source

FragDenStaat.de publishes every Freedom-of-Information (IFG) request directed at Bonn public bodies through a free, auth-less JSON API — what citizens are formally asking their administration (roadworks, building projects, records). This is the civic-transparency layer the Lagebild currently lacks; feasibility was verified live 2026-07-13 (epic lage-ir7u): 195 requests against Kommunalverwaltung Bonn, the newest being the Adenauerallee cycle-lane rebuild — squarely Lagebild-relevant.

  • Add a new collector kind fragdenstaat (API-polling, like open311) that
  • Add a new transparenz category to the Category literal
  • Register the source in sources.yaml: seed allowlist
  • Items carry no coordinates (FOI requests have no location); they flow
  • Re-polls re-emit each request under its stable guid (FragDenStaat request
+1 weitere →
Change 0/35 Tasks

Add Instagram Collector Ocr ↗

add-instagram-collector-ocr

Some Bonn-relevant organisations are most active on Instagram, where a meaningful share of the information lives **inside the graphics** — road closures, maps, event flyers — as text burned into images, not in the post caption. The existing `feed-collector` cannot read Instagram, and nothing in the pipeline extracts text from images. The motivating example is `@radiobonnrheinsieg`, but the capability is **per-handle and registry-driven**: any number of Instagram accounts can be added as registry sources without code changes.

  • Add a new instagram source **kind** to the registry, parameterized by a handle.
  • Extend LageItem with a media: list[str] field (post image / video URLs) and an
  • Add a **separate, asynchronous enrichment consumer** (its own deployment) that
  • **Provenance for scraped + derived content** (the "no source, no trust" invariant):
  • *Acquisition trail* — each scraped item records that it arrived via Apify and which
+3 weitere →
Change 0/33 Tasks

Add Mastodon Baustelle Poster ↗

add-mastodon-baustelle-poster

The Lagebild collects roadworks ("Baustellen tagesaktuell") but only shows them on the Karte to people who visit the site. Bonn residents who follow local traffic on the Fediverse get nothing. A small agent that posts each newly-appearing Baustelle to a bonn.social account turns the existing, already-public roadworks data into a push channel — no new data collection, just a new outbound surface over what the Baustellen panel already renders.

  • New **agent mastodon-baustelle-poster**: on a cadence, it reads the baustellen panel's entries from the internal panels API (/api/panels), diffs them against a persisted watermark of already-announced baustelle guids, and renders one Mastodon status per newly-seen Baustelle from the fields the panel entry actually exposes (title = street + Maßnahme, a best-effort teaser, and a coordinate-anchored /karte?lat=..&lon=.. link). It does **not** state a validity window — the panel carries no true planned end (see design.md).
  • **Consumes the panels API, not Kafka.** The poster is a read-only client of the rendered Baustellen panel; it does not subscribe to collector.baustelle and adds no coupling to the collector's transport (ADR-0006 stays an internal concern). Nothing is posted until the baustelle collector (change baustellen-tagesaktuell) lands and the panel carries entries — until then the poster runs and finds an empty panel, which is a valid no-op.
  • **Dry-run by default.** With no credentials configured, the agent renders each status to a log/preview sink and advances its watermark **without publishing**. Publishing to bonn.social happens only when a bot token and an explicit enabled flag are both present. This is the human gate: outbound public action is off until deliberately switched on. The autonomy decision — unattended public posting under a bonn.social identity — is recorded in **ADR-0009** (with ADR-0008 as precedent), not settled implicitly here. Note this re-syndicates data already public on the Karte; it is not a bypass of the correlator's confirm-gate (Baustellen are never auto-confirmed Events — ADR-0003 D-1).
  • **First-run burst guard.** On first run against a non-empty panel (or any run where the watermark is missing), the agent seeds the watermark from the current panel **without posting**, and caps posts-per-cycle thereafter, so standing up the agent never floods bonn.social with the entire current backlog of open roadworks.
  • **Not framed as Events.** Baustellen are *planned* construction with a validity window — ConstructionProject, explicitly **not** an Event (ADR-0003 D-1). The poster reads the Baustellen *items* panel, never the confirmed-Events table, and mints no Event URN. Its posts are traffic notices, not event announcements.
+1 weitere →
Change 0/20 Tasks

Add Nordbruecke Topic Page ↗

add-nordbruecke-topic-page

The Nordbrücke (Friedrich-Ebert-Brücke, A565) is fully closed since 2026-06-03; the Vorlandbrücke is being demolished (since 2026-07-15) and rebuilt until end of 2028, while the long-term 6-lane Ersatzneubau (2030s) is in Vorplanung. This is Bonn's dominant traffic crisis — commuters need a 10-second mobile answer to "what's the impact right now", and residents need "what changed" — but the story is scattered across autobahn.de, bonnbewegt.de, bonn.de press, and ALLRIS. lage.bonn already collects most of the raw data; a single canonical `/nordbruecke` topic page is the missing piece. Research: `docs/research/2026-07-16-nordbruecke-scope-sources-feasibility.md` (lage-zasw.1, feeds bd lage-qbtr).

  • New mobile-first topic page /nordbruecke following the /fahrradfahren pattern, section order by value-per-visit: status banner (closed / reopening target) → live Verkehr impact → official build status → milestone timeline → Stadtrat decision trail (collapsible) → static context box (Rheinspange 553, Ennertaufstieg — context only, no data tracking).
  • Autobahn fetcher additionally ingests the A565 closure service (today only roadworks/warning) — the Nordbrücke closure entries live there, incl. Bauphase dates and "Ende der Gesamtmaßnahme".
  • New collector scraping bonnbewegt.de news (official Autobahn GmbH Rheinland comms; only source with build-status narrative: Vergabe, Abriss, milestones). RSS there is broken/empty — scrape of the Drupal news listing with stable dated URLs.
  • Build-status press slice: keyword regex (Nordbrücke|Friedrich-Ebert-Brücke|A\s?565) over already-collected stadt-bonn-presse items inside the panel function. (A separately registered filtered press RSS was battle-tested out: its guids are a subset of the existing feed, so fingerprint dedup would leave the new source owning ~zero rows.)
  • Curated milestone timeline: repo-owned data file seeded from the research note (1967 opening → closure → demolition → 2028 reopening → 2030s Ersatzneubau), served as a panel. Auto-append is deferred (v1.x); curation stays a git-reviewed file edit — deterministic, no new admin surface.
+2 weitere →
Change 30/30 Tasks

Add Pegel Bonn Page ↗

add-pegel-bonn-page

The Rhein at Bonn is the city's dominant natural risk-and-resource axis — low water throttles shipping and reads as drought; high water is flood and, past a legal threshold, closes navigation. The public `W` (Wasserstand) gauge for **Rhein / BONN** (PEGELONLINE UUID `593647aa-9fea-43ec-a7d6-6476a76ae868`, km 654.8, gauge zero 42.713 m ü. NHN) is free, auth-less, and updates every 15 minutes. A single climatology chart — today's level read against the 2000–2025 seasonal envelope — turns a bare number into "is this normal for mid-July?" at a glance, and pairs naturally with the civic "so what?": **is shipping on the Rhein currently allowed?** Bonn's own UDP dashboard already publishes the water-level view; lage.bonn should carry it first-party, plus the navigation-status layer the dashboard lacks.

  • Add a standalone **/pegel** page (the SvelteKit /fahrradfahren route
  • **Gauge hero**: the current level in cm as prominent digits, an hourly
  • **Static river cross-section**: a non-animated SVG schematic showing the
  • The centerpiece **climatology / envelope chart** matches the original
  • Median 2000 bis 2025 — median of daily means per calendar day (blue line)
+13 weitere →
Change 4/43 Tasks

Add Statewide Review Queue ↗

add-statewide-review-queue

ADR-0015's statewide arm auto-publishes Events whose WDR member links sit directly under `/nrw/<slug>.html`. lage-yj0c measured that **~1 in 6 of those are in fact about one specific non-Bonn city** — the lage-y45z retraction class. The operator decision (2026-07-21) is to accept that rate rather than forgo statewide coverage, which is defensible **only if every statewide publish is actually reviewed**.

  • Post-publish review becomes a **first-class Event lifecycle state**, distinct from
  • A **persisted review marker** so the queue can be driven to zero. A time-boxed
  • A **review deadline enforced by the existing cron**: any statewide publish still
  • The queue **recomputes** which Events published via the statewide arm from persisted
  • Reviewing an Event **records a decision** (kept / retracted), so the queue answers
+2 weitere →
Change 17/17 Tasks

Add Swb Schedule Board ↗

add-swb-schedule-board

`/bahn`'s departure board (`BahnAbfahrten` + `CorridorSchematic`, `node.departures` off `/api/corridor`) only ever shows a station's board when the live EFA feed is currently tracking a departure. The genuinely real-time SWB transit layer (GTFS-RT ServiceAlerts, vehicle positions) is blocked on a gated API key that has to be requested from opendata.bonn.de/VRS (lage-e3i/lage-axt4 — code landed dormant, key not yet issued). Rather than wait on that human step, this change routes around it: the VRS regional **GTFS-static** schedule feed (`download.vrsinfo.de/gtfs/google_transit.zip`) is a separate, confirmed **key-free** download (DL-DE Zero 2.0, daily rebuild) — enough to fill empty board slots with a scheduled ("geplant", no live confirmation) departure instead of nothing, at the stations that matter most for corridor accessibility.

  • **Critical-station set** (corridor_geo.critical_stations()): a documented,
  • **SWB GTFS-static collector** (collectors/swb_schedule.py): parses the
  • **Blend rule — REALTIME WINS, schedule only FILLS GAPS** — enforced at the
  • **--planned blue departure token** (app.css, all 4 theme blocks, WCAG-AA
  • **Realtime-coverage KPI** (/kpi/realtime-coverage, `/api/kpi/realtime-
+1 weitere →
Change 0/12 Tasks

Add Vision Readiness Kpi ↗

add-vision-readiness-kpi

VISION.md commits to a civic graph where `Ereignis ≠ Bericht ≠ Behauptung`, every `Anliegen` chains to an `Akteur` and a `Beschluss`, every `Indikator` carries the `Angebot`s that address it, and every entity carries provenance and a verification level. Today's schema is a flat `LageItem`; none of those entities exist yet.

  • Add a docs/kpi/vision-readiness-kpis.md catalog: each Tier-2 KPI with its
  • Add a harness module that exposes each KPI through a uniform contract returning
  • Add GET /api/kpi/vision-readiness returning the harness snapshot, plus a Prometheus
  • The prerendered /kpi/vision-readiness burndown page is **deferred** until ≥2 KPIs can
  • As each backing change archives, a follow-up task here flips that KPI's provider from
Change 11/13 Tasks

Allow Statewide Wdr Geo Gate ↗

allow-statewide-wdr-geo-gate

Operator policy (lage-s5a6): NRW-wide (statewide) WDR Lokalzeit Bonn content is acceptable to auto-confirm because it includes Bonn; only stories about a specific non-Bonn place must stay on the human gate. The `bonn_located` condition (ADR-0013) gates on concrete Bonn geo only, so statewide items — which typically carry no Bonn `stadtbezirk` or coordinates — are gated too: stricter than intended. Example: `bonn-2026-1159` ("Rhein bei Niedrigwasser", NRW-wide) was auto-confirmed pre-gate and deliberately KEPT in the lage-y45z retraction, but under the live gate an identical item would be skipped.

  • sources.yaml's auto_approve flag gains a third accepted value:
  • One statewide link classifier for wdr-lokalzeit-bonn, as a code constant
  • wdr-lokalzeit-bonn switches auto_approve: bonn_located
  • lage event auto-approve dry-run and confirm output label statewide-passed
  • New **ADR-0015** (statewide-scope exemption), extending ADR-0013 the same way
+1 weitere →
Capabilities event-curation-api2
Change 21/21 Tasks

Fix Blaulicht False Merge ↗

fix-blaulicht-false-merge

Production event `bonn-2026-1388` (id 2421) merged six unrelated Blaulicht press releases from two police authorities into one `Ereignis`: two Oberberg traffic crashes, two Euskirchen traffic crashes, school vandalism, and a Gewahrsam case. The event's public title claims "Fahrradfahrer bei Verkehrsunfall schwer verletzt" while its member list is a random slice of two press feeds. A reader cannot trust that an `Ereignis` describes one occurrence.

  • **Generalise the authority-prefix strip.** Normalisation SHALL strip a leading
  • **Add a Blaulicht boilerplate stoplist.** Normalisation SHALL drop the
  • **Drop bare numeral tokens.** Dates, times and ages (20, 17, 35) are not
  • **Expose --min-shared-idf-mass as a Correlator CLI flag.** The floor is pinned at
  • the shared-mass floor's sum-vs-peak semantics,
+2 weitere →
Capabilities correlator2
Change 16/17 Tasks

Narrow Bonn Only Ingest Scope ↗

narrow-bonn-only-ingest-scope

`bonn_only` is meant to keep out-of-town Blaulicht noise out of the Lagebild (`sources.yaml`'s own comments say "keep only Bonn/Umland-relevant items"), but the producer only tests `geo_source != "none"` — a gazetteer-*hit* test, not a location test. Before lage-yfdn, a suppressed Umland mention (`geo_source == "neighbor"`) carried no coordinates yet still passed this test, so `polizei-oberbergischer-kreis` / `polizei-euskirchen` items with `lat`/`lon`/ `stadtbezirk` all NULL reached the store despite the filter's stated intent. lage-yfdn (merged) now gives most of these items real coordinates via the presseportal dateline, which narrows the gap but does not close it — the test itself is still the wrong shape, and three Blaulicht feeds that cover an entire Kreis (`polizei-rhein-sieg`, `feuerwehr-sankt-augustin`, `feuerwehr-bad-honnef`) carry no `bonn_only` at all. Over a 200-event sample of the live API, out-of-town Blaulicht outnumbers Bonn's own police roughly 6:1 in the public event stream.

  • Replace the geo_source != "none" test with a real location test: an item passes bonn_only
  • Extract the (currently duplicated three times in pipeline.py, once in feed.py) inline
  • Wire geo.enrich_dateline_geo into pipeline.py's RSS/RadioBonn/SWB fetch paths (it currently
  • Extend bonn_only: true to the three currently-unscoped region/Kreis-wide Blaulicht feeds

02 Specifications (active) 33

03 Architecture Decision Records 25

Architektur­entscheidungen (ADR) halten das Warum hinter tragenden Designentscheidungen fest. Sie liegen unter docs/adr/ auf Codeberg.

ADR·0001 Accepted
Central Postgres (CloudNativePG) as the shared state store ↗
ADR·0002 Active
Alembic for versioned database schema migrations ↗
ADR·0003 Active
Event entity: thin referent, staging-only identity, hybrid key ↗
ADR·0004 Active
Canonical English model identifiers; German via JSON-LD vocabulary labels ↗
ADR·0005 Active
`category` as SKOS concept scheme(s); notation-string binding ↗
ADR·0006 Accepted
Kafka as the transport backbone (collector → store and watcher → panel) ↗
ADR·0007 Active
A Vorlage is evidence, never an Event ↗
ADR·0008 Active
Trusted-source auto-approval: an opt-in, guardrailed bypass of the human confirm gate ↗
ADR·0009 Active
Outbound public posting: an opt-in, two-key, dry-run-default autonomy grant ↗
ADR·0010 Accepted
Two-tier storage for the rail corridor Lagebild (snapshot + event log) ↗
ADR·0011 Proposed
SpacetimeDB is not adopted as the system of record ↗
ADR·0012 Active
Citizen-authored (UGC) sources are categorically ineligible for auto-approval ↗
ADR·0013 Active
Geo-conditional allow-list membership: Bonn-located gate on ADR-0008 auto-approve ↗
ADR·0014 Proposed
Advocacy-operated sources — operator classification, attribution, and licensing posture ↗
ADR·0015 Active
Statewide-scope exemption to the Bonn geo gate ↗
ADR·0016 Active
Automated retraction of unreviewed conditional-arm publishes ↗
ADR·0017 Active
Community-resolution provenance overrides derived liveness on the internal Event, not the published referent ↗
ADR·0018 Active
Enriched `evtcat` event-category vocabulary: theme layer + new leaves ↗
ADR·0019 Proposed
ADR: Trinkbrunnen map layer — OSM source and no runtime coupling ↗
ADR·0020 Proposed
ADR: OParl as the council-data interface, and `Akteur` identity ↗
ADR·0021 Active
Curation audit actor is a path, not an identity ↗
ADR·0022 Active
Kreis-wide Blaulicht outside Bonn+Umland is out of the public corpus ↗
ADR·0023 Active
The auto-approve geo gate follows the shared Bonn+Umland boundary ↗
ADR·0024 Active
Site feedback is stored without a client identifier and is moderated, never acted on ↗
ADR·0025 Proposed
The bike networks are a routable tier, not a display overlay ↗

04 Changes (archived) 36

05 Spec Quality Index

SQI misst wie billig eine Spec implementiert wurde — keine Stakeholder-Fragen, keine Rework-Runden, keine falschen Behauptungen. Zwei Teilscores: I (Implementability) und F (Fidelity); Blend 50/50 → SQI. Methodologie ↗

A 90–100 Straight-throughB 80–89 Minor gapsC 70–79 CorrectionsD 60–69 EscalationF <60 Unusable
Change SQI I F Reqs Conf. Graded
add-community-resolution-feedback 67 D 67 67 19 2026-07-23
S6 S6 S5 S4 S4 S4 S3 S3 S3 S3 S3 S3 S3 S3 S3
add-event-kritisch-label 89 B 100 78 3 2026-07-18
S1 Spec explicitly offered 'inaktiv styling OR omit the severity badge' for no-severity Events. Impl resolved the choice by omitting (SEVERITY_COLOR has no 'inaktiv' entry, so the badge renders nothing). Spec did its job by naming the choice.S3 Spec required 'reusing the signal badge's severity-driven colour' but was silent on the colour SOURCE. Impl inlined hex values (#ef4444/#f59e0b/#22c55e) in EventSeverityBadge rather than importing a shared token, so the signal/event colours are now two copies that can drift.
add-event-reject-retraction 88 B 100 75 6 2026-07-18
S4 Proposal asserted the POST /reject endpoint default flips to 'candidate' as a clean change; it oversimplified — both existing frontend callers (CandidateCard reject-noise, ereignisse/[slug] delete) posted bodyless and relied on the old default='rejected'. Impl corrected by making both send explicit {to:'rejected'}, preserving their semantics. No human needed.S3 tasks.md 4.1 said 'append a curation-surface note, forward pointer not re-narration' but did not cite docs/adr/CLAUDE.md's append-discipline form. canon-warden flagged the custom-titled section above References as drift; impl self-fixed to a dated ## Postscript at file bottom (one gate round).S1 tasks.md 4.4 was explicitly conditional ('Update event-* architecture.md IF documented there'); impl introspected and found no architecture.md files exist (only spec.md), so the invariant/URN-allocation shape is carried by the delta specs the archive syncs. Resolved without a human.
add-geo-conditional-auto-approve 74 C 100 47 2 2026-07-17
S3 Design said auto_approve_source_ids() stays booleans-only but was silent that the existing truthy check had to be tightened to `is True` so a string value never also counts as unconditional; impl resolved this alone to honor D2's partition.S3 Spec/design silent on the dry-run --json output shape for geo-gated skips; impl decided to keep the flat selected-list (no new geo_skipped key) to avoid breaking external consumers.S3 Design D2 discussed the two registry accessors but was silent that _SourceBase.auto_approve: bool in models.py would reject the string value 'bonn_located' at the Pydantic loader; impl surfaced it (task.blocked) and, after lead confirmed the delta spec's fail-closed intent, widened to bool|str. Surfaced as a lead round-trip only because of the dev-loop's artificial scope walls forbidding models.py edits; in a solo apply this is a silent gap-fill, not a stakeholder escalation — hence S3, not S6. The mechanical resolution (union, not Literal) was already dictated by the delta spec's ignore-unknown-values requirement.
refine-issue-category-street-granularity 100 A 100 100 1 2026-07-16
add-rail-delay-aggregates 80 B 100 61 4 2026-07-16
S3 S4 S2 S1
add-manual-collector 95 A 100 90 5 2026-07-13
S3 Spec was silent on the collector's CLI name ('manual') vs its source id ('manual-ops') split, and that run.py's is_enabled(args.name, args.name) keys on the CLI name, not the source id. Impl self-resolved by mirroring the existing open311 precedent (yaml id 'anliegen-bonn' != CLI name 'open311'). No human, non-blocking.
2026-07-04-fix-event-model-item-guids-canon 100 A 100 100 1 2026-07-12
S0 Landed diff (45726fa) matches the MODIFIED delta 1:1 — the three `LageItem.fingerprint` mislabels (field def + create scenario + update scenario) corrected to the guid-token wording exactly as the delta specced. Doc-only, no code/migration change, as designed. Remaining diff lines are incidental blank-line churn from `openspec archive` sync, not a spec divergence.
2026-07-09-add-non-autobahn-geometry 84 B 100 69 4 2026-07-10
S1 Radverkehr route-matching spike returned 0 valid Overpass matches (counting stations are point sensors, not named routes). Impl took the design.md-named fallback (keep existing point geometry). Spec explicitly framed this as a spike with a pre-named fallback.S4 Spec's proposed News mechanism (spaCy NER + Nominatim, per bd-kto0 and task 3.2 'add Nominatim call site') was found wrong/oversimplified: heavy dep + would bypass the gazetteer NEIGHBOR_GEMEINDEN suppression. Impl corrected to an offline-gazetteer Point-upgrade, no human, no Nominatim call.S1 Amtlich WFS spike found no OGC WFS exists at stadtplan.bonn.de; impl took the design.md D4 static-Stadtbezirk-polygon fallback, already delivered via geo._apply. Spec named the fallback for this contingency.
2026-07-09-add-ereignisse-linked-data 94 A 100 87 7 2026-07-10
S4 Spec (ADR-0003 D-1 / task 1.1) asserted eventStatus in the emitted allow-list, but it is not a defined term in either the lage or NGSI-LD core context and the Event model has no field/source for it. Impl corrected the interpretation: allow-list = MAY emit; eventStatus is never emitted today, so both the closed-allow-list and defined-term invariants hold. No human, no vocab change.
2026-07-09-baustellen-tagesaktuell 98 A 100 96 11 2026-07-10
S3 Spec (task 3.1 'confirm/extend EndPoll purge handling') was silent on a concrete defect: consumers/base.py run_stdin only recognized NINA-shaped warningId:msgType guids, so feed-mode items (open311 and now baustelle) were rejected on the stdin path. Impl found and fixed this gap alone; no human, no rework round.
add-trusted-source-auto-approve 92 A 100 83 4 2026-07-02
S1 Allow-list membership explicitly deferred to the operator at merge, with a recommended seed. impl resolved it via the documented recommendation; no human block, work completed.S3 Spec covered sources.schema.yaml docs + registry reader but was silent that the extra=forbid pydantic loader needs a matching field for the new auto_approve key. impl discovered and added it proactively (no verifier fail).
improve-correlation-precision 66 D 100 32 2 2026-07-02
S4 S3 S1 S1 S1
add-event-membership-editing 95 A 100 90 20 2026-06-23
S1 Spec explicitly deferred whether /review/ereignisse redirect is permanent or temporary (default: redirect now). Impl resolved autonomously to a 301 permanent redirect. No human.S1 Spec explicitly deferred whether split lets the curator choose event_category (default: derive default). Impl followed the derive-default path. No human.S4 Spec asserted the column be added to src/lage/models.py (LageItem model / items table mapping). The items table mapping actually lives in src/lage/store.py (_items sa.Table); impl added the column there and a DB-only drop in row->LageItem hydration. models.py was not touched. Spec location was wrong; corrected without a human.S3 Spec frontend Impact named only routes/admin/events, relocated review components, and TopNav. Impl additionally had to extend web/src/lib/eventStream.ts with a CurationItem interface and curation_locked field for the excluded-items view. Spec was silent; impl decided alone.
harden-openapi-contract 70 C 54 86 8 2026-06-21
S7 Spec (proposal+design+tasks 2.4, via battle-test C2) asserted /api/events should model the AS-IS bare-array body and /api/events/{id} a flattened no-wrapper dict. Against current api.py this premise was false (api.py:674 already returns canon {items,total}; :796 returns {event,items,signals}); modelling the stale shape would have broken the task 3.2 schema-vs-body gate. Implementer journaled task.blocked ('Halting for artifact-update decision'); design.md + tasks.md were edited at apply to unblock. Spec defect halted impl until the spec changed.S3 Spec route enumeration was written against an older api.py and was silent on 5 live public routes that needed typed models: /api/radzaehlung/counts, /api/kpi/geo-coverage, /api/kpi/product, /api/events/urn/{slug}, /feed/allris.atom. Implementer discovered and added them to scope alone, no human.S3 Spec was silent on whether the curator-token-gated routes (GET /api/curation/events + 4 POST .../{confirm,recategorize,merge,reject}) belong in the public schema. Implementer decided to exclude them, extending the C6 precedent set for /api/versions, with no human.S1 Design Open Question explicitly deferred: shared ErrorResponse model vs per-route inline shape for /api/events/{id} 404. Spec named the unknown and told impl to resolve at task 2.4; impl resolved to one shared ErrorResponse via introspection, no human.
add-event-identification-ui 88 B 92 85 19 2026-06-20
S2 server_default satisfies NOT NULL without separate backfillS3 spec silent on open_events excluding rejected (C-A1 leak fix)S3 spec silent on festival keyword match algorithmS4 tasks.md asserted router module contradicting monolith requirement; impl inlinedS3 spec silent on migrating pre-existing tests to confirmed-onlyS1 spec deferred generate-py OR load-at-import; impl resolved to load-at-importS5 one extra verifier FAIL round on dual-dialect migration task
add-event-correlation 91 A 91 91 16 2026-06-16
S3 S5 S3 S3
open311-bonn-collector 61 61 5 2026-06-15
S3 S3 S3 S1 S3 S2
add-vorlagen-party-filter 95 A 100 90 5 2026-06-15
S3 Spec is internally inconsistent on the exact aria-live status string: the scenario/example text reads "Grüne · 7 von 23 Vorlagen" while design.md (D4) and tasks.md 2.4 show "{selected} · {visible.length} von {data.count}" with no trailing 'Vorlagen'. The implementer silently chose the shorter form (no 'Vorlagen' word) with no human input. Under-specified detail filled alone.
add-radzaehlung-source 89 B 100 78 6 2026-06-15
S3 GeoJSON location fallback property names (bezeichnung, name) — spec said join on lage; impl added undocumented alternatives when lage is absentS3 CSV column name case variants (Lage, Wann_Datum, Anzahl_Raeder) — spec referenced lowercase column names; impl silently handled mixed-case CSV headersS3 Summary/title format strings — spec said conveys the hourly profile; impl chose format independently
add-provenance-invariant-gate 42 F 7 78 3 2026-06-14T15:30:00Z
S7 task.blocked: original violation definition unimplementable — spec halted until lage-tlb shipped + full re-specS6 decision: user consulted to choose scope fork A/B/CS3 metrics-collection path unspecified; impl chose /api/status hookS1 alert for: window not specified; impl resolved to 5m
oepnv-per-bezirk-signals 89 B 100 78 4 2026-06-13
S4 Spec asserted UI tests for the suppression scenarios; no component test framework exists in web/ and the scope wall forbids adding one. Impl substituted svelte-check type-verification of the HEALTHY_SUPPRESSED map structure, journalled the decision, and rewrote the task. Spec claim was oversimplified; resolved without a human.
add-issue-category-scheme 84 B 100 69 4 2026-06-13
S3 Spec required byte-identical deterministic sync but was silent on how to model each mapping resource. Impl found blank nodes get fresh rdflib IDs each parse (non-deterministic .nt) and chose named IRIs (bonn:1.1 .. bonn:retired-gruenschnitt) alone, without a human. Gap the spec should have named.S4 Serving task asserted that registering both resources in registry.ts (plus verifying the index/content-negotiation) was the serving step. In this repo routing is per-resource: each served resource also needs its own SvelteKit +server.ts route handler, or the path 404s on every representation while still listing on the /lage index. Spec/task omitted the route-handler step; the build-only verifier never made an HTTP request so it false-passed. Corrected without escalation by adding the two route handlers and re-verifying over live HTTP.
add-allris-watcher-signal 89 B 100 78 1 2026-06-12
S1 HTTP-200 HTML maintenance page parses as well-formed empty feed (bozo=False) -> watcher returns 'up'. Spec explicitly flagged this as verify-and-branch (design D2 'verified by the fixture, not assumed'; Risks documents residual false-up as accepted; tasks 5.2 conditional bead). Impl confirmed via fixture, xfail'd the test, filed lage-0i4. Resolved by introspection, no human, no rework loop.
add-allris-atom-feed 56 F 69 42 4 2026-06-11
S4 /feed.atom renamed to /feed/current.atom — spec said combined /feed.atom route 'SHALL be unaffected' and scenario 'Combined feed is unchanged'; /atom alias kept, /feed.atom URL goneS4 proposal asserted 'k8s: none — no new workload, ingress' change; manifest/base/ingress.yaml required routing for /feed/*.atom (399eedf)S5 deploy fail->fix->fail->fix: first fix routed /feed/*.atom to api (399eedf), still broken because OpenShift drops pathType Exact, second fix to Prefix (f03c254) — 1 extra roundS3 HEAD handlers added for all feed routes — spec silentS3 /feed web index page (+page.svelte) + nav/footer links — spec silent on any web UIS3 scripts/check-feeds.py + scripts/openspec-journal.py — spec silent on operational helpers
nina-warn-signal 92 A 100 85 6 2026-06-10
S4 Delta spec asserted the window cut-off 'SHALL be evaluated in the database query (NOW()-relative) for single-clock comparison'. Impl computes the cutoff client-side (effective_now - timedelta) and compares first_seen >= cutoff string. Built without a human; verifier passed. The single-clock/DB-side intent (lens F6) was the spec's assertion; impl simplified to a passed-now. Fidelity divergence the spec should be corrected to match (or impl hardened).
migrate-feed-pipeline-to-kafka 98 A 100 95 9 2026-06-10
S1 design.md left 'one collector for all sources vs one Deployment per source-type' open. Impl resolved it via introspection — one feed-collector run_once iterating all due sources (collectors/feed.py). No human, no rework. The spec did its job by naming the unknown.S1 design.md D-3 left 'consumer re-tags vs trusts the producer tag' to decide in tasks. Impl resolved it — geo.tag_item + bonn_only applied at the producer (collectors/feed.py). Self-resolved, no human.
nina-collector 80 B 100 61 9 2026-06-09
S4 Spec asserted wrong NINA API field names: payload.data[] (not info[]), languageCode (not language); effective treated as reliable (absent in practice). Impl discovered real shape from live API, self-corrected. Spec updated in-place during apply (write-back done).S4 Spec listed wrong geocodes: 051110000000 (Düsseldorf) and 053160000000 (Leverkusen) instead of 053140000000 (Bonn) and 053820000000 (Rhein-Sieg-Kreis). Impl fixed silently in commit e04ca4d. Archived spec still contains the wrong values — write-back NOT done.S3 Spec stated poll interval=60s default but was silent on CLI configurability. Impl added --interval flag and --once mode (needed for CronJob pattern). No human consulted.S3 Spec silent on observability. Impl added Prometheus /metrics HTTP endpoint and Kubernetes ServiceMonitor (commits 5338d0e, ba3239b). No mention in spec or tasks.S3 Spec silent on liveness mechanism. Impl added --heartbeat-path file-touch for Kubernetes livenessProbe (commit 7b6768f). Infra requirement not stated.S3 Spec silent on Kafka producer durability config. Impl explicitly sets acks=all on Producer to prevent silent drops on Cancel messages. Derived from operational reasoning, not the spec.S3 Spec said provision a KafkaTopic but was silent on the Strimzi bootstrap service name. Impl required lage-kafka-bootstrap (Strimzi naming convention), corrected in commit 7db82b3 after initial failure.

scripts/gen-specs.py (just update-specs) · scripts/gen-sqi.py (just update-sqi)