{"openapi":"3.1.0","info":{"title":"StatsMapped API","description":"Irish public data -- housing, crime, health, the economy and social welfare -- for every county, sourced directly from seven official publishers (CSO, PSRA, Central Bank of Ireland, DHLGH, NTPF, OGP, EU Publications Office) on each publisher's own update cadence, not a fixed schedule. Licence varies by dataset -- see /ireland/sources for the definitive, publisher-by-publisher breakdown before reusing any figure. /api/v1/* is the current, documented, stable surface; the unprefixed /api/* paths are undocumented legacy aliases kept only for this site's own front end and may change without notice. Some routes are rate-limited per caller (see each route's own description); the limiter fails open on its own error rather than blocking requests.","contact":{"name":"StatsMapped","url":"https://statsmapped.com/ireland/sources","email":"feedback@statsmapped.com"},"version":"1"},"servers":[{"url":"https://statsmapped.com","description":"Production"}],"paths":{"/healthz":{"get":{"summary":"Healthz","description":"Uptime-monitor target. Checks the actual dependency (DB connectivity), not just\nthat the process is up -- a process that's alive but can't reach Postgres is down in\nevery way a reader cares about, and a monitor that only checks the process would miss\nexactly that failure mode.","operationId":"healthz_healthz_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/ireland":{"get":{"summary":"Dashboard","description":"Serve this process's own homepage/dashboard shell. See _dashboard_response().","operationId":"dashboard_ireland_get","responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/ireland/sources":{"get":{"summary":"Sources Page","operationId":"sources_page_ireland_sources_get","responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/ireland/county/{slug}":{"get":{"summary":"County Page","description":"This process's own country, at its own established literal URL -- unchanged\nbehaviour, byte-identical to before _county_page_response() was extracted (see\nthat function's own docstring for the real logic and the per-request routing\ncontext).","operationId":"county_page_ireland_county__slug__get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/ireland/county/{slug}/{dataset}":{"get":{"summary":"County Dataset Page","description":"This process's own country, at its own established literal URL -- unchanged\nbehaviour; see _county_dataset_page_response() for the real logic.","operationId":"county_dataset_page_ireland_county__slug___dataset__get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}},{"name":"dataset","in":"path","required":true,"schema":{"type":"string","title":"Dataset"}}],"responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/ireland/compare/{pair_key}":{"get":{"summary":"Comparison Page","description":"Real server-rendered URL for the \"Compared with\" section (TODO.md's scope\nbroaden, 2026-08-27: this had NO shareable URL at all before, hash or otherwise).\nCalls comparison_detail() directly, same in-process-reuse pattern as the two\nroutes above.\n\ncommercial-review finding, 2026-08-27: the URL segment was the raw internal\nPAIR_KEY, not a readable slug -- same bug class as the dataset-slug leak this\nfile already rewrites for county/dataset pages. The raw key still resolves (a\nreal URL may already be indexed) but 301s to the readable PAIR_SLUG, matching\nthe canonicalization pattern county_dataset_page() already uses.","operationId":"comparison_page_ireland_compare__pair_key__get","parameters":[{"name":"pair_key","in":"path","required":true,"schema":{"type":"string","title":"Pair Key"}}],"responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/ireland/rankings":{"get":{"summary":"Rankings Hub Page","description":"This process's own country, at its own established literal URL -- unchanged\nbehaviour; see _rankings_hub_response() for the real logic.","operationId":"rankings_hub_page_ireland_rankings_get","responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/ireland/rankings/{dataset}":{"get":{"summary":"Rankings Page","description":"Serve this process's own ranking page. See _rankings_page_response().","operationId":"rankings_page_ireland_rankings__dataset__get","parameters":[{"name":"dataset","in":"path","required":true,"schema":{"type":"string","title":"Dataset"}},{"name":"facet","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Facet"}},{"name":"level","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Level"}}],"responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/series":{"get":{"tags":["series"],"summary":"List Series","description":"All datasets with their latest value and changes.\n\nA GROUP is returned as ONE entry carrying a `facets` array — not as N flat rows and\nnot omitted. The first cut of this dropped grouped series entirely, which silently\nremoved `rppi_national_houses` (the panel's national comparison line) the moment\nproperty gained facets. Returning 334 flat rows instead was the other extreme: it\nmade this the slowest endpoint in the app.\n\n`include_facets=true` additionally returns every facet as its own top-level row.\n\n`stat_key`: restrict to one stat (e.g. 'crime', 'sale_price'); omit for every\nseries site-wide. See /api/v1/stats for valid keys. Added for the MCP tool\npackage's rank_areas -- an unfiltered call measures ~6.5MB (every series, every\ngeography); ranking one stat across areas needs only a slice of that. Plain\ndefault (not Query(...)) deliberately: this function is also called directly as\na plain Python function in this codebase's own tests, and a Query(...) object\nsurvives unresolved as the literal default there, not as None -- the exact bug\nalready caught and fixed once on series_for_geography's own stat_key filter\nearlier this session.","operationId":"list_series_api_v1_series_get","parameters":[{"name":"include_facets","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Facets"}},{"name":"stat_key","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Stat Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SeriesEntry"},"title":"Response List Series Api V1 Series Get"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Not authorized for this request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/api/v1/series/{series_key}/history":{"get":{"tags":["series"],"summary":"Series History","operationId":"series_history_api_v1_series__series_key__history_get","parameters":[{"name":"series_key","in":"path","required":true,"schema":{"type":"string","title":"Series Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SeriesHistoryResponse"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Not authorized for this request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/api/v1/geographies":{"get":{"tags":["geographies"],"summary":"List Geographies","description":"Areas StatsMapped knows about.\n\n`with_boundary=true` returns GeoJSON geometry — off by default because the raw\nboundaries are heavy: Tailte's Generalised-20m layers still carry full coastline and\nisland detail, so the 8 CSO regions alone are **3.2 MB** unsimplified.\n\n`simplify` is a Douglas-Peucker tolerance in degrees, applied server-side via\nST_SimplifyPreserveTopology. The default 0.005 (~500 m) is far below what is visible\non a map of Ireland a few hundred pixels wide, and cuts the payload by ~95%. Pass\n`simplify=0` for the exact boundary.\n\nCaveat worth knowing: simplification is per-geometry, so adjacent areas can develop\nhairline gaps at shared borders. Acceptable for a choropleth at this scale; use\nsimplify=0 if the geometry is ever needed for real spatial analysis rather than\ndisplay.\n\n`simplify` and `min_part_km2` are capped (0.1 degrees, 1000 km²) rather than left\nunbounded — the front end only ever requests the defaults, and an uncapped value on\neither param is a resource-cost knob a caller could otherwise turn arbitrarily far in\neither direction on a shared free-tier database (security-architecture-and-audit.md\n§4). 0 remains a valid floor for both so the documented \"exact boundary\" escape hatch\nfor real spatial analysis still works; the caps only stop nonsensical/extreme inputs.\n\n`level` is required when `with_boundary=true` (cycle 45, Option B of the\nsecurity-privacy-auditor's 2026-08-27 open-routes finding: `with_boundary=true`\nwith no level filter had no row cap against a 4-connection pool -- one request\ncould ask for the full boundary set, every level at once, at full resolution).\n`level` stays optional without `with_boundary` -- app.js's own bare\n`fetch('/api/geographies')` (state.geoIndex at init()) needs the full unfiltered\nindex and requests no geometry, so it carries none of the cost this guards\nagainst. Checked live: every existing with_boundary=true call site (map.js's two)\nalready passes level, so this tightens the contract without breaking a real caller.","operationId":"list_geographies_api_v1_geographies_get","parameters":[{"name":"level","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Level"}},{"name":"with_boundary","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"With Boundary"}},{"name":"simplify","in":"query","required":false,"schema":{"type":"number","maximum":0.1,"minimum":0.0,"default":0.005,"title":"Simplify"}},{"name":"min_part_km2","in":"query","required":false,"schema":{"type":"number","maximum":1000.0,"minimum":0.0,"default":1.0,"title":"Min Part Km2"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/GeographyEntry"},"title":"Response List Geographies Api V1 Geographies Get"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Not authorized for this request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/api/v1/stats":{"get":{"tags":["stats"],"summary":"List Stats","description":"Every stat, with which boundary levels it may honestly colour.\n\n`compatible_levels` is computed live, per request, from `geography_relations` --\nthe same two branches `geo_match()` (databeat/geography.py) uses to decide\nwhether ONE series may honestly answer for ONE requested area, replicated faithfully\nrather than reimplemented: `exact` (a stat's series geography IS a geography at this\nlevel) or `aggregate` (a stat's series geography directly `contains` a geography at\nthis level, one hop only -- geo_match never walks a second hop; that's visible in its\nSQL, a single non-recursive join, not stated in its module docstring, which is about\nthe geography-identifier model instead). See stat-region-selector-design.md section 2\nfor the full derivation and why this is computed live rather than cached (row counts\nmake it a non-issue: a few hundred rows cross-joined against 265, both sides already\nindexed).\n\n`cluster` / `cluster_label` / `cluster_sort_order` are the product's five subject\nareas (migration 014), served from here so the map's \"Colour by\" chips, the panel's\ntabs, the card ordering and the alert-strip quota all read ONE source of truth rather\nthan four approximations of it. Until this landed the taxonomy existed only in\ndocuments, and every one of those surfaces fell back to insertion order or to raw\nseries count -- see unified-design-and-data-plan.md section 2.\n\nA level absent from `compatible_levels` is not a bug to investigate -- it is the\nsame honest \"cannot be shown here\" geo_match already enforces per-request for the\npanel and deep dive, computed once for the whole stat instead of once per click.\nRestricted to KNOWN_BOUNDARY_LEVELS: a stat's series is always trivially \"exact\"\ncompatible with its OWN geography's level (crime with garda_division, RPPI's\nregional facets with cso_rppi_region, any nationally-published series with nuts3),\nwhich is a correct geo_match verdict but not a level the front end has a button\nfor -- surfacing it would be noise, not signal.","operationId":"list_stats_api_v1_stats_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/StatEntry"},"type":"array","title":"Response List Stats Api V1 Stats Get"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Not authorized for this request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/api/v1/geographies/{geography_id}/series":{"get":{"tags":["geographies"],"summary":"Series For Geography","description":"Every series that can say something about this area, split by whether it can\nhonestly be attributed to it.\n\n`stat_key`: restrict to one stat (e.g. 'crime', 'sale_price'); omit for every\ndataset this area has. See /api/v1/stats for valid keys. Plain default (not\n`Query(...)`) deliberately -- this function is also called directly as a plain\nPython function (county_page()), and a `Query(...)` object survives unresolved\nas the literal default in that path, not as `None` -- confirmed live: it broke\ncounty_page() with `psycopg2.ProgrammingError: can't adapt type 'Query'` the\nfirst time this was written the other way. FastAPI still exposes a bare\n`str | None = None` non-path parameter as an optional query param on its own.\n\n`partial` values go in a SEPARATE ARRAY, not merely flagged. A frontend cannot\naccidentally render them by ignoring a boolean — the separation is structural.\nSee architecture-design.md 4.8.\n\napi-product-consultant, 2026-08-30: an unfiltered call for one populous county\n(Dublin, every dataset it has any series for) measured ~230,000 characters --\nan unreasonable payload for a caller that wants one dataset, human or AI agent\nalike. `stat_key` is additive and optional -- omitted, behaviour is byte-for-\nbyte what it always was (confirmed: this function's own internal caller,\ncounty_page(), calls it positionally with no stat_key and is unaffected).\nFilters in SQL, not in Python after the fact -- the point is to never fetch the\nother stats' rows at all, not just to hide them from the response.","operationId":"series_for_geography_api_v1_geographies__geography_id__series_get","parameters":[{"name":"geography_id","in":"path","required":true,"schema":{"type":"string","title":"Geography Id"}},{"name":"stat_key","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Stat Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SeriesForGeographyResponse"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Not authorized for this request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/api/v1/geographies/{geography_id}/datasets/{dataset}":{"get":{"tags":["geographies"],"summary":"Dataset Detail","description":"Everything the deep-dive view needs about ONE dataset for ONE area.\n\n`dataset` is either a series `group_key` (e.g. 'crime:louth-cavan-monaghan') or a\nplain `series_key` for ungrouped series.\n\nReturns the underlying series with full history, the glossary, the caveats and the\ncomputed summary — so the reader can see what was counted, what it means, what to be\ncareful of, and check the numbers themselves.\n\n`history_months` trims the returned history (0 = everything) -- converted to this\ndataset's own PERIOD count via `_history_points_for_months()` before slicing, not\nused as a raw row count. Property runs to 258 months; crime to 5 quarters.","operationId":"dataset_detail_api_v1_geographies__geography_id__datasets__dataset__get","parameters":[{"name":"geography_id","in":"path","required":true,"schema":{"type":"string","title":"Geography Id"}},{"name":"dataset","in":"path","required":true,"schema":{"type":"string","title":"Dataset"}},{"name":"history_months","in":"query","required":false,"schema":{"type":"integer","default":0,"title":"History Months"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DatasetDetailResponse"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Not authorized for this request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/api/v1/stats/sale_price/dispersion":{"get":{"tags":["stats"],"summary":"Sale Price Dispersion","description":"The county-to-county sale-price SPREAD over time, not any one county's own level --\nsee `_sale_price_dispersion()`'s docstring for the finding this exists to show.","operationId":"sale_price_dispersion_api_v1_stats_sale_price_dispersion_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SalePriceDispersionResponse"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Not authorized for this request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/api/v1/comparisons/{pair_key}":{"get":{"tags":["comparisons"],"summary":"Comparison Detail","description":"The curated cross-dataset \"Compared with\" section (correlation-feature-proposal.md).\n\nDeliberately no `?search=` or arbitrary-pair form -- `pair_key` is one of a small,\nhand-registered set (`databeat/comparisons.py`'s `PAIRS`-equivalent), enumerable and\nfinite by design (proposal §3.3): if this endpoint cannot answer \"compare arbitrary\nseries A to arbitrary series B\", nobody can build a discovery UI on top of it later\nwithout a deliberate decision to add one.\n\nr, rho and the leave-one-out range are computed here, not in the browser, so any\nfuture consumer (an alert email, a newsletter) gets the number and its qualification\ntogether rather than reconstructing them.\n\n404 for an unknown key OR a known one with too little data to say anything --\nindistinguishable to the caller on purpose, matching `renderCompanion()`'s existing\n\"a bonus, never a blocker\" rule: the frontend degrades either case to nothing shown.","operationId":"comparison_detail_api_v1_comparisons__pair_key__get","parameters":[{"name":"pair_key","in":"path","required":true,"schema":{"type":"string","title":"Pair Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ComparisonResponse"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Not authorized for this request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/api/v1/alerts/notable":{"get":{"tags":["alerts"],"summary":"Notable Alerts","description":"The most notable recent movement in each series, spread across datasets.\n\n`limit` is capped at 50 (the front end only ever requests 8) rather than left\nunbounded — see security-architecture-and-audit.md §4.\n\n`stat_key`, when given, narrows the strip to alerts from ONE stat only (stat-\nregion-selector-design.md §4) — the front end passes it once a reader has\nexplicitly picked a stat as the map's active colour-by layer, so a crime-only\nreader sees a crime-only strip. Omitted (the default) reproduces today's\nbehaviour exactly: unfiltered, ranked across every dataset by the same fairness\nrule below. This is additive — no existing caller changes behaviour until the\nfront end starts passing the parameter.\n\nNot simply `/api/alerts` ordered by date: series are published on different\nschedules, so a date-ordered list is dominated by whichever dataset published most\nrecently. Property is monthly and crime quarterly, which meant property's June\nalerts filled any reasonable page size and crime's January alerts were never\nreached — the crime data was effectively invisible.\n\nDISTINCT ON takes each series' newest alert first, so the ranking happens across\nseries rather than within one publication date.\n\nTHEN THE SAME FAILURE RECURRED ALONG A SECOND AXIS. Ranking the survivors by raw\npercentage let whichever dataset *swings hardest* crowd out the rest, exactly as\ndate-ordering had let whichever dataset published last crowd out the rest. Once PSRA\nlanded, all 8 slots were crime: divisional crime counts move 40-70 per cent between\nquarters as a matter of course, so a county median sale price moving 31 per cent —\nextraordinary for property, on 800k transactions — ranked about 17th and never\nappeared. Percentages from different units are not comparable, and sorting them into\none list silently asserts that they are.\n\nSo rank WITHIN each dataset and interleave: every dataset offers its largest\nmovement before any dataset offers its second. The strip stays ordered by size\nwithin each round, and no dataset can monopolise it.\n\nAND THEN IT RECURRED A THIRD TIME, ALONG A THIRD AXIS. \"Dataset\" was defined as\n`group_name`, which is per-HOSPITAL for NTPF (\"Beaumont Hospital — Outpatient Waiting\nList\") because Dublin has ~14 hospitals and the name has to distinguish the cards. So\nNTPF's 90-odd group_names each contributed a rank-1 alert and swept the first round:\n5 of 8 notable alerts were NTPF, four of them the same waiting band. Not because\nhospital waits are the biggest story, but because NTPF contributes 360 of 418 series\nand therefore wins any count-based contest by construction — a newspaper front page set\nby word count. The interleaving was working exactly as designed; the buckets were the\nwrong size.\n\nFIXED BY QUOTA, in three nested passes, using the cluster taxonomy (migration 014):\n\n  1. Best alert per group_name, so the two NTPF entries that survive are two different\n     hospitals rather than two bands at the same one.\n  2. **At most `MAX_ALERTS_PER_STAT` per `stat_key`** — the level a reader would call\n     one dataset, and the level at which NTPF is one thing rather than ninety.\n  3. Round-robin across the five CLUSTERS: every cluster offers its best before any\n     cluster offers its second. The strip therefore leads with one housing, one crime,\n     one health, one social and one economy story, which is the product's own editorial\n     structure rather than an artefact of which publisher emits the most rows.\n\nThe quota is suspended when `stat_key` is passed, because a reader who has explicitly\nasked for a crime-only strip wants crime alerts, not two of them.","operationId":"notable_alerts_api_v1_alerts_notable_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":50,"minimum":1,"default":8,"title":"Limit"}},{"name":"stat_key","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Stat Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/NotableAlert"},"title":"Response Notable Alerts Api V1 Alerts Notable Get"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Not authorized for this request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/api/v1/alerts":{"get":{"tags":["alerts"],"summary":"List Alerts","description":"Alerts carry their series' caveats. This is the endpoint where that matters most:\nan alert is the moment someone is most likely to act on a figure without opening the\ndashboard, so the number must not travel without its qualification.\n\n`limit` is capped at 200 rather than left unbounded — see\nsecurity-architecture-and-audit.md §4.","operationId":"list_alerts_api_v1_alerts_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"default":50,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlertEntry"},"title":"Response List Alerts Api V1 Alerts Get"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Not authorized for this request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/api/v1/tender_awards":{"get":{"tags":["tender_awards"],"summary":"Search Tender Awards","description":"Search awarded public-procurement contracts (migration 010's `tender_awards`).\n\nEvery filter is optional and AND-ed together. Results are ordered by\naward_published_date desc, id desc (newest first, id as a stable tiebreaker for\nrows sharing a date) -- the same ordering the cursor's keyset comparison is built\non, so pages never skip or repeat a row even if new data lands between requests\n(the one thing an OFFSET-based page number cannot promise).\n\n`supplier=` is the one exception to \"fully open\" -- see `_require_tender_supplier_scope`.\nIndependently of that filter, `awarded_supplier` itself is redacted (null) on every\nrow unless the caller is authorized -- security-privacy-auditor, 2026-08-28: an\nunfiltered listing, paged with `cursor`, returned the field in full regardless, so\nthe whole supplier-name list was harvestable without ever using `supplier=` at\nall. See `_tender_supplier_redaction_reason`.","operationId":"search_tender_awards_api_v1_tender_awards_get","parameters":[{"name":"cpv_code","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Matches any main_cpv_code starting with this value (CPV codes are hierarchical -- e.g. '45' matches every construction-work code).","title":"Cpv Code"},"description":"Matches any main_cpv_code starting with this value (CPV codes are hierarchical -- e.g. '45' matches every construction-work code)."},{"name":"authority","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Case-insensitive substring match on contracting_authority (free text, e.g. 'Health Service Executive').","title":"Authority"},"description":"Case-insensitive substring match on contracting_authority (free text, e.g. 'Health Service Executive')."},{"name":"local_authority_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Exact geographies.id match (e.g. 'la:kerry-county-council') -- populated for the ~31 city/county councils only; most rows have no single honest county home and are NULL, so this filter narrows to a strict subset of the table, never all of it.","title":"Local Authority Id"},"description":"Exact geographies.id match (e.g. 'la:kerry-county-council') -- populated for the ~31 city/county councils only; most rows have no single honest county home and are NULL, so this filter narrows to a strict subset of the table, never all of it."},{"name":"supplier","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Case-insensitive substring match on awarded_supplier. Requires a registered, manually-approved API key (Authorization: Bearer <key>) -- see POST /api/v1/tender_awards/access-requests. Every other filter here is open without one.","title":"Supplier"},"description":"Case-insensitive substring match on awarded_supplier. Requires a registered, manually-approved API key (Authorization: Bearer <key>) -- see POST /api/v1/tender_awards/access-requests. Every other filter here is open without one."},{"name":"date_from","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"award_published_date >= this.","title":"Date From"},"description":"award_published_date >= this."},{"name":"date_to","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"award_published_date <= this.","title":"Date To"},"description":"award_published_date <= this."},{"name":"value_min","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"description":"awarded_value_eur >= this.","title":"Value Min"},"description":"awarded_value_eur >= this."},{"name":"value_max","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"description":"awarded_value_eur <= this.","title":"Value Max"},"description":"awarded_value_eur <= this."},{"name":"single_bid_only","in":"query","required":false,"schema":{"type":"boolean","description":"Only rows where bids_received = 1. journalist-workflow: the filter that makes 'single-bid contracts by council' answerable (combine with local_authority_id).","default":false,"title":"Single Bid Only"},"description":"Only rows where bids_received = 1. journalist-workflow: the filter that makes 'single-bid contracts by council' answerable (combine with local_authority_id)."},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"From a previous response's next_cursor; omit for page 1.","title":"Cursor"},"description":"From a previous response's next_cursor; omit for page 1."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"default":50,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenderAwardSearchResponse"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Not authorized for this request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}},"security":[{"BearerAuth":[]}]}},"/api/v1/tender_awards/access-requests":{"post":{"tags":["tender_awards"],"summary":"Submit Tender Access Request","description":"Register interest in `supplier=` search access. Always either silently\nno-ops or inserts a 'pending' row -- see this section's own header comment on\nwhy nothing here can grant access itself.","operationId":"submit_tender_access_request_api_v1_tender_awards_access_requests_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenderAccessRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenderAccessRequestResult"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Not authorized for this request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/ireland/tender-awards/api-access":{"get":{"summary":"Tender Api Access Docs","operationId":"tender_api_access_docs_ireland_tender_awards_api_access_get","responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/ireland/tender-awards":{"get":{"summary":"Tender Awards Page","description":"The browse page's primary, no-JS-required interaction -- GET with every open\nfilter as a query param, sharing `_run_tender_awards_query` with the JSON API so\nthe two can never disagree on what a filter matches. `supplier` is deliberately\nnot accepted here at all (not even to reject it) -- it only exists on the POST\nsub-form below, so it can never appear in this page's own URL.","operationId":"tender_awards_page_ireland_tender_awards_get","parameters":[{"name":"authority","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authority"}},{"name":"cpv_code","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cpv Code"}},{"name":"local_authority_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Local Authority Id"}},{"name":"date_from","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date From"}},{"name":"date_to","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date To"}},{"name":"value_min","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"title":"Value Min"}},{"name":"value_max","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"title":"Value Max"}},{"name":"single_bid_only","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Single Bid Only"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}}],"responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"summary":"Tender Awards Supplier Search","description":"The supplier sub-form's target. POST-only and reads the key from a form\nfield, never a header or query string -- see this section's header comment.\nRe-renders the same page shell, filters preserved via hidden fields, with the\nsupplier results appended (or a banner explaining why the key was rejected).","operationId":"tender_awards_supplier_search_ireland_tender_awards_post","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Body_tender_awards_supplier_search_ireland_tender_awards_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/ireland/tender-awards/overview":{"get":{"summary":"Tender Awards Overview Page","description":"Cycle 37 item 3. Scoped to a trailing window throughout -- so the headline\ntotal and every breakdown below it describe the SAME set of awards, rather than\na window-scoped headline sitting above all-time breakdowns that don't reconcile\nwith it (the exact kind of inconsistency this codebase's caveat discipline exists\nto avoid). Reuses _build_tender_awards_where() for that one shared date filter,\nsame WHERE-builder every other tender_awards query goes through.\n\nCycle 45 item 9: was `date(date.today().year, 1, 1)` -- calendar-year-to-date,\nwhich resets to empty every January 1st (a genuinely empty page, not just a\nquiet one, for however many days it takes real award data to land) and thins\nout for months after that as the year is still young. A rolling trailing\nwindow never has that boundary: there is always a full 365 days of awards\nbehind `window_start`, any day of the year.","operationId":"tender_awards_overview_page_ireland_tender_awards_overview_get","responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/ireland/tender-awards/authority/{slug}":{"get":{"summary":"Tender Awards Authority Page","description":"Item 8. `slug` is the same local_authority_id spelling _tender_awards_counties()\nalready returns, minus its `la:` prefix -- no new slug-generation/resolution\nmechanism, since that id is already a stable, kebab-case, URL-safe string every\nother tender-awards filter already accepts verbatim.","operationId":"tender_awards_authority_page_ireland_tender_awards_authority__slug__get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/ireland/tender-awards/category/{code}":{"get":{"summary":"Tender Awards Category Page","description":"Item 8's other route. `code` is the bare 2-digit CPV division (e.g. '45'),\nnever a full leaf-level CPV code -- deliberately the coarsest level, so this stays\na small, genuinely browsable set of pages (~15-40, checked live) rather than one\nper leaf code (thousands, most with a handful of awards each -- thin content, the\nexact failure mode this project's own \"no head-terms-nobody-searches\" SEO\ndiscipline warns against elsewhere in TODO.md).","operationId":"tender_awards_category_page_ireland_tender_awards_category__code__get","parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string","title":"Code"}}],"responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/ireland/live-tenders":{"get":{"summary":"Live Tenders Page","operationId":"live_tenders_page_ireland_live_tenders_get","responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/ireland/live-tenders/authority/{slug}":{"get":{"summary":"Live Tenders Authority Page","description":"`slug` is the same local_authority_id spelling tender_awards_authority_page()\nalready uses, minus its `la:` prefix -- no new slug mechanism.","operationId":"live_tenders_authority_page_ireland_live_tenders_authority__slug__get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/feedback":{"post":{"tags":["feedback"],"summary":"Submit Feedback","description":"Capture one feedback submission and email a verification link.\n\nChecks, in order: rate limit (429 past threshold, fail-open on limiter errors --\nsee databeat/rate_limit.py), comment length bounds (400 outside [10, 2000]\ncharacters), basic email shape (400 if it doesn't look like an email at all).\n\nA failed verification-email SEND (e.g. RESEND_API_KEY unset, or Resend itself\nrejecting the request) does not fail the submission — the row is already\ndurably stored, which is the useful part (spec section 4: unverified\nsubmissions are kept, not discarded). The failure is logged to stderr so it is\nvisible in server logs, and surfaced back to the caller via `email_sent: false`\nrather than hidden, but the HTTP status stays 200: the reader's feedback was\ncaptured either way.","operationId":"submit_feedback_api_v1_feedback_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackSubmission"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackResult"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"description":"Not authorized for this request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/api/feedback/verify":{"get":{"summary":"Verify Feedback","description":"Confirm a feedback submission's email address.\n\nA plain HTML confirmation page, NOT a redirect back into the app (spec section\n4: \"simpler, and there's no session/login state to restore\").\n\nSingle-use: on a successful verification the token hash is cleared\n(`verification_token_hash = NULL`), so a second request with the same raw\ntoken no longer matches any row and is treated identically to an invalid\ntoken — this is the actual reuse guard, not merely documentation of intent.","operationId":"verify_feedback_api_feedback_verify_get","parameters":[{"name":"token","in":"query","required":true,"schema":{"type":"string","title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"text/html":{"schema":{"type":"string"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AlertEntry":{"properties":{"country":{"type":"string","title":"Country"},"series_key":{"type":"string","title":"Series Key"},"display_name":{"type":"string","title":"Display Name"},"rule_type":{"type":"string","title":"Rule Type"},"threshold_pct":{"type":"number","title":"Threshold Pct"},"period_date":{"type":"string","title":"Period Date"},"prev_value":{"type":"number","title":"Prev Value"},"curr_value":{"type":"number","title":"Curr Value"},"pct_change":{"type":"number","title":"Pct Change"},"direction":{"type":"string","title":"Direction"},"created_at":{"type":"string","title":"Created At"},"caveats":{"items":{"$ref":"#/components/schemas/Caveat"},"type":"array","title":"Caveats"}},"additionalProperties":true,"type":"object","required":["series_key","display_name","rule_type","threshold_pct","period_date","prev_value","curr_value","pct_change","direction","created_at"],"title":"AlertEntry"},"Body_tender_awards_supplier_search_ireland_tender_awards_post":{"properties":{"supplier":{"type":"string","title":"Supplier","default":""},"api_key":{"type":"string","title":"Api Key","default":""},"authority":{"type":"string","title":"Authority","default":""},"cpv_code":{"type":"string","title":"Cpv Code","default":""},"local_authority_id":{"type":"string","title":"Local Authority Id","default":""},"date_from":{"type":"string","title":"Date From","default":""},"date_to":{"type":"string","title":"Date To","default":""},"value_min":{"type":"string","title":"Value Min","default":""},"value_max":{"type":"string","title":"Value Max","default":""},"single_bid_only":{"type":"string","title":"Single Bid Only","default":""}},"type":"object","title":"Body_tender_awards_supplier_search_ireland_tender_awards_post"},"Caveat":{"properties":{"country":{"type":"string","title":"Country"},"kind":{"type":"string","title":"Kind"},"severity":{"type":"string","title":"Severity"},"label":{"type":"string","title":"Label"},"body":{"type":"string","title":"Body"},"source_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Url"},"origin":{"type":"string","title":"Origin"},"interpretive":{"type":"boolean","title":"Interpretive","default":false}},"additionalProperties":true,"type":"object","required":["kind","severity","label","body","origin"],"title":"Caveat","description":"See databeat/caveats.py's `_caveat()` -- the one shape every caveat in this\nAPI has, whether stored in the `caveats` table or computed per-request\n(geographic/outlier/comparison caveats)."},"ComparisonAxis":{"properties":{"country":{"type":"string","title":"Country"},"label":{"type":"string","title":"Label"},"unit":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Unit"},"series_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Series Key"},"period":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Period"},"periodicity":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Periodicity"},"publisher":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Publisher"}},"additionalProperties":true,"type":"object","required":["label"],"title":"ComparisonAxis"},"ComparisonPoint":{"properties":{"country":{"type":"string","title":"Country"},"area_id":{"type":"string","title":"Area Id"},"area_name":{"type":"string","title":"Area Name"},"x":{"type":"number","title":"X"},"y":{"type":"number","title":"Y"}},"additionalProperties":true,"type":"object","required":["area_id","area_name","x","y"],"title":"ComparisonPoint"},"ComparisonResponse":{"properties":{"country":{"type":"string","title":"Country"},"points":{"items":{"$ref":"#/components/schemas/ComparisonPoint"},"type":"array","title":"Points"},"x":{"$ref":"#/components/schemas/ComparisonAxis"},"y":{"$ref":"#/components/schemas/ComparisonAxis"},"stats":{"$ref":"#/components/schemas/ComparisonStats"},"caveats":{"items":{"$ref":"#/components/schemas/Caveat"},"type":"array","title":"Caveats"}},"additionalProperties":true,"type":"object","required":["points","x","y","stats"],"title":"ComparisonResponse"},"ComparisonStats":{"properties":{"country":{"type":"string","title":"Country"},"n":{"type":"integer","title":"N"},"r":{"type":"number","title":"R"},"rho":{"type":"number","title":"Rho"},"loo_min":{"type":"number","title":"Loo Min"},"loo_max":{"type":"number","title":"Loo Max"},"most_influential_area":{"type":"string","title":"Most Influential Area"}},"additionalProperties":true,"type":"object","required":["n","r","rho","loo_min","loo_max","most_influential_area"],"title":"ComparisonStats"},"DatasetDetailResponse":{"properties":{"country":{"type":"string","title":"Country"},"geography":{"$ref":"#/components/schemas/GeographyRef"},"canonical_geography_id":{"type":"string","title":"Canonical Geography Id"},"dataset":{"$ref":"#/components/schemas/DatasetGroup"},"caveats":{"items":{"$ref":"#/components/schemas/Caveat"},"type":"array","title":"Caveats"},"glossary":{"items":{"$ref":"#/components/schemas/GlossaryEntry"},"type":"array","title":"Glossary"},"sources":{"items":{"$ref":"#/components/schemas/SourceLink"},"type":"array","title":"Sources"},"publisher":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Publisher"}},"additionalProperties":true,"type":"object","required":["geography","canonical_geography_id","dataset"],"title":"DatasetDetailResponse"},"DatasetGroup":{"properties":{"country":{"type":"string","title":"Country"},"dataset":{"type":"string","title":"Dataset"},"readable_slug":{"type":"string","title":"Readable Slug"},"stat_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Stat Key"},"name":{"type":"string","title":"Name"},"unit":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Unit"},"periodicity":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Periodicity"},"geo_match":{"type":"string","title":"Geo Match"},"geo_level":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Geo Level"},"geo_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Geo Name"},"relation_note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Note"},"facets":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Facets"},"latest_period":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Latest Period"},"data_refreshed_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Data Refreshed At"},"latest_value":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Latest Value"},"year_on_year_pct":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Year On Year Pct"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary"}},"additionalProperties":true,"type":"object","required":["dataset","readable_slug","name","geo_match","facets"],"title":"DatasetGroup","description":"dataset_detail()'s `dataset` object -- one series/group with full history per\nfacet. Left loose (extra=\"allow\") on the facet/summary fields dataset_detail()\ncomputes conditionally (`summary`, `latest_value`, `year_on_year_pct` all depend on\nwhich of the three headline shapes this group turned out to be)."},"ErrorDetail":{"properties":{"status":{"type":"integer","title":"Status"},"message":{"type":"string","title":"Message"},"errors":{"anyOf":[{"items":{"additionalProperties":true,"type":"object"},"type":"array"},{"type":"null"}],"title":"Errors"}},"type":"object","required":["status","message"],"title":"ErrorDetail"},"ErrorEnvelope":{"properties":{"error":{"$ref":"#/components/schemas/ErrorDetail"}},"type":"object","required":["error"],"title":"ErrorEnvelope","description":"The one error shape every /api/* response uses, success or failure irrelevant\nhere -- this describes only the failure case. Wraps FastAPI's default bare\n`{\"detail\": ...}` so a consumer always finds an error the same way regardless of\nwhich of this file's routes produced it."},"FeedbackResult":{"properties":{"country":{"type":"string","title":"Country"},"status":{"type":"string","title":"Status"},"email_sent":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Email Sent"}},"additionalProperties":true,"type":"object","required":["status"],"title":"FeedbackResult"},"FeedbackSubmission":{"properties":{"email":{"type":"string","title":"Email"},"comment":{"type":"string","title":"Comment"},"page_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Page Hash"}},"type":"object","required":["email","comment"],"title":"FeedbackSubmission"},"GeographyEntry":{"properties":{"country":{"type":"string","title":"Country"},"id":{"type":"string","title":"Id"},"level":{"type":"string","title":"Level"},"name":{"type":"string","title":"Name"},"name_ga":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name Ga"},"publishable":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Publishable"},"vintage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Vintage"},"source_note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Note"},"boundary":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Boundary"},"centroid":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Centroid"},"parents":{"items":{"$ref":"#/components/schemas/GeographyParent"},"type":"array","title":"Parents"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"additionalProperties":true,"type":"object","required":["id","level","name"],"title":"GeographyEntry"},"GeographyParent":{"properties":{"country":{"type":"string","title":"Country"},"id":{"type":"string","title":"Id"},"level":{"type":"string","title":"Level"},"name":{"type":"string","title":"Name"},"relation":{"type":"string","title":"Relation"}},"additionalProperties":true,"type":"object","required":["id","level","name","relation"],"title":"GeographyParent"},"GeographyRef":{"properties":{"country":{"type":"string","title":"Country"},"id":{"type":"string","title":"Id"},"level":{"type":"string","title":"Level"},"name":{"type":"string","title":"Name"},"identifiers":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Identifiers"}},"additionalProperties":true,"type":"object","required":["id","level","name"],"title":"GeographyRef"},"GlossaryEntry":{"properties":{"country":{"type":"string","title":"Country"},"term":{"type":"string","title":"Term"},"definition":{"type":"string","title":"Definition"},"source_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Url"}},"additionalProperties":true,"type":"object","required":["term","definition"],"title":"GlossaryEntry"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"NotableAlert":{"properties":{"country":{"type":"string","title":"Country"},"series_key":{"type":"string","title":"Series Key"},"display_name":{"type":"string","title":"Display Name"},"group_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Group Name"},"rule_type":{"type":"string","title":"Rule Type"},"period_date":{"type":"string","title":"Period Date"},"prev_value":{"type":"number","title":"Prev Value"},"curr_value":{"type":"number","title":"Curr Value"},"pct_change":{"type":"number","title":"Pct Change"},"direction":{"type":"string","title":"Direction"},"geography_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Geography Id"},"stat_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Stat Key"},"cluster":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cluster"},"periodicity":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Periodicity"},"caveats":{"items":{"$ref":"#/components/schemas/Caveat"},"type":"array","title":"Caveats"}},"additionalProperties":true,"type":"object","required":["series_key","display_name","rule_type","period_date","prev_value","curr_value","pct_change","direction"],"title":"NotableAlert"},"SalePriceDispersionResponse":{"properties":{"country":{"type":"string","title":"Country"},"years":{"items":{"$ref":"#/components/schemas/SalePriceDispersionYear"},"type":"array","title":"Years"},"movers":{"items":{"$ref":"#/components/schemas/SalePriceRankMover"},"type":"array","title":"Movers"},"caveats":{"items":{"$ref":"#/components/schemas/Caveat"},"type":"array","title":"Caveats"}},"additionalProperties":true,"type":"object","required":["years","movers"],"title":"SalePriceDispersionResponse"},"SalePriceDispersionYear":{"properties":{"country":{"type":"string","title":"Country"},"year":{"type":"integer","title":"Year"},"min":{"type":"number","title":"Min"},"p25":{"type":"number","title":"P25"},"median":{"type":"number","title":"Median"},"p75":{"type":"number","title":"P75"},"max":{"type":"number","title":"Max"},"min_area":{"type":"string","title":"Min Area"},"max_area":{"type":"string","title":"Max Area"}},"additionalProperties":true,"type":"object","required":["year","min","p25","median","p75","max","min_area","max_area"],"title":"SalePriceDispersionYear"},"SalePriceRankMover":{"properties":{"country":{"type":"string","title":"Country"},"geography_id":{"type":"string","title":"Geography Id"},"area_name":{"type":"string","title":"Area Name"},"from_year":{"type":"integer","title":"From Year"},"from_rank":{"type":"integer","title":"From Rank"},"to_year":{"type":"integer","title":"To Year"},"to_rank":{"type":"integer","title":"To Rank"},"delta":{"type":"integer","title":"Delta"}},"additionalProperties":true,"type":"object","required":["geography_id","area_name","from_year","from_rank","to_year","to_rank","delta"],"title":"SalePriceRankMover"},"SeriesEntry":{"properties":{"country":{"type":"string","title":"Country"},"series_key":{"type":"string","title":"Series Key"},"display_name":{"type":"string","title":"Display Name"},"region_label":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Region Label"},"unit":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Unit"},"geography_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Geography Id"},"stat_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Stat Key"},"periodicity":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Periodicity"},"latest_period":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Latest Period"},"latest_value":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Latest Value"},"data_refreshed_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Data Refreshed At"},"prev_period_pct":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Prev Period Pct"},"year_on_year_pct":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Year On Year Pct"},"caveats":{"items":{"$ref":"#/components/schemas/Caveat"},"type":"array","title":"Caveats"},"group_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Group Name"},"facets":{"anyOf":[{"items":{"$ref":"#/components/schemas/SeriesFacet"},"type":"array"},{"type":"null"}],"title":"Facets"},"group_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Group Key"}},"additionalProperties":true,"type":"object","required":["series_key","display_name"],"title":"SeriesEntry","description":"One row of GET /api/v1/series -- an ungrouped series, or one entry per group\ncarrying its own `facets` array (see list_series()'s own docstring)."},"SeriesFacet":{"properties":{"country":{"type":"string","title":"Country"},"series_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Series Key"},"label":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Label"},"latest_value":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Latest Value"},"year_on_year_pct":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Year On Year Pct"},"unit":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Unit"}},"additionalProperties":true,"type":"object","title":"SeriesFacet","description":"One facet inside a grouped series' `facets` array. Deliberately loose beyond\nthe handful of keys every facet shape shares -- the three call sites that build\nthis (list_series, series_for_geography, dataset_detail) each carry a different\nextra subset (`label`+`order` vs `is_total`+`data_refreshed_at` vs full `history`),\nand `extra=\"allow\"` carries whichever set the actual route attached."},"SeriesForGeographyEntry":{"properties":{"country":{"type":"string","title":"Country"},"series_key":{"type":"string","title":"Series Key"},"display_name":{"type":"string","title":"Display Name"},"stat_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Stat Key"},"unit":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Unit"},"periodicity":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Periodicity"},"publisher":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Publisher"},"geo_match":{"type":"string","title":"Geo Match"},"geo_level":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Geo Level"},"geo_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Geo Name"},"relation_note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Note"},"latest_period":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Latest Period"},"latest_value":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Latest Value"},"year_on_year_pct":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Year On Year Pct"},"data_refreshed_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Data Refreshed At"},"caveats":{"items":{"$ref":"#/components/schemas/Caveat"},"type":"array","title":"Caveats"},"group_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Group Name"},"facets":{"anyOf":[{"items":{"$ref":"#/components/schemas/SeriesFacet"},"type":"array"},{"type":"null"}],"title":"Facets"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary"},"total_series_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Total Series Key"}},"additionalProperties":true,"type":"object","required":["series_key","display_name","geo_match"],"title":"SeriesForGeographyEntry","description":"One entry in series_for_geography()'s available/not_county_comparable/national\narrays -- SeriesEntry's fields plus the geo_match verdict spread onto it, so this\nis deliberately a separate model rather than a GeoMatchVerdict field."},"SeriesForGeographyResponse":{"properties":{"country":{"type":"string","title":"Country"},"geography":{"$ref":"#/components/schemas/GeographyRef"},"available":{"items":{"$ref":"#/components/schemas/SeriesForGeographyEntry"},"type":"array","title":"Available"},"not_county_comparable":{"items":{"$ref":"#/components/schemas/SeriesForGeographyEntry"},"type":"array","title":"Not County Comparable"},"national":{"items":{"$ref":"#/components/schemas/SeriesForGeographyEntry"},"type":"array","title":"National"}},"additionalProperties":true,"type":"object","required":["geography","available","not_county_comparable","national"],"title":"SeriesForGeographyResponse"},"SeriesHistoryPoint":{"properties":{"country":{"type":"string","title":"Country"},"period_date":{"type":"string","title":"Period Date"},"value":{"type":"number","title":"Value"},"sample_size":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Sample Size"}},"additionalProperties":true,"type":"object","required":["period_date","value"],"title":"SeriesHistoryPoint"},"SeriesHistoryResponse":{"properties":{"country":{"type":"string","title":"Country"},"series_key":{"type":"string","title":"Series Key"},"display_name":{"type":"string","title":"Display Name"},"values":{"items":{"$ref":"#/components/schemas/SeriesHistoryPoint"},"type":"array","title":"Values"},"caveats":{"items":{"$ref":"#/components/schemas/Caveat"},"type":"array","title":"Caveats"}},"additionalProperties":true,"type":"object","required":["series_key","display_name","values"],"title":"SeriesHistoryResponse"},"SourceLink":{"properties":{"country":{"type":"string","title":"Country"},"label":{"type":"string","title":"Label"},"url":{"type":"string","title":"Url"},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Note"}},"additionalProperties":true,"type":"object","required":["label","url"],"title":"SourceLink"},"StatEntry":{"properties":{"country":{"type":"string","title":"Country"},"key":{"type":"string","title":"Key"},"label":{"type":"string","title":"Label"},"caption":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Caption"},"cluster":{"type":"string","title":"Cluster"},"cluster_label":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cluster Label"},"cluster_sort_order":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Cluster Sort Order"},"compatible_levels":{"items":{"type":"string"},"type":"array","title":"Compatible Levels"}},"additionalProperties":true,"type":"object","required":["key","label","cluster"],"title":"StatEntry"},"TenderAccessRequest":{"properties":{"email":{"type":"string","title":"Email"},"use_case":{"type":"string","title":"Use Case"},"affirmation":{"type":"boolean","title":"Affirmation"}},"type":"object","required":["email","use_case","affirmation"],"title":"TenderAccessRequest"},"TenderAccessRequestResult":{"properties":{"country":{"type":"string","title":"Country"},"status":{"type":"string","title":"Status"}},"additionalProperties":true,"type":"object","required":["status"],"title":"TenderAccessRequestResult"},"TenderAward":{"properties":{"country":{"type":"string","title":"Country"},"tender_id":{"type":"string","title":"Tender Id"},"contracting_authority":{"type":"string","title":"Contracting Authority"},"local_authority_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Local Authority Id"},"tender_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tender Name"},"main_cpv_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Main Cpv Code"},"main_cpv_code_description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Main Cpv Code Description"},"contract_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Contract Type"},"competition_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Competition Type"},"threshold_level":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Threshold Level"},"notice_published_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Notice Published Date"},"award_published_date":{"type":"string","title":"Award Published Date"},"awarded_value_eur":{"type":"number","title":"Awarded Value Eur"},"estimated_value_eur":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Estimated Value Eur"},"awarded_supplier":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Awarded Supplier"},"bids_received":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Bids Received"},"sme_bids_received":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Sme Bids Received"}},"additionalProperties":true,"type":"object","required":["tender_id","contracting_authority","award_published_date","awarded_value_eur"],"title":"TenderAward"},"TenderAwardSearchResponse":{"properties":{"country":{"type":"string","title":"Country"},"results":{"items":{"$ref":"#/components/schemas/TenderAward"},"type":"array","title":"Results"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"additionalProperties":true,"type":"object","required":["results"],"title":"TenderAwardSearchResponse"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}},"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","description":"Required only for GET /api/v1/tender_awards's supplier= filter, and to see real awarded_supplier values on any request to that route -- every other endpoint here is fully open. Obtain a key via POST /api/v1/tender_awards/access-requests; every request is reviewed manually, there is no automatic approval."}}}}