PLAN-001 — API-shaped mart_* views
IMPLEMENTATION RULES: Before implementing this plan, read and follow:
- WORKFLOW.md — The implementation process
- PLANS.md — Plan structure and best practices
Status: Complete — 2026-04-27 (merged in PR #21)
Goal: Build the ~9 mart_<feature> dbt views identified by the per-route audit in INVESTIGATE-public-api-surface.md, so PostgREST has stable, OpenAPI-friendly endpoints to project for the dogfood API. Pure data-side work — no API code, no frontend changes in this PLAN. The Next.js frontend keeps reading marts.* directly until PLAN-E migrates it.
Final outcome (2026-04-27)
All 7 phases complete. 9 mart views landed under atlas-data/dbt/models/marts/api/, each with full schema.yml descriptions and tests; sample-row diffs against the original inline queries match. check-osmosis.sh strict gate stays green; full dbt build runs 654 PASS / 21 WARN / 0 ERROR / 675 TOTAL across the whole project. The 21 WARNs are 20 pre-existing data-quality warns plus 1 expected new warn for Svalbard (kommune_nr 2100, Longyearbyen Røde Kors) on mart_kommune_local_chapters — kept visible by severity: warn rather than failing the build.
PLAN-D.2 (PostgREST stand-up) is now unblocked.
Two PLAN expectations were off; verified actual is correct:
- PLAN said
mart_distrikt_summary= 19; actual = 18. The originallistDistrikter()query also returns 18 for Red Cross today. - PLAN said
mart_kommune_local_chaptersfor kommune 0301 (Oslo) returns the same chapter list as/kommuner/0301. Both old and new return 0 because Red Cross Osloredcross-L032has no rows infact_chapter_activitiesyet — known supply-side gap, separate from this PLAN.
Phase 1 outcome (2026-04-27)
Phase 1 ran. dbt-osmosis baseline propagation surfaced 180 columns in existing models that have no description (initial pass surfaced 164; a second pass — required for full convergence — surfaced 16 more). Per option D (resolved during Phase 1), these are accepted as-is and tracked separately in PLAN-002-fill-schema-yml-description-gaps.md. The check script atlas-data/dbt/check-osmosis.sh is strict on models/marts/api/ (the new mart_* views from this PLAN) and lenient (report-only) on existing models. As PLAN-002 phases land, the gap count goes down.
Verification at Phase 1 close:
dbt parseclean,dbt test521 PASS / 20 WARN / 0 ERROR / 541 TOTALdbt-osmosis yaml document --dry-run --checkexits 0 (idempotent after the two-pass baseline)
Net Phase 1 deliverables:
dbt-osmosis>=1.0,<2inrequirements.txt+dbt-osmosis: schema.ymlconfig indbt_project.yml- 12 schema.yml/sources.yml files reformatted + descriptions propagated + 180 newly-discovered columns surfaced
atlas-data/dbt/check-osmosis.sh(strict-on-marts/api/, lenient-elsewhere)atlas-data/dbt/README.mddocuments the new hygiene workflowPLAN-002-fill-schema-yml-description-gaps.mdtracks the 180 backlog
Last Updated: 2026-04-27
Investigation: INVESTIGATE-public-api-surface.md
Prerequisites: None blocking. May overlap with PLAN-A from INVESTIGATE-semantic-foundation-before-expansion.md (dbt MCP + dbt-osmosis); both share the schema.yml hygiene work in Phase 1 below. Either plan can install dbt-osmosis; the other inherits.
Problem
Atlas's Next.js code today executes hand-written SQL with CTEs and ad-hoc joins for ~10 route patterns that don't fit a simple "select rows from one table" shape. Examples (from the audit):
/dataruns a CTE findingmax(year)per(source_id, contents_code), then groups + counts to summarise every indicator./coverage-gap/barnefattigdomruns a CTE finding latest year forssb-08764 / EUskala60, then self-joins for both EU60 and Personer./ngo/redcrossruns 6 count subqueries (chapters by level, activities, distinct kommuner)./ngo/redcross/distrikteraggregates child chapter counts per distrikt.
These query shapes can't be expressed cleanly via PostgREST's column-projection API, but they can be expressed as dbt views. Promoting each one to a mart_<feature> view:
- Lets PostgREST project them as stable OpenAPI endpoints (PLAN-D.2).
- Keeps the naming-conventions doctrine intact: query logic in dbt, API stays projection.
- Consolidates the SQL — currently scattered across Next.js page files and
atlas-frontend/src/lib/{indicators,supply}.ts— into the proper dbt layer. - Adds dbt tests on each view, catching shape regressions automatically.
The 9 views
From the audit (INVESTIGATE-public-api-surface.md → Per-route audit):
| View | Backs route(s) | Source query |
|---|---|---|
mart_indicator_summary | /data | listIndicators() in atlas-frontend/src/lib/indicators.ts |
mart_indicator_latest_values | /data/[source_id]/[contents_code] | loadIndicatorValues() |
mart_indicator_missing_kommuner | /data/[source_id]/[contents_code] | listMissingKommuner() |
mart_coverage_gap_barnefattigdom | /coverage-gap/barnefattigdom | inline CTE in atlas-frontend/app/coverage-gap/barnefattigdom/page.tsx |
mart_kommune_local_chapters | /kommuner/[kommune_nr] | listChaptersInKommune() in atlas-frontend/src/lib/supply.ts |
mart_ngo_index | /ngo | listNgos() |
mart_ngo_overview | /ngo/redcross | getNgoOverview() |
mart_activity_catalog | /ngo/redcross/aktiviteter | listActivities() |
mart_distrikt_summary | /ngo/redcross/distrikter, /distrikt/[id] | listDistrikter() |
Optional 10th view, only if the service-category filter on the chapters page is kept: mart_chapters_with_service_categories. Defer the decision to PLAN-E (frontend migration) — by then we'll know if that filter stays.
Decisions resolved (2026-04-27)
All six decision points settled before implementation begins.
[Q1]dbt-osmosis vs hand-rolled CI gate → install dbt-osmosis. Atlas already has 27+ dbt models with 9 more landing in this PLAN; manual schema.yml maintenance scales poorly. dbt-osmosis's description propagation across lineage (writekommune_nronce indim_kommune, auto-fills downstream) pays back fast, and theyaml checkCI gate adds free insurance. Shared with PLAN-A from semantic-foundation. Resolved.[Q2]Subfolder placement →atlas-data/dbt/models/marts/api/subfolder from day one. With 9 views landing in one PLAN, we cross the naming-conventions threshold ("promote tomarts/api/subfolder once 5+ exist") immediately. Resolved.[Q3]Materialisation per view →tablefor all 9. Atlas's data rebuilds daily at most; consumers want fast reads.viewwould re-execute on every PostgREST request. Resolved.[Q4]Sample-row diff method → option (a) for the pattern-setter (Phase 2), option (c) for the rest. Usedbt show --inline "<existing query>"anddbt show --select <new_view>side-by-side formart_indicator_summaryto validate the pattern. For the other 8, eyeball-only unless something looks off — they're mechanical applications of the same template. Resolved.[Q5]Schema.yml description richness → full descriptions on all 9. Every column documented; table-level prose explaining what query shape the view serves. Developers consuming the public API need rich documentation to trust the system. PostgREST's auto-generated OpenAPI uses these descriptions verbatim. Resolved.[Q6]Frontend update timing → separate PLAN. Next.js stays on inline SQL until the frontend-migration PLAN (PLAN-E lineage fromINVESTIGATE-public-api-surface.md) lands. This PLAN ships dbt views only;atlas-frontend/is untouched. Resolved.
Phase 1: dbt-osmosis + schema.yml CI gate
Install dbt-osmosis as the schema.yml safety net before any new mart views land. Coordinate with PLAN-A from INVESTIGATE-semantic-foundation-before-expansion.md — whichever PLAN runs first installs it; the other inherits.
Tasks
- 1.1 Add
dbt-osmosistoatlas-data/dbt/requirements.txt. Pin to a recent stable version. - 1.2 Run
uv pip install -r requirements.txtinatlas-data/dbt/.venvto install. - 1.3 Run
dbt-osmosis yaml refactor --target-path target/againstmodels/to baseline-propagate descriptions across the existing lineage. Review the resulting schema.yml diffs in PR. - 1.4 Add a CI step (or pre-commit hook) that runs
dbt-osmosis yaml checkand fails on missing descriptions on anymarts.*model. - 1.5 Commit the description backfill as a separate sub-commit so the diff is reviewable separately from the new mart views.
Validation
dbt-osmosis yaml checkexits 0 against the existing repo.dbt buildstill passes (the description backfill should be metadata-only, no model changes).
Phase 2: Pattern-setter view — mart_indicator_summary
Build one view end-to-end as the template for the other 8. Verify shape against the existing listIndicators() query. This phase tests the pattern; the per-view phases that follow are mechanical applications.
Phase 2 outcome (2026-04-27)
Phase 2 built mart_indicator_summary end-to-end: 171 rows, identical top-5 and identical row count to the original listIndicators() inline query. dbt build --select mart_indicator_summary clean (1 model, 8 tests passing). check-osmosis.sh strict gate confirmed the new schema.yml under models/marts/api/ is fully documented (the gate was previously skipping the directory because it didn't exist).
Pattern for Phases 3-6:
- SQL file: copy the relevant inline query from
atlas-frontend/src/lib/{indicators,supply}.tsintomodels/marts/api/mart_<feature>.sql. Replacemarts.fact_<x>references with{{ ref('fact_<x>') }}(andmarts.dim_<x>with{{ ref('dim_<x>') }}). Keeporder byfor stable output. No{{ config(...) }}block — themarts:block indbt_project.ymlalready provides+materialized: table++schema: marts, which inherits tomarts/api/. - schema.yml: append a model entry to
models/marts/api/schema.yml(single file shared across all 9 views, per dbt-osmosis's one-schema.yml-per-directory convention). Every column gets a description (Q5). Tests:not_nullon key columns,dbt_utils.unique_combination_of_columnson the natural key,dbt_utils.accepted_rangewhere ranges are knowable. - Verify:
dbt build --select mart_<feature>(clean),dbt show --select mart_<feature> --limit 5vsdbt show --inline "<original SQL>" --limit 5(identical top-5), and./check-osmosis.sh(strict-on-marts/api/ stays green). - Pitfall:
dbt show --inlineappendsLIMIT Nitself, so don't includelimitin the inline SQL — pass--limit 5as a flag.
Tasks
- 2.1 Write
atlas-data/dbt/models/marts/api/mart_indicator_summary.sqlwithmaterialized='table'. Source the SQL fromlistIndicators()inatlas-frontend/src/lib/indicators.ts:29-53— the CTE + group-by-source-and-contents already there. Addsource_id,contents_code,contents_label,latest_year,kommuner_with_value,kommuner_with_null,min_value,max_value,upstream_updatedcolumns. - 2.2 Add
atlas-data/dbt/models/marts/api/schema.ymlwith the new model: full description per [Q5] (what it represents, who reads it, query shape), every column documented,not_nullon key columns,accepted_rangeonlatest_year. - 2.3 Run
dbt build --select mart_indicator_summary— verify clean run, all tests pass. - 2.4 Sample-row diff (option a per [Q4]):
dbt show --select mart_indicator_summary --limit 5vs. the equivalent oflistIndicators()(run via psql or a one-off script). Expect identical row shape. - 2.5 Document the pattern in this PLAN (a one-paragraph "lessons learned" note) so the next 8 follow the same shape.
Validation
User reviews the first view, schema.yml description style, and shape-diff result. Approves the pattern before Phase 3 begins.
Phase 3: Data-explorer views (2 more)
Apply the Phase 2 pattern.
Tasks
- 3.1
mart_indicator_latest_values— source fromloadIndicatorValues(). Pre-filterfact_kommune_indicatorsto latest year per(source_id, contents_code). Columns:source_id,contents_code,kommune_nr,kommune_name,fylke_name,value,status,year. PostgREST will filter via?source_id=eq.X&contents_code=eq.Y. - 3.2
mart_indicator_missing_kommuner— source fromlistMissingKommuner(). Active kommuner with no value at latest year per(source_id, contents_code). Columns:source_id,contents_code,kommune_nr,kommune_name. - 3.3 schema.yml descriptions for both, full style per Phase 2.
- 3.4
dbt build --select mart_indicator_latest_values mart_indicator_missing_kommuner— clean.
Validation
User confirms the data-explorer views produce shapes equivalent to what /data/[source_id]/[contents_code] currently displays.
Phase 4: Coverage-gap view
Tasks
- 4.1
mart_coverage_gap_barnefattigdom— source from the inline CTE inatlas-frontend/app/coverage-gap/barnefattigdom/page.tsx. Latest year per kommune forssb-08764 / EUskala60joined withPersoner. Columns:kommune_nr,kommune_name,fylke_name,year,value_pct,personer. - 4.2 schema.yml description (mention this is the "barnefattigdom map" view; future similar views will follow the same
mart_coverage_gap_*family naming). - 4.3
dbt build --select mart_coverage_gap_barnefattigdom.
Validation
Shape matches the existing barnefattigdom map's data shape; row count equals number of active kommuner with EU60 data.
Phase 5: NGO views (3)
Tasks
- 5.1
mart_ngo_index— source fromlistNgos(). One row per NGO fromdim_ngowithchapter_countandhas_supplyjoined in. Columns: alldim_ngofields +chapter_count+has_supply. - 5.2
mart_ngo_overview— source fromgetNgoOverview(). One row per NGO with the 6 counts (chapters total, by level, activities, distinct kommuner). Columns:orgnr,chapter_count,national_count,regional_count,local_count,activity_count,kommune_count. - 5.3
mart_activity_catalog— source fromlistActivities(). One row per (NGO, activity) with service-category label + chapter count. Columns:activity_id,ngo_orgnr,canonical_name,service_category_code,service_category_label_no,is_active,chapter_count. - 5.4 schema.yml descriptions for all three.
- 5.5
dbt build --select mart_ngo_index mart_ngo_overview mart_activity_catalog.
Validation
Row counts: mart_ngo_index = 11 (matches dim_ngo); mart_ngo_overview = 11; mart_activity_catalog = 35 (current Red Cross activity count, will grow as more NGOs land).
Phase 6: Supply views (2)
Tasks
- 6.1
mart_distrikt_summary— source fromlistDistrikter(). One row per regional chapter (distrikt) with child counts and kommune coverage. Columns:chapter_id,name,kommune_nr,kommune_name,child_count,kommune_coverage_count,ngo_orgnr. - 6.2
mart_kommune_local_chapters— source fromlistChaptersInKommune(). Distinct chapter+activity rows for active local chapters in a kommune. Columns:kommune_nr,chapter_id,name,ngo_orgnr,ngo_name,ngo_brand_name,service_category_code,service_category_label_no,sort_order. PostgREST filters via?kommune_nr=eq.X. - 6.3 schema.yml descriptions for both. Note that
mart_kommune_local_chaptersreturns multiple rows per chapter (one per service category) — call this out so consumers don't expect chapter-uniqueness. - 6.4
dbt build --select mart_distrikt_summary mart_kommune_local_chapters.
Validation
mart_distrikt_summary = 19 rows (matches Red Cross distrikt count). mart_kommune_local_chapters for kommune 0301 (Oslo) returns the same chapter list /kommuner/0301 currently shows.
Phase 7: Final verification
Tasks
- 7.1 Full
dbt buildagainst the whole project. All 9 new views materialise; all tests pass; schema.yml coverage gate (Phase 1) passes. - 7.2 Spot-check 2-3 of the new views in the database directly (
uv run --env-file ../ingest/.env dbt show --select mart_<view> --limit 5). - 7.3 Update
atlas-data/dbt/seeds/README.mdoratlas-data/dbt/models/marts/api/README.md(new) explaining themart_*family — what it's for (PostgREST API surface), naming convention, when to add a new one.
Validation
User confirms: all 9 views build clean, tests pass, schema.yml descriptions reviewed, sample rows look right. PLAN-D.2 (PostgREST stand-up) is now unblocked.
Acceptance Criteria
- dbt-osmosis installed and
yaml checkpasses (Phase 1). - All 9
mart_<feature>views exist as dbt models inatlas-data/dbt/models/marts/api/. - All 9 have full schema.yml descriptions covering every column.
- All 9 have at least 2 dbt tests (
not_null+ one ofunique,accepted_values,relationships,accepted_range). -
dbt buildruns clean end-to-end. - Sample-row diffs against existing inline SQL (Phase 2 fully, others spot-checked) confirm shape equivalence.
- No frontend changes —
atlas-frontend/is untouched. The existing inline SQL keeps working until PLAN-E migrates it.
Files to Modify
atlas-data/dbt/requirements.txt(adddbt-osmosis)atlas-data/dbt/models/marts/api/(new subfolder, 9 new.sqlfiles + 1schema.yml)atlas-data/dbt/models/marts/api/README.md(new — explains themart_*family)atlas-data/dbt/models/marts/schema.ymlmay need updating if any of the new views reference existing models that need clarification- CI config (likely
.github/workflows/*.ymlif it exists, otherwise aMakefiletarget or apre-commithook) to wiredbt-osmosis yaml check
Files NOT modified
- Anything under
atlas-frontend/— frontend migration is PLAN-E's job. - Anything under
atlas-data/ingest/— ingest is unaffected.
What's next after this PLAN
- PLAN-D.2 — Stand up PostgREST against
marts.*(now including these 9 new views). PostgREST auto-generates OpenAPI from the schema; the descriptions written in this PLAN become the public API documentation. - PLAN-E — Migrate
atlas-frontend/src/lib/{indicators,supply,db}.tsto call PostgREST instead of executing SQL directly. Replace each inline-SQL function with afetch()call against the correspondingmart_*endpoint. - PLAN-F — Publish the OpenAPI spec at
api.atlas.helpers.no/docs(Swagger/Redoc). - PLAN-G — Lift the freeze on supply-side data adds (per
INVESTIGATE-semantic-foundation-before-expansion.md).