HWHeat Waves
    DashboardUtforsk
    Analyse
    Data Kilder
      • Catalog
      • Pipeline
      • Entrypoints
    • Design Rationale
    • Doc Map
    DocsSettings
    DashboardAtlasUtforsk
    Analyse
    Data Kilder
    1. Documentation
    2. Ingestion
    3. Pipeline Slices Idempotency And Triggers

    Loading documentation...

    ETL Pipeline — BRIS / BRASK / SSB → fire_data

    Canonical contract reference for the HeatWaves ETL adapters — the canonical pipeline for BRIS, BRASK, and SSB. The adapters implement a registry-driven bridge between three native fire-statistics APIs (BRIS, BRASK, SSB) and the HeatWaves fire_data schema, writing to fact_yearly with two-tier idempotency.

    Read vs write grain: Adapters today persist fact_yearly rows for yearly histogram/API buckets and may persist fact_daily where upstream emits DATE-grain cells (see §10). Chart reads use fire_data.get_fire_data, which branches on p_granularity: year selects fact_yearly only; sub-year selects fact_daily plus sources.supports_subyear — never a single SQL UNION across both tables in the shipped RPC (docs/fire-data-schema/postgres-contract-rls-and-keys.md, docs/fire-data-schema/schema-history/incremental-patches-and-rpc-changelog.md).

    For filters.standard_code tokens such as RESIDENTIAL, get_fire_data resolves to every matching registry row for SQL aggregates. Ingest POST bodies use per-axis native ids (filter-axes-native-to-filter-id.md §§2–4); rollup tokens expand to one or more HTTP calls per adapter.


    1. Schema reality check

    Live-contract authority: docs/fire-data-schema/postgres-contract-rls-and-keys.md plus generated types/fire_data.database.types.ts. Schema history narratives (v2-001-initial-migration-narrative.md, incremental-patches-and-rpc-changelog.md) are design context; derive executable types from the postgres contract and generated types only.

    fact_yearly columns the adapters write into:

    ColumnTypeResolution
    source_idINTEGER FK → sources.source_idCaller supplies — UI populates from the sources table
    dim_codeINTEGER FK → dimensions.dim_code1=Årsak, 2=Tennkilde, 3=Objekt, 4=Arnested, 5=Bygningstype (SSB only) — see category-native-to-cat-code.md §1
    cat_codeINTEGER, source-scopedResolved from native API label via the categories registry
    fire_type_codeINTEGER NULL FK → filters.id (axis=fire_type)Resolved from native upstream value via the filters registry
    building_type_codeINTEGER NULL FK → filters.id (axis=building_type)Resolved via the filters registry
    building_age_codeINTEGER NULL FK → filters.id (axis=building_age)BRASK only; BRIS/SSB write NULL
    region_codeINTEGER NULL FK → filters.id (axis=region)Resolved via the filters registry; canonical mapping in regions-and-cresta.md
    valueNUMERICCounts (antall) for BRIS/BRASK/SSB
    value_krNUMERIC NULLBRASK damage only: stored unit is tusen kroner (integer n ⇒ 1000n NOK). BRIS/SSB leave NULL.
    yearSMALLINT2016..present

    fact_yearly and fact_daily each have a BEFORE INSERT OR UPDATE trigger — fact_yearly_enforce_filter_axes and fact_daily_enforce_filter_axes — that executes fire_data.enforce_fact_filter_code_axes(). The function rejects rows whose non-null filters.id references the wrong filters.axis, or violates filters.applicable_dim_codes for the row’s dim_code (see postgres-contract-rls-and-keys.md for full behavior). Foreign keys to filters(id) reject unknown ids; axis / applicability checks are trigger-only. The trigger does not verify filters.source_id = fact.source_id — adapters must keep registry lookups scoped to the slice’s source_id.

    fact_buildings_yearly (SSB building stock) keeps its legacy TEXT key shape and is out of scope for this pipeline. Its building_type_code is constrained to the four mutually-exclusive leaf buckets RESIDENTIAL, COMMERCIAL, INDUSTRY, OTHER (the partition of the building stock used as a denominator for fire-rate calculations). ALL_BUILDINGS and NON_RESIDENTIAL are deliberately excluded there because they are roll-ups, not partition members.


    2. Pipeline overview

    flowchart LR bris["BRIS<br/>(Police, DSB, Brigade)"] --> brisAdapter["bris.ts"] brask["BRASK<br/>(HTML form)"] --> braskAdapter["brask.ts"] ssb["SSB<br/>(json-stat2)"] --> ssbAdapter["ssb.ts"] registry["loadSourceRegistry()<br/>categories + filters"] --> brisAdapter registry --> braskAdapter registry --> ssbAdapter brisAdapter --> totalCheck{"api_total ==<br/>db_total?"} braskAdapter --> totalCheck ssbAdapter --> totalCheck totalCheck -->|"yes"| skip["Skip<br/>(unchanged)"] totalCheck -->|"no"| transform["Transform<br/>+ assert"] transform --> upsert["Chunked upsert<br/>(8-col natural key)"] upsert --> factYearly["fact_yearly"]

    Scope: Figures in §2 sketch default yearly admin ingest (grain omitted or 'yearly') into fact_yearly. fact_daily is written by brask.daily.ts (source_id = 1, grain: 'daily') and bris.daily.ts (source_id = 2, Police only, grain: 'daily') — §10, §10.3, §10.4. bris.ts / brask.ts remain the yearly materialization paths for their sources.

    Three layers per adapter:

    fetchXxx(slice, registry) → raw API response transformXxx(raw, ctx) → FactYearlyInsert[] (pure, registry-driven) ingestXxxSlice(supabase, registry, slice) → SliceResult ingestXxx(supabase, slices) → IngestResult (loads registry once)

    2.1 Translation lens — integer-ids ⇄ native-API vocabulary

    The pipeline has one input language (HeatWaves integer ids) and three output languages (BRIS numeric ids, BRASK ASP.NET form keys, SSB PxWebApi codes). The adapters translate twice per slice, both times against the same SourceRegistry:

    1. Outbound translation (request building) — when an adapter needs to ask a native API for a slice, it converts the integer-id slice into the shape the API expects (Norwegian labels, Matrikkel groups, ContentsCode strings, BRASK ASP.NET form fields).
    2. Inbound translation (response parsing) — when the native response comes back, the adapter walks the buckets / table cells / json-stat indices and looks each native key up in the registry to recover cat_code and filters.id integers, plus the within-slice time axis (year for yearly adapters, date for daily adapters).
    flowchart TD ui["UI / cron client<br/>POST /api/admin/ingest<br/>{ source_id, slices?, mode? }"] ui --> dispatch["_dispatch.ts<br/>route by source_id"] dispatch --> adapter["Adapter<br/>(bris.ts | brask.ts | ssb.ts)"] registry[("SourceRegistry<br/>loaded once per run<br/>(categories + filters)")] registry -. "filters.id → native_code<br/>cat_code → native_name" .-> reqBuild adapter --> reqBuild["Build native request<br/>(Norwegian labels,<br/>Matrikkel ids,<br/>ContentsCode strings,<br/>BRASK form fields)"] reqBuild --> nativeAPI[("Native HTTP<br/>BRIS / BRASK / SSB")] nativeAPI --> rawResp["Raw response<br/>(JSON buckets,<br/>HTML cells,<br/>json-stat array)"] rawResp --> transform["transformXxx<br/>(pure, registry-driven)"] registry -. "native_name → cat_code<br/>native_code → filters.id" .-> transform transform --> idempot{"api_total ==<br/>db_total?"} idempot -->|"yes"| skip["Skip<br/>(no upsert)"] idempot -->|"no"| factRows["FactYearlyInsert[]<br/>(only integer ids:<br/>source_id, dim_code, cat_code,<br/>year, fire_type_code,<br/>building_type_code,<br/>region_code, building_age_code)"] factRows --> upsert["Chunked upsert<br/>ON CONFLICT (8 cols)<br/>NULLS NOT DISTINCT"] upsert --> factYearly[("fact_yearly")] trigger["enforce_fact_filter_code_axes()<br/>(axis + applicable_dim_codes)"] -. guards .-> factYearly

    Key invariants the diagram encodes:

    • Adapters never write strings into fact tables. The only strings that reach Postgres are diagnostic — every value used as a row identifier is an integer that exists in sources / dimensions / categories / filters.
    • The registry is the only translation surface. Both the outbound (request build) and inbound (response parse) translations look up against the same Maps, so adding a row to categories or filters is a no-code change to the adapter.
    • The 8-column natural key is the contract with Postgres. onConflict must match fact_yearly_natural_key exactly (see §5.1).

    2.2 Worked example — one BRIS slice end-to-end

    Trace one Police slice (Årsak / Developed / dwellings, all years) through the full pipeline. The slice is described in pure integer-id space; every arrow that crosses a layer is a translation moment.

    UI surface (what code constructs) ────────────────────────────────── slice = { source_id: 2, // Police dim_code: 1, // Årsak fire_type_code: 17, // filters.id, axis=fire_type, native='10086' (Developed) building_type_code: 41, // filters.id, axis=building_type, native='0' (Dwellings) region_code: null, // national rollup building_age_code: null, // not applicable to BRIS years: [2016..2025], } ↓ outbound translation (registry.filterByIdToNativeCode) Native API request body ─────────────────────── { questionId: 407, // Police's Årsak question id (cat_code source-of-truth) revisedMissionTypes: ['10086'], buildingTypes: ['0'], dateRange: { start: 2016-01-01, end: 2025-12-31 }, ... } ↓ POST /api/v1/missionreports/aggregate Native response (truncated) ─────────────────────────── { "result": [{ "buckets": { "Påsatt brann": { "count": 4708, "subAggregationBuckets": { "1451606400000": { "count": 557 }, "1483228800000": { "count": 612 }, ... }}, "Røyking": { "count": 3120, "subAggregationBuckets": {...} }, "-1": { "count": 2104, "subAggregationBuckets": {...} }, ... }, "total": 32320 }] } ↓ inbound translation (registry.catByNativeName + UTC year derivation) FactYearlyInsert[] (what reaches Postgres — integer-only) ───────────────────────────────────────────────────────── [ { source_id: 2, dim_code: 1, cat_code: 102, year: 2016, fire_type_code: 17, building_type_code: 41, region_code: null, building_age_code: null, value: 557 }, { source_id: 2, dim_code: 1, cat_code: 102, year: 2017, fire_type_code: 17, building_type_code: 41, region_code: null, building_age_code: null, value: 612 }, { source_id: 2, dim_code: 1, cat_code: 199, year: 2016, // -1 → "Ikke besvart" fire_type_code: 17, building_type_code: 41, region_code: null, building_age_code: null, value: 412 }, ... ] ↓ idempotency check (sum(value) == db sum for these 6 slice keys?) ↓ if changed: chunked upsert ON CONFLICT (8-col natural key) Postgres state ────────────── fact_yearly rows keyed only by integers; cross-source rollup joins via standard_code in categories / filters.

    Cross-source rollups happen at read time, not at ingest time: fact_yearly carries source-scoped cat_code and source-scoped filters.id; analytical queries join the categories / filters lookup tables on standard_code to merge BRIS / BRASK / SSB rows that mean the same thing.

    Explore (/explore) URL prefers comma-separated filters.id tokens (native registry rows) where practical so UI picks map 1:1 to filters.id on facts; standard_code tokens still expand to every matching filter row on that axis — this expands cleanly for get_fire_data aggregates regardless of fact_yearly vs fact_daily branch. Cold-start materialization uses fire_data.ingest_runs + POST /api/admin/ingest (docs/application-architecture/explore-fire-data-slice-probe-and-ingest.md), not live Explore fetches.


    3. Registry-driven catalog loading (the bridge)

    Adapters bridge native API vocabulary (Norwegian labels, ASP.NET ids, BRIS numeric ids, SSB content codes) and HeatWaves integer ids (source_id, dim_code, cat_code, filters.id).

    The bridge is dynamic — every translation looks up against sources / dimensions / categories / filters rows, never against hardcoded constants in adapter code. Add a row to categories and the next run picks it up; no code edit required.

    3.1 lib/data-model/adapters/_registry.ts

    export interface FilterByNativeCode { fire_type: Map<string, number> building_type: Map<string, number> building_age: Map<string, number> region: Map<string, number> } export interface SourceRegistry { source_id: number /** Inbound: `catKey(dim_code, native_name)` → `cat_code`. */ catByNativeName: Map<string, number> /** Inbound: `native_code` → `filters.id`, by axis. */ filterByNativeCode: FilterByNativeCode /** Outbound: `filters.id` → `native_code` (for native-API request building). */ filterByIdToNativeCode: Map<number, string> /** Resolve a `filters.id` to its native code (alias for outbound bridge). */ filterNativeCode: (filters_id: number) => string | undefined /** `filters.standard_code` when present (e.g. BRIS building Matrikkel rollups). */ filterStandardCode: (filters_id: number) => string | null | undefined } export async function loadSourceRegistry( supabase: FireDataClient, source_id: number, ): Promise<SourceRegistry>

    Loaded once per ingest run and passed by reference into every transform call. No DB queries inside the per-row hot loop. Cost: two SELECT … WHERE source_id = X calls per run; ~100 filter rows + ~200 category rows total.

    3.2 Filter seeding prerequisite

    Before any adapter writes, filters must contain rows for every native code the adapters will encounter. Unknown filters.id values fail the FK; wrong axis or dim_code vs applicable_dim_codes fails enforce_fact_filter_code_axes() — adapters can't seed-as-they-go.

    AxisBRASKBRISSSB
    fire_typelbType=1 Varm, lbType=2 KaldrevisedMissionTypes: 10026, 10086, 10212, 10175— (not supported)
    building_typelbNæring codesMatrikkel groupsKOSboligbranner0000, KOSbygningsbrann0000
    building_age13 BRASK native buckets → 6 standard buckets per filter-axes-native-to-filter-id.md §3——
    regionCresta-sone 1–22, 99Counties (current admin)Municipality codes (4-digit, 358 current) + canonical county aggregates (EKA{nn}, 15 rows). standard_code is always the 2-digit canonical county.

    Region rows must carry standard_code matching the canonical 15-county set in regions-and-cresta.md when deterministic; NULL otherwise (Svalbard, Jan Mayen, BRIS merged historic counties without municipality detail).

    3.3 Schema relationships

    flowchart LR sources --> categories sources --> filters dimensions --> categories categories --> factYearly["fact_yearly"] categories --> factDaily["fact_daily"] filters -.id FK.-> factYearly filters -.id FK.-> factDaily trigger["enforce_fact_filter_code_axes()<br/>(axis + applicable_dim_codes)"] -.guards.-> factYearly trigger -.guards.-> factDaily

    4. Per-source flow

    4.1 BRIS (/aggregate and /restrictedaggregation)

    Live response shape (datasets 1–11 in adapter-mappings/.live_responses/ — identical for both endpoint variants; see source-api-request-examples.md for capture policy):

    { "result": [{ "buckets": { "Kjøkken": { "count": 4708, "calculatedSubAggregationValues": [], "value": null, "subAggregationBuckets": { "1451606400000": { "count": 557, "subAggregationBuckets": {} } } }, "-1": { "count": 6, "subAggregationBuckets": { "...": { "count": 6 } } } }, "total": 32320 }] }

    Transform contract:

    • Bucket key = native Norwegian label → cat_code via registry.catByNativeName.
    • '-1' (missing-answer bucket) → native_name = 'Ikke besvart' then catByNativeName (resolveCatCodeFromBrisBucket in _registry.ts).
    • Inner key = Unix-ms timestamp → year via new Date(ms).getUTCFullYear().
    • calculatedSubAggregationValues and value: null are ignored by the transform.
    • Police uses /aggregate (full body); Brigade and DSB use /restrictedaggregation (slim body, no buildingTypes).

    Question-id mapping (per category-native-to-cat-code.md "BRIS Source-Of-Truth Question IDs") is source-scoped — Police uses 407 for Årsak while the Fire Brigade uses 269 for the same dim_code=1.

    Ingestion-time validation: sum(rows.value) must equal the sum of bucket time-series counts (reconcilableBrisTotal — sum of subAggregationBuckets, not necessarily raw.result[0].total when the API declares a wider mission total). Mismatch indicates a bucket label without a matching categories.native_name row — hard-fail the slice.

    4.2 BRASK (HTML form)

    docs/fire-data-schema/schema-history/brask-ingest-loop-and-invariants.md owns BRASK loop invariants (§4–§7).

    Adapter behavior:

    • Two HTTP requests per slice. GET / for ASP.NET hidden fields; POST / with form body.
    • Parsed tuples only — cheerio scoped to #ctl00_Innhold_lblResultat table; raw HTML is not persisted.
    • Sends Accept-Encoding: gzip.
    • antall vs kr. rblVerdi=1 → value, rblVerdi=2 → value_kr. One call returns one metric; the slice descriptor carries metric: 'antall' | 'kr'.
    • Partial upsert columns. Transformed rows include only the metric column for that pass (value or value_kr) so the two passes can fill the same natural-key row without the absent column overwriting the other with NULL. validateFactRows (lib/data-model/adapters/_validate.ts) requires at least one finite, non-negative metric per row.
    • value_kr scale. BRASK reports erstatningsbeløp in tusen kroner (rblVerdi=2). Persist that scale unchanged in value_kr (stored n = 1000n NOK); multiply by 1000 only when comparing or labeling full NOK.
    • building_age_code is BRASK-only — populated from lbAldersgruppe.
    • Tier-1 idempotency total is the bottom-right SUM × SUM cell of the result table. Marginal SUM rows/columns are skipped (derivable on read).
    • Zero-cells are skipped per brask-ingest-loop-and-invariants.md §7 (Zero-cell skipping).

    Supported analytical dims: only 1 (Årsak) and 2 (Tennkilde). Objekt/ Arnested are not represented in BRASK's grid.

    4.3 SSB (json-stat2, table 12058)

    PxWebApi query shape, variable inventory, and cross-table notes: adapter-mappings/ssb-pxwebapi-tables.md. Verified upstream request: source-api-request-examples.md Dataset 10.

    { "class": "dataset", "id": ["KOKkommuneregion0000", "ContentsCode", "Tid"], "size": [357, 1, 10], "dimension": { "KOKkommuneregion0000": { "category": { "index": { "0": 0, "0301": 1, ... }, "label": { ... } } }, "ContentsCode": { "category": { "index": { "KOSboligbranner0000": 0 }, "label": { ... } } }, "Tid": { "category": { "index": { "2016": 0, "2017": 1, ... }, "label": { ... } } } }, "value": [265, 214, 247, ...] }

    value[] is a flat array in row-major order across the dimension cross- product. For dim order [a, b, c] with sizes [A, B, C], the index for (ai, bi, ci) is ai*B*C + bi*C + ci. The transform inverts that.

    Filter spine: SSB has no causal/object/area-of-origin breakdown — ingestible rows use dim_code = 5 (Bygningstype) and either cat_code = 501 (Boligbranner) or 502 (Bygningsbranner). 501 stores building_type_code = 64 (RESIDENTIAL filter id). 502 stores building_type_code = NULL (NULL-as-ALL — no filter row for «alle bygg»; see adapter-mappings/filter-axes-native-to-filter-id.md §2). ContentsCode is resolved from cat_code, not from the building_type filter axis.

    Dim 7 (Nøkkeltall) holds the remaining table-12058 ContentsCode variables as is_native_only categories — live read only (lib/data-model/native/), never written to fact_yearly. On Explore, NativeBranch requests only the ContentsCode values for the selected cat_standard topic group on SSB dim 7 (see explore doc); implementation: lib/data-model/ssb-nokkelgrupper.ts.

    Region mapping (table 12058): Each decoded cell carries a KOKkommuneregion0000 native code. EAK (Hele landet) is stored as region_code NULL. Municipality codes are stored as region_code = the matching filters.id for source_id = 5, axis = 'region', looked up by filters.native_code (region catalog: seed_ssb_region_filters_12058.sql, regenerate from PxWeb metadata when SSB adds codes). Non-null region_code must reference fire_data.filters (postgres-contract-rls-and-keys.md); cells without a catalog row are not written. The adapter logs skipped and continues the slice (lib/data-model/adapters/ssb.ts). Explore kommune slices request only codes already in the catalog; national EAK ingests return a single region dimension value.

    Region axis (table 12058):

    Explore / ingest intentPxWeb KOKkommuneregion0000fact_yearly.region_code
    Alle regioner (no URL region filter){ "filter": "item", "values": ["EAK"] }NULL (national landstall — same NULL-as-ALL rule as BRASK/BRIS totals)
    One or more region filter ids{ "filter": "item", "values": [<native codes>] }Matching filters.id per municipality

    Selection logic: ssbTable12058RegionSelection in lib/data-model/upstream/pxweb-table-12058.ts. National reads use the same NULL-as-ALL convention as other sources (omitted p_region → region_code IS NULL in get_fire_data).


    5. Idempotency model — store atoms, skip on equal total

    Sums + totals — store atoms, compute on read: adapters persist atomic category rows only; chart and RPC layers aggregate on read (no synthetic x00 rollups in facts).

    Most scheduled runs find historical slices unchanged. Compare upstream api_total (BRIS result[0].total, BRASK SUM × SUM cell, SSB sum of value[]) to SUM(value) on the same slice keys; equal totals skip the upsert.

    flowchart TD fetchAPI["Fetch native API for slice"] --> apiTotal["api_total"] apiTotal --> dbTotal["dbTotal = SUM(value)<br/>FROM fact_yearly<br/>WHERE slice keys"] dbTotal --> compareGrand{"api_total == dbTotal?"} compareGrand -->|"yes &amp; dbTotal &gt; 0"| skipAll["Skip upsert<br/>(unchanged)"] compareGrand -->|"dbTotal == 0"| fullPath["Full upsert<br/>(first run / backfill)"] compareGrand -->|"differs"| perYear["Compute per-year sums<br/>from API response"] perYear --> dbPerYear["SELECT year, SUM(value)<br/>FROM fact_yearly<br/>GROUP BY year"] dbPerYear --> diffYears["Diff: changed years only"] diffYears --> partialUpsert["Upsert rows for<br/>changed years only"]

    For 26 datasets daily:

    ScenarioWithout checkWith two-tier check
    Stable history (typical day)26 full upserts (~7,000 rows)26 SUM queries + 0 upserts
    Today's data updated26 full upserts26 SUM queries + 1–2 partial upserts (~50–200 rows)
    Backfill / first run26 full upserts26 full upserts (dbTotal=0 triggers full path)

    5.1 Slice keys (8-column natural key)

    Per postgres-contract-rls-and-keys.md:

    CREATE UNIQUE INDEX fact_yearly_natural_key ON fire_data.fact_yearly ( source_id, dim_code, cat_code, year, fire_type_code, building_type_code, region_code, building_age_code ) NULLS NOT DISTINCT;

    onConflict lists all 8 columns:

    const ON_CONFLICT = 'source_id,dim_code,cat_code,year,fire_type_code,building_type_code,region_code,building_age_code'

    NULLS NOT DISTINCT is critical — without it, every NULL filter creates a duplicate row on every run.

    cat_code and year are within-slice axes — the slice key fixes the other six dimensions and the SUM aggregates over cat_code and year.

    5.2 Atomic-only storage

    The full ingest is ~7,000 rows; SUM in JS or SQL is microseconds. Stored derived totals drift the moment one fact row is upserted without a matching total update. No fact row should ever carry cat_code = x00 — x00 is the synthetic "Alle kategorier" label for the lookup table only.

    Compute locationUse when
    Postgres view / RPCMultiple pages need the same totals. Extend get_fire_data with a p_with_totals flag, or sibling get_fire_data_with_totals.
    Server ComponentPage-specific totals — fetch atoms via getFireData, run a reduce once, pass { rows, categoryTotals, yearTotals } as props. Recommended for the new chart page.
    Client useMemoTotals must recompute as the user toggles filters/series without refetching.

    Shared helper: lib/utils/totals.ts — buildTotals(rows) is used by both the live BRIS chart path and the Supabase chart page.

    5.3 Precise filter semantics — ingest vs chart reads

    Facts do not carry an is_aggregate (or similar) flag. Precision comes from the natural key: each row stores at most one FK per filter axis (fire_type_code, building_type_code, building_age_code, region_code), each pointing at filters.id for the correct axis, guarded by fire_data.enforce_fact_filter_code_axes. Cross-source roll-ups such as CONFINED or RESIDENTIAL are represented only in filters.standard_code and chart/RPC resolution, not as separate “total rows” in fact_*. The per-axis «Alle» / total is the NULL-FK slice (omitted get_fire_data axis param), never a roll-up filter row.

    Ingest path (adapters):

    • One slice fixes one combination of axis FKs (concrete id or NULL per axis). Every transformed row from that fetch inherits that combination.
    • BRIS fire type: one upstream revisedMissionTypes.ids entry per slice. The adapter maps slice.fire_type_code → exactly one native mission-type id (lib/data-model/adapters/bris.ts, toMissionTypeIds). Documentation examples that show multiple ids in one POST body describe upstream capability; for HeatWaves storage, run separate slices for each leaf mission type and tag rows with the matching fire_type_code so atoms stay 1:1.
    • BRASK: empty multiselect = “Alle” on that axis (filter-axes-native-to-filter-id.md); a non-empty selection implies one resolved native code per slice pass where the adapter wires it that way — treat “all vs leaf” as different slice keys (different natural keys / totals checks).

    Read path (fire_data.get_fire_data):

    • Omitting a filter-axis argument (NULL) — target contract: keep only facts where that axis FK IS NULL.
    • Passing tokens in p_fire_type / p_building_type / etc. resolves to filters.id values; facts must match any resolved id (OR within axis, AND across axes).
    • standard_code is the cross-source bridge: e.g. RESIDENTIAL resolves to all matching filters.id rows (BRASK Beboelse catalog row, BRIS bolig leaves, SSB Boligbranner); the RPC then matches those integers against fact FK columns.

    Why this matters: chart queries that pass standard_code expand to all matching ids on that axis (union of atoms). Ingest must still write those atoms with native-accurate FKs; otherwise totals checks and cross-source joins drift.

    5.4 Operational awareness — backfills and steady-state drift

    This subsection does not enumerate blocking prerequisites before the first fact insert. Postgres already rejects invalid filters.id FKs, enforce_fact_filter_code_axes() violations (axis / applicable_dim_codes), and BRIS adapters abort slices when transformed sums diverge from upstream totals (postgres-contract-rls-and-keys.md).

    Residual exposure is process-level: silent omission of buckets when categories rows are absent, incorrect physical grain (fact_yearly vs fact_daily), and semantics outside DB validation (e.g. multi-id BRIS requests with a single slice fire_type_code). The table maps each class to existing guards versus operational ownership; narrative orchestration diagrams live in catalog-governance-and-reconciliation.md.

    Risk classDatabase / adapter guardOperational ownership
    Registry gaps (filters, categories)FK + trigger failures for filters; BRIS total assertionMaintain categories so bucket keys resolve; reconcile IngestResult after seed edits
    Grain mismatchget_fire_data branching + supports_subyearRoute ingest adapters per source cadence (§10)
    Duplicate semantic slicesNatural-key upsert convergenceSingle canonical slice definition per catalog tuple
    BRIS multi-id POST + one fire_type_codeNone§5.3 invariant
    Asymmetric standard_code by sourceRPC resolves ids — semantics catalog-dependentSource-specific filters documentation
    filters.source_id vs fact.source_idNot enforcedSourceRegistry scoped to ingest source_id

    5.5 Granular write diff (natural-key classification)

    Tier-1 (grand total) and tier-2 (per-year) behaviour above is unchanged: when the slice is classified unchanged, adapters skip transform-time classification and write no rows.

    When the adapter proceeds to evaluate-and-write, it loads a before snapshot of existing facts for the relevant key set (beforeByKey: 8-column natural key → { value, value_kr }), classifies each transformed row, and builds toWrite = rows that are new or whose metrics differ on the columns that path actually sets (BRASK may send value-only or value_kr-only partial rows — omitted columns must not be treated as changes).

    Counter (per slice, rolled up on ingest_runs)Meaning
    rows_addedComposite natural key not in beforeByKey — first insert.
    rows_updatedKey exists; value and/or value_kr differ from DB for the compared columns.
    rows_skipped_identicalKey exists; metrics match — omitted from toWrite (no PostgREST row).

    rows_upserted on fire_data.ingest_runs is the cumulative count of rows sent through the writer (i.e. rows_added + rows_updated for the tracked run, slice by slice). It does not include identical keys skipped in toWrite. ledger_events (when used) align with written keys only.

    SQL writer: fire_data.upsert_fact_yearly_batch(p_rows jsonb) performs INSERT … ON CONFLICT DO UPDATE … WHERE row IS DISTINCT FROM excluded so heap/WAL work tracks real changes. Prefetch: fire_data.fact_yearly_slice_ingest_prefetch returns grand_total, by_year, and row_count without shipping all fact rows — adapters use it for tier-1/tier-2 gates before a heavier beforeByKey load when needed. Migration: 20260510140000_ingest_runs_metrics_fact_prefetch_upsert.sql under supabase/migrations/; patch note: docs/fire-data-schema/schema-history/incremental-patches-and-rpc-changelog.md.

    5.6 fire_data.ingest_runs analytics columns (operator / Explore)

    These columns are durable run metadata for dashboards and the Explore poll endpoint. They do not replace fact_yearly as the mathematical source of truth for chart aggregates (facts + triggers remain canonical; see catalog-governance-and-reconciliation.md §6).

    ColumnUse
    rows_added, rows_updated, rows_skipped_identicalCumulative across slices in the run; see §5.5.
    rows_upsertedCumulative written row count (matches batch upserts + §5.5).
    api_totalUpstream grand total for the request (incident-style sum) when slices_total === 1 (Utforsk / single-slice). NULL when slices_total > 1 to avoid a misleading single scalar.
    transformed_row_countCardinality of transformed atoms for that run when slices_total === 1; NULL for multi-slice batch runs.
    per_slice_metricsWhen slices_total > 1: JSON array of objects (one per completed slice) with at least slice_index, slice_key, api_total, transformed_row_count, optional write_stats. Version the object shape here when adding fields.

    Route rollup: app/api/admin/ingest/route.ts updates these on each onSliceComplete; app/api/ingest/status/[runId]/route.ts exposes them to the polling client. Explore flow (probe, branching, polling): explore-fire-data-slice-probe-and-ingest.md. Table DDL: postgres-contract-rls-and-keys.md and migrations under supabase/migrations/.

    phase_detail lifecycle on completion: The route handler emits a synthetic route_lifecycle payload around the adapter loop — queued (5%) before the registry load and finalize_metrics_cache (95%) only on success, between the adapter loop and revalidateTag. On status === 'error' the ceiling is not emitted, so the adapter's last brask_daily_fanout / bris_police_daily_fanout payload (with its failing (year, month) cursor and in-progress canonical_progress) survives as the terminal phase_detail. This lets operators read where the run died without grepping server logs, and pairs with the BRASK POST 500 (YYYY-MM) form of error_message (§10.3) for the what. The Explore detail sheet (IngestRunDetailSheet.tsx) suppresses the PhaseStrip when status === 'complete' so the green Status row is the single completion signal — no redundant "Ferdig" line.


    6. Trigger flow — Route Handler

    sequenceDiagram actor cron as Cron / curl participant route as POST /api/admin/ingest participant after as next/server after() participant dispatch as ingestBySourceId participant registry as loadSourceRegistry participant adapter as Adapter participant supabase as Supabase cron->>route: POST { source_id, slices? } route->>route: Verify x-ingest-key route-->>cron: 202 Accepted (runId) route->>after: schedule runIngest after->>dispatch: ingestBySourceId(supabase, source_id, slices) dispatch->>registry: loadSourceRegistry(source_id) registry->>supabase: SELECT categories, filters supabase-->>registry: rows registry-->>adapter: SourceRegistry loop per slice adapter->>adapter: fetch native API adapter->>supabase: SELECT SUM (slice keys) alt totals match adapter->>adapter: skip (unchanged) else totals differ adapter->>adapter: transform via registry adapter->>supabase: upsert chunks end end

    'server-only' import causes a build error if a module is imported client-side. after() keeps the function alive on Vercel via waitUntil(). For very long runs (full 26-dataset refresh), a Supabase Edge Function or pg_cron is more reliable than a Route Handler.


    7. Error handling

    Error classCauseBehavior
    Filter resolution failureNative code missing in filters for active (source_id, axis)Hard-fail the slice, surface error in IngestResult.slices[].error, continue to next slice
    Category resolution failureBucket label missing in categoriesSkip the bucket + soft warn (per-row)
    Total mismatch (BRIS)sum(rows.value) !== reconcilableBrisTotal(raw) (sum of bucket time-series counts)Hard-fail the slice — missing categories.native_name row (often -1 / Ikke besvart)
    enforce_fact_filter_code_axes violationNon-null filters.id with wrong axis, missing filters row, or dim_code outside applicable_dim_codesUpsert fails (**postgres-contract-rls-and-keys.md)
    Postgrest chunk errorSchema constraint, network, FK violationSurface PostgrestError.message in error, abort slice
    Unmapped SSB municipality cellPxWeb native_code has no filters row (source_id = 5, axis = region)Cell omitted from fact_yearly; transform skipped count + log; slice continues (§4.3)

    Every adapter wraps its slice runner in try/catch and returns a SliceResult with status: 'error' rather than throwing — this lets the full IngestResult complete and surface every slice's status.


    8. Adding a new source

    1. Insert a row into fire_data.sources (allocate the next source_id).
    2. Insert dimensions rows if the source brings a new analytical axis.
    3. Insert categories rows per (source_id, dim_code) — including the Ikke besvart row (x99, native_name = 'Ikke besvart', standard_code = 'UNKNOWN') if the source can return missing-answer (-1) buckets.
    4. Insert filters rows for every native code the adapter will encounter. Unknown ids fail the FK to filters; wrong axis or dim_code vs applicable_dim_codes fails enforce_fact_filter_code_axes (§1).
    5. Add lib/data-model/adapters/<source>.ts exporting ingest<Source>(supabase, slices) → IngestResult.
    6. Wire the new source_id case in lib/data-model/adapters/_dispatch.ts.
    7. Smoke test with the Route Handler: curl -X POST /api/admin/ingest -H 'x-ingest-key: …' -d '{"source_id": N}'.

    9. Implementation checklist

    Before changing ingestion code or schema:

    • Generated types regenerated (npx supabase gen types typescript) when schema changes.
    • All label/code translation goes through SourceRegistry (no static lookup Maps in adapters).
    • onConflict lists the 8 natural-key columns in fact_yearly_natural_key order.
    • Fact filter ids match axis and filters.applicable_dim_codes vs row dim_code.
    • BRIS: one native mission-type id per ingest slice; sum(rows.value) === reconcilableBrisTotal(raw).
    • '-1' buckets resolve to the Ikke besvart row via native_name (catalog row must exist per dim).
    • Facts store category atoms only (no aggregate/total columns on fact tables).
    • BRASK fetcher sends Accept-Encoding: gzip; persists parsed tuples only.
    • Per-slice try/catch returns SliceResult instead of throwing.
    • Route handler preserves INGEST_SECRET check and after() fire-and-forget pattern.

    10. Grain — yearly vs daily facts

    Adapters write fact_yearly and/or fact_daily depending on upstream cadence (see §10.1). get_fire_data never derives yearly totals by scanning fact_daily — yearly charts require rows ingested into fact_yearly. Both fact tables share the same 8-column natural-key shape aside from the time axis (year SMALLINT vs date DATE):

    fact_yearly natural key: source_id, dim_code, cat_code, year, fire_type_code, building_type_code, region_code, building_age_code fact_daily natural key: source_id, dim_code, cat_code, date, fire_type_code, building_type_code, region_code, building_age_code

    Yearly extracts are intentionally unbounded at fetch time. BRASK yearly keeps lbÅr='' in its POST body — one request returns the full 1985-current archive for the slice — and the BRIS Police /aggregate / /restrictedaggregation (DSB and Brigade) endpoints similarly return every year the upstream knows about. We do not narrow the fetch to the Explore-requested window because:

    • One cold ingest then covers every future Explore request for that slice forever (the slice-level natural key + filter axes are what gate idempotency, not the year axis). The outer Explore probe only needs Level A (row_count > 0) over the requested window: probe_slice_coverage when granularity = year, else probe_slice_coverage_daily (must match the get_fire_data branch — see Explore doc).
    • Each yearly fetch is one HTTP call regardless of span. Limiting to a sub-window is not gentler on upstream — it just costs more roundtrips when the user later widens the slider.

    Daily is the only path where the Explore time-range bounds the ETL sequence (BRASK monthly fan-out, §10.3) — and even there, the bounds gate which months get a POST, not which years exist.

    The route handler captures the actual stored year span (per slice year_window_written, aggregated to a run-level ingested_year_window) onto ingest_runs.slice_config, so the Explore Last imported list can show Requested 2024–2025 (stored 1985–2026) when intent and reality differ — see ../application-architecture/explore-fire-data-slice-probe-and-ingest.md (ingest_runs and status JSON).

    Display granularity vs write grain. The Explore time-range slider walks five display granularities (year → quarter → month → week → day — see components/fire-data/console/rpc-time-range.ts), but the ingest dispatcher in app/(main)/explore/actions.ts collapses every sub-year choice to a single physical grain:

    const grain: IngestGrain = slice.granularity === 'year' ? 'yearly' : 'daily'

    Consequence: fire_data.ingest_runs.slice_config.granularity can be 'quarter' (or 'month', 'week', 'day') on a row whose facts live in fact_daily. The Explore monitor surfaces only the write grain (Dag / År) — the original display label stays in slice_config.granularity_label for JSON diagnostics. The collapse helper is writeGrainLabelFromSliceConfig and it powers both the "Sist importert" column and the "Lagringsgrain" row in ingest-detaljer.

    10.1 Source coverage by grain (verified against the live APIs)

    SourceEndpointfact_yearlyfact_dailyHow the daily grain is reached
    BRIS — Police (source_id=2)POST /missionreports/aggregateyesyesOne request, nextLevelAggregationDefinition set to MISSION_CALL_TIME_DAY (type: DATE_HISTOGRAM, interval: DAY). Live probe (Apr 2026): bucket count 3,772 for 2016-01-01..2026-04-22.
    BRIS — Brigade (source_id=3)POST /missionreports/restrictedaggregationyesno — server-enforcedLive probe (Apr 2026): same body with interval: MONTH or DAY returns HTTP 403. Only interval: YEAR is accepted on this endpoint (year-only time sub-aggregation on restricted datasets).
    BRIS — DSB (source_id=4)POST /missionreports/restrictedaggregationyesno — server-enforcedSame restriction as Brigade — both share the restricted endpoint.
    BRASK (source_id=1)POST https://brask.finansnorge.no/yesyes (per-month fan-out)The BRASK form does have a dag selector (BRASK_FILTER_FIELD_MAP), but the table grid only fits a single (year, month) pair when dag is the column. Daily ingestion is a fan-out: one POST per (year, month) pair with column = dag, parsing 28–31 day cells per response. ~12 requests per year per slice.
    SSB (source_id=5)POST data.ssb.no/api/v0/no/table/12058yesno — API designAll KOSTRA tables are annual; PxWebApi exposes no sub-annual time dimension.

    BRIS — Police, ingest vs upstream: lib/data-model/adapters/bris.ts still always uses subGroupBy: MISSION_CALL_TIME_YEAR and writes only fact_yearly (one POST returns the full upstream history per slice — see §10 on unbounded yearly fetches). Day-grain materialisation lives in the sibling module lib/data-model/adapters/bris.daily.ts, dispatched when the admin route is called with grain: 'daily' and source_id = 2 (§10.4). The two paths share registry + helpers; bris.ts exports BRIS_QUESTION_ID, endpointForBris, sanitizeBrisSliceForEndpoint, and the parametrized fetchBrisData(slice, registry, { subGroupBy?, periodStart?, periodEnd? }) so bris.daily.ts reuses one HTTP body builder. Brigade/DSB still have no daily path — /restrictedaggregation rejects sub-yearly intervals server-side.

    10.2 What lands cleanly today

    Adding fact_daily adapters is a parallel module set, not an architectural change:

    1. The SourceRegistry is grain-agnostic — same categories / filters loaded once per run; BRASK yearly (ingestBrask) and daily (ingestBraskDaily) both reuse it.
    2. BRASK splits by grain across two modules: brask.ts → fact_yearly; brask.daily.ts → fact_daily when ingest passes grain: 'daily' (§10.3). BRIS mirrors that split for Police only: bris.ts materialises fact_yearly (always MISSION_CALL_TIME_YEAR); bris.daily.ts materialises fact_daily when ingest passes grain: 'daily' and source_id = 2 (§10.4). Brigade/DSB stay yearly-only because /restrictedaggregation rejects sub-yearly intervals server-side (§10.1). Sub-year chart reads are gated by sources.supports_subyear / dimension read-grain (see postgres-contract-rls-and-keys.md on get_fire_data + taxonomy taxonomy-and-eu-firestat-alignment.md §3.1).
    3. BRASK daily fan-out: the daily adapter takes a (years[], dim_code, filters…) slice and explodes it into years.length × 12 BRASK form POSTs (one per (year, month) pair, column = dag). Each response contributes 28–31 fact-daily rows per category. Two-tier idempotency compares per-month sums to detect changed months; unchanged months skip their POST entirely.
    4. BRIS Brigade/DSB daily is unavailable — /restrictedaggregation rejects sub-yearly intervals (§10.1).
    5. Idempotency works the same way at daily grain — the slice-level natural key fixes the other 6 dimensions, the API total compares against SUM(value) GROUP BY (source_id, …), per-bucket diff is per-day instead of per-year.
    6. _dispatch.ts carries the grain: 'yearly' | 'daily' parameter and maps each source-grain combination to its adapter module: (BRASK, daily) → ingestBraskDaily, (BRIS-Police, daily) → ingestBrisDaily, (BRIS-Brigade|BRIS-DSB|SSB, daily) → typed error.
    7. fact_daily_enforce_filter_axes (same function as yearly) is installed on fact_daily (postgres-contract-rls-and-keys.md), so the same checks apply.

    The existing fact_buildings_yearly table (annual building stock for denominator calculations) stays separate — building-stock data has no daily grain.

    10.3 BRASK daily four-tier (brask.daily.ts)

    The daily BRASK adapter never re-fetches an unchanged window. Each call goes through four sequential gates; on a stable history every gate downstream of Tier-1 short-circuits.

    flowchart TD start["Slice = (dim_code, metric, fire_type, naering, age, region, date_start..date_end)"] start --> t0["Tier-0 (DB only) — fact_daily_slice_ingest_prefetch<br/>daily_row_count > 0 ?"] t0 --> t1pre{"fact_yearly populated<br/>for this slice?"} t1pre -->|"no"| chainYearly["Auto-chain: run ingestBraskSlice (yearly) first.<br/>Same runId, same registry, same supabase client.<br/>Re-run Tier-0 prefetch."] chainYearly --> coldBranch t1pre -->|"yes"| coldBranch coldBranch{"daily_row_count == 0<br/>(cold)?"} coldBranch -->|"yes"| coldFan["Cold fan-out:<br/>monthsInWindow restricted to<br/>years present in yearlyByYear"] coldBranch -->|"no"| t1 coldFan --> emptyCheck{"any years left?"} emptyCheck -->|"no"| unchanged["Slice unchanged.<br/>No BRASK POST. No write."] emptyCheck -->|"yes"| t3 t1["Tier-1 (DB only) — yearly drift detector<br/>driftedYears = { y | yearlyByYear[y] - dailyByYear[y] != 0 }"] t1 -->|"empty"| unchanged t1 -->|"non-empty"| t2 t2["Tier-2 (BRASK) — 1 POST per drifted year<br/>row=arsak|kilde, column=maned, filters.ar=[year]<br/>dbByMonth = prefetch.daily_by_year_month restricted to year<br/>changedMonths = { m | apiByMonth[m] - dbByMonth[m] != 0 }"] t2 -->|"empty for year"| nextYear["go to next driftedYear"] t2 -->|"non-empty"| t3 nextYear --> t2 t3["Tier-3 (BRASK) — 1 POST per changed month<br/>row=arsak|kilde, column=dag, filters ar+maned<br/>28..31 day cells"] t3 --> filterDays["Drop impossible days:<br/>new Date(Date.UTC(y, m-1, d)).getUTCDate() !== d"] filterDays --> classify["classifyWritesForDailySlice -> toWrite + write_stats"] classify --> upsert["upsert_fact_daily_batch<br/>WHERE row IS DISTINCT FROM EXCLUDED"] upsert --> phaseUpdate["onPhaseChange -> ingest_runs.phase_detail"] phaseUpdate --> nextMonth["next month"]

    Tier-0 vs the outer coverage probe. The diagram's Tier-0 is the adapter's internal cold check. It asks one yes/no question — is fact_daily.daily_row_count > 0 for this slice + window? — using the same fact_daily_slice_ingest_prefetch round-trip that supplies the Tier-1 / Tier-2 aggregates. The outer Explore coverage probe (fire_data.probe_slice_coverage_daily when the URL requests sub-year granularity — see Explore doc) is a different thing: it answers "should we trigger the adapter at all?" and adds last_run_completed so Explore can distinguish "empty and never ingested" from "empty but an ingest just finished" before opening a polling channel. The two checks live at different layers; Tier-0 is the adapter's short-circuit, the coverage probe is the page's gating predicate.

    Why Tier-1 reads fact_yearly (not BRASK). In Explore the granularity selector flow yearly → daily guarantees fact_yearly was just refreshed for the slice. That makes fact_yearly the authoritative yearly truth, and re-asking BRASK for the same numbers is pure redundancy. Tier-1 compares two Postgres aggregates from the same database — exact integer math, no HTTP round-trip, no rate-limit exposure. The fact_yearly_slice_ingest_prefetch call ships both yearly_by_year (from fact_yearly) and daily_by_year / daily_by_year_month (from fact_daily) in one round-trip.

    Auto-chain fallback (t1pre branch). If fact_yearly is empty for the slice (cron / operator path, or first-ever run), the daily adapter invokes the existing ingestBraskSlice (yearly) before the cold / drift split. Same runId, same supabase client, same registry. This mirrors the natural Explore flow without imposing a precondition error on non-UI callers, and — critically for cold mode — gives the fan-out an authoritative yearlyByYear snapshot to filter against (see next paragraph).

    Cold fan-out is filtered by yearlyByYear. In cold mode (daily_row_count == 0), after the optional yearly auto-chain, driftedYears is monthsInWindow.years ∩ yearlyByYear.keys(). Years absent from yearlyByYear are skipped (BRASK returns HTTP 500 for years with no rows). An empty intersection short-circuits to status: 'unchanged' with zero BRASK POSTs. Drift mode uses diffNumberMaps over the same year keys.

    Failing-cursor error message. postBraskForm accepts an optional { year, month } context and surfaces it in the thrown message (e.g. BRASK POST 500 (2026-01)). The route handler stores that message on fire_data.ingest_runs.error_message, and the in-progress brask_daily_fanout phase_detail (with its own cursor: { year, month }) is preserved on error — see §5.6 below for the lifecycle-on-error rule.

    Subtraction diff vs row-equality. diffNumberMaps is strict numeric subtraction over a Map<key, number> — keys missing on one side count as zero. BRASK skips zero cells on the API side, and the BRASK adapter likewise skips zero rows on the DB side (see §4.2); the diff stays correct because both sides are missing the same zeros.

    Impossible-day filter. BRASK always returns 31 day columns. transformBraskDailyTable drops days that fail the JS UTC round-trip — new Date(Date.UTC(year, month-1, day)) auto-rolls Feb 30 to March 2, so the check is exactly:

    const d = new Date(Date.UTC(year, month - 1, day)) if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) continue if (numeric === 0) continue // matches yearly zero-cell skip

    No daysInMonth table, no leap-year branching.

    RPC contracts (supabase/migrations/20260512010000_fact_daily_helpers_and_phase_detail.sql):

    • fire_data.fact_daily_slice_ingest_prefetch(p_source_id, p_dim_code, p_*_code, p_metric, p_date_start, p_date_end) → (daily_grand_total, daily_by_year, daily_by_year_month, daily_row_count, yearly_by_year). One round-trip yields every aggregate the four-tier probe needs.
    • fire_data.probe_slice_coverage_daily(p_source_id, p_dim_code, p_*_codes integer[], p_slice_hash, p_date_start, p_date_end, p_metric) → (min_date, max_date, last_ingested, row_count, last_run_completed). Sibling of the yearly probe.
    • fire_data.upsert_fact_daily_batch(p_rows jsonb) — same WHERE row IS DISTINCT FROM EXCLUDED gate as the yearly batch upsert; date replaces year in the natural key.

    phase_detail progress contract. Some daily adapters finish one logical slice (slices_total = 1) across many internal steps (extra HTTP round-trips or months). They emit a versioned jsonb payload on fire_data.ingest_runs.phase_detail so the UI can show monotonic canonical_progress (0..1) instead of inferring progress from slices_done / slices_total.

    Two kind values share the same field names today so Explore polling and GET /api/ingest/status/[runId] stay parser-stable; the discriminated union is typed as PhaseDetail in lib/data-model/types.ts:

    kindModuleNotes
    brask_daily_fanoutbrask.daily.tsMany (year, month) BRASK POSTs per slice; done / total count months in the run window.
    bris_police_daily_fanoutbris.daily.tsSame tier labels (tier0_cold_check … tier3_daily_fetch); done / total also count months in the window so the bar matches BRASK semantics.

    BRASK example (brask_daily_fanout):

    { "v": 1, "kind": "brask_daily_fanout", "phase": "tier3_daily_fetch", // tier0_cold_check | tier1_yearly_probe | tier2_monthly_probe | tier3_daily_fetch | write | done "done": 37, "total": 120, "canonical_progress": 0.308, // monotonic 0..1; UI reads this for the bar "cursor": { "year": 2019, "month": 3 }, "started_at": "2026-05-11T19:30:00Z", "stats": { "rows_added": 412, "rows_updated": 5, "rows_skipped_identical": 1893 } }

    BRIS Police example (bris_police_daily_fanout): same keys; phase uses the same tier string union; stats matches SliceWriteStats.

    { "v": 1, "kind": "bris_police_daily_fanout", "phase": "tier2_monthly_probe", "done": 14, "total": 120, "canonical_progress": 0.117, "cursor": { "year": 2020, "month": 2 }, "started_at": "2026-05-12T10:00:00Z", "stats": { "rows_added": 0, "rows_updated": 0, "rows_skipped_identical": 0 } }

    The polling status route (GET /api/ingest/status/[runId]) selects phase_detail and passes it through. Consumers in the Explore workspace treat brask_daily_fanout and bris_police_daily_fanout the same way: prefer canonical_progress over slices_done / slices_total when either kind is present.

    slice_hash is a Postgres-generated column. fire_data.ingest_runs.slice_hash is GENERATED ALWAYS AS fire_data.compute_slice_hash(...) STORED from the canonical slice_config JSON (migration 20260512000000). Adapters and Explore never compute the hash in TypeScript; pre-insert lookups call the compute_slice_hash RPC, which uses the same SQL function behind the partial unique index ingest_runs_active_slice.

    Cost on a 10-year slice, fully stable history (Explore flow).

    TierDB RPCsBRASK POSTsWrites
    0100
    10 (data folded into Tier-0 prefetch)00
    2000
    3000
    Total100

    When only the last 2 months changed: Tier-1 surfaces 1 drifted year, Tier-2 sends 1 BRASK POST, Tier-3 sends ≤ 2 BRASK POSTs, and the upsert RPC writes ~50–60 rows after IS DISTINCT FROM filters out unchanged keys.

    10.4 BRIS Police daily four-tier (bris.daily.ts)

    Police is the only BRIS sub-source the upstream lets us hit at sub-yearly intervals (§10.1). The adapter mirrors the BRASK four-tier vocabulary but each tier issues at most one POST instead of per-(year, month) fan-out: BRIS /aggregate accepts nextLevelAggregationDefinition.interval of MONTH or DAY together with optional periodStart / periodEnd, so one HTTP call covers a whole year (Tier-2) or a whole month (Tier-3).

    flowchart TD start["Slice = (sub=POLICE, dim_code, fire_type, building_type, region, date_start..date_end)"] start --> t0["Tier-0 (DB only) — fact_daily_slice_ingest_prefetch<br/>daily_row_count > 0 ?"] t0 --> t1pre{"fact_yearly populated<br/>for this slice?"} t1pre -->|"no"| chainYearly["Auto-chain: ingestBrisSlice (yearly).<br/>Same runId, same registry, same supabase client.<br/>Re-run Tier-0 prefetch."] chainYearly --> coldBranch t1pre -->|"yes"| coldBranch coldBranch{"daily_row_count == 0<br/>(cold)?"} coldBranch -->|"yes"| coldFan["Cold fan-out:<br/>monthsInWindow restricted to<br/>years present in yearlyByYear"] coldBranch -->|"no"| t1 coldFan --> emptyCheck{"any years left?"} emptyCheck -->|"no"| unchanged["Slice unchanged.<br/>No BRIS POST. No write."] emptyCheck -->|"yes"| t3 t1["Tier-1 (DB only) — yearly drift detector<br/>driftedYears = { y | yearlyByYear[y] - dailyByYear[y] != 0 }"] t1 -->|"empty"| unchanged t1 -->|"non-empty"| t2 t2["Tier-2 (BRIS) — 1 MONTH POST per drifted year<br/>subGroupBy = MISSION_CALL_TIME_MONTH<br/>periodStart=y-01-01, periodEnd=y-12-31<br/>changedMonths = diff vs daily_by_year_month for year"] t2 -->|"empty for year"| nextYear["go to next driftedYear"] t2 -->|"non-empty"| t3 nextYear --> t2 t3["Tier-3 (BRIS) — 1 DAY POST per changed month<br/>subGroupBy = MISSION_CALL_TIME_DAY<br/>periodStart=y-MM-01, periodEnd=last day of month"] t3 --> filterDays["Skip zero-count day cells:<br/>BRIS zero-pads every day, mirrors BRASK skip"] filterDays --> classify["classifyWritesForDailySlice -> toWrite + write_stats"] classify --> upsert["upsert_fact_daily_batch<br/>WHERE row IS DISTINCT FROM EXCLUDED"] upsert --> phaseUpdate["onPhaseChange -> ingest_runs.phase_detail (kind: bris_police_daily_fanout)"] phaseUpdate --> nextMonth["next month"]

    Tier-0 / Tier-1 are shared with BRASK daily. Both adapters call the same fact_daily_slice_ingest_prefetch RPC and read the same yearly_by_year / daily_by_year aggregates. The only behavioural difference is that Tier-1's auto-chain falls back to ingestBrisSlice (not ingestBraskSlice) when fact_yearly is empty for the slice — same contract, different source. Cold-mode fan-out is filtered by yearlyByYear the same way as BRASK daily (§10.3): symmetric behaviour keeps the two adapters drift-free even though BRIS /aggregate is more forgiving than the BRASK form for empty future periods.

    Tier-2 is one BRIS POST per drifted year. parseBrisMonthlySums(raw) sums count across all category buckets (including the -1 "Ikke besvart" bucket, because Tier-3 writes those rows under the same native_name lookup). The result is compared month-for-month against prefetch.dailyByYearMonth filtered to the year via filterDailyByYearMonthForYear (same shape used by BRASK daily). On a stable history Tier-2 returns no drifted months and Tier-3 issues zero POSTs.

    Tier-3 is one BRIS POST per drifted month. The DAY response zero-pads every calendar day in the period (verified by docs/ingestion/adapter-mappings/.live_responses/dataset_12_police_arnested_DAY_2024.json — 366 day buckets per category for 2024, most with count: 0). transformBrisDailyResponse skips count === 0 entries the same way transformBraskDailyTable skips zero cells, so fact_daily only records actual incident days.

    No impossible-day filter. BRIS responds with proper UTC-midnight timestamps for real calendar days only — Feb 30 etc. simply don't appear, so the JS Date.UTC round-trip BRASK uses isn't needed here.

    phase_detail.kind = 'bris_police_daily_fanout'. Same phase set as brask_daily_fanout (tier0_cold_check, tier1_yearly_probe, tier2_monthly_probe, tier3_daily_fetch, write, done) and the same monotonic canonical_progress curve. The Explore polling UI handles both discriminators identically (app/(main)/explore/_lib/ingest-progress.ts, app/(main)/explore/_components/IngestRunDetailSheet.tsx).

    Cost on a 10-year slice, fully stable history.

    TierDB RPCsBRIS POSTsWrites
    0100
    10 (folded into Tier-0 prefetch)00
    2000
    3000
    Total100

    When the latest month changed: Tier-1 surfaces 1 drifted year → Tier-2 sends 1 BRIS POST → Tier-3 sends ≤ 1 BRIS POST per drifted month → the upsert RPC writes only the days whose count actually moved.


    11. References

    • docs/ingestion/catalog-governance-and-reconciliation.md — global ingest policy (catalog, adapters, monitoring) + Mermaid overviews.

    • docs/fire-data-schema/schema-history/README.md — database docs index and migration contract references.

    • docs/fire-data-schema/postgres-contract-rls-and-keys.md — live schema contract authority.

    • docs/ingestion/adapter-mappings/category-native-to-cat-code.md — dim_code / cat_code source-of-truth + Ikke besvart §5.

    • docs/ingestion/adapter-mappings/filter-axes-native-to-filter-id.md — per-axis native ↔ standard filter mappings.

    • docs/ingestion/adapter-mappings/regions-and-cresta.md — canonical region model + source-to-canonical mapping.

    • docs/ingestion/adapter-mappings/source-api-request-examples.md — the 11 verified BRIS slice templates plus BRASK/SSB request shapes.

    • docs/ingestion/adapter-mappings/ssb-pxwebapi-tables.md — SSB PxWebApi tables and region inventory.

    • docs/fire-data-schema/schema-history/brask-ingest-loop-and-invariants.md — BRASK ingestion-loop invariants.

    • docs/fire-data-schema/schema-history/incremental-patches-and-rpc-changelog.md — get_fire_data argument summary (including multi-token filter OR).