Plan: Ingest IMDi's bosettingstall (refugee resettlement) kommune statistics
Ingests IMDi's kommune-level refugee resettlement figures — requested, decided, settled, and settled-with-collective-protection counts, per kommune, per year — as a new Atlas source. The investigation's "no clean open API" claim held up under live verification, but almost everything else about this source still needed checking directly: licence, file shape, and a real alternative API lead that turned out not to pan out within reasonable effort. All recorded below.
IMPLEMENTATION RULES: Before implementing this plan, read and follow:
- WORKFLOW.md - The implementation process
- PLANS.md - Plan structure and best practices
Status: Completed
Goal: Add imdi-bosetting as a served Atlas source, giving Report #8 (Integration Outcomes
Gradient) the inflow signal it is currently missing — how many refugees a kommune actually
received per year, not just who already lives there.
Last Updated: 2026-10-01
Investigation: INVESTIGATE-new-norwegian-public-sources.md §Tier 1 #5 ([Q14]–[Q16], [Q42]–[Q44])
Prerequisites: None — imdi is already a valid publishers.yaml provider (#486). [Q1]
(licence) is resolved: Terje, 2026-10-01 — "IMDI is ok. we can use it." That is an
authorization to proceed, not a citation of a specific licence IMDi itself states (this agent still
could not find one on IMDi's own site). manifest.yml uses NLOD — Atlas's documented default for
Norwegian public-sector sources with no stated licence (manifest.schema.json's own field
description: "NLOD for Norwegian public-sector sources by default") — labelled in the manifest as
an applied default under this authorization, not as something verified on IMDi's page. If IMDi ever
states a different licence, correct the manifest then; don't treat this as settled research.
Problem Summary
Atlas has demographic snapshots of immigrant populations (fhi-innvkat, fhi-innvandrere) but no
flow signal — nothing says how many refugees a kommune received, or when. Report #8 (Integration
Outcomes Gradient) needs exactly that to distinguish a kommune's integration outcomes from its
integration starting conditions. IMDi's bosettingstall (settlement figures) is the authoritative
Norwegian source for this, published annually per kommune since at least 2022.
Phase 1: Confirm the real shape — DONE (verified 2026-10-01)
Tasks
-
1.1 Find where IMDi actually publishes kommune-level bosettingstall, and in what format. Confirmed:
https://www.imdi.no/bosetting/bosettingstall/is a hub page linking one page per year —/bosetting/bosettingstall/nokkeltall-bosetting-<YYYY>/— currently 2022 through 2026, all five live and fetched directly (no guessing a URL pattern that might not hold; every year checked was actually a working link on the hub page). Each year's page is static, server-rendered HTML — no SPA framework, no client-side rendering (checked for__NEXT_DATA__/React markers; none found; kommune names like "Eigersund" appear directly in the raw HTML response). Simpler to scrape than either NAV source — no JS execution needed, no headless browser. -
1.2 A real, promising-looking alternative was investigated and did not pan out — recorded so it isn't re-chased.
data.norge.nohas exactly one dataset registered for IMDi (org987879696, found via the real search API —search.api.fellesdatakatalog.digdir.no/search, not a generic web search): "Statistikk om innvandring og integrering",isOpenData: true, with atext/csvformat hint and a description explicitly naming "bosetting av flyktninger" as one of its covered topics, plus a reference to a "Statistikkbasen" tool. That tool is real and live — IMDikator, atarkiv.imdi.no/statistikk/, backed by a dedicated API host (app-simapi-prod.azurewebsites.net, found via the page'sdata-api-hostattribute) and currently updated (data as recent as January 2025 seen on the page). But: no Swagger/OpenAPI docs found at the usual paths, the actual API-calling code loads via a dynamically-injected bundle this agent could not locate through static analysis (would need a headless browser to observe the real network calls — out of proportion for this plan), and the tool's visible scope (population composition, immigration reasons, world regions, residence duration) reads as closer to the investigation's [Q42]/[Q43] extensions (imdi-innvandringsgrunn-kjonn,imdi-landbakgrunn) than to bosettingstall itself — not confirmed to carry resettlement counts at all. Decision: do not block this plan on reverse-engineering IMDikator. Thedata.norge.no-registered dataset's own catalogue record is also 6 years stale (modified: 2020-05-26), the same kind of staleness that has been wrong before this session (NAV, Bufdir). IMDikator is a real, separate lead worth a future investigation for the [Q42]/[Q43] extensions — see Implementation Notes — but this plan does not fold them in, contradicting the investigation's own [Q42] recommendation to combine all three into one PLAN. Named explicitly as a deliberate deviation, not an oversight. -
1.3 Download and inspect the real table structure (sampled 2022, 2025, 2026). Confirmed: under an
<h2>heading readingOversikt over bosettingen i kommunene i <year>(the live 2026 page appends(oppdatert <date>)to this heading — match on the fixed prefix, not an exact string), one<table>per fylke, each with:<th>Kommune</th><th>Antall personer kommunen har blitt anmodet om å bosette:</th><th>Antall personer kommunen har vedtatt å bosette:</th><th>Antall bosatte personer:</th><th>Antall bosatte personer med kollektiv beskyttelse:</th>Four real metrics, not a count/share pair like the Bufdir or NAV sources — requested, decided, settled, settled-under-collective-protection are genuinely different quantities, not format variants of one indicator. See [Q2] in Implementation Notes for how this plan represents that.
-
1.4 Confirm the suppression marker, and find its definition. Confirmed and, unlike every prior source this session, IMDi states its own rule inline on the page: "Enkelte celler i tabellene nedenfor vil ha tegnet : Dette betyr at tallet er fjernet av personvernhensyn. Det betyr som regel at det er under fem personer i feltet eller at totalsum på bosatte minus bosatte fra Ukraina er under fem." — the literal character
:means the cell was removed for privacy, usually meaning under 5 people in that cell, or that total settled minus settled-from-Ukraine is under 5. Worth keeping that exact wording in the manifest'smethodology_notesrather than paraphrasing it away. -
1.5 IMDi publishes kommune names only — no kommune code at all, in any year sampled. Genuinely different from every source ingested this session so far (SSB/FHI/Bufdir/NAV all publish a code). Atlas already has exactly the tool for this:
crosswalk_kommune_name(derived fromdim_kommune, built for "resolving free-text municipality strings from upstream sources" — this is precisely that). Its own docstring already warns names aren't globally unique pre-2020 ("Os"existed in two different fylker) and that ambiguous matches need "additional context (postal code, fylke)."⚠️ That disambiguating fylke context is not reliably available here. IMDi's own fylke groupings are not stable across the years this plan ingests: 2022's page groups kommuner under the pre-2024-reform fylke names (
Troms og Finnmark,Vestfold og Telemark,Viken— 11 fylke sections); 2025/2026 use the post-reform 15-fylke scheme. A rawfylkecolumn captured from the page would itself need a second, year-dependent crosswalk to even become useful as disambiguating context — not worth building for a problem that may not materialise. [Q3] Recommendation: storekommune_nameonly (not fylke) inraw.imdi_bosetting; resolve viacrosswalk_kommune_nameon name alone at the dbt layer; check empirically during Phase 3 whether any of IMDi's actual kommune names collide in the crosswalk (the known collision,"Os", is a pre-2020 historical case — confirm whether it even appears in a bosettingstall table before building disambiguation logic for a risk that may be theoretical only).
Validation
✅ Confirmed 2026-10-01. Phase 1 is DONE — five real pages downloaded and inspected directly (2022/2023/2024/2025/2026 all confirmed live from the hub page), one genuine alternative lead (IMDikator) chased far enough to make an informed decision not to depend on it.
Phase 2: Ingest module + raw table
Tasks
-
2.1 Create
atlas-data/ingest/src/sources/imdi-bosetting/:parse.ts:discoverYearPages(hubHtml)— find every/bosetting/bosettingstall/nokkeltall-bosetting-\d{4}/link on the hub page, dedupe, return as a list of{year, path}. Discover, don't hardcode 2022-2026 — a future year appearing on the hub should be picked up automatically, the same "discover, don't guess" philosophy as every prior source's URL discovery.parseKommuneTables(html, year)— find the<h2>matching/^Oversikt over bosettingen i kommunene i \d{4}/(prefix match, not exact — see Phase 1.3), then every<table>between it and the next<h2>. For each row: kommune name (first<td>)- metric cells resolved by header TEXT, not fixed position — [Q5, new] found the column
count is not fixed (4 metrics most years, 6 on Oslo's 2024 table and on every table from 2026).
Use
cheerio(already a transitive dependency viacrawlee; added as an explicit direct dependency rather than rely on an undeclared transitive one) — this page is simple, consistent static HTML, a proper parser is still safer than regex against markup.
- metric cells resolved by header TEXT, not fixed position — [Q5, new] found the column
count is not fixed (4 metrics most years, 6 on Oslo's 2024 table and on every table from 2026).
Use
parseCell(raw)— the literal string:→ null (IMDi's own suppression marker, see Phase 1.4), and any other unparseable text (e.g."avventer vedtak") → null as a side effect of the sameNumber.parseInt→NaN→ null path; otherwise parse as integer.
index.ts— fetch hub page →discoverYearPages→ fetch each year page →parseKommuneTables→ upsertraw.imdi_bosetting. Full-table replace per run, same convention as every other source this session — v1 re-ingests every discovered year on every run (cheap: 5 pages, no reason to diff).fetch_retry.ts— copy, adapted header comment.manifest.yml—source_id: imdi-bosetting,provider: imdi,periodicity: P1Y,eu_theme: SOCI,tags.topic: social,license: NLOD(Atlas's documented default for an unstated Norwegian public-sector licence, applied under Terje's 2026-10-01 authorization — see [Q1] — not a citation of a licence IMDi itself states).README.mdand__tests__/— golden-file tests against real downloaded fixture pages. Ended up with 5 (hub + 2022/2024/2025/2026), not the originally-planned 3 — 2024 and 2026 were added specifically because they're what exercises the header-driven metric resolution from [Q5], which the original 2-fylke-era sample (2022 pre-reform, 2025 post-reform) would have missed entirely since both of those years happen to have the same 4-column shape.
-
2.2 Migration
raw.imdi_bosetting— columnskommune_name(text, verbatim from IMDi — notregion_code, there isn't one),year,metric(text:anmodet/vedtatt/bosatte/bosatte_kollektiv_beskyttelse/avtalt/avtalt_kollektiv_beskyttelse— see [Q2] and [Q5]),value,loaded_at. PK(kommune_name, year, metric). Novalues_json— unlike the monthly/multi-year sources, each (kommune, year, metric) triple is already a single scalar with nothing to spine across; adding it would just wrap one number in JSON for no reason. -
2.3 Dagster registration —
cadence.weekly_polled()/WEEKLY_FRESHNESS, same as the Bufdir sources (annual data, polled weekly so a new year's page doesn't sit undiscovered for months). Added to_ANNUAL_SOURCE_IDSinschedules.py(the existingannual_sources_refreshjob — no new job needed, unlikenav-uforetrygd's monthly cadence).
Validation
cd atlas-data/ingest && npm test -- imdi-bosetting
✅ Done, 2026-10-01. 30 tests pass. A live run against the real hub page discovered all 5 years
currently linked (2022-2026) and produced 7,848 real rows end to end (NDJSON + local Postgres),
zero rows silently dropped. npm run typecheck clean.
Phase 3: dbt staging and marts
Tasks
- 3.1 Add
raw.imdi_bosettingtosources.yml—ingest_cadence: weekly, freshness matching the Bufdir sources' annual-data bounds (400/800 day), not the NAV monthly bounds. - 3.2
indicators__imdi_bosetting.sql—kommune_nrviacrosswalk_kommune_name(a join Atlas hasn't needed before — every prior source this session resolvedkommune_nrfrom an upstream-published code viaclassify_region_code/region_code_to_kommune_nr; this is the first to resolve it from a bare name instead). [Q3] checked empirically — see its own entry above; restricting the join to active (canonical/alternative) crosswalk rows resolved the ambiguity completely, no disambiguation machinery needed. Also excludesdim_kommune.is_sentinel(SSB's 9999 'Uoppgitt') explicitly, defensively — never observed in real data, but the crosswalk's active branches don't filter it themselves (check-kommune-marts-exclude-sentinels.shgate). - 3.3 Document columns in
schema.yml; validated against a local Postgres loaded with the real ingest (7,848 rows), not an empty schema —dbt buildgreen, including thekommune_nr→dim_kommunerelationships test. - 3.4
mart_indicators__imdi_bosetting.sqlapi passthrough +marts/api/schema.ymlentry. Applied to the localapi_v1schema and queried live —GET /indicators__imdi_bosetting?kommune_name=eq.Oslo&year=eq.2024returns the real 6-metric row includingavtalt/avtalt_kollektiv_beskyttelse. All drift-gate checks (check-api-v1.sh,check-lineage-is-current.sh,check-inventory-depends-on.sh,check-kommune-marts-exclude-sentinels.sh,check-root-document-indexes-every-relation.sh, and 11 more) regenerated and green. - 3.5
dbt build+dbt test— relationship testkommune_nr→dim_kommune,not_nullon PK columns,accepted_valuesonmetric. All 12 data tests pass.
Validation
cd atlas-data/dbt && dbt build --select indicators__imdi_bosetting mart_indicators__imdi_bosetting
✅ Done, 2026-10-01, against a local Postgres loaded via the real ingest. Checked the kommune_nr
relationship test was not silently passing on an all-null column — it was not: 352 of 359
distinct kommune_name values resolve to a real kommune_nr (98%, measured directly with SQL, not
inferred from the test passing), restricted to active crosswalk rows with zero ambiguity. The 7
unmatched (2%) are named in [Q3] above — well above the "flag below ~95%" threshold this plan
set for itself.
Phase 4: Deploy and verify arrival
Tasks
- 4.1 Regenerated every drift-gated artifact up front — see Phase 3's validation note. One real
gap found on the first CI push:
template-info.yamlstill said 54 raw tables after058_raw_imdi_bosetting.sqlmade it 55 —render-template-info.shcaught it, fixed in a follow-up commit, all 8 CI checks green after that. - 4.2 Filed the deploy request to imac, as
urb-agents#1799. Named the exact
relations (
raw.imdi_bosetting,api_v1.indicators__imdi_bosetting/mart_indicators__imdi_bosetting), both image digests labelled by object (ghcr.io/terchris/atlas-data:v20261001-8e672cfcode-location,ghcr.io/terchris/atlas-data/uis:v20261001-8e672cfartifact), and the derivedLANDS WITH(atlas-data/uis/lands-with.sh bbabfef..8e672cf:annual_sources_refreshthentransform_and_publish—imdi-bosettingwas added to the existingannual_sources_refreshjob, no new job needed unlikenav-uforetrygd's monthly one). Row-count prediction: 7,848 (measured locally 2026-10-01), with the same "2026 is still open, a republish could move it" caveat class as NAV's monthly file, and thekommune_nrmatch rate (352/359, 7 expected-NULL names listed) so a non-zero null count isn't mistaken for a new defect. - 4.3 Verified arrival, 2026-10-01 — imac ran
annual_sources_refresh(run3d694a6a, 978.3s) thentransform_and_publish(run2de94244, 623.3s), both SUCCESS,raw.imdi_bosettinglanded exactly 7,848 rows matching this plan's own prediction. Not taken on trust — independently re-checked against the live public API myself before closing:7,848 rows — an exact match to both this plan's local prediction and imac's independent cluster-side measurement. One real discrepancy surfaced and was traced rather than waved through:GET /meta_sources?source_id=eq.imdi-bosetting&select=served_as-> [{"served_as":["indicators__imdi_bosetting"]}]GET /indicators__imdi_bosetting?kommune_name=eq.Oslo&year=eq.2024&order=metric-> 6 rows: anmodet=1650, avtalt=105, avtalt_kollektiv_beskyttelse=67,bosatte=1539, bosatte_kollektiv_beskyttelse=1203, vedtatt=1648HEAD with Prefer: count=exact-> content-range 0-7847/7848 (cf-cache-status: MISS — a fresh server read, not stale cache)kommune_nr=is.nullreturned 134 rows, not this plan's own ~42 estimate. imac traced it — the ~42 figure was7 names × 6 metricsfor a single year; the real count sums each of the same 7 names across every year it actually appears in (22×5 + 12×2 = 134, matching exactly) — a single-year arithmetic shortcut in the original estimate, not a new defect, and the underlying 7 names and the "6 metrics max" shape were exactly as predicted. No regression innav-uforetrygd(6,560),bufdir-barnevern(191,673) orfhi-innvandrere(123,680). Full exchange: urb-agents#1799, closedcompleted.
Acceptance Criteria
-
imdi-bosettingingests cleanly from all currently-linked hub-page years with zero rows silently dropped. 7,848 rows across 2022-2026. -
raw.imdi_bosettingstoreskommune_name, not a code — and not afylkecolumn, per Phase 1.5's finding that IMDi's own fylke groupings aren't stable across the years ingested. -
kommune_nrresolves viacrosswalk_kommune_namewith a measured, reported match rate — not assumed to be 100%. 352/359 (98%), zero ambiguity, 7 unmatched named explicitly. -
indicators__imdi_bosettingandmart_indicators__imdi_bosettingbuild and test clean against real loaded data. -
imdi-bosettingappears inmeta_sources.served_asafter a real deploy, independently verified via livecurl.served_as: ["indicators__imdi_bosetting"]. - The investigation and
1PRIORITY.mdare updated to mark this candidate shipped. - [Q1] (licence) is resolved — Terje, 2026-10-01: "IMDI is ok. we can use it." Recorded as an authorization, not a citation; see Implementation Notes.
Implementation Notes
- [Q1] Licence — resolved by authorization, 2026-10-01, not by finding IMDi's own statement.
Checked IMDi's bosettingstall page itself (nothing), the page footer (only privacy/accessibility
links, no data-licence link), a broad web search, and the
data.norge.nocatalogue record for IMDi's one registered dataset (isOpenData: true, but no licence field present at all). Unlike NAV (CC BY 4.0, found on NAV's own stated-terms page) this agent could not find an equivalent statement for IMDi, and did not find one afterward either. Terje: "IMDI is ok. we can use it." That authorizes proceeding; it is not a citation of a specific licence IMDi states.manifest.ymlrecordslicense: NLODas Atlas's own documented default for an unstated Norwegian public-sector licence (permanifest.schema.json's field description), applied under this authorization — keep that distinction visible in the manifest's own text (not just here), so a future reader doesn't mistake "NLOD" for something verified on IMDi's page. If IMDi publishes an explicit licence statement later, correct the manifest then. - [Q2] Four distinct metrics, not a count/share pair — modelled as a
metriclong-format dimension, matching Atlas's established per-source-indicator convention (the Bufdir sources have 22-24 genuinely distinct indicators and are still modelled long, one row per indicator×region×year, not wide columns per indicator). Namedmetric, notcategory_format(which the Bufdir/NAV sources use) — these aren't format variants of one quantity, they're stages of the resettlement pipeline (requested → decided → settled → settled-with-collective-protection), andcategory_formatwould misdescribe that. Flagged as a resolved-but-debatable decision, not silently picked, since a wide 4-column table is the more obvious first instinct for 4 named figures and a future reader might reasonably ask why it wasn't done that way. - [Q3] RESOLVED, measured live 2026-10-01 against the real ingested data (7,848 rows, all 5
discoverable years). The unrestricted crosswalk (all three of its canonical/alternative/
historical name_kinds) makes 259 of 359 distinct
kommune_namevalues match 2+kommune_nr— not a rare "Os"-style edge case, the majority. Restricting the join toname_kind IN ('canonical', 'alternative')— i.e. active kommune codes only, which is correct here because IMDi's data starts in 2022, after the 2020 reform, so a historical pre-reform code is never the right match — resolves this completely: 352 of 359 match with ZERO ambiguity. The 7 that still don't match are IMDi's own fylke-disambiguation suffixes ("Bø (Nordland)", "Nes (Ak.)", "Os (Hedm.)", "Sande (Møre og Romsdal)", "Våler (Hedm.)") plus two spelling differences ("Kåfjord" vs the crosswalk's "Kåfjord - Kaivuono", and bare "Våler") — left NULL inindicators__imdi_bosettingrather than guessed. See that model's own header comment. No disambiguation machinery was needed; the fix was restricting which crosswalk rows to join against, not building a new lookup. - IMDikator (
arkiv.imdi.no/statistikk/, API hostapp-simapi-prod.azurewebsites.net) is a real, separate lead for the investigation's [Q42]/[Q43] extensions (imdi-innvandringsgrunn-kjonn,imdi-landbakgrunn) — not chased further here. A future investigation reverse-engineering its actual API (likely needs a headless browser to observe real network calls, since the bundle is loaded dynamically and no Swagger docs were found) could plug both of those gaps at once, and possibly provide a cleaner, coded alternative to the bosettingstall HTML scrape too, if it turns out to carry the same resettlement figures — worth checking first before assuming it doesn't. - Do not fold
imdi-innvandringsgrunn-kjonn/imdi-landbakgrunninto this plan, despite the investigation's own [Q42] recommendation to do so. That recommendation assumed a shared, already-understood data source; Phase 1 found the actual mechanism for those two extensions is unconfirmed. Combining them now would mean drafting Phase 2/3 against an unverified foundation — exactly the mistake this session's corrections have been about catching, not repeating. - [Q5, new] Column count is NOT fixed across this source's own history — found during Phase 2
implementation, not anticipated in Phase 1. Phase 1's sample (one year's page) only ever showed
4 metrics per kommune row. A live dry-run against all 5 then-discoverable years (2022-2026)
failed on 2024 with a header-column-count mismatch: IMDi piloted 2 extra "avtalt" (agreed) metrics
on Oslo's table alone in 2024, then rolled them out to every fylke table from 2026 on. The parser
(
parse.ts) was rewritten to resolve metrics by header TEXT (HEADER_TO_METRIC) rather than fixed position, specifically so this represents what IMDi actually published each year rather than normalizing it into a single 4-column shape. An unrecognized header still throws. Seeparse.ts's own header comment andREADME.md's quirks section for the full detail, including a free-text non-numeric cell found the same way (Moskenes 2024:"avventer vedtak", not the:suppression marker —parseCellalready handled it correctly as a side effect of mapping any unparseable text to null).
Outcome
Shipped end to end, 2026-10-01: ingest (5 pages, 7,848 rows, zero dropped) → dbt staging and
api_v1 publication → live cluster deploy → independently verified arrival. Two real findings
during implementation that Phase 1's single-year sample didn't anticipate: IMDi's own metric
column count isn't fixed (Oslo's 2024 table piloted 2 extra "avtalt" metrics before they rolled
out everywhere in 2026 — parse.ts resolves metrics by header text rather than position to
represent this), and crosswalk_kommune_name is far more ambiguous than assumed unrestricted
(259 of 359 names matched 2+ codes) but resolves completely once restricted to active-only rows
(352/359, zero ambiguity). Plugs the inflow signal Report #8 (Integration Outcomes Gradient) was
missing — how many refugees a kommune actually received per year, not just who already lives
there.
Files to Modify
atlas-data/ingest/src/sources/imdi-bosetting/manifest.yml(new)atlas-data/ingest/src/sources/imdi-bosetting/index.ts(new)atlas-data/ingest/src/sources/imdi-bosetting/parse.ts(new)atlas-data/ingest/src/sources/imdi-bosetting/fetch_retry.ts(new, copied)atlas-data/ingest/src/sources/imdi-bosetting/README.md(new)atlas-data/ingest/src/sources/imdi-bosetting/__tests__/(new)atlas-data/ingest/package.json(cheeriodependency,ingest:imdi-bosettingscript)atlas-data/migrations/<next>_raw_imdi_bosetting.sql(new)atlas-data/dagster/atlas_data/assets/raw_other.py,schedules.py(asset registration)atlas-data/dbt/models/indicators/sources.yml,indicators__imdi_bosetting.sql(new),schema.ymlatlas-data/dbt/models/marts/api/mart_indicators__imdi_bosetting.sql(new),schema.ymlatlas-data/dbt/models/marts/api/mart_atlas_inventory.sql(depends_on + counted list)atlas-data/dbt/scripts/generate_api_v1.py(SCHEMA_COMMENT'simdilisting)atlas-data/template-info.yaml,website/docs/developers/index.md(regenerated counts)website/docs/ai-developer/plans/backlog/INVESTIGATE-new-norwegian-public-sources.md(mark shipped)website/docs/ai-developer/plans/backlog/1PRIORITY.md(mark shipped)