Plan: Atlas as a provider
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-06. All three phases shipped and deployed-and-verified on the real
cluster (#560, #561/#562, #563/#564 — the latter two each needed one follow-up fix a deploy verification surfaced: #562 fixed a production OOM incident #561's deploy exposed, #564 fixed a seed-test gap #563's deploy exposed; both are documented in their own commits). Approved by Terje 2026-10-05 ("add Atlas as a provider and ingest and publish all the datasets"), two scope questions answered the same day (see Decisions below).
Goal: Atlas isn't only a republisher of upstream public data — some of what it publishes is Atlas's own authored content: classification schemes, cross-source syntheses, and self-description. Right now none of that is attributed to anyone on the public catalogue; it just appears under whichever upstream source happens to be nearby. Add "Atlas" as a publisher, attribute the ~20 already-published relations that are genuinely Atlas's own, and bring two more bodies of Atlas-authored content across the ingest→publish line that haven't crossed it yet.
Last Updated: 2026-10-05
Prerequisites: None. Independent of PLAN-001 (NGO chapter matching, completed same day) except that PLAN-001's two new models are one of this plan's publish targets (Phase 2).
Decisions (answered 2026-10-05, not re-litigated per phase)
- Taxonomy crosswalk load policy: load the proposed taxonomy and crosswalk AS-IS (confidence
levels visible, nothing blocked on review) — "publish what we have, we will improve on it later."
This matches [Q6] in
INVESTIGATE-ngo-activity-taxonomy.md's own stated recommendation. The 92 LOW-confidence crosswalk rows are not being resolved by this plan — they load with theirconfidencecolumn intact, same as every other row. - PLAN-001's two new models: publish now as registry-only (not held for the later site-crawl
reconciliation phase) — "there was 11 ngos with data." Confidence stays capped at
medium(never overstated as corroborated); this is an honest publish of what's verified, not a claim the reconciliation phase is done.
What "Atlas as a publisher" means in this catalogue, concretely
The catalogue's existing publisher mechanism (publishers.yaml, cross-checked against each
source's manifest.yml) is per-ingested-source — it has no concept of attributing a derived
model to anyone. Rather than invent a new mechanism, this reuses what's already there: mart
schema.yml files already carry a meta: block the generator reads (meta.title,
meta.stability — confirmed in atlas-data/dbt/models/marts/api/schema.yml and
website/scripts/generate-sources-registry.mjs). Phase 1 adds one more key, meta.publisher,
read the same way, validated against publishers.yaml the same way a manifest's publisher:
field already is.
Phase 1: Add Atlas as a publisher, attribute the ~20 already-published relations
The list (verified against the live catalogue 2026-10-05, not recalled):
Atlas's own classification schemes:
mart_ref_atlas_service_category, mart_dim_activity, mart_dim_chapter, mart_ref_region_kind
Atlas's own cross-source syntheses:
mart_activity_catalog, mart_fact_chapter_activities, mart_distrikt_summary,
mart_chapter_kommune_coverage, mart_kommune_local_chapters, mart_kommune_ngo_summary,
mart_kommune_ngo_totals, mart_indicator_summary, mart_indicator_latest_values,
mart_indicator_missing_kommuner, mart_coverage_gap_barnefattigdom, mart_unattributed_totals,
mart_supply__redcross_chapter_kommune_coverage
Atlas's own meta/self-description:
mart_atlas_inventory, mart_meta_sources, mart_meta_endpoints, mart_meta_dimensions,
mart_ingest_health, mart_source_freshness
The NGO registry:
mart_ngo_index, mart_ngo_overview
Tasks
- 1.1
publishers.yaml: new entry —id: atlas,display_name: Atlas,homepage: https://atlas.sovereignsky.no, a logo (reuse the site favicon/mark — checkwebsite/static/img/for an existing asset before adding one), notes explaining Atlas is both a republisher and, for these relations specifically, the author. - 1.2
website/scripts/generate-sources-registry.mjs: readmeta.publisheroff each mart's dbt node (same pattern as the existingmeta.titleread), validate it resolves to apublishers.yamlid (fail loud like the manifestpublisher:cross-check already does, don't silently drop an unmatched value), and surface these relations on/publishers/atlasthe same way a source's manifest surfaces it on its own publisher's page. - 1.3 Tag
meta.publisher: atlason each of the ~20 models listed above, in their respectiveschema.ymlentries. - 1.4 Regenerate the catalogue (
npm run sources:generate), confirmcheck-publisher-logos.mjsandcheck-dataset-page-coverage.mjsstill pass, confirm/publishers/atlasrenders with all ~20 and nothing else.
Validation
node scripts/generate-sources-registry.mjsthennode scripts/check-publisher-logos.mjsandnode scripts/check-dataset-page-coverage.mjs— both green.npm run build(Docusaurus) succeeds;/publishers/atlaspage content spot-checked against the list above (not just "it rendered").
Phase 2: Publish PLAN-001's two NGO chapter models
int_ngo_chapter_registry_match and int_ngo_chapter_subunits currently live in
models/intermediate/ as internal views, deliberately not published (see
PLAN-001-brreg-chapter-matching-and-underenheter.md). Per the decision above, publish them now,
registry-only, confidence column intact.
Tasks
- 2.1 Wrapped each in a
mart_model undermodels/marts/api/(mart_ngo_chapter_registry_match,mart_ngo_chapter_subunits—+post-hook: restore_api_v1_view()applies automatically at the directory level, no per-file config needed). Theint_models are unchanged. - 2.2
meta.publisher: atlason both. - 2.3 Full
schema.ymldocs + tests for both — one real YAML bug found building this (an unquoted description starting with a single-quoted scalar, broken by an apostrophe later in the same string; fixed by double-quoting the whole value), caught bydbt parsefailing loud rather than by inspection. - 2.4 Lineage,
api_v1_generated.sql/api_v1_state.json,template-info.yaml, and the catalogue all regenerated via their own generator scripts, not hand-edited. - 2.5
check-dataset-page-coverage.mjs: 96 relations (was 94), two new dataset pages.check-every-source-is-served.sh:brreg-underenhetermoved from unserved to served —brreg-underenheter-oppdateringer(the change feed, not the bootstrap) remains the one deferred source.
Validation — done for real against live data, not simulated
Built and tested against a fresh real ingest of the full Brreg enhetsregister + underenheter
register (not fixtures, not the prior session's torn-down instance). Both PLAN-001 acceptance
tests pass unchanged (ngo_chapter_registry_match = 2750 rows, exact sum of the 9 federated
NGOs' targets; ngo_chapter_subunits = 329 rows = 177 + 152) — the wrapper is a pure passthrough,
confirmed by row count, not assumed from the SQL. All 40 new/existing tests touching these two
relations pass, independently re-run twice (once by the agent that built this, once standalone
afterward against the same live database) with identical results both times.
Public API confirmation deferred to the deploy step (per this repo's "a deploy is not successful until the data arrives" rule) — not claimed here before it has actually shipped.
Phase 3: Ingest and publish the NGO activity taxonomy
Source material (PR #555, docs/research/ngo-research/taxonomy/): taxonomy-nb-en.csv (38
categories) + taxonomy-nb-en-families.csv (10 families), crosswalk_activity_service_category.csv
(589 activity→category rows, confidence + reviewed_by columns), search-terms-validated.csv
(859 rows, the search-volume evidence).
Tasks
- 3.1 🔴 DESIGN CORRECTION, found while implementing, better than the draft above.
Checked who actually depends on
ref_atlas_service_categorybefore creating a parallel seed: it's not just a decoder, it's INNER-JOINed inmart_activity_catalog.sqlandmart_kommune_local_chapters.sql, and relationship-tested againstdim_activity/supply__redcross_branch_activities.service_category_code. A brand-newref_activity_categoryseed would have meant either leaving those pointed at a now-incomplete 22-row decoder (silently dropping every reclassified Red Cross activity from two INNER JOINs) or repointing four files for no benefit. Instead: upgradedref_atlas_service_category.csvin place — all 22 original codes kept verbatim (verified an exact subset via Python'scsvmodule after a naivecut -d','check gave a false mismatch on embedded commas inside quoted fields), 16 added, 10 new columns (family_code, description_no, search_terms_no, need_terms_no, volunteer_terms_no, terms_en, label_no_evidence, en_measured, search_entry, previous_label_no). Every existing join/test keeps working unchanged against the richer 38-row vocabulary — no separate decoder, no deprecation decision needed. Also addedref_activity_family(10 rows, genuinely new) andref_atlas_activity_crosswalk(589 rows, fromcrosswalk_activity_service_category.csv, confidence/reviewed_by columns carried through unchanged). - 3.2
ref_activity_search_termseed fromsearch-terms-validated.csv(858 data rows) — loaded as data for a later search-index PLAN (explicitly out of scope here per the investigation's own Next Steps), not wired into anything yet. ⚠️ 3 of itscodevalues (_volunteering_general,women_migrant_network,violence_support) aren't validref_atlas_service_categorycodes — a synthetic umbrella term, a merged-away category, and a rejected candidate — kept as evidence, deliberately no FK test on this column. - 3.3 🔴 SCOPE CORRECTION, found while implementing, not assumed from the investigation doc.
dim_activity.sql's own header says it today: "Source: SELECT DISTINCT from per-NGO supply___branch_activities staging models... PLAN-003 will add Folkehjelp; this model gains a UNION ALL clause then." Checked directly —atlas-data/dbt/models/supply/has exactly three files, all Røde-Kors-only (supply__redcross_branches,_branch_activities,_chapter_kommune_coverage). No other NGO has a staging model. The crosswalk CSV names all 11 NGOs' activities, but 10 of them have nowhere in the warehouse to attach a category to — that requires each NGO's own activity data to be ingested first (the per-NGO site-crawl work this session has repeatedly deferred elsewhere), which is NOT what "ingest and publish the taxonomy" asked for. So: retired the Røde-Kors-onlyCASEinsupply__redcross_branch_activities.sqlfor Røde Kors only, replaced with a join againstref_atlas_activity_crosswalkfiltered tongo = 'redcross'(is_primary = 'true'forservice_category_code, a plaindistinct name, is_serviceforis_service— verified via Python first that neither join can fan out: zero names have 2+is_primary='true'rows, zero names have contradictoryis_servicevalues). The crosswalk rows for the other 10 NGOs load and publish as real, queryable data (task 3.6) — showing what category each NGO's activities fall under — but do not reachdim_activityfor those NGOs this round. That gap is pre-existing (those NGOs were never indim_activityat all, categorized or not) and not something this plan introduces or is positioned to close. - 3.4
dbt_project.ymlseed+column_types: addedfamily_code,activity_id,ngo,service_category_codeastext.is_primary/is_servicealso forced totext, notboolean— 29 of 589 crosswalk rows are blank by design (anis_service = falserow has no category, sois_primarydoesn't apply), and a blank CSV value is not a valid boolean literal. Same NULL-vs-empty-string class of bug this repo already found once inref_atlas_ngo_match_rule(PLAN-001) — avoided here by never trying to force a boolean type onto a column that can be legitimately blank. - 3.5
schema.ymldocs + tests for all new/changed seeds (not_null/unique/accepted_values/ relationships). - 3.6 Published
ref_activity_familyas a new decoder mart (same pattern asmart_ref_brreg_icnpo),meta.publisher: atlas. No separateref_activity_categorymart needed — see 3.1's design correction;mart_ref_atlas_service_categoryalready is that mart, now richer.ref_atlas_activity_crosswalkandref_activity_search_termstay internal (one feedssupply__redcross_branch_activities, the other feeds a future search-index PLAN) — a crosswalk audit trail and raw search-term evidence aren't themselves consumer-facing datasets. - 3.7 Regenerated lineage,
api_v1_generated.sql/api_v1_state.json,template-info.yaml, the catalogue via their own generator scripts. One real gap the generators themselves don't catch automatically:scripts/generate_api_v1.py's hand-writtenSCHEMA_COMMENT(the root OpenAPI document's catalogue text) needed a 3-line entry forref_activity_familyadded by hand — caught bycheck-root-document-indexes-every-relation.sh, not by inspection.
Validation — done for real against live data, not simulated
- 22→38 subset verified directly via Python's
csvmodule (not a naivecut, which gave a false mismatch on embedded commas inside quoted fields) — zero of the 22 original codes missing from the new 38. Crosswalk shape verified against the real seed file: 589 rows, 500 distinctactivity_id, confidence HIGH/MEDIUM/LOW = 306/191/92,reviewed_byempty on all 589 (matches the investigation's own numbers exactly). - Synthetic control test, not a real-data test —
raw.redcross_branch_activitiesis empty by design (credential-gated ingest, confirmed inmart_dim_activity.sql's own header), so a normal build would pass vacuously. Manually inserted 12 synthetic rows covering 3 unchanged mappings, all 7 reclassified activities, oneis_service = falseexample, and one name absent from the crosswalk entirely (to confirm theelse truefallback) — deleted afterward, real state unaffected. 12/12 exact matches against hand-computed expected values, including all 7 reclassifications from the investigation's "what changes" table landing exactly as documented. - Both PLAN-001 acceptance tests (
ngo_chapter_registry_counts_match_acceptance_targets,ngo_chapter_subunit_counts_match_acceptance_targets) pass unchanged against a fresh real Brreg ingest (1,176,724 + 72,833 + 867,183 rows) — this phase didn't touch those models, confirming the seed--full-refreshthis change required didn't regress anything else. - Full static-gate battery (16 scripts +
dbt compile/check-models-compile.sh+ ingestcheck-manifests.sh+render-template-info.sh) green after the onegenerate_api_v1.pyfix.
Deploy
Per this repo's standing rule, every deploy request to imac names per-relation expected row
counts, and a release isn't done until transform_checks/api_v1_checks confirm the data arrived
— not just that the build was green. Phases 1-3 each get their own deploy request (don't bundle
unrelated phases into one — a deploy is a change with a reason, not a commit range), following the
same pattern as PLAN-001's #1857.
Open questions carried forward, not resolved here
- Whether
ref_activity_search_term's 859 rows ever become a real search feature is a separate, not-yet-started PLAN (the investigation's own Next Steps already says so). - The 92 LOW-confidence crosswalk rows remain unreviewed by design (see Decisions) — a future pass should revisit them, but this plan does not block on that.