Loading documentation...
Source of truth: Conceptual BRASK loop below; executable adapters, RPCs, and which table each path writes are in docs/ingestion/pipeline-slices-idempotency-and-triggers.md §10.
Do not restate these details in parallel notes; promote durable facts here or into postgres-contract-rls-and-keys.md.
Status: Reference document. Authored 2026-04-29.
Scope: Conceptual model and step-by-step behaviour of the BRASK ingestion adapter.
Companion docs: crosstab-and-bucket-modeling.md (schema decisions), v2-002-filter-codes-migration-narrative.md (the schema the adapter writes into).
Executable split: brask.ts → fact_yearly (year axis). brask.daily.ts → fact_daily (date axis). Both share the same categories / filters / natural-key filter FKs; only the time column and upsert RPC differ. Pipeline §10 is the live contract for routes, prefetch RPCs, and Explore grain dispatch.
This document describes the BRASK ingestion loop in conceptual terms (translator → unified facts). BRIS Police now has a sibling daily writer (bris.daily.ts); Brigade/DSB remain yearly-only on their endpoints.
Ingest BRASK fire-incident data into unified fire_data fact rows — fact_yearly via brask.ts, fact_daily via brask.daily.ts — while:
fact_yearly and fact_daily (fire_type_code, building_type_code, building_age_code, region_code → fire_data.filters.id). filters.native_code holds the stable API-side identifier, in the same role as native_api_code in docs/ingestion/adapter-mappings/category-native-to-cat-code.md (BRASK: documented numeric / form ids; BRIS: compound ids — see §Rules there); for filter axes the row-level map is docs/ingestion/adapter-mappings/filter-axes-native-to-filter-id.md (native_value ↔ native_name). Human-readable native vocabulary for filters lives in filters.native_name; drill-down and labeling resolve through JOINs. Characteristic breakdowns use categories (aligned with native_api_code / native_name in category-native-to-cat-code.md).filters and categories — the adapter resolves natives to lookup rows at write time so query time stays cheap.crosstab-and-bucket-modeling.md §5) are computable without re-ingest.year range on fact_yearly, date range on fact_daily).The adapter is the first real ingestion surface. Subsequent adapters (BRIS, SSB) follow the same skeleton with source-specific fetch shapes and filter dimensions.
The server module lib/data-model/adapters/brask-passthrough.ts (via _braskWebForms.ts) speaks fluent BRASK. It accepts the source's native parameters (row / column, filters in BRASK's slug vocabulary), POSTs them to brask.finansnorge.no, and returns a parsed JSON response. The ETL adapters use the same WebForms mechanics and rewrite each response into FactYearlyInsert[] or FactDailyInsert[] for fact_yearly vs fact_daily respectively.
A useful analogy: the passthrough fetch layer is a chef who only cooks Norwegian dishes and only knows Norwegian dish names. The adapter is a line cook who plates each dish onto the standard heatwaves plate so any waiter (the analytical layer) can carry it without learning Norwegian. The chef's recipes don't change. Only the plating.
The translation has two parts:
docs/ingestion/adapter-mappings/category-native-to-cat-code.md: the adapter matches the response's native_name (label text) to a categories row via (source_id, dim_code) and writes that row's cat_code on the fact. native_api_code is reference metadata for the source's internal numbering, not the runtime join key. categories.standard_code is the cross-source bridge.filters rows → fact FK columns. Each iteration resolves upstream natives to a filters.id, then writes that integer into the fact FK column (building_type_code, etc.). filters.native_code (e.g. BRASK næring='0') lives on the catalog row, not on facts. Leave the fact FK NULL only when that axis was not applied on the request. Cross-source charts resolve standard_code → matching filters.id set at read time; facts are never tagged with standard strings.After the adapter has run, nothing is lost: counts sit on fact_yearly and/or fact_daily (depending on which module ran), and every source-native filter value referenced by facts is reconstructible one JOIN away via filters (characteristic breakdown via categories).
Canonical path for materializing fire_data is POST /api/admin/ingest with an explicit slice list (typically from app/(main)/explore cold-start flow: authenticated trigger → fire_data.ingest_runs → same-origin ingest POST → adapters). Other pages read Supabase via RPCs; they do not replace this orchestration.
/explore — BRASK passthrough)Utforsk may show a live BRASK crosstab via server-side fetchBraskPassthrough (Explore native-passthrough branch). That path is not the same pipeline as ingest_runs + service-role upserts into fact_yearly / fact_daily. Do not assume browsing Utforsk backfills fire_data — use cold-ingest on /explore (or cron invoking POST /api/admin/ingest) when facts must land in Postgres.
A cron job or operator invokes ingestBySourceId / POST /api/admin/ingest with explicit date ranges and slice lists. Same adapter code path as Explore-driven ingest, without URL slice parameters.
Adapter writes use a service-role Supabase client, not the user's anon/authenticated session. Reads from fact_daily / fact_yearly are public via RLS; writes are gated to trusted server paths (ingest route handlers, jobs). Never write facts from the browser.
BRASK exposes filter parameters that you must iterate to get joint distributions.
Canonical native option ids and labels (including every naering value BRASK Utforsk exposes) live in lib/data-model/adapters/_braskWebForms.constants.ts — BRASK_EXPLORER_DIMENSIONS_JSON.dimensionValues. Treat that JSON as exhaustive for scraped BRASK vocab.
Semantics note: HeatWaves has a single filter axis for this: building_type_code + filters (axis='building_type') with standard_code for cross-source buckets (e.g. RESIDENTIAL vs other). BRASK does not expose a column named building_type — it exposes næring (sector / use-type). The adapter is the only place that maps native næring (e.g. 0 = Beboelse) onto HeatWaves building-type natives and filters.standard_code. No separate HeatWaves dimension for næring.
Full-universe BRASK joint distribution — iterate BRASK natives for fire type, building type via næring (all ids from BRASK_EXPLORER_DIMENSIONS_JSON.dimensionValues.naering), and building age:
for fire_type in [KALD, VARM]: # shorthand; persisted as filters.native_code = BRASK lbType ids ('2','1') per filter-axes-native-to-filter-id.md
for naering in ['0'..'21', '99']: # × 23 → building_type_code / filters
for building_age in [Ny, 1-5, ..., Over 100, Ukjent]: # × 13 (`bygningsalder`)
slice = fetch(characteristic='cause', filters)
rows = translate(slice)
upsert(rows)
mark_state(slice)
That's 598 fetches per characteristic per date window (2 × 23 × 13). KILDE is another 598. Bygningsalder as a standalone dimension is its own ingest path.
If you intentionally narrow scope during bring-up (e.g. naering=['0'] / Beboelse only), use 2 × 1 × 13 = 26 fetches per window.
This is the price of analytical depth. The alternative — fetching only the rolled-up slice — would lose the joint distribution forever and force a re-fetch when any cross-tab analysis is requested. Per crosstab-and-bucket-modeling.md §3, ingesting at the most granular level the source offers is the rule.
BRASK Beboelse is a child of the all-buildings total. Keep them on distinct slices: Beboelse is a filters row (standard_code=RESIDENTIAL) with its filters.id on facts; the all-buildings total is the building_type_code IS NULL slice (no filter row). Do not sum the NULL total with its Beboelse child as if disjoint — read the total via the omitted axis param and use standard_code only when intentionally bridging cross-source.
A user filtering building_type resolves via filters to the right filters.id set and gets only those rows; a query for an overlapping roll-up (for example an alle-style native) resolves to that native’s row and gets only those rows. Facts keep distinct filters.id per native so overlapping BRASK sectors stay distinguishable; standard_code on filters is for cross-source buckets. If overlapping natives were collapsed to the same standard code on the fact grain, SUMs could silently double-count.
BRASK's API takes year + month filters and returns daily-resolution rows for that month. The outer loop iterates months within the requested date range; the cross-product runs inside each month.
598 × 12 = 7 176 fetches per year per characteristic.næring) × 12 months: 26 × 12 = 312.For historical backfills, totals scale roughly linearly with the number of iterated naering values. Run once during initial setup or in chunks; then incremental.
The skeleton is fetch → translate → upsert → mark state for every source. Below, SQL and examples follow the daily BRASK path (brask.daily.ts → fact_daily): date in the natural key, day cells from the pivot. brask.ts (yearly) uses the same translation spine but fact_yearly, year, and bulkUpsertFactYearlyRpc / upsert_fact_yearly_batch — see docs/ingestion/pipeline-slices-idempotency-and-triggers.md §5 and §10.
Each iteration of the innermost loop performs these steps in order. All four steps run inside one transaction so partial failure leaves no half-written state.
Call fetchBraskPassthrough (or the adapter's internal fetch) with parameters shaped for the current slice:
import { fetchBraskPassthrough } from '@/lib/data-model/adapters/brask-passthrough' const response = await fetchBraskPassthrough({ row: 'dag', // daily resolution column: 'arsak', // the characteristic — cause valueType: 'antallSkader', // count of incidents calculationType: 'verdi', // raw values filters: { ar: ['2024'], maned: ['1'], type: ['1'], // VARM; '2' is KALD (per `BRASK_EXPLORER_DIMENSIONS_JSON`) naering: ['0'], // Beboelse — maps via adapter + filters to building_type standard_code bygningsalder: ['5'], // Bygningsalder option id (same JSON) }, })
The response is BRASK's pivot table: rows are days of January 2024, columns are causes, cells contain counts (and optionally kr damage figures). The adapter receives the same response Utforsk receives. No transformation has happened yet.
Two lookups happen for every cell in the response.
For each cause label in the response (e.g. "Elektrisk"):
SELECT cat_code FROM fire_data.categories WHERE source_id = (SELECT source_id FROM fire_data.sources WHERE name = 'BRASK') AND dim_code = 1 -- Årsak (INTEGER PK; category-native-to-cat-code.md) AND native_name = 'Elektrisk'; -- illustrative; production maps BRASK column keys to **`native_api_code`** / **`native_name`** per **`category-native-to-cat-code.md`**
If found, the adapter uses the existing cat_code. If not — first time the adapter sees this cause — it INSERTs a new row:
INSERT INTO fire_data.categories (source_id, dim_code, cat_code, native_name, name, standard_code, sort_order) VALUES ((SELECT source_id FROM fire_data.sources WHERE name = 'BRASK'), 1, <next-cat-code>, 'Elektrisk', 'Elektrisk', 'INSTALLATION_FAILURE', <sort>); -- align **`native_api_code`**, **`cat_code`**, **`standard_code`** with **`category-native-to-cat-code.md`**
The standard_code is filled from the adapter/registry mapping. Every seeded category carries a defined standard_code; unmapped upstream labels are curated into the taxonomy before cross-source use.
The categories table grows organically as new natives appear. After enough runs it stabilises (BRASK has a finite vocabulary). This mirrors how categories.standard_code is intentionally updateable — adding a missing standard mapping later is one UPDATE and propagates to every fact row that references it via JOIN.
The adapter knows the four filter values for this iteration before the fetch even runs. Before writing the first fact row, it ensures matching filters rows exist:
INSERT INTO fire_data.filters (source_id, axis, native_code, native_name, standard_code, sort_order) -- If your deployment still lists `display_name`, use it as **`native_name`** until the column rename lands. VALUES ( (SELECT source_id FROM fire_data.sources WHERE name = 'BRASK'), 'building_age', '51-75', '51-75', 'AGE_51_75', 10 ) ON CONFLICT (source_id, axis, native_code) DO NOTHING;
ON CONFLICT DO NOTHING makes this idempotent — the first iteration ever to use this slice inserts the row, every subsequent one is a no-op.
Integrity: incident facts FK filters.id. The adapter upserts filters first, resolves each id, then writes fact_daily (daily path) or fact_yearly (yearly path) in the same transaction so FK + trigger enforce_fact_filter_code_axes always succeed.
Daily path: for each (date, cause) cell in the response, one INSERT against fact_daily:
-- Numeric ids are illustrative — resolve source_id / dim_code from registry rows and -- filter codes via lookup after upserting filters. INSERT INTO fire_data.fact_daily (source_id, dim_code, cat_code, date, fire_type_code, building_type_code, building_age_code, region_code, value, value_kr, ingested_at) VALUES ( (SELECT source_id FROM fire_data.sources WHERE name = 'BRASK'), 1, -- Årsak — INTEGER dim_code per category-native-to-cat-code.md / dimensions catalog 47, '2024-01-15', (SELECT id FROM fire_data.filters WHERE source_id = (SELECT source_id FROM fire_data.sources WHERE name = 'BRASK') AND axis = 'fire_type' AND native_code = '2'), -- Kald; native_name = 'Kald' (SELECT id FROM fire_data.filters WHERE source_id = (SELECT source_id FROM fire_data.sources WHERE name = 'BRASK') AND axis = 'building_type' AND native_code = '0'), (SELECT id FROM fire_data.filters WHERE source_id = (SELECT source_id FROM fire_data.sources WHERE name = 'BRASK') AND axis = 'building_age' AND native_code = '16-20'), NULL, 7, 285000, now() ) ON CONFLICT ( source_id, dim_code, cat_code, date, fire_type_code, building_type_code, region_code, building_age_code ) DO UPDATE SET value = EXCLUDED.value, value_kr = EXCLUDED.value_kr, ingested_at = now();
Production adapters should SELECT id once per iteration (cached), not embed correlated subqueries per row — shown verbosely here so types line up. WHERE … native_code = … must use the BRASK API ids (native_value in filter-axes-native-to-filter-id.md), not the short mnemonic labels (KALD/VARM belong in filters.native_name).
The NULLS NOT DISTINCT unique index (see docs/fire-data-schema/postgres-contract-rls-and-keys.md) handles dedup. Re-running the same fetch overwrites value / value_kr. Historical BRASK counts are assumed stable unless upstream revises classification.
ingested_by_user_id is intentionally not part of fact rows in v2. Adapter writes run with service-role credentials and are traceable through server logs; payload-level audit should live in ingest.raw_payloads when enabled.
region_code stays NULL — Cresta-Sone is future work; the natural-key index still lists region_code so slices remain disjoint once populated.
Yearly path: same filter/category resolution; upsert targets fact_yearly with year (see docs/fire-data-schema/postgres-contract-rls-and-keys.md — fact_yearly_natural_key).
After all rows from the slice have been upserted successfully:
INSERT INTO ingest.extraction_state (source_id, dim_code, fire_type_code, building_type_code, region_code, building_age_code, period_start, period_end, last_fetched_at, status, last_row_count, last_total_value, error_message) VALUES ( (SELECT source_id FROM fire_data.sources WHERE name = 'BRASK'), 1, -- Årsak (dim_code) 'KALD', '0', NULL, '16-20', '2024-01-01', '2024-01-31', now(), 'success', 217, 217, NULL) ON CONFLICT ( source_id, dim_code, period_start, period_end, (COALESCE(fire_type_code, '')), (COALESCE(building_type_code, '')), (COALESCE(region_code, '')), (COALESCE(building_age_code, '')) ) DO UPDATE SET last_fetched_at = now(), status = 'success', last_row_count = EXCLUDED.last_row_count, last_total_value = EXCLUDED.last_total_value, error_message = NULL;
extraction_state is scheduling metadata, not change detection. It answers "when did we last fetch this slice?" so the cron job can decide what's stale. It does not gate writes — the upsert in step 5.3 already handles dedup by content. If extraction_state were dropped tomorrow, the schema's correctness would be unaffected; only the scheduler would have to re-discover what's been fetched.
The deployed schema is deliberately normalized (source_id, dim_code, filter-axis columns, and period_start/period_end) so scheduler predicates are indexable and conflict targets are explicit SQL columns instead of JSONB path extraction.
Day-grain example (as brask.daily.ts would materialize). To make all of this tangible, walk through the iteration (lbType='2' = Kald, naering='0' Beboelse, bygningsalder bracket for ages 16–20) — human shorthand (fire_type Kald, sector 0/Beboelse, …) — over 2024-01-01..2024-01-31:
Fetch. Call fetchBraskPassthrough with the filter body shown in §5.1. Response is a 31-row pivot (one per day of January) with N columns (one per cause that appeared at least once that month), totalling perhaps ~200 non-zero cells.
Translate. First pass over the response:
Elektrisk, Røyking, Påsatt, Selvantennelse, Annet, Ukjent.categories. Suppose Elektrisk is the only one already present from an earlier run — the other five are INSERTed with cat_code values 50–54 and standard mappings filled from the adapter's hard-coded table.filters ON CONFLICT DO NOTHING for ('BRASK', 'building_age', '16-20') — already present from a previous iteration, no-op.Upsert. Roughly 200 upserts into fact_daily (daily path only; the yearly adapter aggregates to year on fact_yearly). Two of them representative:
| date | dim_code | cat_code | fire_type (filters.id; label via filters.native_name) | building_type_code (filters.id; API id in native_code, label in native_name) | building_age (filters.id) | value | value_kr |
|---|---|---|---|---|---|---|---|
| 2024-01-15 | 1 (Årsak) | 47 | 2 (Kald via native_name) | 0 (Beboelse via native_name) | bracket key / id per seed | 7 | 285 000 |
| 2024-01-22 | 1 (Årsak) | 50 | 2 (Kald via native_name) | 0 (Beboelse via native_name) | bracket key / id per seed | 3 | 91 000 |
Cells with value 0 are skipped — no point storing rows that contribute nothing to any aggregation.
Mark state. One row in extraction_state recording that this specific normalized slice was fetched at now() with last_row_count=200.
The next iteration of the loop changes one filter value — say BRASK type flips '1' (Varm / native_code) — and runs the same four steps. After all cross-product iterations (598 for full næring coverage, or 26 if sector is fixed to Beboelse — naering='0' — only), cause for January 2024 is fully ingested at joint-distribution granularity. Tier-2 routes can now answer:
All three queries hit the same fact rows. None requires re-fetching from BRASK.
These are the source-specific gotchas the adapter handles. None of them generalize to other sources.
naering native definitions (no ambiguity). BRASK Utforsk labels map to stable native codes on filters.native_code:
| Label | Native code | standard_code | Stored as |
|---|---|---|---|
| Beboelse | 0 | RESIDENTIAL | filters.id in building_type_code |
| Alle næringer | `` (empty string) | — | NULL building_type_code (no filter row) |
Beboelse is a filters catalog row; its filters.id is written into building_type_code (integer FK). "Alle næringer" (empty form field) is the unfiltered total — stored as NULL on that axis, not an ALL filter row. The Native code column is filters.native_code on the catalog row only. For cross-source charts, standard_code=RESIDENTIAL resolves to matching filters.id rows; facts are matched by integer FK, not by '0'.
naering and type semantics (corrected). In BRASK, næring / naering is the source-native vocabulary for sector / use-type; HeatWaves still models it under the single filter axis building_type_code + filters (axis='building_type'). There is no separate naering_code column — BRASK sector ids ('0'..'21', '99' from BRASK_EXPLORER_DIMENSIONS_JSON.dimensionValues.naering) are the filters.native_code values on that axis (API ids; labels in filters.native_name).
type is the fire-type axis ('1'=Varm, '2'=Kald at fetch-time per the same JSON’s dimensionValues.type); the adapter maps that to filters rows per filter-axes-native-to-filter-id.md (native_value → native_code, native_name e.g. Varm/Kald), then writes the resolved filters.id into fire_type_code (INT4 FK). The fact row stores only the id, not the API string or label.naering='0' / Beboelse is one filters catalog row; the adapter writes its filters.id into building_type_code. standard_code=RESIDENTIAL on that catalog row is for cross-source RPC resolution, not a value stored on facts.The fetch loop may iterate all 23 naering values for full BRASK coverage; the database still stores one filters.id per fact row on building_type_code (integer FK), not the BRASK API string. Native ids ('0'..'21', '99') are on filters.native_code only.
Bygningsalder is a both-dimension-and-filter property. Per crosstab-and-bucket-modeling.md §4, Bygningsalder is ingested twice:
dim_code = 6 for Bygningsalder — see category-native-to-cat-code.md §7) for "fires by age bracket" charts — this is its own adapter call without iterating age in the cross-product.building_age_code column) on cause and KILDE rows — this is the cross-product loop above.These are two separate fetch loops producing two distributions: one marginal, one joint. They don't duplicate information — they answer different analytical questions. Shipped code centers on ingestBrask / ingestBraskSlice; treat the two loops as orthogonal slice plans rather than distinct exported functions with the historical names that appeared in early drafts.
Erstatningsutgifter is value_kr, not a separate dimension. BRASK reports both incident counts and monetary damage. They live as two columns on the same fact row (value and value_kr) — same grain, two metrics. The adapter (lib/data-model/adapters/brask.ts) runs one HTTP fetch per metric: each pass emits rows with only value or only value_kr, so upserts merge into the same natural key without NULL-clobbering the other column. After both passes, the row holds both metrics where data exists. Read-side RPCs such as get_fire_data use p_metric to choose which column to aggregate. Unit convention: upstream rblVerdi=2 amounts are tusen kroner; write them into value_kr at that scale (stored integer n means 1000n NOK). Scale to full kroner only at display or when comparing against NOK-denominated sources — do not silently reinterpret stored values as whole-NOK kroner.
Cresta-Sone is deferred. BRASK exposes regional breakdown but the v2 adapter doesn't ingest it yet. The region_code column stays NULL. Adding region ingestion later is a fifth axis on the cross-product loop and a new section in filters — no schema change needed.
Zero-cell skipping. BRASK returns 0 for cells where no incidents occurred. The adapter skips writing rows with value=0 to avoid bloating fact_yearly / fact_daily with non-informative entries. Aggregations across "missing" rows naturally treat them as zero.
The four-step skeleton is identical for every other source adapter. Only the source-specific contents change.
BRIS (Police, Brigade, DSB) — uses buildBrisRequestBody in _brisRequestBody.ts (constants in _brisUpstream.constants.ts) for upstream fetch shapes (mission-type IDs, building-type ID arrays, JSON aggregationDefinition). The cross-product changes per dataset family per heatwaves-taxonomy:
Writers: lib/data-model/adapters/bris.ts → fact_yearly. lib/data-model/adapters/bris.daily.ts (Police only, Explore / admin grain: 'daily') → fact_daily.
filters for BRIS already seeded with the 4-value and 3-value Oppdragstype enumerations (per v2-002-filter-codes-migration-narrative.md §6). The adapter's translation step ensures each Oppdragstype native exists in filters (native_code from the API id, native_name from the BRIS label per filter-axes-native-to-filter-id.md), then writes the corresponding filters.id into fire_type_code; the standard mapping is resolved at query time via filters.standard_code.
SSB — different shape entirely. Yearly-only, no fire type filter, building type via dataset selection (Bygningsbranner vs Boligbranner). Writes into fact_yearly only (no fact_daily path). Two separate ingest paths, one per dataset, each producing aggregate counts with no characteristic breakdown (SSB has no Arnested / Objekt / Årsak / Tennkilde — it's totals only).
In all cases the four steps remain: fetch → translate → upsert → mark state. The categories and filters tables grow as new natives appear. The same RPC reads everything uniformly.
Historical notes from early BRASK bring-up; many items are resolved in shipped adapters — treat docs/ingestion/pipeline-slices-idempotency-and-triggers.md as current behavior. Retained here for rationale and vocabulary only:
Bygningsalder bracket boundaries (resolved from BRASK_EXPLORER_DIMENSIONS_JSON.dimensionValues.bygningsalder). The BRASK option set is:
Ny, 1-5, 6-10, 11-15, 16-20, 21-25, 26-30, 31-40, 41-50, 51-75, 76-100, Over 100, Ukjent
(form ids 1..12,99). Keep this exact native vocabulary in filters (axis='building_age') — no merging or re-bucketing.
Ukjent building_age mapping (resolved). filters.native_code='Ukjent' (API id side) carries the bracket key; filters.native_name holds the BRASK label; standard_code = 'UNKNOWN' (not NULL).
naering and type scope (resolved). Full native sets are defined in BRASK_EXPLORER_DIMENSIONS_JSON in lib/data-model/adapters/_braskWebForms.constants.ts. Sector is modeled as filters.native_code on axis='building_type'; facts reference filters.id via building_type_code, never the API string directly.
Standard-code mapping for cause and KILDE. The adapter carries a hard-coded native-to-standard table in code. Decide where this lives (a TS constant in lib/data-model/adapters/brask.ts, a YAML file checked into the repo, or seed rows in a migration) and what review process governs changes. For dimensions, categories.standard_code is the runtime bridge; for filter axes (including BRASK næring → HeatWaves building type), filters.standard_code plays the same role. The question is which artifact in the repo matches production rows authoritatively.
Reconciliation against BRASK source totals. Per crosstab-and-bucket-modeling.md §1 (data-quality goal), the SUM of granular slices may differ from BRASK's no-filter total — that difference is a useful data-quality indicator. Decide whether to capture source totals in a separate ingest.source_totals table during the same fetch loop, with a periodic reconciliation job, or to defer this entirely until the thesis section that needs it. Not a schema-level decision — both options work against the existing schema.
Error handling and retries. Out of scope for this document, but the production adapter needs: per-slice retry with exponential backoff, partial-failure handling (one slice fails, others succeed), and structured logging into ingest.extraction_state (status='failed' plus error_message). This is operational-quality work; the four-step skeleton above is the correct shape regardless of how robust the wrapper is.
Same spirit as crosstab-and-bucket-modeling.md §11. These would break the design properties:
POST /api/admin/ingest, cron). Explore BRASK passthrough reads upstream live — it does not perform fire_data upserts.filters write before upserting fact rows. The integrity guarantee depends on every native value referenced by fact_yearly / fact_daily having a matching filters entry. Skipping it produces orphan natives that no chart can label.NULL-axis total with its children (e.g. the all-buildings NULL slice with Beboelse). Keep filters.id leaves separate from the NULL total; use standard_code only when intentionally requesting a cross-source bridge.Bygningsalder=alle slice and skip the per-bracket fetches. The combined slice is unrecoverable into per-bracket data — the cross-tab use case dies silently.extraction_state for content correctness. It's scheduling metadata. The natural-key unique index is what guarantees no duplicates.filters CHECK constraint. Each is a one-line change; missing any of them produces silent data corruption.