Plan 001: Brreg chapter matching + brreg-underenheter
IMPLEMENTATION RULES: Before implementing this plan, read and follow:
- WORKFLOW.md - The implementation process
- PLANS.md - Plan structure and best practices
Status: Completed 2026-10-05 (#557, #558). All three deliverables shipped: dim_ngo.structure,
int_ngo_chapter_registry_match, brreg-underenheter + int_ngo_chapter_subunits.
int_ngo_chapter_reconciled (joining against each NGO's own site crawl) was explicitly out of
scope for this PLAN and is a separate, not-yet-started PLAN.
Goal: Give every one of the 11 NGOs in dim_ngo a registry-sourced chapter row — the nine
federated NGOs from dim_brreg_enhet (already ingested, pure dbt derivation, no new ingest) and the
two unitary NGOs (Frelsesarmeen, Kirkens Bymisjon) from a new brreg-underenheter source — before
any NGO site is scraped. This is explicitly first in the build order per
Q7 and lands P1 and P4 of
atlas-model-proposals.md.
Last Updated: 2026-10-04
Investigation: INVESTIGATE-ngo-research-handover.md
(method: docs/research/ngo-research/ingestion-specs/brreg-chapter-matching.md and
brreg-underenheter.md)
Prerequisites: None — dim_brreg_enhet is live today; brreg-underenheter is new NLOD data,
no scraping involved.
Blocks: Every per-NGO site PLAN (reconciliation needs registry rows to join against) and P8's
chapter_data_shape correction for redcross (needs the NGO's structure column this plan adds).
Priority: High — first item in the handover's Next Steps.
⚠️ Check before starting: INVESTIGATE-semantic-foundation-before-expansion.md (backlog,
last updated 2026-04-25, never decided) proposes freezing NGO supply-source expansion beyond
Folkehjelp until cross-NGO activity taxonomy, chapter_subtype vocabulary and dbt contract scope
are settled — its own [Q4] is the cross-NGO taxonomy question the now-merged taxonomy PR (#555)
answers. This PLAN (pure Brreg registry derivation, no site crawl, no chapter_subtype
promotion, no taxonomy adoption) does not touch any of the frozen decisions directly, so it should
be safe regardless — but the freeze proposal itself was never resolved one way or the other, and
Terje's own instruction to incorporate PRs #553–555 is the kind of event that investigation's
[Q2] asked about. Flagged to Terje directly in the #1844 report rather than resolved here.
Overview
Three deliverables, each independently testable before the next starts:
dim_ngo.structure(federated/unitary) — a one-column seed addition. Drives which path a given NGO takes in steps 2 and 3. Federated: Røde Kors, Folkehjelp, Diabetesforbundet, Mental Helse, LHL, 4H Norge, Speiderforbundet, Sanitetskvinnene, Nasjonalforeningen. Unitary: Frelsesarmeen, Kirkens Bymisjon.int_ngo_chapter_registry_match(dbt model, no new ingest) — the nine federated NGOs' chapters out ofdim_brreg_enhet, by the per-NGO name rule inref_atlas_ngo_match_rule(a new seed, one row per NGO so adding NGO #12 is a seed row, not code — per the spec's own recommendation).brreg-underenheter(new ingest source, same shape asbrreg-enheter-alle) +int_ngo_chapter_subunits(dbt model filtering to the two unitary NGOs viadim_ngo.structure).
Both (2) and (3) emit the same column shape (chapter_orgnr, ngo_orgnr, name, chapter_level,
chapter_type, kommune_nr, is_active, confidence, registration, reconciliation,
parent_chapter_id, source_url) so a later int_ngo_chapter_reconciled (next PLAN, joins against
each NGO's own site crawl) can UNION ALL them without per-source special-casing.
Not built in this PLAN (deferred, each to its own later PLAN per the investigation's Next
Steps): any site crawl, description-redaction, geocoding, dim_chapter_contact. dim_chapter
itself keeps its current Red-Cross-only wrapper until the first site-crawl PLAN reconciles against
these registry rows — this PLAN populates int_ models only, nothing public-facing changes.
Decision-points specific to this PLAN:
- [P001.Q1]
ref_atlas_ngo_match_ruleas a seed vs. hardcoded CTEs. Recommendation: seed — the spec's own reasoning (a new NGO is a row) matches this repo's existing preference for data-driven over code-driven per-entity rules (e.g._sources_manifest.csv). - [P001.Q2] Where
brreg-underenheter's raw table(s) land. Recommendation: mirrorbrreg-enheter-alleexactly — same migration pattern, sameraw.brreg_underenheter_*naming, same bulk-file-preferred-over-paging approach — so this is boring, not novel. - [P001.Q3] Confidence values are stated by the spec (
high/medium/lowby corroboration signal) — implement as written, don't re-derive a scoring function.
Phase 1: dim_ngo.structure seed column
Tasks
- 1.1 Add
structurecolumn toatlas-data/dbt/seeds/dim_ngo.csv:unitaryforfrelsesarmeenandkirkens-bymisjon,federatedfor the other 9. - 1.2
schema.yml:accepted_valuestest (federated,unitary),not_null. - 1.3 While touching this seed: corrected
redcross's ANDnasjonalforeningen'schapter_data_shapetocms_bins(both are scraped, not API-sourced, per [Q3]/P8).
Validation
cd atlas-data/dbt
dbt build --select dim_ngo
dbt show --inline "select slug, structure from dim_ngo order by structure, slug"
Confirms 9 federated / 2 unitary, matching the NGO list above exactly.
Phase 2: ref_atlas_ngo_match_rule seed + int_ngo_chapter_registry_match
Tasks
- 2.1
ref_atlas_ngo_match_rule.csv: one row per federated NGO —ngo_orgnr, ngo_slug, include_pattern, exclude_pattern, strong_pattern, website_host, transcribed verbatim frombrreg-chapter-matching.md's per-NGO rule table (9 rows). - 2.2
int_ngo_chapter_registry_match.sqloverdim_brreg_enhetfiltered toregistrert_i_frivillighetsregisteret = true: folds Ø/Æ/Å via a newfold_norwegian_for_matchingmacro (no existing one did this for matching keys), applies include/exclude/strong per NGO, emitschapter_levelfrom the DISTRIKT/FYLKESLAG/… keyword rule,related_entityfororganisasjonsform_kodein (AS,STI),confidenceper the spec's three-tier rule. ⚠️ Two real bugs found building this, both against live data not fixtures: Postgres's~uses Tcl ARE, where\bis not a word boundary (\yis) — silently zeroed 8 of 9 NGOs' matches until translated in apatterns_pgCTE. And dbt's seed loader reads an empty CSV field asNULL, not''—exclude_pattern != ''wasNULL(notfalse) for the 7 NGOs with no exclude pattern, andWHERE NULLdrops the row exactly likeWHERE false; fixed withcoalesce(exclude_pattern, ''). - 2.3
ngo_chapter_registry_counts_match_acceptance_targets.sql: a singular test per NGO against the spec's targets, ±10% per [Q9]. - 2.4 Confirmed Ø/Æ/Å folding on Røde Kors and Sanitetskvinnene — both land within 1 of target (382/381, 465/463) once the two bugs above were fixed; before the fix both were zero.
Validation
dbt build --select int_ngo_chapter_registry_match
dbt show --inline "select ngo_orgnr, count(*) from int_ngo_chapter_registry_match group by 1"
Compare each count against acceptance-targets.csv's enheter column (Sanitetskvinnene 463+,
Nasjonalforeningen 370, 4H 494, LHL 228, Mental Helse 188, Diabetesforbundet 117, Folkehjelp 107 —
Røde Kors and Speiderforbundet not given a registry-only count in that spec; use its
brreg-chapter-matching.md table instead, 381 / 393).
Phase 3: brreg-underenheter ingest + int_ngo_chapter_subunits
3.1–3.3 done 2026-10-04/05 (atlas) — the ingest half, ahead of Phase 1/2 at Terje's explicit
instruction ("do the underenheter... you must also create a job that updates it, just like the
other"). 3.4–3.5 (the actual reconciliation model) wait on Phase 1's dim_ngo.structure column,
not yet built.
Tasks
- 3.1 New migrations
072_raw_brreg_underenheter_snapshot.sql+073_raw_brreg_underenheter_change_feed.sql, mirroringbrreg-enheter-alle's 052/053 field for field (snapshot table, feed watermark, oppdateringer log, versions history — all as separate tables from enheter's own, independent id space). - 3.2 New ingest modules
brreg-underenheter/(bootstrap) andbrreg-underenheter-oppdateringer/(change feed poller), mirroringbrreg-enheter-alle's andbrreg-oppdateringer's structure. One real finding beyond the mirror: aFjernetentity can answer HTTP 410 rather than the HTTP-200 stub documented forSletting— confirmed live, and the SAME is true for the existing enheter feed (its own "not 404 or 410" claim was wrong, corrected inbrreg-oppdateringer/parse.tswhile building this). - 3.3 Registered in Dagster — added to the EXISTING
BRREG_BULK_SOURCES/BRREG_DAILY_SOURCES/BRREG_FEED_SOURCESlists inraw_brreg.py, so both sources ride the already-namedbrreg_bootstrap/brreg_change_feedjobs rather than needing a new job name. Verified, not assumed:render-template-info.shreports "first_data covers all 59 automated sources" andlands-with.sh(once committed) names the right job — the exact gap that bitssb-10501/ssb-12891on PR #550 does not recur here. - 3.4
int_ngo_chapter_subunits.sql: filtersraw.brreg_underenheter_snapshotbyoverordnetEnhetto the two unitary NGOs (viadim_ngo.structure), emits the parent entity (fromdim_brreg_enhet, since the national row is an ENHET not an UNDERENHET) as the national row, classifiesorganisasjonsform_kode = 'AS'(checked first, for Fretex) and HOVEDKONTOR/ADM-type names asrelated_entity, DIVISJON/REGION-type names asregional, elselocal. Lands within 1 of target (176/175 Frelsesarmeen, 151/151 Kirkens Bymisjon). Two deliberate, documented gaps, deferred not silent: no incremental reconciliation yet (reads the bootstrap snapshot only — deletions via the change feed aren't reflected here); area-splitting from spec rule 3 is simplified to literalAVD-suffix extraction, since nothing downstream renders chapter service areas yet. - 3.5 Same column shape as Phase 2's output — verified by construction (both models' final
selectlists match column-for-column) and by the acceptance tests passing identically.
Validation — 3.1–3.3, done for real against live data, not simulated
npm run migrateagainst a real local Postgres: all 73 migrations apply,073idempotent.npm run ingest:brreg-underenheter(no--sample): 867,000 rows upserted from the real bulk file, matching the live API'spage.totalElementsexactly; watermark seeded at oppdateringsid 21,390,729. Re-run: identical row count, watermark correctly left alone ("a feed already ahead of this snapshot must not be moved").npm run ingest:brreg-underenheter-oppdateringer: caught up from the seeded watermark to the live feed's head in one run, 671 real changes (Endring 441, Ny 127, Sletting 93, Fjernet 10), correctly reached the absent-_embeddedcaught-up state. Re-run: 0 changes, correctly caught up, no error. 10 of 671 entity fetches failed with HTTP 410 (allFjernet) — handled as designed (doc = null,classify(endringstype)unaffected).npx vitest runon both new source directories: 29/29 pass.render-template-info.sh: fully green, "table counts agree (70 raw = migrations, 96 marts stated once)", "first_data covers all 59 automated sources".
Phase 3's own remaining validation (3.4/3.5, once built):
dbt build --select brreg_underenheter+ int_ngo_chapter_subunits
dbt show --inline "select ngo_orgnr, count(*) from int_ngo_chapter_subunits group by 1"
Expect 175 (Frelsesarmeen) and 151 (Kirkens Bymisjon), per acceptance-targets.csv and
brreg-underenheter.md's own table — a fresh run will differ somewhat; a large gap is the signal
per [Q9], not a number to force.
Overlap with INVESTIGATE-all-brreg-organisations — resolved 2026-10-04/05
INVESTIGATE-all-brreg-organisations.md's own "underenheter, a named follow-on" is this: Phase
3.1–3.3 ingests the full underenheter register (867,024 rows, measured live), not a filtered
subset — raw.brreg_underenheter_snapshot holds every sub-unit in the country, and the NGO-specific
filter (structure = 'unitary') only ever applies downstream, in the not-yet-built
int_ngo_chapter_subunits (3.4). That investigation's own follow-on is satisfied by this ingest;
its own doc has been updated to point here rather than naming a separate, still-to-be-spawned PLAN.
Original note, kept for the reasoning that shaped the design above:
This PLAN's Phase 3 proposed a narrower brreg-underenheter source, filtered in practice to
two NGOs (~326 rows). Building it independently risked either duplicating that follow-on PLAN when
it lands, or needing to be torn out and replaced by it. Before starting Phase 3, check whether
INVESTIGATE-all-brreg-organisations.md's underenheter follow-on has a PLAN number yet:
- If it does, or lands first: skip this PLAN's Phase 3 entirely and filter the full-register model
down to
dim_ngo's two unitary NGOs instead — same column shape, less to build. - If it doesn't, and this PLAN needs to go first: build Phase 3 to the full-register shape (not an
NGO-specific filter baked into the ingest), so the later full-register PLAN can absorb it rather
than duplicate it — i.e., ingest
raw.brreg_underenheter_*unfiltered, and do the two-NGO filter only inint_ngo_chapter_subunits, exactly as Phase 3's current task list already describes. This ordering is why Phase 3 is written the way it is above — confirm it still holds before starting.
Open questions carried from the investigation
raw.brreg_underenheter_snapshotalready holds every unitary organisation in the register, not just the two NGOs this PLAN cares about — the ingest is unfiltered by design (see the overlap note above). Whoever next needs unitary-org data for a different purpose has the raw table already; only a new downstream model is needed, not a new ingest.- Precision for the nine federated NGOs' registry match is only measured end-to-end for Røde Kors
(93.6%/95.4%); the other eight are validated via
both(confirmed by their own site) in the next PLAN, not by an independent precision figure here.