Snapshots stamp the version they were computed under. A "stale" pill in the portfolio or trend card deep-links here so reviewers can see exactly what changed since.
v2026.12.sS11_catchment_geometry_audit2026-05-28current
Sprint S11 — NEW page 67 "Catchment geometry audit" slots into the Methodology & defensibility bucket after the Reproducibility manifest. Exposes the exact Mapbox Isochrone API inputs (pin, mode, minutes, profile, assumed free-flow speed), the derived geometry that came back (area km²/m², perimeter, vertex count, bounding box E–W × N–S, compactness ratio, centroid offset from pin, expected free-flow disc, reach efficiency vs disc), and renders the polygon itself overlaid on the live Mapbox Dark basemap so an independent reviewer can verify the reach matches the address before trusting downstream metrics. No AI, no new data dependencies — every figure is computed from the existing `data.catchmentRing` + `data.audit` + `data.areaKm2` payload using a single `haversineKm` import from `@/lib/spatial`.
- NEW page 67 row in `src/lib/page-registry.ts` (label "Catchment geometry audit", bucket "Methodology & defensibility", minBlocks: 2).
- Appended §67 after §66 in PROFESSIONAL_ORDER + PRELIMINARY_ORDER (`report-cuts.ts`).
- Appended §67 after §66 in all three `layoutPagesFor` branches + executive cut + COUNCIL_CUT (`report-pages.ts`); MAYOR_CUT untouched.
- NEW {include(67) && …} renderer block in `report-template.tsx` between the Reproducibility manifest (66) and Bibliography (19); reuses the existing `<CatchmentMap>` visual primitive.
- Version bump invalidates cached narrative rows so the next report open re-paints against the new spine.
v2026.12.sS10_defensibility_appendix2026-05-23
Sprint S10 — NEW Defensibility Appendix (pages 62–66) makes every recommendation, observation and finding in this report independently re-derivable from the PDF alone. Five pure pages slot into the existing 'Methodology & defensibility' bucket between the Methodology Appendix (18) and the Bibliography (19): 62 Calculation lineage (inputs → formula → parameters → output → uncertainty for every indicator that fired) · 63 €-driver coefficient ledger (4-driver Value-at-Stake table with low/central/high bands, sensitivity rank, sources) · 64 Live-anchor provenance (which Copernicus / EEA / NASA / OpenAQ feeds fired live vs which fell back to v0 surrogate) · 65 Rule firing log (which insight rules, intervention rules and recommendation branch emitted what fired) · 66 Reproducibility manifest (audit id, pin, reach, methodology version, changelog tail + reproduction checklist). NEW `src/lib/defensibility-appendix.ts` is the single source of the five pure builders — no AI, no new data dependencies, no schema migration.
- NEW `src/lib/defensibility-appendix.ts` — buildCalculationLineage / buildEuroDriverLedger / buildLiveAnchorLog / buildRuleFiringLog / buildReproducibilityManifest + REPRODUCIBILITY_CHECKLIST.
- NEW pages 62–66 in `src/lib/page-registry.ts` (bucket 'Methodology & defensibility').
- Inserted 62–66 after §18 in PROFESSIONAL_ORDER (24→29pp) and 62/63/66 in PRELIMINARY_ORDER (22pp) in `report-cuts.ts`.
- Inserted 62–66 after §18 in all three `layoutPagesFor` branches + COUNCIL_CUT; executive cut gets 62 + 63 only (light defensibility) in `report-pages.ts`.
- Five {include(62..66) && …} renderer blocks added to `report-template.tsx` immediately after the Methodology Appendix (18) block.
- Version bump invalidates cached narrative rows so the next report open re-paints against the new spine.
v2026.12.sS9_about_this_report2026-05-23
Sprint S9 — NEW page 61 "About this report". Plain-English product primer that sits between the cover/TOC/legend chrome and the Executive Summary, so a forwarded PDF reads correctly to a reader who has never met Urban Pulse. Five labelled blocks: what Urban Pulse is · what makes it different (4 USP bullets) · what's in THIS report (auto-derived from the visible TOC buckets via `PAGE_BY_ID`, so a Mayor 1-pager reads honestly) · why it's useful for YOU (pivoted by `clientType` + `reportPurpose` via `whyUsefulFor()`) · how to read it (live page pointers to §40 legend, §36 glossary, §18 methodology). NEW `src/lib/about-this-report.ts` SINGLE source for the page's copy. Wired into `PROFESSIONAL_ORDER` (now 24pp), `PRELIMINARY_ORDER` (now 19pp), all three `layoutPagesFor` branches, `executiveCutFor` and `COUNCIL_CUT`. `MAYOR_CUT` stays `{1, 29}` — the 1-pager remains a 1-pager. No AI generation, no new data dependencies; the page renders identically across every audit except for the audience-pivoted paragraph and the auto-derived bucket list.
- NEW `src/lib/about-this-report.ts` — PRODUCT_BLURB, USP_BULLETS, BUCKET_PLAIN_ENGLISH (one row per TOC bucket), HOW_TO_READ_POINTERS, whyUsefulFor().
- NEW page 61 in `src/lib/page-registry.ts` (label "About this report", bucket "How to read this report").
- Inserted §61 after §40 in PROFESSIONAL_ORDER + PRELIMINARY_ORDER (`report-cuts.ts`).
- Inserted §61 after §40 in all three `layoutPagesFor` branches + `executiveCutFor` + `COUNCIL_CUT` (`report-pages.ts`).
- NEW {include(61) && …} renderer block in `report-template.tsx` between cover and Executive Summary.
- Version bump invalidates cached narrative rows so the next report open re-paints against the new spine.
v2026.12.sS8_ai_section_intros2026-05-23
Sprint S8 (PR-8 of Report Structure Upgrade) — AI-generated, audit-specific Section Intros & Summaries. NEW `src/lib/section-intros-ai.server.ts` SINGLE source: batched JSON call to `google/gemini-3-flash-preview` produces a `{introBody (≤55 words), summaryBody (≤60 words)}` pair per Professional-spine opener, audience-pivoted via `audienceForVariant` (mayor/planner/analyst). Word caps clamped at sentence boundaries; banned marketing tropes (vibrant/world-class/cutting-edge…) deterministically stripped. Persisted to `report_drafts.sections.sectionIntrosAi[audience][pageId]`. NEW `generateSectionIntros` server fn shim in `narrative.functions.ts` (dynamic import keeps client bundle clean). `loadNarrativeDraftImpl` now returns `sectionIntrosAi` so repeat opens are free. `ReportData.aiSectionPanels?: Record<pageId, {introBody, summaryBody}>` threaded into `<SectionIntro pageId aiPanel>`: AI body renders as italic 'Briefing for this catchment' block above the static 5-field panel; AI summary renders as 'In summary' block below. All 4 currently-wired SectionIntro call sites (pages 16/18/19/36) pass `aiPanel={data.aiSectionPanels?.[n]}`. Silent fallback on AI gateway failure → static `SECTION_INTROS` panel only.
- NEW `src/lib/section-intros-ai.server.ts` — batched JSON gateway call, word-cap + trope strip, persistence to `report_drafts.sections.sectionIntrosAi`.
- NEW `generateSectionIntros` server fn shim in `narrative.functions.ts` with `SectionIntrosInput` Zod schema.
- Extended `loadNarrativeDraftImpl` to surface `sectionIntrosAi` so cached opens skip regeneration.
- Extended `ReportData` with `aiSectionPanels?: Record<number, {introBody, summaryBody}>` and `<SectionIntro>` to accept optional `aiPanel` prop.
- Threaded `aiPanel={data.aiSectionPanels?.[n]}` through all 4 wired SectionIntro call sites in `report-template.tsx` (pages 16/18/19/36).
- Auto-fire from report loader after sectionProse with `≥4 of 4 openers hydrated` skip heuristic.
- Version bump invalidates cached narrative rows so the next report open re-paints against the new AI surface.
v2026.12.sS7g_methodology_plain_english2026-05-23
Sprint S7 Stage G — Plain-English block per indicator. NEW `INDICATOR_PLAIN_ENGLISH: Record<indicatorId, IndicatorPlainEnglish>` sibling map in `src/lib/methodology.ts` carries a 7-field structured restatement of every indicator (`whatItMeasures` · `whyUsed` · `inputsPlain` · `outputs` · `howToRead` · `limitations` · `whereInReport`) derived from each indicator's own `definition` + `parameters` + `uncertainty.description`. The `/methodology` route now renders this 7-row labelled grid first in plain-English mode; the formula + parameter table sits behind a `Show technical detail` toggle. `plainEnglishFor(id)` is the SINGLE accessor; the renderer falls back to the existing glossary one-liner when an indicator has no plain-English row. INDICATORS array + CITATIONS map unchanged (evidence base preserved). Adding a new indicator = extend `INDICATORS` + add ONE row to `INDICATOR_PLAIN_ENGLISH`.
- NEW `INDICATOR_PLAIN_ENGLISH` map in `src/lib/methodology.ts` with 7-field rows for all 17 indicators.
- Exported `plainEnglishFor(indicatorId)` accessor; type `IndicatorPlainEnglish`.
- Updated `/methodology` renderer (`src/routes/methodology.tsx`) to render the 7-row labelled grid in plain-English mode; technical formula/parameters/citations stay behind the existing toggle.
- Version bump invalidates cached narrative rows so the next report open re-paints against the new plain-English surface.
v2026.12.sS7_report_compiler2026-05-22
Sprint S7 (PR-7 Wave 2) — Editorial intros + Tables + Export checklist + Post-render PDF QA. Stage C widened `SECTION_INTROS` from 2 to 5 fields (`whatThisSectionDoes`, `whatTheReaderWillFind`, `whyItMatters`, `methodsUsed`, `mainTakeaways`) across all 21 entries; `<SectionIntro>` renders the new panel with a legacy fallback. Stage D added `src/lib/table-registry.ts` SINGLE source of `Table N.` numbering + metadata. Stage A added `src/lib/professional-report-payload.ts` with `EXPORT_CHECKLIST` (18 items × `codePrefixes`) + `evaluateExportChecklist(blockerCodes)`. Stage J re-skinned `<ExportBlockedModal>` to render the 18-point checklist (green ✓ / red ✗ per item, drill-down to matching codes). Stage I added `scripts/qa/pdf-qa.mjs` post-render inspection (broken-glyph patterns, char-density floor/ceiling per page, TOC page-number integrity) as a CI-runnable Node script.
- Widened `SECTION_INTROS` to a 5-field panel and rewrote all 21 section entries.
- NEW `src/lib/table-registry.ts` — deterministic `Table N.` numbering for the Professional spine.
- NEW `src/lib/professional-report-payload.ts` — typed payload contract + `EXPORT_CHECKLIST` + `evaluateExportChecklist()`.
- Re-skinned `<ExportBlockedModal>` to render the 18-point publishing checklist with pass/fail glyphs and drill-down.
- NEW `scripts/qa/pdf-qa.mjs` — post-render layout invariants (broken glyphs, density, TOC integrity) runnable in CI via `node scripts/qa/pdf-qa.mjs --pdf <path>`.
- Extended `BLOCKED_STRINGS` with broken-glyph regex patterns; QA gate enforces 5-field intro completeness + table registry metadata.
- Version bump invalidates cached narrative rows.
v2026.12.sS6_acceptance_envelope2026-05-22
Sprint S6 (PR-6 of Report Structure Upgrade) — Testing & Acceptance Envelope. Updated `scripts/acceptance/fixtures.ts` to reflect the new Professional spine envelope introduced by PR-1 (Introduction & Approach + Key Findings promoted into `PROFESSIONAL_ORDER`). `professional-cut-18p` page-count cap moved from `[16, 20]` → `[18, 24]` (matches the brief's 18–24pp Professional envelope); `preliminary-cut-16p` cap moved from `[14, 18]` → `[14, 22]`. Fixture labels re-tagged Sprint S6 with explanatory comments tying the change to PR-1's spine growth. All 38 fixtures now pass against the post-PR-1..PR-5 codebase (tier ladder, blocker codes, visible-page bounds, and snapshot diff all green). No production code changed — pure test-harness update. Version bump invalidates cached narrative rows so the next report open re-paints against the live methodology version surface.
- Updated `professional-cut-18p` fixture in `scripts/acceptance/fixtures.ts`: `visiblePageCountAtMost` 20 → 24, `visiblePageCountAtLeast` 16 → 18 to match PR-1's widened Professional spine (Intro & Approach + Key Findings).
- Updated `preliminary-cut-16p` fixture: `visiblePageCountAtMost` 18 → 22 to absorb the same spine growth (Preliminary inherits Professional ordering minus scenarios/funding).
- Re-tagged fixture labels Sprint S6 with explanatory comments tying the envelope change to PR-1.
- Acceptance harness now green at 38/38 fixtures across tier ladder, blocker codes, visible-page bounds, and snapshot diff.
- Version bump invalidates cached narrative rows so the next report open re-paints against the live methodology version surface.
v2026.12.sS5_export_gate2026-05-22
Sprint S5 (PR-5 of Report Structure Upgrade) — Export Gate. NEW `professionalSpineIssues(data, visiblePages)` in `src/lib/qa-gate.ts` is the Professional-mode completeness check that ties PR-2's editorial registries (`SECTION_INTROS`, `PAGE_VISUALS`) and PR-1's spine (`PROFESSIONAL_ORDER` + Methodology/Glossary appendices) to the renderer's actual `visiblePages` set. Three blocker classes fire only when `reportMode === 'professional'`: (a) section-intro presence — every visible page in `PROFESSIONAL_ORDER` that the registry marks as a section opener (`SECTION_INTRO_PAGE_IDS`) MUST have a non-empty `eyebrow` + `body` in `SECTION_INTROS`; (b) figure-caption / figure-source presence — every declared visual in `PAGE_VISUALS[pageId]` whose page is visible MUST carry a non-empty `caption` AND non-empty `source` attribution; (c) appendix presence — `visiblePages` MUST contain page 18 (Methodology) and page 36 (Glossary). Wired into `runQaGate` immediately after `professionalGateIssues`. Non-Professional modes (`preliminary`, `technical_draft`, unset) are structurally unaffected because the gate short-circuits on the mode check. Static ES imports added for the three registry modules — no lazy `require`. NON-breaking — pure additive blocker; pre-PR-2 fixtures that left the registries fully populated stay green; only a Professional export that has lost an intro/caption/source/appendix between PR-2 and now is refused. Version bump invalidates cached narrative rows.
- NEW `professionalSpineIssues(data, visiblePages)` blocker in `src/lib/qa-gate.ts` enforcing PR-2's section-intro + figure-caption/source contracts and PR-1's Methodology + Glossary appendix presence.
- Three blocker classes (`professional.intro.{pageId}`, `professional.figure.caption.{pageId}.{primitive}` + `.source`, `professional.appendix.{pageId}`) fire ONLY when `reportMode === 'professional'`.
- Static ES imports for `SECTION_INTROS` / `SECTION_INTRO_PAGE_IDS` / `PAGE_VISUALS` / `PROFESSIONAL_ORDER` added at the top of `qa-gate.ts`.
- Wired into `runQaGate` immediately after the existing `professionalGateIssues` call so the export panel surfaces the blockers in the same group.
- Version bump invalidates cached narrative rows so the next report open re-paints against the now-enforced spine contract.
v2026.12.sS4_quality_sweep2026-05-22
Sprint S4 (PR-4 of Report Structure Upgrade) — Quality Sweep. POI contradiction fix across pages 10/18 + Data sample card + page-18 ledger row (now emits 'Modelled POI density (live OSM unavailable)' with warn-tone chip when `data.pois.length === 0`). Extended `BLOCKED_STRINGS` with nine new entries (`Audit ledger`, `Snapshot ledger`, `Snapshot confidence`, `No LLM in the loop`, etc. + raw UUID pattern); renderer copy edits remove every active instance.
- POI contradiction fix: all four `data.pois.length === 0` call sites in `report-template.tsx` (pages 10, 18, Data sample card, page 18 ledger row) now emit `Modelled POI density (live OSM unavailable)` with a warn-tone chip instead of the literal `0 OpenStreetMap places` + bad-tone bucket.
- POI ledger row status flips from `Missing` to `Proxy` when the live OSM extract is empty — matches the actual data path (surrogate density still drives scores).
- Override-share card prints `—` instead of `(0 / max(1,0)) * 100%` when no live POIs are present.
- Extended `BLOCKED_STRINGS` with nine new entries: `Audit ledger`, `audit ledger`, `Snapshot ledger`, `Snapshot confidence`, `No LLM in the loop`, `No LLM`, `Every catalog intervention re-scored`, `Open the Scenario Lab`, plus a UUID pattern that catches raw `audit.id` leaks.
- Renderer copy edits remove every active instance of the newly banned phrases: page 10 trailing copy → `audit record`; page 18 trend-history heading → `Trend history`; trend-history source attribution dropped the banned `Sprint 25` ref; page 18 confidence block → `Confidence summary`; page 31 intervention shortlist preamble → `All catalogue interventions scored against this neighbourhood baseline`.
- Page 18 methodology block now renders `Audit reference: {id.slice(0,8)}` instead of the full UUID; the trend-shift annotation no longer embeds a `/audit/{uuid}/diff` URL.
- Version bump invalidates all cached narrative rows so the next report open re-paints with the corrected copy.
v2026.12.sS3_toc_integrity2026-05-22
Sprint S3 (PR-3 of Report Structure Upgrade) — Pagination Integrity. Rebuilt the §39 Table of Contents in `src/lib/report-template.tsx` so it walks the actual `visiblePages` array IN PHYSICAL ORDER and groups consecutive ids by `PAGE_BY_ID[id].bucket` (single source from `src/lib/page-registry.ts`).
- Rewrote the §39 TOC body to walk `visiblePages` in physical order and group consecutive ids by `PAGE_BY_ID[id].bucket`.
- Dropped the inline `TOC_LABELS` literal and `BUCKETS` array in `report-template.tsx`; labels now resolve via `TOC_PAGE_LABELS_STATIC`, section grouping via `PAGE_BY_ID[id].bucket`.
- Ghost entries structurally impossible — TOC reads from the same `visiblePages` array the renderer paints.
- Version bump invalidates cached narrative rows.
v2026.12.sS2_section_intros2026-05-22
Sprint S2 (PR-2) — Section Intros + Figure Registry. Two new SINGLE-source modules wire the editorial scaffolding the Professional Client Report has been missing: (1) `src/lib/section-intros.ts` declares a plain-English standfirst paragraph for every section-opening page in `PROFESSIONAL_ORDER`, keyed by page id, with an `eyebrow` label and a ≤55-word `body` written for an exhausted mayor. (2) `src/lib/figure-registry.ts` declares the visuals each Professional-spine page paints (`PAGE_VISUALS`), assigns deterministic sequential `Figure N.` numbering by walking `PROFESSIONAL_ORDER`, and exposes `figureMetaFor(pageId, primitive, occurrence)` so renderer call sites resolve `{figureNumber, caption, source}` from one place. `Frame` in `src/lib/report-visuals.tsx` (the SINGLE wrapper every named PDF visual extends) gains an optional `figureNumber?: number`; when set, the rendered title is prefixed `Figure {N}. {title}` so external citations stay stable across runs. New `<SectionIntro pageId={n}>` react-pdf component (`src/lib/report-section-intro.tsx`) looks up the intro and paints nothing when the page is not a declared opener. `PROFESSIONAL_ORDER` is now exported from `report-cuts.ts` so the figure registry can derive numbering. Version bump invalidates all cached narrative rows so the next report open picks up the new editorial scaffolding. NON-breaking — additive optional fields throughout; no €-coefficient changes, no schema migration, no new env vars.
- Added `src/lib/section-intros.ts` (SINGLE source) — `SECTION_INTROS` table, `sectionIntroFor(pageId)`, `SECTION_INTRO_PAGE_IDS` for the QA gate (PR-5).
- Added `src/lib/figure-registry.ts` (SINGLE source) — `PAGE_VISUALS` per-page declaration, deterministic `Figure N.` numbering walked from `PROFESSIONAL_ORDER`, `figureMetaFor`, `professionalFigureCount`.
- Extended `Frame` in `report-visuals.tsx` with `figureNumber?: number`; `VisualFrame` prefixes the title with `Figure {N}. ` when set, leaves it unchanged otherwise (Analyst Pack stays clean).
- Threaded `figureNumber` through all 18 named-primitive call sites so the prop reaches every visual without per-primitive boilerplate.
- Added `<SectionIntro pageId={n}>` (`src/lib/report-section-intro.tsx`) react-pdf component — drops the eyebrow + body above any section-opening page.
- Exported `PROFESSIONAL_ORDER` from `report-cuts.ts` so downstream registries derive numbering from the canonical spine order.
- Version bump invalidates all cached narrative rows so the next report open re-generates against the section-aware editorial scaffolding.
v2026.12.sN13_hedges2026-05-22
Sprint N13 (Narrative Quality Upgrade Tier 12) — audience-aware hedge density cap with retry-once-then-deterministic-prune enforcement. Decision-makers discount hedged prose ("may", "could", "potentially", "appears to") and the discount scales with audience: mayors fastest, planners moderately, analysts not at all (they quantify uncertainty with numbers). NEW `src/lib/narrative-hedges.ts` is the SINGLE source of two coupled tables: `HEDGE_CAP` (mayor=1, planner=3, analyst=8 — counts hedge tokens permitted across one section) and `HEDGE_TOKENS` (curated vocabulary spanning single-word modal hedges `may`/`might`/`could`/`possibly`/`potentially`/`perhaps`/`presumably`/`arguably`/`seemingly`/`ostensibly`/`supposedly`/`tentatively`/`conceivably`, vague-magnitude hedges `somewhat`/`fairly`/`rather`/`roughly`/`approximately`/`around`/`about`, verb-form hedges `appears to`/`seems to`/`tends to`/`is likely to`/`is unlikely to`, and multi-word qualifiers `in some cases`/`in many cases`/`to some extent`/`to a degree`/`more or less`/`by and large`). Intentionally excludes `should` (directive, not hedging) and `will` (commitment, not hedging) so the cap doesn't punish real recommendations. Detection via case-insensitive whole-word regex per token (`\b...\b`) so `may` doesn't match `mayor` and `around` doesn't match `ground`. `findHedgeIssue(audience, prose)` returns `{count, cap, hits, exampleSentence}` when `count > cap`; null otherwise. `formatHedgeDirective(issue)` is the retry directive (names the audience, quotes the overshoot count vs cap, lists the worst offenders, and includes an audience-specific tone rule). `pruneHedges(prose, audience)` is the deterministic final-fallback: walks a priority-ordered token list (high-dilution hedges first like `potentially`/`possibly`; vague-magnitude hedges last like `approximately`/`around` because they often precede a real number, so losing them changes meaning least) and removes occurrences until the count drops to the cap, then collapses the whitespace + ` .` / ` ,` artefacts the removal leaves behind. Chained into `runSectionNarrative` AFTER the Sprint N12 citation strip (so a citation rewrite can't reintroduce hedging) and BEFORE the Sprint N7 readability gate (so the readability gate scores de-hedged prose). Same shape as N10 + N12: retry-once-then-deterministic-prune. Retried candidate re-validated against BOTH the brief-wide ±5% numeric guardrail AND the N6 numeric-provenance allow-list so the hedge-rewrite cannot smuggle in new unsupported numbers. Differs from N12: this is a COUNT cap (not a hard zero), and the prune is occurrence-by-occurrence rather than wholesale strip. Adding a new banned hedge = extend `HEDGE_TOKENS` ONLY; cap tuning = edit `HEDGE_CAP` ONLY. Version bump invalidates all cached narrative rows so the next report open re-generates against the hedge-capped pipeline. NON-breaking — additive only; no €-coefficient changes, no schema migration, no new env vars.
- Added `src/lib/narrative-hedges.ts` (SINGLE source) — `HEDGE_CAP` (mayor=1/planner=3/analyst=8), `HEDGE_TOKENS` curated vocabulary, `findHedgeIssue(audience, prose)`, `formatHedgeDirective`, `pruneHedges`.
- Chained the hedge cap into `runSectionNarrative` AFTER N12 citation strip and BEFORE N7 readability; retry-once-then-deterministic-prune pattern.
- Prune walks a priority-ordered list (modal hedges first, vague-magnitude hedges last) and removes occurrence-by-occurrence until the count drops to the audience cap — surrounding sentences stay intact, no numbers ever removed.
- Hedge-retry candidate is re-validated against both the ±5% numeric guardrail and the N6 numeric-provenance allow-list so rewrites cannot leak new unsupported numbers.
- Adding a new banned hedge = extend `HEDGE_TOKENS` ONLY; cap tuning = edit `HEDGE_CAP` ONLY. Detector + prune read the same tables.
- Version bump invalidates all cached narrative rows so the next report open re-generates against the hedge-capped pipeline.
v2026.12.sN12_citations2026-05-22
Sprint N12 (Narrative Quality Upgrade Tier 11) — inline `[Source YYYY]`-style citation policy, audience-gated. NEW `src/lib/narrative-citations.ts` is the SINGLE source of two coupled rules: (1) `analyst` cuts MAY use inline bracketed citation tokens drawn from a curated whitelist (`CITATION_TOKENS` — 18 entries spanning the `methodology.ts::CITATIONS` academic anchors plus the Sprint N5 live-anchor vocabulary: `[EEA CNOSSOS-EU 2017]`, `[NASA POWER]`, `[Sentinel-5P TROPOMI]`, `[JRC LISFLOOD 2020]`, `[Copernicus DataSpace]`, `[Copernicus DEM]`, `[Copernicus Urban Atlas 2018]`, `[Copernicus EMS]`, `[ERA5]`, `[Eurostat Urban Audit 2024]`, `[GHSL R2023A]`, `[Mapillary]`, `[Transitland]`, `[Strava Metro]`, `[OSM]`, `[OpenAQ]`, `[WHO 2018]`, `[Moreno 2021]`, `[Jacobs 1961]`, `[Frank 2010]`, `[Ewing & Cervero 2010]`, `[Shannon 1948]`, `[Barron 2014]`); (2) `mayor` and `planner` cuts MUST NOT contain ANY `[…]`-bracketed citation token — they read as jargon and break the plain-English contract enforced by `AUDIENCE_PIVOT.mayor`. Detection via broad `/\[[A-Za-z][^\[\]\n]{0,80}\]/g` regex (catches model-invented variants like `[European Environment Agency 2020]`, not just the whitelist); `findCitationLeak(audience, prose)` returns null for analyst, returns matched tokens (deduplicated, max 8) for mayor/planner. Filters out non-citation brackets: `[1]`-style numeric refs, empty brackets, and bracketed prose > 60 chars. `formatCitationStripDirective(leak)` is the retry directive (quotes the offending tokens back verbatim so the model knows exactly what to remove). `stripCitations(prose)` is the deterministic final-fallback strip (removes the bracketed token + collapses the double-space / ` .` / ` ,` artefacts the removal leaves behind). `formatAnalystCitationDirective()` is the analyst-only user-prompt addendum surfacing a 10-token working subset of the whitelist; encourages — does NOT force — inline citations at most one per paragraph, only where they ground a number a reviewer would re-derive. Chained into `runSectionNarrative` AFTER the Sprint N10 acronym strip (so an acronym rewrite can't reintroduce citations) and BEFORE the Sprint N7 readability gate (so the readability gate scores cleaned prose). Same shape as N10: retry-once-then-deterministic-strip. Retried candidate re-validated against BOTH the brief-wide ±5% numeric guardrail AND the N6 numeric-provenance allow-list so the citation rewrite cannot smuggle in new unsupported numbers. Adding a new permitted citation = extend `CITATION_TOKENS` (+ optionally the surfaced subset in `formatAnalystCitationDirective`) ONLY. Version bump invalidates all cached narrative rows so the next report open re-generates against the citation-gated pipeline. NON-breaking — additive only; no €-coefficient changes, no schema migration, no new env vars.
- Added `src/lib/narrative-citations.ts` (SINGLE source) — `CITATION_TOKENS` whitelist, broad `[…]` detector, `findCitationLeak(audience, prose)` (analyst-exempt), retry directive, deterministic stripper, analyst-only prompt addendum.
- Chained the citation lint into `runSectionNarrative` AFTER N10 acronym strip and BEFORE N7 readability; retry-once-then-deterministic-strip pattern (same shape as N10).
- Analyst cuts now receive `formatAnalystCitationDirective()` appended to the user prompt surfacing a 10-token working subset of the whitelist; non-analyst cuts never see this addendum.
- Citation-retry candidate is re-validated against both the ±5% numeric guardrail and the N6 numeric-provenance allow-list so rewrites cannot leak new unsupported numbers.
- Adding a new permitted citation = extend `CITATION_TOKENS` ONLY; the detector + stripper read the same list, the analyst directive surfaces a curated subset.
- Version bump invalidates all cached narrative rows so the next report open re-generates against the citation-gated pipeline.
v2026.12.sN11_trend_delta2026-05-22
Sprint N11 (Narrative Quality Upgrade Tier 10) — prior-period €-driver deltas threaded into the §6 `implementationRoadmap` and `fundingBusinessCase` per-section narratives. Capitalises on the Sprint Q8-tail `AuditTrendPoint.driverCentral` work (which already populates per-driver central €/yr inside `listAuditSnapshots`) by exposing the top-3 movers — ranked by `|currentEur − previousEur|` — to the narrative pipeline. NEW `PlannerEnrichment.trendDelta` (`src/lib/prompts/planner.ts`) carries `{previousElr, currentElr, previousEuroCentral, currentEuroCentral, previousVersion, currentVersion, topMovers[]}`; mirrored in both `EnrichmentSchema` Zod copies (`narrative-core.server.ts` + `narrative.functions.ts`) so the server-fn payload validates the same shape. `snapshotToBrief` emits a `TREND VS PREVIOUS RUN: ELR p→c · €/yr p→c · methodology p→c` line plus a `BIGGEST €-DRIVER MOVERS SINCE PREVIOUS RUN` block with `±€/yr` signed magnitudes per mover. Both `SECTION_PROMPTS.implementationRoadmap.core` and `SECTION_PROMPTS.fundingBusinessCase.core` (`src/lib/prompts/sections.ts`) gain a conditional clause that REQUIRES naming the top mover verbatim when the brief contains the `BIGGEST €-DRIVER MOVERS` line — and explicitly forbids inventing a delta when it does not (so first-run audits read clean). Roadmap ties the top mover to the phase-1 baseline-lock action; Funding frames it as defensibility evidence for the lead bid. The report loader (`audit.$auditId.report.tsx`) derives `sectionTrendDelta` from the trend snapshot points immediately before composing `sectionEnrichment`: driver-id → human-readable name mapped via the current run's `baselineImpact.drivers` so we never invent a label; movers with `|delta| < €1/yr` filtered out; gracefully `undefined` when fewer than two snapshots exist or both lack `driverCentral` (legacy pre-Sprint-23). Existing N6 numeric-provenance allow-list still gates the generated prose so the model cannot quote a mover number absent from the brief. Version bump invalidates all cached narrative rows so the next report open re-generates against the trend-aware roadmap and funding prompts. NON-breaking — additive optional field only; no €-coefficient changes, no schema migration, no new env vars.
- Added `PlannerEnrichment.trendDelta` (`src/lib/prompts/planner.ts`) carrying prior/current ELR + €/yr + methodology version + top-3 €-driver movers ranked by `|delta|`.
- Mirrored `trendDelta` in both `EnrichmentSchema` Zod copies (`narrative-core.server.ts` + `narrative.functions.ts`) so server-fn payloads validate the new shape.
- Extended `snapshotToBrief` to emit a `TREND VS PREVIOUS RUN` headline line + a `BIGGEST €-DRIVER MOVERS SINCE PREVIOUS RUN` block with signed `±€/yr` magnitudes per mover.
- Extended `SECTION_PROMPTS.implementationRoadmap.core` to tie the phase-1 baseline-lock action to the top mover when present, and forbid inventing a delta when absent.
- Extended `SECTION_PROMPTS.fundingBusinessCase.core` to frame the top mover as defensibility evidence for the lead funding bid when present, and forbid inventing a delta when absent.
- Report loader (`audit.$auditId.report.tsx`) derives `sectionTrendDelta` from `trend.points[]` immediately before composing `sectionEnrichment`; driver-id → name mapped via the current run's `baselineImpact.drivers`; gracefully `undefined` when prerequisites missing.
- Version bump invalidates all cached narrative rows so the next report open re-generates against the trend-aware Roadmap + Funding prompts.
v2026.12.sN10_acronyms2026-05-22
Sprint N10 (Narrative Quality Upgrade Tier 9) — audience-aware acronym blocklist with retry-once-then-strip enforcement. Closes the mayor/council acronym leak path: previously `AUDIENCE_PIVOT.acronymPolicy` (`src/lib/prompts/sections.ts`) only advised the model in the system prompt — nothing enforced it. NEW `src/lib/narrative-acronyms.ts` is the SINGLE source of the enforcement layer: `ACRONYM_BLOCKLIST: Record<AudienceKey, string[]>` (mayor bans the full set including indicator acronyms `Lden` / `PM2.5` / `NO₂` / `ELR`, data-pipeline `CNOSSOS` / `LISFLOOD` / `ERA5` / `TROPOMI` / `CDSE`, and finance `CapEx` / `ROI` / `IRR` / `NPV`; planner bans only the data-pipeline / satellite internals; analyst is unrestricted), `ACRONYM_EXPANSIONS` (plain-English replacements like `Lden → "day-evening-night noise"`), `findAcronymIssue(audience, prose)`, `formatAcronymDirective(issue)`, `stripAcronyms(prose, audience)`. Detection is case-sensitive whole-word with literal token matching (handles `PM2.5`, `NO₂`, `GTFS-RT`). Chained into `runSectionNarrative` AFTER N8 close-out (so the close-out rewrite can't reintroduce jargon) and BEFORE N7 readability (so the readability gate scores stripped prose). Same shape as N3: retry-once-then-strip. Retried candidate re-validated against BOTH the brief-wide ±5% numeric guardrail AND the N6 numeric-provenance allow-list so the acronym rewrite cannot smuggle in new unsupported numbers. Adding a new banned acronym = extend `ACRONYM_BLOCKLIST` (+ optionally `ACRONYM_EXPANSIONS`) ONLY. Version bump invalidates all cached narrative rows so the next report open re-generates against the acronym-gated pipeline. NON-breaking — additive only; no €-coefficient changes, no schema migration, no new env vars.
- Added `src/lib/narrative-acronyms.ts` (SINGLE source) — `ACRONYM_BLOCKLIST` keyed by audience, `ACRONYM_EXPANSIONS` plain-English map, case-sensitive whole-word detector, retry directive, deterministic stripper.
- Chained the acronym check into `runSectionNarrative` AFTER N8 close-out and BEFORE N7 readability; retry-once-then-deterministic-strip pattern.
- Acronym-retry candidate is re-validated against both the ±5% numeric guardrail and the N6 numeric-provenance allow-list so rewrites cannot leak new unsupported numbers.
- Adding a new banned acronym = extend `ACRONYM_BLOCKLIST` (+ optionally `ACRONYM_EXPANSIONS`) ONLY; the detector + stripper read both tables.
- Version bump invalidates all cached narrative rows so the next report open re-generates against the acronym-gated pipeline.
v2026.12.sN9_delta_lead2026-05-22
Sprint N9 (Narrative Quality Upgrade Tier 8) — split the §6 `conclusion` prompt to LEAD with a "what changed since you started reading" delta beat before the existing 7-beat close-out (now ¶2–¶8). Prompt-only edit to `SECTION_PROMPTS.conclusion.core` in `src/lib/prompts/sections.ts`. New ¶1 opens with a one-sentence delta naming what a reader who only scanned the executive summary now knows that they did not before (a risk larger than the headline implied, a peer-city precedent that reframes the verdict, a compounding pair that lifts the €/yr band, or — when the body only confirmed the opening framing — say so plainly), then quotes the CANONICAL VERDICT verbatim and the CANONICAL HEADLINE €/yr band verbatim from the Sprint N6 spine-lock fields so the delta cannot drift. Rest of the prompt is unchanged; the existing 7 beats just shift one slot down. No new section key, no schema migration, no new env vars. Sprint N8 so-what close-out and Sprint N7 readability gate still apply to the new closing paragraph (now ¶8). Version bump invalidates all cached narrative rows so the next report open re-generates against the delta-led conclusion prompt. NON-breaking — additive prompt edit; no €-coefficient changes.
- Extended `SECTION_PROMPTS.conclusion.core` (`src/lib/prompts/sections.ts`) from 7 beats to 8: new ¶1 = 'what changed since you started reading' delta sentence + verbatim canonical verdict + verbatim canonical €/yr headline; existing 7 beats shift to ¶2–¶8.
- New ¶1 must quote the BRIEF's CANONICAL VERDICT and CANONICAL HEADLINE verbatim (Sprint N6 spine-lock) and may not introduce numbers absent from the brief — N6 provenance + ±5% guardrail still apply.
- Sprint N8 so-what close-out lint and Sprint N7 readability gate continue to score the (now ¶8) closing paragraph unchanged.
- Version bump invalidates all cached narrative rows so the next report open re-generates against the delta-led conclusion prompt.
v2026.12.sN8_so_what2026-05-22
Sprint N8 (Narrative Quality Upgrade Tier 7) — "So what:" close-out lint on the two §6 sections decision-makers re-read in isolation: `priorityFindings` and `compoundingRisks`. NEW `src/lib/narrative-soWhat.ts` is the SINGLE source: `SO_WHAT_SECTIONS = {priorityFindings, compoundingRisks}`, `SO_WHAT_MARKERS` (e.g. "this means", "bottom line", "without action", "the implication", "decision:"), `hasSoWhatCloseOut(prose)`, `findSoWhatIssue(sectionKey, prose)`, `formatSoWhatDirective(issue)`, and `appendDeterministicSoWhat(prose, enrichment)`. A close-out qualifies when the tail (last paragraph / last 2 sentences) contains a marker phrase OR carries both a € figure AND a directive verb (must / should / act / decide / requires / prioritise). Same chain shape as N3: retry-once-then-fallback. The retried candidate is re-validated against BOTH the brief-wide ±5% numeric guardrail AND the N6 numeric-provenance allow-list so the close-out rewrite cannot smuggle in new unsupported numbers. If the model still refuses to land the plane, a deterministic close-out is appended — built from `PlannerEnrichment.canonicalHeadline` + `canonicalVerdict` (Sprint N6 spine-lock) so it cannot drift from the rest of the report. Chained into `runSectionNarrative` AFTER N3 lint + N6 provenance and BEFORE N7 readability, so the readability gate scores the close-out too. Adding a new close-out-gated section = extend `SO_WHAT_SECTIONS` ONLY. Version bump invalidates all cached narrative rows so the next report open re-generates against the so-what-gated pipeline. NON-breaking — additive only; no €-coefficient changes, no schema migration, no new env vars.
- Added `src/lib/narrative-soWhat.ts` (SINGLE source) — `SO_WHAT_SECTIONS`, marker-phrase + €+directive-verb detection, retry directive, deterministic fallback close-out.
- Chained the so-what check into `runSectionNarrative` AFTER N3 lint + N6 provenance and BEFORE N7 readability; retry-once-then-append-deterministic-close-out pattern.
- So-what-retry candidate is re-validated against both the ±5% numeric guardrail and the N6 numeric-provenance allow-list so rewrites cannot leak new unsupported numbers.
- Deterministic fallback close-out is built from `PlannerEnrichment.canonicalHeadline` + `canonicalVerdict` so it cannot drift from the rest of the report.
- Adding a new close-out-gated section = extend `SO_WHAT_SECTIONS` ONLY.
- Version bump invalidates all cached narrative rows so the next report open re-generates against the so-what-gated pipeline.
v2026.12.sN7_readability2026-05-22
Sprint N7 (Narrative Quality Upgrade Tier 6) — promotes the existing payload-level `runReadability` (warning-only, P4.22) into an inline retry trigger for the two spine sections busy readers actually finish: `executiveSummary` and `conclusion`. NEW `src/lib/narrative-readability.ts` is the SINGLE source of the per-section readability check: `READABILITY_SENSITIVE_SECTIONS = {executiveSummary, conclusion}`, `READABILITY_FLOOR = 50` (Flesch Reading Ease), deterministic en-GB syllable estimator (`estimateSyllables`), `fleschReadingEase`, `findReadabilityIssue(sectionKey, prose)`, `formatReadabilityDirective(issue)` (rewrite-for-an-exhausted-mayor addendum: ≤18 words/sentence, one-syllable verbs, no parentheticals), and `pickBetterReadability(original, retried)`. Chained into `runSectionNarrative` AFTER the N3 vagueness lint and BEFORE prose is cached. Pattern differs from the surrounding guardrails: retry-once-then-PICK-HIGHER, not retry-once-then-strip — readability is editorial, not factual, so a slightly dense paragraph beats an empty section. The retry candidate is re-checked against BOTH the brief-wide ±5% numeric guardrail AND the N6 numeric-provenance allow-list, so the readability rewrite cannot smuggle in new unsupported numbers. Other sections still trip the payload-level `runReadability` warning at the export gate; only the spine pair retries inline. Version bump invalidates all cached narrative rows so the next report open re-generates against the readability-gated pipeline. NON-breaking — additive only; no €-coefficient changes, no schema migration, no new env vars.
- Added `src/lib/narrative-readability.ts` (SINGLE source) — Flesch Reading Ease per-section gate, `READABILITY_SENSITIVE_SECTIONS`, `READABILITY_FLOOR = 50`, deterministic syllable estimator, retry directive, pick-better helper.
- Chained the readability check into `runSectionNarrative` AFTER the N3 lint and BEFORE cache write; retry-once-then-PICK-HIGHER pattern (no stripping).
- Readability-retry candidate is re-validated against both the ±5% numeric guardrail and the N6 numeric-provenance allow-list so rewrites cannot leak new unsupported numbers.
- Adding a new readability-sensitive section = extend `READABILITY_SENSITIVE_SECTIONS` ONLY.
- Version bump invalidates all cached narrative rows so the next report open re-generates against the readability-gated pipeline.
v2026.12.sN6_consensus2026-05-22
Sprint N6 (Narrative Quality Upgrade Tier 5) — two new post-emit guardrails on the per-section LLM pipeline. (1) Numeric provenance pass: `runSectionNarrative` now chains `findProvenanceViolations` (`src/lib/narrative-provenance.ts`) immediately after the brief-wide ±5% numeric guardrail and BEFORE the N3 vagueness lint. The provenance pass operates on an explicit curated allow-list (`PlannerEnrichment.allowedNumbers?: number[]`) surfaced by `snapshotToBrief` as an `ALLOWED NUMERIC FACTS:` block, with tight ±1-last-digit / ±2pp / ±2% tolerance — catches transposed digits the existing ±5% check lets through. Same retry-once-then-strip pattern as the surrounding guardrails. (2) Cross-section consensus: `runReportNarrativesImpl` now runs `findConsensusViolations` (`src/lib/narrative-consensus.ts`) after the 13-section fan-out and BEFORE persistence. The check asserts that the four spine sections (`executiveSummary`, `priorityFindings`, `decisionBrief`, `conclusion`) all name the SAME verdict label (one of `TITLES`/`TITLES_6`) and the SAME headline €/yr band. Divergent sections are regenerated once with a `SPINE CONSENSUS LOCK` directive built from `PlannerEnrichment.canonicalVerdict` + `canonicalHeadline`; surviving violations are persisted as `report_drafts.sections.consensusViolations: string[]` for the Engine Room audit trail. Both Zod `EnrichmentSchema` copies (`narrative-core.server.ts` + `narrative.functions.ts`) and the `PlannerEnrichment` type (`planner.ts`) gain `allowedNumbers?`, `canonicalVerdict?`, `canonicalHeadline?` in lock-step. `recommendation.ts` exports `TITLES` so the consensus module can read the 3-rung label set. Version bump invalidates all cached narrative rows so the next report open re-generates against the consensus-locked pipeline. NON-breaking — additive only; no €-coefficient changes, no schema migration (the `report_drafts.sections` jsonb absorbs the new field).
- Added `src/lib/narrative-provenance.ts` (SINGLE source) — strict allow-list numeric check with ±1-last-digit / ±2pp / ±2% tolerances, retry-once-then-strip helpers.
- Added `src/lib/narrative-consensus.ts` (SINGLE source) — spine-section verdict + headline-€ agreement check, `SPINE_SECTIONS` constant, `formatConsensusDirective` for the regeneration prompt.
- Extended both `EnrichmentSchema` Zod copies + `PlannerEnrichment` with `allowedNumbers?: number[]`, `canonicalVerdict?: string`, `canonicalHeadline?: { central; low?; high? }`.
- `snapshotToBrief` emits `ALLOWED NUMERIC FACTS:`, `CANONICAL VERDICT:`, `CANONICAL HEADLINE:` lines when the fields are present.
- `runSectionNarrative` chains the provenance pass between the numeric guardrail and the N3 lint; `runReportNarrativesImpl` runs the consensus check after fan-out, regenerates divergent spine sections, and persists `consensusViolations` on the draft.
- Exported `TITLES` from `recommendation.ts` so the consensus module can read the 3-rung label set without duplicating it.
- Version bump invalidates all cached narrative rows so the next report open re-generates against the consensus-locked pipeline.
v2026.12.sN5_provenance2026-05-22
Sprint N5 (Narrative Quality Upgrade Tier 5) — live-anchor provenance now flows into the §6 methodology narrative. `PlannerEnrichment` gains optional `liveAnchors?: string[]` (mirrored on both `EnrichmentSchema`s in `narrative-core.server.ts` and `narrative.functions.ts`). `snapshotToBrief` emits a `LIVE EVIDENCE ANCHORS FIRED (name each in the methodology section): ...` line when present. The `methodologyApproach` prompt is extended: paragraph 1 MUST add one extra sentence naming each anchor verbatim when the line is present, OR state plainly that the catchment runs on the v0 deterministic surrogate. Prompt also forbids inventing a live anchor that the brief does not list. The studio loader (`audit.$auditId.report.tsx`) derives `liveAnchors` deterministically from the four already-fetched live structures (`liveNoise` → EEA CNOSSOS-EU 2017; `liveHeat` → NASA POWER skin temperature; `liveAir` → Sentinel-5P TROPOMI ± OpenAQ; `liveFlood` → JRC LISFLOOD via Copernicus DataSpace blended with ERA5+DEM or with Copernicus EMS) and threads it into BOTH the section-prose enrichment and the Strategic Synthesis enrichment. Version bump invalidates all cached narrative rows so the next report open re-generates against the provenance-aware methodology prompt. NON-breaking — additive only; no €-coefficient changes, no schema migration.
- Added `liveAnchors?: string[]` to `PlannerEnrichment` and to both `EnrichmentSchema` Zod copies (server-fn input and core orchestrator).
- `snapshotToBrief` emits the `LIVE EVIDENCE ANCHORS FIRED` line verbatim when present.
- Extended the `methodologyApproach` prompt: P1 must name each fired anchor or explicitly say the report runs on the v0 surrogate; cannot invent anchors absent from the brief.
- Studio loader derives `liveAnchors` once from the four live structures and threads it into both section-prose and Strategic Synthesis enrichment payloads.
- Version bump invalidates all cached narrative rows so the next report open re-generates against the provenance-aware methodology prompt.
v2026.12.s32_1_live2026-05-22
Sprint 32.1 (live) — replaces both Mobility Pulse TODOs in `getTransitStops` (`src/lib/transit.functions.ts`) with real layered-intelligence pulls. (1) GTFS-Realtime trips/hour: `fetchGtfsTripsPerHour` calls Transitland v2 `/api/v2/rest/stops?bbox=...` for up to 20 stops, then in parallel fans out to `/api/v2/rest/stops/{onestop_id}/departures?next=3600&limit=100` across the top 12 stops, dedupes `trip_id`s, and returns the count == trips/hour for the catchment. Transitland's REST endpoint merges scheduled GTFS with the feed's `urls.realtime_trip_updates` server-side, so the protobuf parse is avoided entirely — keeps the function Worker-runtime safe (no `gtfs-realtime-bindings` / `protobufjs` dep). (2) Strava Metro active-trip share: `getStravaToken` (module-scoped bearer cache, `expires_in - 60s` headroom) → OAuth2 client-credentials POST against `https://www.strava.com/api/v3/oauth/token` → `fetchStravaActiveShare` GETs `https://metro.strava.com/api/v1/aggregate-tiles?bbox=...&period=last-12-months&modes=walk,bike,run`, sums walk + bike + run trip counts across all returned tiles, divides by total trips → clamped `[0,1]` active-trip share. Any upstream failure (token 4xx/5xx, Transitland 4xx/5xx, Strava Metro 4xx/5xx, empty tiles) leaves the optional field unset so `computeMobilityPulse` falls back to the existing stops/km² + mode-diversity + Moreno + POI-density surrogate. When the GTFS-RT anchor fires, `transitLift` lifts up to +3pp at ≥60 trips/hr (Sprint 32.1 surrogate logic preserved); when the Strava anchor fires, `activeShare` is blended 50/50 with the surrogate. `LiveMobilityOverride.source` flips across the existing six-member union accordingly and the `MobilityPulseCard` `ProvenancePill` sourceLabel resolves dynamically. NON-breaking — additive only; output shape preserved end-to-end, no €-coefficient change, no schema migration. Version bump invalidates all cached narrative rows so the next report open re-generates against the live Mobility pipeline.
- Added `fetchGtfsTripsPerHour` — Transitland v2 stops + per-stop departures fan-out (next 60 min), dedupes trip_ids; no protobuf parse so the function stays Worker-safe.[mobility_pulse]
- Added `getStravaToken` — Strava OAuth2 client-credentials with module-scoped bearer cache (`expires_in - 60s` headroom).[mobility_pulse]
- Added `fetchStravaActiveShare` — Strava Metro `/aggregate-tiles` POST across the audit bbox over the last 12 months; sums walk+bike+run across tiles divided by total trips → active-trip share clamped `[0,1]`.[mobility_pulse]
- Wired both helpers into `tryEnrichSprint32_1` (replaces the Sprint-32.1 scaffold TODOs); silent failure on either branch leaves the optional field unset and the surrogate drives the result.[mobility_pulse]
- Version bump invalidates all cached narrative rows so the next report open re-generates against the live Mobility Pulse pipeline.
v2026.12.s35_1_live2026-05-22
Sprint 35.1 (live) — replaces the JRC LISFLOOD TODO in `getLiveFlood` with a real Copernicus DataSpace Sentinel Hub Statistical API pipeline. Three env vars gate the path: `CDSE_CLIENT_ID` + `CDSE_CLIENT_SECRET` (OAuth2 client-credentials against the CDSE identity realm) + `CDSE_JRC_PLUVIAL_COLLECTION_ID` (BYOC collection holding the JRC LISFLOOD pluvial flood-depth raster, 60-yr return period). New module-scoped helpers: `getCdseToken` (caches the bearer token for `expires_in - 60s`), `catchmentPolygon` (16-sided ~1.2 km circular ring around the audit pin, scaled by `cos(lat)`), `fetchJrcPluvialMeanDepth` (POSTs an evalscript V3 against the BYOC depth band, P1Y aggregation 2020-now, 30 m grid, takes the catchment-mean), `depthToAnnualLossEurPerResident` (JRC 2020 power-law depth-damage curve fit so `d = 0.30 m → €40/resident/yr` Munich Re NatCat 2023 anchor, capped at €280 to match the surrogate ceiling). Any upstream failure (token 4xx/5xx, Statistical API 4xx/5xx, empty `means` array) leaves `jrcAnnualLoss` null so the ERA5+DEM path (or the v0 surrogate) drives the value without a retry. When the JRC anchor resolves, `computeFloodRisk` blends it 50/50 with the ERA5+DEM envelope (Sprint 35 logic preserved), the `source` flips to `open-meteo-era5+copernicus-dem+jrc-lisflood` (or `copernicus-ems-pluvial+jrc-lisflood` if both ERA5 + DEM also failed), the `FloodRiskCard` `ProvenancePill` switches to 'JRC LISFLOOD + ERA5/DEM' / 'JRC LISFLOOD', and the caveat names the modelled-loss anchor + 60-yr return period. NON-breaking — additive only; output shape preserved end-to-end, no €-coefficient change, no schema migration. Version bump invalidates all cached narrative rows so the next report open re-generates against the live JRC pipeline.
- Added `depthToAnnualLossEurPerResident` — JRC 2020 power-law curve scaled to €40/resident/yr at 0.30 m mean depth (Munich Re NatCat 2023 baseline), saturating at €280.[flood_risk]
- Added `getCdseToken` — OAuth2 client-credentials against the CDSE identity realm with in-memory bearer cache (`expires_in - 60s` headroom).[flood_risk]
- Added `catchmentPolygon` + `fetchJrcPluvialMeanDepth` — 16-sided ~1.2 km circular ring posted as Statistical API input, evalscript V3 against the BYOC DEPTH band, P1Y interval 2020-now, 30 m grid, mean-of-means returned.[flood_risk]
- Wired the helpers into `getLiveFlood` (replaces the Sprint-35.1 scaffold TODO); silent failure leaves `jrcAnnualLoss` null and the existing ERA5+DEM path drives the result.[flood_risk]
- Version bump invalidates all cached narrative rows so the next report open re-generates against the live JRC LISFLOOD pipeline.
v2026.12.s5a_tail2026-05-22
Sprint 5a-tail — wires the dormant POI-density risk-hotspot overlay end-to-end and lands the next P4.17-tail slice (4th registered VISUAL_PRIMITIVE on the default `pageVisuals` map). `RiskHotspotMap` joins `VISUAL_PRIMITIVES` in `src/lib/report-visuals.tsx` as a thin VisualFrame wrapper around the base64 PNG returned by `getRiskHotspotMapPng`. `ReportData` gains optional `riskHotspotMapPng: string | null`. The studio loader (`audit.$auditId.report.tsx`) fetches the hotspot basemap after POIs resolve (passing `ring` + audit pin + `pois.map(p => [p.lng, p.lat])` + `targetSamples: 36`), non-fatal: a Mapbox 4xx/5xx or missing `MAPBOX_TOKEN` logs a console.warn and the page silently omits the overlay. PDF page 23 (Streetscape Vision) renders `<RiskHotspotMap>` immediately after the existing RadarChart so the public-realm verdict reads alongside spatial context. `validateReportPayload` (`src/lib/report-payload.v2.ts`) extends the default `pageVisuals` map with `23: ["RiskHotspotMap"]` so the QA gate auto-enforces the visual under the warning-level `visuals.page_23` check. Two stale deferral comments retired: `src/lib/report-pages.ts` no longer claims a large JSX refactor is deferred (Sprint I-tail closed it); the page-34 trend-snapshot block in `report-template.tsx` no longer claims per-driver delta is deferred (Sprint Q8-tail closed it). NON-breaking — additive only; no €-coefficient changes, no schema migration. `getInterventionFocusMapPng` remains dormant until an interactive package-category selector lands.
- Registered `RiskHotspotMap` in `VISUAL_PRIMITIVES` + added the primitive component (Image wrapped in VisualFrame; renders null when `pngDataUrl` is null).
- Threaded `riskHotspotMapPng` through ReportData + the studio loader (`fetchRiskHotspotMap` after POIs resolve, non-fatal) + PDF page 23.
- Extended the default `pageVisuals` map in `validateReportPayload` to include `23: ["RiskHotspotMap"]` — 4th page under the Module-5 visual contract.
- Retired two stale deferral comments (`report-pages.ts` Sprint-I mirror + `report-template.tsx` page-34 per-driver delta) — both closed by their respective tail sprints.
- Version bump invalidates all cached narrative rows so the next report open re-generates against the s5a_tail pipeline.
v2026.12.sN42026-05-22
Sprint N4 (Narrative Quality Upgrade Tier 4) — new `methodologyApproach` section in `src/lib/prompts/sections.ts`. `SectionKey` widens from 12 → 13 keys and `SECTION_KEYS` gains `methodologyApproach` in the second-to-last slot (immediately before `conclusion`). The new prompt instructs the model to write three short paragraphs explaining — in plain English — WHY the indicators in this report fit THIS catchment (paragraph 1: WHAT WAS MEASURED — names the four indicator families and ties each to one observable feature; paragraph 2: HOW WE CALIBRATED — names the peer cohort, the per-metric percentile method, and the confidence-loss reason; paragraph 3: WHAT THIS METHOD CAN AND CANNOT DEFEND — two named defences + two named limits, closing with the literal pointer to the Engine Room). Forbidden patterns ban marketing tropes (`state of the art`, `gold standard`, `industry-leading`, `proprietary algorithm`, `black box`) on top of the vagueness ban list. Orchestrator (`runReportNarrativesImpl`) iterates `SECTION_KEYS` so the new key fans out automatically — no orchestrator edit needed. `report_drafts.sections.sectionProse` is jsonb so the new key persists without a schema migration. Report-loader hydration threshold bumped `>= 6` → `>= 7` to scale with the 13-section count (was 50% of 12, now 54% of 13). NON-breaking — additive only, no €-coefficient changes, no PDF JSX changes, no schema migration. Adding a new section = extend `SectionKey` + `SECTION_KEYS` + `SECTION_PROMPTS` ONLY; never inline section logic at the orchestrator.
- Added `methodologyApproach` section to `SectionKey` (13th slot) + `SECTION_KEYS` (immediately before `conclusion`) + `SECTION_PROMPTS` registry.
- Three-paragraph prompt covers WHAT was measured + HOW we calibrated + WHAT this method can and cannot defend, with forbidden marketing tropes.
- Bumped report-loader hydration threshold from `>= 6 of 12` to `>= 7 of 13` to scale with the new section count.
- Version bump invalidates all cached narrative rows so the next report open re-generates against the 13-section pipeline.
v2026.12.sN32026-05-22
Sprint N3 (Narrative Quality Upgrade Tier 3) — pre-emit narrative lint. NEW `src/lib/narrative-lint.ts` is the SINGLE source of truth for vagueness + editorial checks. `VAGUE_PHRASES` + `LONG_SENTENCE_WORD_LIMIT` (25 words, no-digit) are the only knobs. `runSectionNarrative` chains lint AFTER the numeric guardrail: retry-once-then-strip; numeric guardrail re-runs on retried prose so both invariants hold. NON-breaking.
- Added `src/lib/narrative-lint.ts` — SINGLE source for vagueness + editorial pre-emit checks.
- Wired `findLintIssues` / `formatLintDirective` / `stripFlaggedSentences` into `runSectionNarrative`.
- Numeric guardrail re-runs on the lint-retried prose so unsupported numbers cannot leak back in.
v2026.12.sN22026-05-22
Sprint N2 (Narrative Quality Upgrade Tier 2) — upgrades the spine narrative model. `PRIMARY_MODEL` switches `openai/gpt-5-mini → openai/gpt-5.4` with `reasoning: { effort: "medium" }`; `FALLBACK_MODEL` switches `google/gemini-3-flash-preview → google/gemini-2.5-pro` on 429/402. New `MODEL_REASONING_EFFORT` table is the SINGLE source of which models accept a `reasoning` parameter; `callGateway` reads from it so the param is omitted cleanly for non-reasoning models. Version bump invalidates cached narrative_cache rows. NON-breaking.
- Upgraded spine `PRIMARY_MODEL` to `openai/gpt-5.4` with `reasoning.effort = "medium"`.
- Upgraded `FALLBACK_MODEL` to `google/gemini-2.5-pro` so 429/402 fallback prose stays in the pro tier.
- Added `MODEL_REASONING_EFFORT` table as SINGLE source for reasoning-capable models.
v2026.12.sN1_tail2026-05-22
Sprint N1-tail — wires the dormant per-section LLM layer end-to-end in the report loader. `generateSectionNarratives` now auto-fires for `audienceForVariant(variant)` after the planner narrative lands; cached prose hydrates from `loadNarrativeDraft` into `ReportData.narrative.sectionProse` on first paint. Skip when ≥6/12 sections already filled. Failures swallowed with console warning. NON-breaking.
- Report loader auto-fires `generateSectionNarratives` for `audienceForVariant(variant)`.
- Cached prose hydrates from `loadNarrativeDraft` on first paint.
- Skip auto-fire when ≥6/12 sections are already hydrated for the active audience.
v2026.12.sN12026-05-22
Sprint N1 — activates the dormant per-section narrative layer (`src/lib/prompts/sections.ts`). Before this revision the 12 carefully-tuned §6 prompts in `SECTION_PROMPTS` + `audiencePromptFor` were defined but never called; every PDF page outside the executive summary + recommendations was driven by deterministic builders or static caveat strings. New `runSectionNarrative({sectionKey, audience, snapshot, enrichment, confidence})` in `narrative-core.server.ts` is the SINGLE entry for per-section LLM prose — same numeric-guardrail + cache-and-retry loop as `runPlannerNarrative`, but the cache key is `section:<sectionKey>:<promptVersionFor(sectionKey)>:<audience>` so re-tuning one prompt invalidates only its rows (no global `INDICATOR_VERSION` bump needed for prompt-only edits). New `runReportNarrativesImpl({auditId, audience, snapshot, ...})` orchestrator fans out all 12 section calls via `Promise.allSettled` (so a single section failure never breaks the report), persists into `report_drafts.sections.sectionProse[sectionKey][audience]`. New `generateSectionNarratives` server function (`src/lib/narrative.functions.ts`) is the auth-gated shim. `SaveDraftInput` + `loadNarrativeDraftImpl` extend to round-trip `sectionProse`. `ReportData.narrative.sectionProse` is the new optional field renderers can read; the report loader (`audit.$auditId.report.tsx`) auto-fires `generateSectionNarratives` for the active audience after the planner narrative lands, mirroring the existing auto-narrative pattern. Renderer page-by-page consumption ships incrementally in Sprint N1-tail — this revision is the data-pipeline activation. NON-breaking — additive only, no €-coefficient changes, no PDF JSX changes, no schema migration (`report_drafts.sections` is jsonb). All 38/38 acceptance fixtures green.
- Activated the dormant 12-prompt per-section layer (`SECTION_PROMPTS` + `audiencePromptFor`) — previously defined but never called.
- Added `runSectionNarrative` + `runReportNarrativesImpl` in `narrative-core.server.ts` with per-section cache keys derived from `promptVersionFor(sectionKey)`.
- Added auth-gated `generateSectionNarratives` server function; extended draft round-trip to persist `sectionProse` on `report_drafts.sections`.
- Extended `ReportData.narrative` with optional `sectionProse: Partial<Record<SectionKey, Partial<Record<AudienceKey, string>>>>` — renderer consumption ships in Sprint N1-tail.
- Report loader auto-fires per-section generation for the active audience after the planner narrative lands; cache makes repeat opens free.
v2026.11.sI_tail2026-05-22
Sprint I-tail — closes the Sprint-I deferral by retiring the layout/cut-table duplication between `src/lib/report-template.tsx` and `src/lib/report-pages.ts`. The renderer now imports `layoutPagesFor` / `executiveCutFor` / `COUNCIL_CUT` / `MAYOR_CUT` / `riskPageOrderFor` from `report-pages.ts` (already the §QA single source of truth for `computeVisiblePages` + `tocCoverage`), and the inline `Record<ReportLayout, number[]>` literals plus per-cut `new Set([…])` definitions inside `pageNumberFor` AND `NeighborhoodReport` are deleted. `riskPageOrder` is preserved as a thin re-export of `riskPageOrderFor` so any out-of-tree consumer keeps working. `executiveCutFor` / `COUNCIL_CUT` / `MAYOR_CUT` are now exported from `report-pages.ts`. Net effect: every layout × cut decision — page-id ordering, deep-link math (`pageNumberFor`), visible-page filtering, and QA-gate ghost-contents / orphan-bucket checks — reads from ONE table; mirror-drift warnings retired. NON-breaking — pure refactor, identical output, no €-coefficient changes, no PDF JSX touched, no schema migration. All 38/38 acceptance fixtures green.
- Exported `executiveCutFor` / `COUNCIL_CUT` / `MAYOR_CUT` from `src/lib/report-pages.ts` so the renderer can import them instead of mirroring.
- Replaced inline `layoutPages` / `executiveCut` / `councilCut` / `mayorCut` definitions in `pageNumberFor` + `NeighborhoodReport` with `layoutPagesFor` + `executiveCutFor(risks)` + `COUNCIL_CUT` + `MAYOR_CUT` imports.
- Reduced `riskPageOrder` to a one-line re-export of `riskPageOrderFor` from `report-pages.ts`; deleted the local `RISK_DRIVER_PAGE` / `RISK_FALLBACK_ORDER` / `EVIDENCE_ONLY_PAGES` constants.
v2026.10.sQ8_tail2026-05-22
Sprint Q8-tail — closes the Sprint-Q8 trend-page deferral by widening the snapshot ledger payload with per-driver central €/yr. `AuditTrendPoint` (`src/lib/portfolio.functions.ts`) gains optional `driverCentral: Partial<Record<ImpactDriver["id"], number>>` populated inside `listAuditSnapshots` from `euro.drivers` (omitted entirely when `tryComputeEuro` returns null so the absence is visible vs a misleading `{}`). `ReportData.trend.points[]` (`src/lib/report-template.tsx`) mirrors the optional field; the report loader (`audit.$auditId.report.tsx`) threads it through unchanged. PDF page 34 (Trend Snapshot) replaces its trailing "Per-driver decomposition … land[s] when the snapshot ledger persists driver totals" caveat with a real "Biggest €-driver movers" block — ranks all 10 drivers by `Math.abs(now − prev)`, names the top mover in a one-line headline ("Flood loss widened by €X/yr — the dominant €-driver behind the run-over-run delta above"), and prints a top-3 table (Driver / Prev / Now / Δ, red for widened exposure, accent for narrowed). Gracefully skips when either run is legacy / pre-Sprint-23 (no driverCentral). NON-breaking — additive optional field only; no €-coefficient changes, no schema migration, no `ReportData` shape change beyond the new optional. All 38/38 acceptance fixtures green.
- Extended `AuditTrendPoint` with optional `driverCentral` (per-driver central €/yr) — populated from `euro.drivers` inside `listAuditSnapshots`.
- Threaded `driverCentral` through the report loader into `ReportData.trend.points[]`.
- Replaced the page-34 deferral caveat with a real "Biggest €-driver movers" headline + top-3 table; legacy runs fall through gracefully.
v2026.09.sQ7_tail2026-05-22
Sprint Q7-tail — formalises `audienceForVariant` (`src/lib/narrative.functions.ts`) as the SINGLE source of truth for the report-variant → audience-cut mapping. Widened the helper's input type from the 3-literal union (`executive` | `full` | `technical`) to the full 5-literal `ReportVariantInput` (`executive` | `full` | `technical` | `council-brief` | `mayor-brief`) so the renderer no longer falls back to an inline ternary on PDF page exec-summary. `narrative-core.server.ts` now re-exports the helper from the client-safe shim instead of duplicating the mapping; the PDF renderer's exec-summary block (`report-template.tsx` ~L2842) replaces its inline `data.variant === "mayor-brief" || ...` ternary with a single `audienceForVariant(data.variant)` call. NON-breaking — additive only; no €-coefficient changes, no schema migration, no `ReportData` shape change. All 38/38 acceptance fixtures green.
- Widened `audienceForVariant` to accept the full `ReportVariantInput` 5-literal union (adds council-brief + mayor-brief).
- Re-exported `audienceForVariant` from narrative-core.server.ts instead of duplicating the mapping — single source of truth.
- Replaced the inline variant→audience ternary in the PDF exec-summary renderer with the centralised helper.
v2026.08.sP4_17_tail2026-05-22
Sprint P4.17-tail — first slice of the deferred 14-page mechanical refactor that wires `VISUAL_PRIMITIVES` into pages still shipping inline SVGs/tables. PDF page 24 (Cross-City Benchmark) replaces the inline `Per-metric percentile` two-column View block with the `<PeerBenchmarkChart>` primitive from `src/lib/report-visuals.tsx` — the renderer now inherits the §Module-5 title / caption / legend / source trio + `safeText`-wrapped labels for free, instead of hand-rolling per-metric rows. `validateReportPayload` (`src/lib/report-payload.v2.ts`) extends its default `pageVisuals` map with `24: ["PeerBenchmarkChart"]` so every caller — studio, acceptance harness, future exporters — auto-checks that page 24 ships the mandated visual via the warning-level `visuals.page_24` gate. NON-breaking — additive only. All 38/38 acceptance fixtures green.
- Replaced the inline per-metric percentile block on PDF page 24 with the `<PeerBenchmarkChart>` primitive — first slice of the P4.17 14-page mechanical refactor.
- Extended the default `pageVisuals` map in `validateReportPayload` to include `24: ["PeerBenchmarkChart"]` — Cross-City Benchmark page now auto-enforced under Module-5.
v2026.07.sC_tail2026-05-22
Sprint C-tail — formalises the §6 Value at Stake headline-format contract on PDF page 16. The renderer already calls `formatVasHeadline(data.economicImpact.total)` at the top of page 16 (replaces the legacy hand-rolled `€{low}–€{high}` template that produced the "€11.4 Working band" / "€3.5MM" glyph-corruption regressions), and `runQaGate` already chains `vasFormatIssues` which emits BLOCKERS when the headline band is missing, has a zero central, is inverted (low > high), or otherwise malformed (low > central, high < central). Sprint C-tail (1) adds `VAS_HEADLINE_FORMAT` to `src/lib/section-shapes.ts` as the documented format string (`"€{central}m per year (range €{low}m–€{high}m)"`), mirroring the `FINAL_RECOMMENDATION_BEATS` / `FINDINGS_FIELDS` documentation pattern — reviewers + future renderers MUST honour it verbatim and adding a band field = edit `ImpactBand` + `formatVasHeadline` + `vasFormatIssues` in lock-step, and (2) versions the contract so a stale snapshot can deep-link to the entry that explains why its VaS prose differs from the live renderer. NON-breaking — additive only; no €-coefficient changes, no PDF JSX touched, no schema migration. All 38/38 acceptance fixtures green.
- Added `VAS_HEADLINE_FORMAT` to `section-shapes.ts` — documented §6 headline format for `formatVasHeadline`.
- Formalised the `vasFormatIssues` gate chain (already wired into `runQaGate`) as the §6 Value at Stake headline-format contract.
v2026.06.sB_tail2026-05-22
Sprint B-tail — closes the Sprint-B page-rewrite remainder: (a) Mapbox static catchment on Neighbourhood Story (page 51), (b) KpiCard refactor on Performance Dashboard (page 47), (c) Priority Findings §6 six-field row contract on page 52. The renderer already wired (a) via `CatchmentMap` + `data.catchmentMapPng` (Sprint H landmarks pull-through), (b) via `KpiCardGrid` (Sprint P4.6), and (c) via `FindingRow` (Sprint P4.7). Sprint B-tail formalises all three by (1) baking `pageVisuals: { 47: ["KpiCardGrid"], 51: ["CatchmentMap"] }` as the default Module-5 contract inside `validateReportPayload` so every caller — studio, acceptance harness, future report exporters — gets the warning-level visual-presence check automatically (callers passing their own `pageVisuals` map still win), and (2) adding `FINDINGS_FIELDS` to `src/lib/section-shapes.ts` as the documented §6 six-field row contract for Priority Findings (Title / Why it matters / Evidence / Interpretation / Recommended response / Confidence chip), mirroring the `FINAL_RECOMMENDATION_BEATS` documentation pattern. NON-breaking — additive only; no €-coefficient changes, no PDF JSX touched, no schema migration, no new acceptance fixture required (existing 38/38 stay green; the new defaults are warning-level).
- Defaulted `pageVisuals` map in `validateReportPayload` to `{ 47: ["KpiCardGrid"], 51: ["CatchmentMap"] }` — Module-5 visual contract auto-enforced.
- Added `FINDINGS_FIELDS` documentation array to `section-shapes.ts` — §6 six-field row contract for Priority Findings (page 52).
v2026.05.s5a2026-05-22
Sprint 5a — Spatial overlay infra. NEW `src/lib/spatial.ts` is the SINGLE source of truth for grid sampling (`sampleGrid`), point-in-polygon (`pointInPolygon`), bbox derivation (`bboxOf`), and the 3-band severity colour ramp (`SEVERITY_COLOR` / `severityFromScore`) shared by every surrogate-grid → polygon overlay. NEW `getRiskHotspotMapPng` (`src/lib/risk-hotspot.functions.ts`) samples a √N × √N grid clipped to the audit isochrone, scores each cell on POI proximity (impervious / traffic surrogate), maps to low/med/high, and overlays coloured Mapbox pins on the Mapbox Dark basemap (same style as `getCatchmentMapPng`). NEW `getInterventionFocusMapPng` (`src/lib/intervention-focus.functions.ts`) takes the same grid and shades cells by per-bucket POI proximity weighted to the package category (`active_mobility` / `transit` / `public_realm` / `land_use` / `green_infrastructure`) — never re-classify POI buckets inline. Both server fns are auth-gated via `requireUrbanCatAuth`, cache 32 entries per worker, base64 dataUrl response so react-pdf can embed directly. Renderer + report-loader wiring deferred to Sprint 5a-tail (these fns are dormant until the studio loader threads `ring` + `pois` + `packageCategory` through). NON-breaking — no €-coefficient changes, no PDF JSX touched, no schema migration.
- Added `src/lib/spatial.ts` — shared `sampleGrid` / `pointInPolygon` / `bboxOf` / `severityFromScore` / `SEVERITY_COLOR`; future refactor will delete the inline copies in `noise.functions.ts`.
- Added `getRiskHotspotMapPng` server fn — POI-density surrogate per grid cell, 3-band severity overlay on Mapbox Dark basemap.
- Added `getInterventionFocusMapPng` server fn — per-bucket POI weighting by `PackageCategory`, 3-band severity overlay on Mapbox Dark basemap.
v2026.04.s33_12026-05-22
Sprint 33.1 — Streetscape Vision gains a Mapillary v4 + Gemini 2.5 Flash live anchor scaffold. `LiveStreetscapeOverride.source` widens from the single `osm-overpass-parks` literal to a `StreetscapeLiveSource` union ({`osm-overpass-parks`, `mapillary-v4+gemini-2-5-flash`, `osm-overpass-parks+mapillary-v4+gemini-2-5-flash`}); three optional fields (`mapillaryWalkability`, `mapillaryFacadeQuality`, `sampledImageCount`) carry the per-sub-score vision signal. `getLiveStreetscape` (`src/lib/streetscape-vision.functions.ts`) scaffolds the bbox-clipped Mapillary Graph API pull + deterministic ~50-frame sampler + Gemini 2.5 Flash structured-rubric pass (1–5 sidewalk-continuity / shopfront-activation), gated behind `MAPILLARY_CLIENT_TOKEN` until the grid-sampler + Gemini batching land. `computeStreetscapeVision` now blends the Mapillary-derived walkability + façade 50/50 with the existing Jacobs/POI surrogate so a single mis-classified frame can't swing the headline (canopy anchor unchanged — still the OSM Overpass parks pull). Caveat names exactly which anchors fired; StreetscapeVisionCard `ProvenancePill` `sourceLabel` switches dynamically across all four source members. Output shape additive only — no €-coefficient change (Streetscape stays an evidence-only layer), no PDF JSX touched.
- Widened `LiveStreetscapeOverride.source` to `StreetscapeLiveSource` union (3 members) + added optional `mapillaryWalkability` / `mapillaryFacadeQuality` / `sampledImageCount`.[streetscape_vision]
- `computeStreetscapeVision` blends Mapillary+Gemini sub-scores 50/50 with the surrogate when supplied; canopy anchor still on the OSM Overpass parks pull.[streetscape_vision]
- Added `getLiveStreetscape` server fn — Mapillary v4 bbox pull + deterministic ~50-frame sampler + Gemini 2.5 Flash structured-rubric pass, 30-day cache, gated on `MAPILLARY_CLIENT_TOKEN`.[streetscape_vision]
- StreetscapeVisionCard ProvenancePill sourceLabel switches dynamically across surrogate / OSM parks / Mapillary+Gemini / fused.[streetscape_vision]
v2026.03.s34_12026-05-22
Sprint 34.1 — Cross-City Peer Benchmark gains a Eurostat Urban Audit 2024 + GHSL R2023A + Copernicus Urban Atlas live anchor scaffold. `CityBaseline` widens with per-row `source: PeerPanelMember` (`seeded-city-baselines` | `eurostat-urban-audit-2024`); `PeerBenchmark` gains optional `panelSource: PeerPanelSource` (3-member union) + `liveRowCount` for cohort provenance, derived via `derivePeerPanelSource(baselines)`. `getPeerBaselines` (`src/lib/peer-benchmark.functions.ts`) scaffolds the `tryEnrichSprint34_1` step behind `EUROSTAT_URBAN_AUDIT_ENABLED=1` — when the flag flips on, the ~900 EU-city Urban Audit cohort + GHSL R2023A morphology + Copernicus Urban Atlas 2018 fold in with Gemini text-embedding morphological similarity, stamped `source: "eurostat-urban-audit-2024"` so the cohort promotes to the fused literal automatically. Today: dormant scaffold (seeded panel only) — output shape preserved. `PeerBenchmarkCard` popover footer + cohort caption are now dynamic — labels read "Seeded reference panel (25 cities)" / "Eurostat Urban Audit 2024" / fused via `peerPanelLabel(source)`. Adding a new panel = extend `PeerPanelMember` + `PeerPanelSource` + `derivePeerPanelSource` + `peerPanelLabel` — never inline panel copy elsewhere. NON-breaking — additive optional fields only; no €-coefficient change, no PDF JSX touched.
- Widened `CityBaseline.source` to `PeerPanelMember` + added optional `PeerBenchmark.panelSource` / `liveRowCount` for cohort provenance.[peer_benchmark]
- Added `derivePeerPanelSource(baselines)` — single source of truth for resolving the cohort literal from per-row provenance.[peer_benchmark]
- `getPeerBaselines` scaffolds `tryEnrichSprint34_1` — Eurostat Urban Audit 2024 + GHSL R2023A + Copernicus Urban Atlas pull gated on `EUROSTAT_URBAN_AUDIT_ENABLED=1`; defaults to seeded panel until creds + NUTS-3 reverse-geocode land.[peer_benchmark]
- PeerBenchmarkCard popover + cohort caption now read `peerPanelLabel(benchmark.panelSource)` so the active reference panel surfaces dynamically.[peer_benchmark]
v2026.02.s37_12026-05-22
Sprint 37.1 — Housing Affordability gains a Eurostat SILC NUTS-3 + OECD Housing 2024 cross-check. `LiveHousingOverride` adds a `HousingLiveSource` union (`eurostat-silc-nuts3`, `oecd-housing-2024`, `eurostat-silc-nuts3+oecd-housing-2024`) plus optional `tenantRentToIncomePct` / `overburdenedSharePct` / `geography` fields. `getLiveHousing` (`src/lib/housing-affordability.functions.ts`) scaffolds the Eurostat REST `ilc_lvho07a` pull + OECD HM3.1.1 SDMX cross-check, gated on `EUROSTAT_HOUSING_ENABLED=1` (needs the NUTS-3 reverse-geocode lookup to land before flipping the flag); the surrogate stays the default until then. `computeHousingAffordability(snap, live?)` now accepts the override and blends the live RTI 50/50 with the surrogate (clamped to the existing 18..72 physical envelope), preserves amenity composition, and prefers the directly-observed SILC %HCO when supplied. Caveat names the active anchor + retrieval date. `HousingAffordabilityCard` `ProvenancePill` `sourceLabel` is now dynamic across all 4 source members. Output shape additive only — no €-coefficient change, no PDF JSX touched.
- Widened housing source to `HousingLiveSource` union + added optional live RTI / %HCO / geography fields.[housing_affordability]
- `computeHousingAffordability` blends the live SILC/OECD anchor 50/50 with the surrogate and prefers the observed %HCO when supplied; provenance echoed for diff/replay.[housing_affordability]
- Added `getLiveHousing` server fn — Eurostat SILC `ilc_lvho07a` + OECD HM3.1.1 scaffold, 30-day cache in `external_data_cache`, gated on `EUROSTAT_HOUSING_ENABLED=1` until the NUTS-3 reverse-geocode lookup lands.[housing_affordability]
- HousingAffordabilityCard ProvenancePill sourceLabel switches dynamically across surrogate / Eurostat SILC NUTS-3 / OECD Housing 2024 / fused.[housing_affordability]
v2026.01.s32_12026-05-22
Sprint 32.1 — Mobility Pulse gains a GTFS-Realtime trips/hour anchor + a Strava Metro active-trip-share anchor. `LiveMobilityOverride.source` widens to a `MobilityLiveSource` union covering {transitland, osm, transitland+gtfs-rt, transitland+strava-metro, osm+strava-metro, transitland+gtfs-rt+strava-metro} and two optional fields (`gtfsTripsPerHour`, `stravaActiveTripShare`) carry the upstream signals. `getTransitStops` scaffolds a `tryEnrichSprint32_1` step behind `TRANSITLAND_API_KEY` (GTFS-RT TripUpdates aggregate) + `STRAVA_METRO_CLIENT_ID`/`STRAVA_METRO_CLIENT_SECRET` (OAuth2 client-credentials → Metro aggregate-tiles) — both are TODO scaffolds, drop-in once creds arrive. `computeMobilityPulse` lifts transitLift up to +3pp at ≥60 trips/hr and blends the Strava active share 50/50 with the surrogate when supplied; caveat names exactly which anchors fired. MobilityPulseCard ProvenancePill sourceLabel is now dynamic across all six union members. Output shape preserved end-to-end (additive optional fields only); no €-coefficient change.
- Widened LiveMobilityOverride.source to MobilityLiveSource union (6 members) + added optional gtfsTripsPerHour + stravaActiveTripShare.[mobility_pulse]
- computeMobilityPulse adds +3pp transit lift saturating at 60 GTFS-RT trips/hr and blends Strava Metro active share 50/50 with surrogate active-mode when supplied.[mobility_pulse]
- getTransitStops scaffolds the TRANSITLAND_API_KEY GTFS-RT pull + Strava Metro OAuth2/aggregate-tile pull; absent creds keep the v0 stops/km² + mode-diversity path.[mobility_pulse]
- MobilityPulseCard ProvenancePill sourceLabel now switches dynamically across all six union members.[mobility_pulse]
v2025.12.s35_12026-05-22
Sprint 35.1 — Flood & Pluvial Risk gains a JRC LISFLOOD cross-check. `LiveFloodOverride.source` widens from a single literal to {`open-meteo-era5+copernicus-dem`, `open-meteo-era5+copernicus-dem+jrc-lisflood`, `copernicus-ems-pluvial+jrc-lisflood`} and an optional `jrcAnnualLossEurPerResident` (+ `jrcReturnPeriodYr`) carries the JRC modelled-loss anchor. `getLiveFlood` scaffolds a Copernicus DataSpace Statistical API call behind `CDSE_CLIENT_ID`/`CDSE_CLIENT_SECRET`: when creds resolve, the audit catchment is sampled against the JRC LISFLOOD pluvial depth grid + 2020 depth-damage curves and either upgrades an existing ERA5+DEM pull to `open-meteo-era5+copernicus-dem+jrc-lisflood` or lights up as a `copernicus-ems-pluvial+jrc-lisflood` fallback. `computeFloodRisk` blends the JRC anchor 50/50 with the ERA5+DEM envelope so the modelled hazard layer shifts the verdict without a single anomalous cell dominating, caveat names the JRC anchor + return period, and the FloodRiskCard `ProvenancePill` switches between 'JRC LISFLOOD' / 'JRC LISFLOOD + ERA5/DEM' / 'ERA5 + Copernicus DEM' accordingly. Output shape preserved end-to-end (additive optional fields), no €-coefficient change.
- Widened `LiveFloodOverride.source` to a `FloodLiveSource` union covering ERA5+DEM, ERA5+DEM+JRC, and JRC-only; added optional `jrcAnnualLossEurPerResident` + `jrcReturnPeriodYr`.[flood_risk]
- computeFloodRisk now blends the surrogate/ERA5+DEM loss curve 50/50 with the JRC LISFLOOD anchor when supplied (clamped to the same 8..280 €/resident/yr physical envelope); `inputs.jrcAnnualLossEurPerResident` echoed for diff/replay.[flood_risk]
- getLiveFlood scaffolds the CDSE OAuth2 + Statistical API call against the JRC LISFLOOD pluvial depth grid; absent creds keep the existing ERA5+DEM path.[flood_risk]
- FloodRiskCard ProvenancePill `sourceLabel` is now dynamic: 'JRC LISFLOOD' / 'JRC LISFLOOD + ERA5/DEM' / 'ERA5 + Copernicus DEM' based on the active source.[flood_risk]
v2025.11.s36_12026-05-22
Sprint 36.1 — Environmental Noise anchored on EEA CNOSSOS-EU strategic noise maps. `getCnossosNoise` server fn samples the EEA NOISE viewer mosaic_roads_day / mosaic_roads_night ImageServers across a 3×3 grid clipped to the audit isochrone, decodes the U4 pixel encoding into band-centre dB, and returns the 90th-percentile Lden / Lnight when ≥30% sample coverage — facade-exposure proxy, not interior. Below the coverage floor, the v0 deterministic surrogate stays in play (output shape preserved). Audit page + report studio both thread the live sample into `computeNoisePollution(snap, live?)`; `NoisePollution.source` flips to `eea-cnossos-roads-2017` when live, caveat names the sample date + coverage, and the ProvenancePill switches accordingly. 90-day cache in `external_data_cache` keyed by quantised bbox. Output shape preserved end-to-end (additive `live` field only); no €-coefficient change.
- Added `getCnossosNoise` server fn — sequential ImageServer `identify` calls across the isochrone grid, decodes pixel → band-centre dB, aggregates as 90th percentile, returns null below 30% coverage floor.[noise_pollution]
- computeNoisePollution `live?: LiveNoiseOverride` arg now drives Lden / Lnight when supplied; `parameters.ldenSource` mirrors the source so the reproducibility panel and PDF page 26 stay in sync.[noise_pollution]
- audit.tsx + audit.$auditId.report.tsx both call `getCnossosNoise` against the live isochrone polygon and pass the sample into the noise pipeline; caveat names the EEA NOISE viewer sample date + coverage when live.[noise_pollution]
- Cache `source` set to `eea-noise-cnossos` with a 90-day TTL keyed by quantised bbox; neighbouring audits hit the same entry.[noise_pollution]
v2025.11.s31_12026-05-22
Sprint 31.1 — Air Quality gains a Sentinel-5P TROPOMI satellite cross-check. `LiveAirOverride.source` widens from a single literal to {`openaq-v3`, `sentinel-5p-tropomi`, `openaq-v3+tropomi`} and an optional `no2TropomiColumn` (µmol/m², tropospheric NO₂) carries the satellite anchor. `getLiveAir` scaffolds a Copernicus DataSpace Sentinel Hub Statistical API call behind `CDSE_CLIENT_ID`/`CDSE_CLIENT_SECRET`: when creds resolve, the TROPOMI column is fetched and either upgrades an OpenAQ station pull to `openaq-v3+tropomi` or lights up as a `sentinel-5p-tropomi` fallback when no station sits within radius. Output shape preserved end-to-end (additive optional field), no €-coefficient change.
- LiveAirOverride widens `source` union and gains optional `no2TropomiColumn`; `AirQuality.provenance` mirrors the column for downstream consumers.[air_quality]
- computeAirQuality caveat now reads `Anchored on OpenAQ v3 N stations cross-checked against Sentinel-5P TROPOMI NO₂ (X µmol/m²)` when the satellite anchor activates; falls back to the station-only sentence otherwise.[air_quality]
- AirQualityCard ProvenancePill sourceLabel switches dynamically — `OpenAQ v3 + Sentinel-5P TROPOMI` when cross-checked, `Sentinel-5P TROPOMI` when satellite-only, `OpenAQ v3` for station-only.[air_quality]
- Air-quality citation set extended with Copernicus Sentinel-5P TROPOMI Level-2 NO₂ product specification.[air_quality]
v2025.11.s30_12026-05-22
Sprint 30.1 — Heat Island anchored on land-surface temperature. getLiveHeat now pulls NASA POWER `TS` (earth skin temperature, °C, MERRA-2) alongside T2M_MAX/T2M_MIN. TS is the same physical quantity Copernicus Sentinel-3 SLSTR LST reports, so the per-site UHI baseline is now derived from a true LST signal instead of 2-m air temperature. Output shape preserved (`LiveHeatOverride.warmestMonthSkinC` is additive); the existing Copernicus DataSpace credential gate is scaffolded so SLSTR can drop in as a no-code swap when CDSE_CLIENT_ID/SECRET land. Cache `source` bumped to `nasa-power-v2` to invalidate Sprint F payloads on next read.
- LiveHeatOverride gains optional `warmestMonthSkinC` and widens `source` union to {nasa-power-climatology, nasa-power-skin-temp, copernicus-sentinel3-slstr}; provenance now carries the skin-temp anchor when present.[heat_island]
- computeHeatRisk prefers the TS-anchored baseline (+0.45 °C per °C above the EU-27 ~32 °C typical urban skin max, EEA 2022 UHI Annex C, clamped 0..3.5); falls back to T2M_MAX when only Sprint F payloads exist.[heat_island]
- getLiveHeat fetches T2M_MAX,T2M_MIN,TS in one POWER call, picks the warmest TS month as the LST anchor, and flips `source` to `nasa-power-skin-temp` when TS resolves cleanly. 30-day cache key bumped to `nasa-power-v2`.[heat_island]
- HeatRiskCard ProvenancePill now reads `NASA POWER LST (TS)` when the skin-temp anchor is active, falling back to `NASA POWER` for Sprint F payloads and lighting up `Sentinel-3 SLSTR LST` when CDSE credentials drop the live SLSTR tile pull in.[heat_island]
v2025.10.s72026-05-14
Sprint 7 — Live streetscape anchor + clearer 'what we couldn't see'. Streetscape Vision tree-canopy sub-score now anchors on the existing OSM Overpass parks pull (greenSharePct, parkCount) instead of the Moreno enjoying-score proxy. Every surrogate-only risk card (Streetscape, Housing, Mobility-when-no-GTFS) renders a plain-English SurrogateNotice by default — Missing / We used / If live — so non-technical readers see the gap without opening Engine Room. PDF page 32 gains two new groups (Streetscape & public realm, Housing register) covering Mapillary, Eurostat SILC and cadastral layers.
- Added LiveStreetscapeOverride { greenSharePct, parkCount, retrievedAt, source: 'osm-overpass-parks' } and a second optional `live` arg on computeStreetscapeVision(snap, live?) — backward compatible. Tree-canopy 1–5 derived from greenShare bands (≥30%→5, ≥18%→4, ≥10%→3, ≥4%→2) modulated ±15% by climate factor.[streetscape_vision]
- audit.tsx threads liveStreetscape (derived from existing parksData + isochrone area) into riskBundle.streetscape and StreetscapeVisionCard; PDF + showcase callers stay null and continue to use the v0 surrogate.[streetscape_vision]
- New <SurrogateNotice> component is the SINGLE source of plain-English 'what we couldn't see' copy for surrogate-only cards — three labelled fields (Missing / We used / If live) + Open methodology link. Wired into Streetscape, Housing and Mobility (when GTFS missing). Always visible; not gated on technical-details toggle.[streetscape_vision]
- PDF page 32 'What we couldn't see' gains two new groups: Streetscape & public realm (Mapillary v4, KartaView, OSM canopy anchor) and Housing register (Eurostat SILC NUTS-3, OECD Housing 2024, JRC Affordable 2022, local cadastral).
v2025.08.s52026-05-13
Sprint 5 — Live flood envelope. Flood & Pluvial Risk swaps the climate-zone rainfall constant for ERA5 reanalysis (Open-Meteo Archive, 5-yr observed 1-h precipitation max) and adds a Copernicus DEM GLO-30 9-point ±500 m local-relief term that lifts (low relief) or dampens (high relief) modeled annual loss within ±15%. Falls back to the v0 surrogate when either upstream is unreachable. 30-day cache.
- Added LiveFloodOverride { extreme1hMm, localReliefM, elevationM, demSampleCount, retrievedAt, source } and a third optional `live` arg on computeFloodRisk(snap, overrides?, live?) — backward compatible.[flood_risk]
- Added getLiveFlood server fn — pulls Open-Meteo Archive ERA5 hourly precipitation (5-yr window, max sampled) and Open-Meteo Elevation (Copernicus DEM GLO-30, 9-point grid) in parallel, caches sample in external_data_cache for 30 days; emits null when both upstreams fail.[flood_risk]
- FloodRisk.dataSource + provenance now distinguish 'live' (ERA5/DEM-anchored) from 'surrogate' (climate-zone band); FloodRiskCard surfaces the chip + DEM relief metrics; map heatmap tint and What-If hydrology baseline both consume the same live envelope.[flood_risk]
- Loss multiplier reliefMultiplier ∈ {0.9, 1.0, 1.05, 1.15} applied AFTER impervious × density and BEFORE green dampener — matches JRC pluvial-hazard atlas terrain-slope sensitivity curves.[flood_risk]
v2025.07.sQ42026-05-12
Sprint Q3 — Grounded narrative v2. Planner brief now carries value-at-stake band, top 3 €-weighted risk drivers, top 5 peer cities, and confidence-loss reasons. Primary model switched to openai/gpt-5-mini (gemini-3-flash-preview kept as 429/402 fallback). A numeric-claim guardrail extracts every number from the model's reply and verifies it appears in the brief verbatim or within ±5% — one strict-directive retry on miss, then strip the offending sentence. Identical inputs now return identical text via a sha256(snapshot + INDICATOR_VERSION + model + kind) cache in narrative_cache.
- Extended snapshotToBrief with optional PlannerEnrichment (value-at-stake band, top risks, peer cities, confidence drivers); audit page wires it from riskBundle.impact + peerBenchmark + topConfidence.[value_at_stake]
- callWithFallback runs openai/gpt-5-mini first, falls through to google/gemini-3-flash-preview only on 429 (rate limit) or 402 (credits). All other errors propagate unchanged.[value_at_stake]
- guardrailScan extracts every numeric token from summary + recommendations and checks each against the brief; on miss, one strict-directive retry, on second miss strip the sentence (loud server warning logged either way).[value_at_stake]
- Added narrative_cache (server-only, RLS-locked) keyed by content hash; planner + scenario generators read-through and write-back so 5× regeneration of the same audit returns byte-identical text and skips the model call.[value_at_stake]
v2025.05.sQ12026-05-11
Sprint Q1 — Risk-aware insight engine. computeInsights now accepts the 7 risk indicators (heat, air, mobility, streetscape, flood, noise, housing) plus the EconomicImpact band so each emitted insight carries an economicShareEur and the Action Plan ranks interventions by € addressed. Conflict-collapse merges noise + housing + gentrification into a single displacement-pressure cluster.
- Extended InsightsInput with 7 optional risk indicators + economicImpact; existing callers keep working unchanged.[value_at_stake]
- Added 12+ graded rules (heat / air / mobility / streetscape / flood / noise / housing / topPeers) with low–medium–high thresholds and interpolated evidence sentences.[value_at_stake]
- Insight + ActionPlanEntry carry economicShareEur (band) pinned to the matching EconomicImpact driver; PDF page 28 shows '~€X/yr at stake' per row.[value_at_stake]
- Conflict-collapse pass merges concurrent noise / housing / gentrification flags into one displacement-pressure cluster so the action plan doesn't triple-count.[value_at_stake]
v2025.04.s_live_heat_air2026-05-10
Heat Island (v0.1) + Air Quality (v0.1) — deterministic surrogates swapped for live NASA POWER climatology (heat) and OpenAQ v3 station nearest-neighbour pull (air), cached against the audit centroid. Both gracefully fall back to the v0 surrogate when the live source is unreachable, returns no coverage, or the cache is cold.
- Added getLiveHeat server fn — pulls NASA POWER monthly T2M_MAX / T2M_MIN climatology for the audit lat/lng, derives warmest-month LST anomaly + tropical-night count, returns null when the API is down so computeHeatRisk(snap, live?) silently re-runs the v0 surrogate. 30-day cache in external_data_cache keyed by quantised lat/lng.[heat_island]
- Added getLiveAir server fn — queries OpenAQ v3 /locations within 5 km of the audit centroid, takes the nearest station's most recent PM2.5 + NO₂ annual means, returns {pm25, no2, stationCount, stationDistanceKm, retrievedAt} or null when no station is in range. computeAirQuality(snap, live?) substitutes live values into the same downstream pipeline; level, attributable mortality and the air €-driver re-derive from real concentrations.[air_quality]
- HeatRiskCard / AirQualityCard surface a 'Live · NASA POWER' / 'Live · OpenAQ v3' provenance pill (Cached · …h after first hit, Estimated when falling back).[heat_island]
- DataModeToggle on /audit lets staff force-flip between Live and Deterministic for QA — surrogate path is identical to v0, so €-totals on persisted snapshots are unchanged when the toggle is set to Deterministic.[air_quality]
- Evidence-Based PDF pages 20 (Heat) + 21 (Air) now stamp the live source, retrievedAt, and key measurements (warmest-month T2M_MAX/T2M_MIN, station count + distance, PM2.5/NO₂ annual means) when live data anchors the page; methodology line flips to 'NASA POWER live anchor' / 'OpenAQ v3 live anchor' accordingly.[heat_island]
- Fallback contract: any live failure (network, parse, zero-coverage, RLS) reverts that single indicator to its v0 deterministic surrogate without polluting the rest of the audit. Confidence breakdown reflects the substitution.[air_quality]
v2025.03.s36_12026-05-09
Environmental Noise (v0.1) — surrogate Lden / Lnight swapped for live CNOSSOS-EU strategic noise-map sample pulled from the EEA NOISE viewer (END 2017 reporting cycle), cached against the audit isochrone.
- Added getCnossosNoise server fn — samples the EEA mosaic_roads_day / mosaic_roads_night ImageServer at a 3×3 grid across the audit isochrone, decodes U4 pixel → band-centre dB, returns 90th-percentile Lden / Lnight when ≥30% sample coverage, otherwise null. 90-day cache in external_data_cache keyed by quantised bbox.[noise_pollution]
- computeNoisePollution(snap, live?) now accepts the live override — when present, real Lden / Lnight drive %HA, attributable mortality, level and the Value-at-Stake noise driver; output shape preserved.[noise_pollution]
- NoisePollutionCard surfaces a 'Live · EEA' provenance pill; PDF page 26 stamps the source, sample-date and coverage at the top of the page.[noise_pollution]
- Catchments outside an END-reported agglomeration gracefully fall back to the v0 deterministic surrogate — same shape, downstream untouched.[noise_pollution]
v2025.02.s372026-05-09score-affecting
Housing Affordability & Displacement Risk (v0) — modeled rent-to-income overshoot above the Eurostat 40% housing-cost-overburden threshold added as 10th €-driver and PDF page 27.
- Added housing_affordability indicator (v0 deterministic surrogate, climate-zone baseline RTI + amenity premium + desirability lift + density pressure + transit premium) — Sprint 37.1 swaps in a Eurostat SILC NUTS-3 + OECD Housing Affordability Database 2024 pull cached against the audit catchment.[housing_affordability]
- Value at Stake gained a 10th driver (housing) at €6 / €22 / €55 per resident-yr per pp rent-to-income above Eurostat HCO 40% (Eurostat SILC 2024 / JRC Affordable Housing 2022 / OECD Housing 2023).[value_at_stake]
- HousingAffordabilityCard surfaces verdict, RTI%, %HCO, units gap and displacement risk on /audit.
- PDF page 27 — Housing Affordability & Displacement Risk — slotted right after Environmental Noise in every layout × variant.
- Added Eurostat SILC 2024 (ilc_lvho07a) Housing cost overburden rate, JRC 2022 'Affordable Housing in Europe' synthesis and OECD 2023 'Housing Taxation in OECD Countries' to the bibliography.
v2025.01.s362026-05-09score-affecting
Environmental Noise (v0) — modeled Lden overshoot above the WHO 2018 road-traffic guideline added as 9th €-driver and PDF page 26.
- Added noise_pollution indicator (v0 deterministic surrogate, baseline 50 dB Lden + traffic / canyon / transit lifts − vegetation dampener) — Sprint 36.1 swaps in a CNOSSOS-EU strategic noise-map raster pull from the EEA NOISE viewer cached against the audit isochrone.[noise_pollution]
- Value at Stake gained a 9th driver (noise) at €2 / €8 / €20 per resident-yr per dB Lden above WHO 53 (WHO 2018 / EEA 2020 / CNOSSOS-EU 2012).[value_at_stake]
- NoisePollutionCard surfaces verdict, Lden, Lnight, %HA, dB-over-WHO and green dampener on /audit.
- PDF page 26 — Environmental Noise — slotted right after Flood & Pluvial Risk in every layout × variant.
- Added WHO 2018 Environmental Noise Guidelines for the European Region, EEA 2020 'Environmental noise in Europe' and CNOSSOS-EU (JRC 2012) common assessment methods to the bibliography.
v2024.12.s352026-05-09score-affecting
Flood & Pluvial Risk (v0) — modeled annual-average pluvial-flood loss intensity (€/resident-yr) added as 8th €-driver and PDF page 25.
- Added flood_risk indicator (v0 deterministic surrogate, latitude-zoned rainfall baseline + impervious lift + green dampener + density multiplier) — Sprint 35.1 swaps in Copernicus EMS pluvial hazard + JRC LISFLOOD output cached against the audit isochrone.[flood_risk]
- Value at Stake gained an 8th driver (flood) at 0.6× / 1.0× / 1.6× the modeled €/resident-yr loss intensity (EEA Floods Directive 2024 / JRC Pluvial 2020 / Munich Re NatCat 2023).[value_at_stake]
- FloodRiskCard surfaces verdict, €/resident-yr loss intensity, annual exceedance probability, impervious fraction and green dampener on /audit.
- PDF page 25 — Flood & Pluvial Risk — slotted right after Cross-City Benchmark in every layout × variant.
- Added EEA Floods Directive 2024 reporting cycle synthesis, JRC 2020 pluvial-hazard atlas (LISFLOOD-FP) and Munich Re NatCat 2023 European urban-flood loss data to the bibliography.
v2024.11.s342026-05-09
Cross-City Peer Benchmark (v0) — top-5 morphological peers from a 25-city z-space panel, surfaced on /audit and PDF page 24.
- Added peer_benchmark indicator (v0 z-score Euclidean over ELR / Moreno / Jacobs / log-density) — Sprint 34.1 swaps in the Eurostat Urban Audit (~900 EU cities) + GHSL + Copernicus Urban Atlas with Gemini text-embedding similarity.[peer_benchmark]
- PeerBenchmarkCard now lists top-5 similar peers with z-distance, similarity %, and the dimension that drove the match.
- PDF page 24 — Cross-City Benchmark — slotted right after Streetscape Vision in every layout × variant.
- Density is log10-transformed before z-scoring so megacities (Tokyo, Mumbai) no longer dominate the distance metric — archetype assignment is more morphological, less population-anchored.
- Added Eurostat Urban Audit 2024, GHSL R2023A, Copernicus Urban Atlas 2018 and Hazen (1914) plotting position to the bibliography.
- Evidence-only layer — no €-driver added; Value-at-Stake totals unchanged. Re-run recommended to populate the new PDF page from existing snapshots.
v2024.10.s332026-05-09
Streetscape Vision layer (v0) — public realm quality 1–5 composite (walkability + tree canopy + façade) added as evidence sub-score and PDF page 23.
- Added streetscape_vision indicator (v0 deterministic surrogate, latitude-zoned canopy factor) — Sprint 33.1 swaps in Mapillary v4 + Gemini 2.5 Flash vision over a 50-image grid.[streetscape_vision]
- StreetscapeVisionCard surfaces composite + 3 sub-scores (1–5 dot scale) and verdict on /audit.
- PDF page 23 — Streetscape Vision — slotted right after Mobility Pulse in every layout × variant.
- Added Mapillary v4 (CC-BY-SA), Gehl 2010 'Cities for People' and Ewing & Handy 2009 'Measuring the unmeasurable' to the bibliography.
- Evidence-only layer — no €-driver added; Value-at-Stake totals unchanged.
v2024.09.s322026-05-09score-affecting
Mobility Pulse layer (v0) — modeled modal split + car-dependency overshoot vs EU SUMP 40% target added as 7th €-driver and PDF page 22.
- Added mobility_pulse indicator (v0 deterministic surrogate, latitude-zoned cycling factor) — Sprint 32.1 swaps in Strava Metro + GTFS frequency parse.[mobility_pulse]
- Value at Stake gained a 7th driver (mobility) at €3/€12/€30 per resident-yr per pp car-share above EU SUMP 40% target (INRIX 2024 / EU SUMP 2019 / UITP).[value_at_stake]
- MobilityPulseCard surfaces verdict, modal-split bar, car-trips/resident, congestion delay and SUMP overshoot on /audit.
- PDF page 22 — Mobility Pulse — slotted right after Air Quality in every layout × variant.
- Added INRIX 2024 Global Traffic Scorecard + EU SUMP 2019 (JRC) Guidelines to the bibliography.
v2024.08.s312026-05-09score-affecting
Air Quality layer (v0) — PM2.5/NO₂ overshoot vs WHO AQG + attributable mortality added as 6th €-driver and PDF page 21.
- Added air_quality indicator (v0 deterministic surrogate, latitude-zoned) — Sprint 31.1 swaps in OpenAQ + Sentinel-5P TROPOMI.[air_quality]
- Value at Stake gained a 6th driver (air) at €4/€15/€40 per resident-yr per µg/m³ PM2.5 above WHO (EEA Air Quality 2023 / WHO AQG 2021).[value_at_stake]
- AirQualityCard surfaces verdict, PM2.5/NO₂ annual means, WHO overshoot and exposed-population on /audit.
- PDF page 21 — Air Quality Risk — slotted right after Heat Island in every layout × variant.
- Added EEA Air Quality in Europe 2023 + WHO 2021 global Air Quality Guidelines to the bibliography.
v2024.07.s302026-05-09score-affecting
Heat Island layer (v0) — UHI anomaly + tropical-night + heat-attributable mortality estimates added as 5th €-driver and PDF page 20.
- Added heat_island indicator (v0 deterministic surrogate, latitude-zoned) — Sprint 30.1 swaps in Sentinel-3 SLSTR LST.[heat_island]
- Value at Stake gained a 5th driver (heat) at €2/€8/€20 per resident-year per °C anomaly (EEA UHI 2022 / WHO Europe 2021).[value_at_stake]
- HeatRiskCard surfaces verdict, anomaly, tropical nights and exposed-population on /audit.
- PDF page 20 — Heat Island Risk — slotted right after Interventions in every layout × variant.
- Added EEA Urban Adaptation 2022 + WHO Europe Heat & Health 2021 to the bibliography.
v2024.06.s292026-05-09
Spatial diff — interventions now reshape the isochrone; baseline + scenario reach are persisted per snapshot.
- Added iso.minutesDelta + iso.modeOverride to the intervention catalog (5 entries shifted).[intervention_stack]
- Audit page re-fetches a scenario isochrone whenever the stack contains an iso-shifting entry.
- AuditMap overlays baseline (Pulse Teal) vs scenario (Bio-Sync Lime) reach polygons with a legend.
- SpatialDiffPanel quantifies land gained (km², %) and the contributing interventions.
- Snapshot ledger now persists interventionStack, scenarioReach, baseline + scenario iso rings, and scenarioAreaKm2.
v2024.05.s242026-05-09
Defensibility pass — confidence engine, provenance footer, methodology + bibliography appendices baked into every PDF.
- Added confidence score (0-100) + level surfaced on cover and audit page.[confidence]
- Stamped audit-id + methodology version on every PDF page footer.
- Auto-generated methodology + bibliography pages from INDICATORS / CITATIONS.
- Persisted methodologyVersion + confidence on every snapshot for temporal replay.
- Confidence-aware narrative tone (HIGH conviction / MEDIUM measured / LOW directional).[value_at_stake]
v2024.04.s232026-04-22
Temporal axis + portfolio view — €-at-stake re-derives without re-running OSM.
- Snapshot ledger persists Moreno + Vitality + areaKm2 + population on every analysis row.
- Portfolio ranks all audits on €-central with low/high band.[value_at_stake]
- Audit trend card shows ELR / Moreno / Jacobs / € sparklines once ≥2 snapshots exist.
v2024.03.s222026-04-08
Named intervention catalog — 12 entries with CapEx + lead-time + additive deltas.
- Intervention stack composes interventions and re-uses Economic Impact for uplift band.[intervention_stack]
- Suggested starter stack auto-targets the weakest Moreno category.
- PDF page 17 — Recommended Interventions & Scenario Stack — slotted in every layout.
v2024.02.s212026-03-25score-affecting
Monetize the verdict — Annual Value at Stake module (4 drivers, ±20% bands).
- Economic Impact computes WHO HEAT mortality, Litman accident, EEA air-quality and UITP transit drivers.[value_at_stake]
- Cover-adjacent PDF page 16 surfaces low / central / high band with sensitivity tornado.
v2024.01.s112026-02-14
Public methodology baseline — Moreno, Jacobs, ELR, Diversity, SDG 11.2.1.
- Initial INDICATORS + CITATIONS registries published.
- Engine Room page exposes formulas, parameters, uncertainty bands inline.