Atlas Data
Norway's civil-society sector as a REST API — municipality-level indicators of humanitarian need, and the NGOs and local chapters that respond to them.
Installs the Atlas data platform as one application: a PostgreSQL database with its migrations, a Dagster code location running the ingest and dbt transforms, and a PostgREST API over the curated api_v1 views.
The data is an open semantic layer over Norwegian public sources — SSB statistics, FHI public-health and living-conditions indicators, Bufdir child-poverty figures and the Brønnøysund business register — joined to NGO supply data, so that humanitarian need and the organisations responding to it can be queried together at kommune level.
The API is self-describing: meta_sources, meta_endpoints and meta_dimensions let a consumer discover what is available without out-of-band documentation.
A fresh install serves an empty API until the first pipeline run — the schema and grants exist from install, the data arrives on the ingest schedule. Exports api-url for other applications to consume.
Installing does not start anything: the schedules and sensors ship stopped, so no data is fetched until an operator turns them on. That is a deliberate go-live decision, not a side effect of installing. Nothing is ever backfilled — but the timing differs between the two halves and only one of them waits: the schedules wait for their next scheduled time, while the automation sensor that drives the ~40 ingest sources can begin fetching within about a minute of being enabled. Launch the first-data jobs BEFORE enabling automation.
terchrisPrerequisites
Template README
Atlas serves Norway's civil-society sector as a REST API — municipality-level indicators of humanitarian need, and the NGOs and local chapters that respond to them.
The data is an open semantic layer over Norwegian public sources: SSB statistics, FHI public-health and living-conditions indicators, Bufdir child-poverty figures and the Brønnøysund business register, joined to NGO supply data, so that need and the organisations responding to it can be queried together at kommune level.
Installing it with UIS brings up the whole application under one name:
- a PostgreSQL database with its migrations applied,
- a Dagster code location for the ingestion pipelines,
- a PostgREST API over the resulting
api_v1views, - and an exported
api-urlother applications can consume.
What Atlas holds
The catalogue used to describe how Atlas refreshes and not one word about what it refreshes. The
pinned definition now carries a data: block:
| sources | 55 |
| publishers | 10 — Folkehelseinstituttet (21), Statistisk sentralbyrå (18), Utdanningsdirektoratet (4), Brønnøysundregistrene (3), Arbeids- og velferdsdirektoratet (3), Barne-, ungdoms- og familiedirektoratet (2), Husbanken (1), Integrerings- og mangfoldsdirektoratet (1), Lotteri- og stiftelsestilsynet (1), Norges Røde Kors (1) |
| public relations | 92 |
| licence | NLOD for 51 of 55 — the exceptions are nav-aap, nav-helt-ledige, nav-uforetrygd (all CC BY 4.0) and redcross-branches (permissive, Red Cross's own data rather than the state's) |
Norwegian public data at kommune level, joined into one semantic layer.
🔵 The live API is the authority, not these counts. They are regenerated from the repository with a CI check that fails on drift, so they cannot silently rot against the code — but they describe the build, and what is served is a separate question:
GET /meta_endpoints what is queryable
GET /meta_sources every upstream, its freshness, and served_as — the
relations it actually reaches (empty = nothing
published depends on it)
GET /meta_dimensions what each coded column MEANS — read it before
interpreting a code
GET /indicator_summary every published series
⚠️ A count here has exceeded what was actually served. On 2026-09-21, at pin
v20260921-2d88589, the block said public_relations: 19 while the live API was reported serving
16 — three relations 404ing during an unrelated transform_and_publish, not caused by that tag.
Whether that is still true is not something this page can know: it is rendered from a published
artifact, not from a running install, and this repository cannot reach the API to check.
Ask the endpoints above.
🔴 Pinning is not deploying
A pin moves what an install would fetch. It does not move data. The image carries the dbt models
for the newest sources; their rows arrive on a separate transform_and_publish. Pin and run are
two actions, and this catalogue only performs the first.
Install
uis template install atlas
⚠️ A fresh install serves an empty API until the first pipeline run
This is correct behaviour, not a broken install. The schema and grants exist from install; the data arrives on the first pipeline run, which an operator has to start — see the next section. The API answers and returns zero rows until then.
Stated here because it is the state most likely to be read as failure by someone installing for the first time.
The API is also self-describing — meta_sources, meta_endpoints and meta_dimensions let a
consumer discover what is available without any out-of-band documentation.
What happens after you install
Installing starts nothing. The Dagster schedules and sensors ship stopped. No data is fetched, and no external service is contacted, until an operator turns them on. Turning them on is the go-live decision.
🔴 Unresolved as of
v20261002-e9e33a5: this sentence may no longer describe every install. The artifact's ownoperational.automationstill says "ships stopped", quoted faithfully above — buturb-agents #1794measured all three schedules and all three sensors come up RUNNING on a real redeploy of this image, with the automation sensor then firing a real, unprompted run (dagster/from_automation_condition: true) about 15 minutes later. Checked atlas's ownuis/definition for an install-time "stop" hook — none exists;uis/init/001_bootstrap.sqlis one line of schema creation and nothing else. Two explanations fit what was measured: either UIS's own install flow (not exercised by that redeploy, which only swapped a running pod's image) stops things after a freshuis template installand this sentence is still correct for that path; or atlas-data'sdefault_status=RUNNINGchange (PR #482) means a genuinely fresh install now starts itself too, and this sentence is stale. Not settled here — raised with atlas on the nomination that landed this pin. If you install fresh and the schedules come up running on their own, that is this discrepancy, not a bug in your install.
⚠️ Turning them on never backfills — no history is replayed, ever. But the timing differs between the two halves, and only one of them waits:
| what you enable | when it starts |
|---|---|
the schedules — transform_and_publish at 05:00, brreg_transform at :10/:40 | at their next scheduled time |
| the automation sensor, which drives all ~40 ingest sources | within about a minute |
The sensor does not wait for a cron. It evaluates on its own tick: an asset becomes eligible when its cron tick has passed and its dependencies updated since that tick — and on a fresh install every tick is already in the past and nothing has run, so both are trivially true. Enabling at 16:55 on a Friday starts work at 16:55.
Concurrency is bounded by ATLAS_MAX_CONCURRENT_INGESTS, so it is a bounded start rather than 40
simultaneous writers.
⚠️ When those six jobs finish, the install is LOADED but NOT VALIDATED
Finishing the first-data sequence means the data is in. It does not mean the data has been checked. Run the checks before treating the install as good.
✅ The installer now says this too, and that condition is met. As of the pinned build the sentence appears in the install summary itself — between the endpoints block and the load-data instructions, no scrolling, no
template infoneeded. It took four mechanisms and three premature declarations of "delivered" to get one sentence onto a screen, including a truncation this page once described that did not exist.🔵 Kept anyway, as corroboration rather than as the only source. One operator has seen that line on one host. This box costs a reader nothing and stops costing the moment a second person confirms it — so it stands until then, not because the condition failed.
Running them costs 6–15 minutes and looks like a hang. All 685 can be run on demand;
uis dagster run transform_checksworks, but measured launches took 364 s and 885 s just to start. The run sitsNOT_STARTEDfor that whole time and then runs.🔴 That is slow, not stuck — and re-launching because it looks hung is how you get duplicate runs. Wait it out.
⚠️ On a busy cluster it can genuinely time out, and it will not look like a timeout: the 885 s measurement sits against a 900 s ceiling, so a slower start presents as an image-pull or scheduling failure rather than as what it is. A later UIS raises the ceiling.
🔴 On an off-catalogue host, uis template info describes the wrong software
info renders the definition the catalogue pins, not the one your host installed. If you
installed a specific version — uis template install atlas --version …@sha256:… — then info prints
a complete, well-formed, authoritative description of a different build.
It even says so and does not connect the two: the off-catalogue warning prints, and the operational
block on the very next line comes from the other version, silently. Everything in it is affected —
deploys, automation, the first-data list, the cadence table.
⚠️ This page has the same limitation, for the same reason. It mirrors the artifact at the pinned tag. If you are running something else, this page describes the catalogue's build, not yours. Read your own definition — pull the artifact at the digest you installed.
🔵 Why this bites the person it bites: anyone installing off-catalogue is by definition ahead of the pin, which is exactly when they most need the description to be of what they installed.
⚠️ And it disappears when you fix the pin. Once the catalogue points at what the host is running, installed and catalogue agree and the symptom is gone — so re-running the test afterwards shows correct output and invites the conclusion that something merged fixed it. A defect that vanishes when the thing it affects is corrected gets closed as fixed every time anyone looks. It was caught here only because a blob had been pulled and kept while the two disagreed.
🔴 Launch the first-data jobs BEFORE enabling automation. Enable first and the sensor decides the
order: brreg_change_feed will start, find no watermark, and fail loudly until brreg_bootstrap
has run. That is by design rather than a fault — but it is noise nobody needs, and it is avoidable by
doing the two in the right order.
First ingest loads roughly 4.1 million rows across 64 raw BASE TABLEs and 94 marts BASE
TABLEs (plus 80 marts views), from 55 sources. That is imac's measured 2,906,194 plus the
1,173,878-record Enhetsregisteret bulk load, so the row count is arithmetic on two measured figures.
The combined wall time is measured — ~30 minutes, 1772 s end to end — and is stated once, in
the cold-install table below, rather than repeated here.
A "table" here means a BASE TABLE; views are excluded and counted separately. That rule is stated because two figures in the artifact used to disagree with each other and with the database while no counting method was written down anywhere.
Once schedules are on, Atlas polls on this cadence (Europe/Oslo) — mirroring
operational.cadence in the artifact at pin v20261003-37769d0:
Every row names the job that owns it, and that is not decoration — see the warning below the table.
| when | job | what |
|---|---|---|
| Sunday 02:00 | annual_sources_refresh | ~42 annual public-sector sources — SSB, FHI, Bufdir, Husbanken |
| 1st of month, 01:00 | klass_refresh | SSB Klass classifications (kommune/fylke) |
| 1st of month, 01:00 | monthly_sources_refresh | Sources whose manifest declares periodicity P1M — nav-uforetrygd, nav-aap, nav-helt-ledige. Same monthly cron as klass_refresh; a separate job because this data is genuinely monthly, not annual data polled monthly. New since v20260922-d077fd7 — not previously on this page. |
🔴 The artifact contradicts itself about this job, and this page has to pick a side. first_data.jobs lists monthly_sources_refresh third, between klass_refresh and seed_sources_refresh — that array is what the install logic actually reads. But this row's own what text ends: "Not in first_data.jobs — adding it there would change which jobs the measured ~30-minute cold-install figure covers, and that figure has not been re-measured with this source included." The array says it is there. The prose about the same job says it is not. Both verified by parsing the artifact, not by reading one and assuming the other agrees.
This page follows the array — first_data.jobs is what the installer executes, the cadence.what text is description — which is why the seven-job table above includes it and the 1772 s floor caveat below applies to it. If the prose is the one that's right, the floor caveat is misdirected at a job that shouldn't be timed as part of a cold install at all; if the array is right, the prose is simply stale. Raised with atlas on urb-agents#1815/#1814 rather than guessed at.
| Every 30 min (:00, :30) | brreg_change_feed | Brønnøysundregistrene change feed — only what moved, ~114 records per cycle at the measured 3.8 changes a minute. Appends to raw only; rebuilds nothing and cannot affect the public API |
| Every 30 min (:10, :40) | brreg_transform | Brreg reconciliation into marts — ONE incremental model, ten minutes behind each poll. Rows are inserted and deleted, never the table replaced, so no api_v1 view is disturbed |
| Daily 04:00 | brreg_change_feed | Frivillighetsregisteret — a full re-walk of ~72,800 organisations (~727 requests) |
| Daily 05:00 | transform_and_publish | dbt transform and publish — no external calls. ⚠️ The only scheduled job that rebuilds marts, and therefore the only one during which api_v1 views are recreated — each restored immediately after its own mart, so the gap is milliseconds per view |
🔴 Two rows, one job — and reading them as one thing has already cost real time. The 04:00
Frivillighetsregisteret row and the half-hourly feed row are the same job, brreg_change_feed,
with different sources and different automation conditions. Two agents independently read the Brreg
rows as a single job that both polled every half hour and rebuilt marts, and one of them costed a
public-API outage at 48× its real rate and nearly weighed a cadence rollback against it (urb-agents
#780, #786).
The ten-minute offset is deliberate. The reconciliation runs at :10/:40 rather than alongside
the feed because firing on the same tick would reconcile data the feed had not yet written, leaving
the dimension permanently one cycle behind.
⚠️ Frivillighetsregisteret is daily and did not move to the feed's cadence. It has no change feed, so every refresh is the whole register — ~727 requests. At half-hourly that would be ~35,000 requests a day against a public-sector API, a 48× increase on someone else's service. The two Brreg ingests are on separate schedules for that reason and must stay that way.
The external services Atlas calls are SSB (Statistics Norway), FHI (Folkehelseinstituttet), Bufdir and Brønnøysundregistrene. All are public-sector APIs. The cadence is deliberately conservative — an annual statistical table is polled weekly, not nightly, because fetching annual tables every night would be roughly 15,000 pointless requests a year against services Atlas depends on staying welcome at.
One source is deliberately unscheduled (redcross-branches) pending a credential. It never
self-triggers; invoking it by hand on a cluster without the private data repo fails, and that is
correct. ⚠️ frr no longer appears in the artifact's unscheduled list at all — it was named
here through every pin since at least d077fd7; checked directly in this bump and it is simply
absent now, not moved elsewhere. Not investigated further — flagging the removal rather than
guessing why.
⚠️ The cadence lives in the image, not in this entry. It changes when the pinned tag changes,
with nothing here to review. If that matters to you, diff cadence.py between tags.
Getting data on day one
Enabling the schedules does not backfill. Every schedule is on_cron, which means next fire —
so a Thursday install waits until Sunday 02:00 for raw data, and until the 1st for the monthly
sources. The API stays empty in the meantime, with nothing to explain why.
To get data immediately, launch these seven jobs from the Dagster UI, serially, in exactly this order:
| order | job | ~time |
|---|---|---|
| 1 | annual_sources_refresh | 474 s (7.9 min) |
| 2 | klass_refresh | 1.0 min |
| 3 | monthly_sources_refresh | not re-measured — see below |
| 4 | seed_sources_refresh | 0.8 min |
| 5 | brreg_bootstrap | 501 s (8.4 min) |
| 6 | brreg_change_feed | not stated |
| 7 | transform_and_publish | 484 s (8.1 min) |
A cold install is ~30 minutes — 1772 s wall, measured end to end on a factory-reset cluster
(urb-agents #1027). brreg_bootstrap is no longer the unmeasured part.
⚠️ Seven jobs, not six, as of monthly_sources_refresh (added 2026-10-01 for nav-uforetrygd,
and now also covering nav-aap and nav-helt-ledige) — and the 1772 s total has NOT been
re-measured with it included. The artifact's own words: "nav-uforetrygd is one xlsx download and
parse, so the real addition to wall time is expected to be small, but that is an expectation, not a
measurement." Treat 1772 s as a floor, not the figure, until someone re-times a cold install.
⚠️ Rows 1, 5 and 7 are from the cold-install measurement; rows 2, 4 and 6 are carried from the earlier four-job run and the artifact does not restate them; row 3 has no measurement at all yet. They are left as they were rather than rescaled, because a number invented to make a table look consistent is worse than a number from a different run that says so. The measured jobs do not sum to the total — the jobs vary far more than the total suggests, which is the point of stating the total separately.
⚠️ An earlier figure of ~11 minutes is still in circulation and understates a cold install by nearly 3x. It was the measurement of the first four jobs (#507: 11.1 min, 2,906,194 rows) and it predates
brreg_bootstrapentirely. A run that looks stalled at 8 minutes may be normal — run the check command rather than waiting, since it reports what has been pulled against what has been applied.
The order is not arbitrary, for two separate reasons:
seed_sources_refreshcontainsraw/_migrationsand runs fourth, so the migrations apply after three source jobs have already written. That is safe because they are idempotent, but reordering has not been tested.brreg_change_feedruns immediately afterbrreg_bootstrapbecause the bootstrap seeds the feed's watermark from the snapshot's own date. The feed has nothing to start from until the bootstrap has run — and run first, it fails loudly rather than silently walking history: it refuses to start without a watermark.
Together these produce ~4.1M rows across 64 raw and 94 marts BASE TABLEs (plus 80 marts
views) from 55 sources — the 1,173,878-record Enhetsregisteret bulk load on top of imac's
measured 2,906,194.
🔴 The raw BASE TABLE figure is hand-maintained, not generated, and had drifted stale before this bump.
uis/generate-holdings.pywrites themarts/viewsfigures; it has no substitution for therawcount (confirmed 2026-10-03 — the script does not mention "raw" at all), so a human types it. It read (56) while the true count was 59, beforenav-helt-ledige's migration made it 60 — caught here, not by the generator. CI (render-template-info.sh) checks it againstatlas-data/migrationsdirectly and would fail a wrong number, but fixing it is still manual until a substitution exists.
✅ RESOLVED in
v20260921-a8d5d1a, at the cause rather than the symptom. Through1a569dcthis field contradicted itself three ways: headline 81 marts against a counting rule reading "46 + 16 = 62"; "eight models land in marts as views" one clause after the 10 it was explaining; and a closing "matchimac's live measurement exactly (61 / 5 / 66 / 52)". It now reads models=63 plus seeds=18 — which is 81, agreeing with its own headline — views=10 named once, and the exact-match claim retired with a sentence giving the reason: "a stale claim of exact agreement is worse than no claim, because it invites the reader to stop checking." Every figure in the field is now written byuis/generate-holdings.py, none by hand.
🔴 That fix also corrected something THIS page was repeating. This page said the figures were "derived and gated by
uis/render-template-info.sh". The artifact now states that credit was wrong — the generator isuis/generate-holdings.py— and the wrong name was on this page because it had been quoted faithfully from the field it described. A quotation inherits the errors of its source, and nothing available here could have caught it: verifying that a page matches an artifact says nothing about whether the artifact is right about itself.
🔴 This page said 61 and 5 for three pins, and the caveat that stood here misquoted the artifact to do it. It attributed "61 marts BASE TABLEs (plus 6 marts views)" and "five" to
install.first_load— strings that appear in none of2d88589,fcf78e6or1a569dc. The headline has read 80, now 81, and 10 since at least2d88589. What this page had latched onto was the artifact's own stale closing line,61 / 5 / 66 / 52: a caveat written to flag a numeric disagreement was quoting the wrong side of it. ⚠️ Found by parsing the field, not by grepping the file — these are folded YAML scalars, so a phrase search across raw lines matches nothing and reads exactly like absence.
✅ The two FIELDS in the artifact do agree, which is the part that used to be broken. Earlier pins had
install.first_loadsaying 60 marts whilefirst_data.takessaid 64, and this page used 64 with a note saying why. Both now say 81, and both moved together from 80 infcf78e6— so the cross-field disagreement really is fixed. The disagreement inside one of those fields outlived the one between them, and is resolved separately above. This box used to add "matchingimac's live measurement exactly"; that phrase was the artifact's own closing line — the one naming61 / 5 / 66 / 52— so repeating it here was repeating the contradiction rather than reporting it.
⚠️ Do not read "~11 minutes" as the total. That figure is imac's measurement of the four jobs
that existed when it was taken — annual_sources_refresh, klass_refresh, seed_sources_refresh
and transform_and_publish — on a clean cluster (10.8 min, 2,906,194 rows, zero failures; urb-agents
#507, #520). It predates both Brreg jobs. ⚠️ This paragraph used to end by calling the
combined wall time "unknown rather than estimated", fifty lines below a table stating it as 1772 s
measured end to end. Both sentences were on this page at once for several pins. The total is
measured — ~30 minutes — and brreg_bootstrap is 501 s of it; what remains unmeasured is narrower
than the old sentence claimed: the bootstrap's write time separately from its 210 MB / 52 s
download, and brreg_change_feed, which the artifact still does not state.
(Stated by naming the four jobs rather than "the first four", because that phrase silently stopped being true when the list grew from four to six.)
🔴 brreg_bootstrap is the one most likely to be skipped
It has no schedule and no automation condition, on purpose. Re-running a 1.17M-record bulk load against a populated database is the only genuinely destructive-looking operation in this pipeline, so nothing self-triggers it. Run it once, here.
- Skipping it leaves the organisation register empty, with nothing saying why.
- Running it twice is safe — it upserts and never truncates — but pointless.
It is deliberately not in the unscheduled list further down: that list means cannot run.
brreg_bootstrap is the opposite — it must run once, on day one, and then be left alone.
Maintainer note — do not trim the paragraph above as duplication. It restates, in this page's own words, why nothing self-triggers
brreg_bootstrap. That looks redundant with the artifact'sfirst_data.how, and the redundancy is deliberate:first_data.howdoes not render at the end of an install — onlyuis template infoshows it — so an operator about to run the chain may see this page's reason and nowhere else's (urb-agents #802).This is the opposite call to the troubleshooting block, which this page deliberately does not copy, and the difference is rot. A copied remedy goes stale silently when its source changes; a sentence in this page's own voice about a property that does not change — a 1.17M-record bulk load must not self-trigger — stays true whether or not the artifact still says it. Copy nothing that has a current source; keep your own words for what would otherwise have none.
brreg_change_feed is different again — run it once here, then leave it to its schedule
Unlike the bootstrap, the change feed is scheduled: once automation is on it runs daily at 04:00. You launch it by hand exactly once, in the sequence above, because a fresh install has no data for the first nightly run to apply a delta to.
So the three kinds of job on this page are not interchangeable:
| runs by itself? | run by hand on day one? | |
|---|---|---|
| the five source/transform jobs | yes, on their crons | yes |
brreg_bootstrap | never — no schedule, no automation condition | yes, exactly once |
brreg_change_feed | yes, nightly at 04:00 | yes, once, after the bootstrap |
redcross-branches | no — parked, cannot run | no |
⚠️ This list mirrors
operational.first_datain the artifact at pinv20261003-37769d0. It is duplicated here, by hand, because as of that pinuis template inforenders none of the artifact'soperationalblock, so this page is the only place an operator can read it. It is therefore capable of going stale on the next bump — the artifact is the source of truth. Generating this section from the artifact at authoring time is the intended fix; seePLAN-application-catalogue.md.
If a run fails, the artifact tells you what to do
The install definition carries a troubleshooting block, and UIS renders it at install as of
1.6.67 — so the remedy reaches the operator rather than living only on this page. It covers four
failure modes that have actually happened:
brreg_bootstrapreporting success while writing nothing — re-run to repair the register, it and the transform both report success and the API still serves the old data;- the bootstrap terminating part-way through the download, and retrying getting less far each time rather than more;
- a transform failing with a dbt schema error naming
dim_brreg_enhet, after an upgrade adds a column to an incrementally-materialised model; and - the public API returning 404 even after the database has been repaired and the view confirmed to exist with rows and grants.
⚠️ One place where this page and the artifact currently disagree, and this page is the correct one.
Both described the schema-change failure as "the source and target schemas on this incremental model
are out of sync". imac ran the upgrade and the model's own watermark subquery hits the missing
column before dbt's schema-change handling runs, so dbt never emits that message. This page now
quotes what is actually printed; the artifact — and therefore what uis template info renders — still
carries the older wording until atlas corrects it. If the product shows you the other sentence, the
remedy is the same one.
Otherwise the authoritative text is the artifact's, not this page's. It is deliberately not copied here: it
is long, it is precise about which database user must run the rebuild and about the ordering that
people miss, and a stale copy of a 02:00 remedy is worse than a pointer to a current one. Read it from
uis template info atlas, or from the definition at the pinned digest.
Installing alongside an atlas that is already running
If a tenant is already deployed, installing under the same name configures the same database and overwrites the running code location. To bring up a parallel tenant instead, pass a different application name:
uis template install atlas --param app_name=atlas-t
app_name isolates everything — database, secret prefix, url prefix and code location —
so an existing tenant is untouched. Verified by imac on urb-agents #481.
Upgrading an existing install
An upgrade happens when this catalogue entry's pin moves and you re-install. What that costs you depends on the release, and it is not always nothing.
✅ What the recent pins add, and the one gap left
meta_sources.served_as (from v20260921-1a569dc) — the first published way to ask what does
this source actually reach? An empty array means nothing published depends on that source. It is
named in data.discover as of v20260921-a8d5d1a, so this page no longer hand-carries it: the
endpoint list above is the artifact's own. ✅ This block previously warned that served_as appeared
nowhere in the install definition and would need re-checking. That was raised on urb-agents#1352 and
the artifact now documents it — the warning is retired because the condition was met, not because
it aged.
🔴 18 of 26 dead series recovered (v20260921-a8d5d1a). Series reporting
kommuner_with_value = 0 fell 26 → 8. The ssb-crime-tables series moved from latest_year
2025 → 2014 with coverage 0 → 64–83 kommuner — and the reason is the useful part: SSB table
08487 carries 16 LovbruddKrim codes, 7 current and 9 legacy, and the legacy vocabulary stopped
in 2013–14. Those series were never empty. They were being reported at a year they had stopped
covering, which reads identically to missing data from the outside. Verified at SSB by ops-dev.
served_as is now computed from the corrected lineage seed, which moved unattributed_totals
from 1 source to 32; served_as empty is still exactly 5. A downstream consumer deleted a
hand-maintained list that had been wrong twice in five hours.
Earlier: 783,104 rows recovered in ssb-06913 from a regex that never matched, and an embedding
check kept as a standing invariant.
✅ ssb-06913 IS CLOSED at v20260921-a657f4f — all eight series at 357. The seven that
returned nothing now report latest_year 2025 with 357 of 357 kommuner, Folkemengde holds
at 2026, and indicator_missing_kommuner for the source went 2,499 → 0. Those 2,499 values
were in the fact table the whole time and are now served. Spot cells were predicted from SSB
before the run and matched to the digit — 0301 Dode 2025 = 4 033, 1103 = 940, 5001 = 1 329,
0301 Folkemengde 2026 = 728 714 — and acceptance was measured twice against two different
dim_kommune versions (1,158 codes and 1,170), identical both times. One FK check cleared, none new,
reproduced by an independent sensor run.
🔵 Worth keeping: this page held the question open rather than adopting the plausible
answer. The February/flow-series explanation for those seven series was consistent with everything
observable from outside, and was not the cause. The cause was that latest_year did not restrict to
the kommuner a relation serves, while SSB zero-fills municipalities dissolved in 1957–59 — Hopen (1915-1959) reporting 0 deaths in 2026, and a zero is not a null, held the whole source at
2026.
⚠️ ONE GAP REMAINS: ssb-12063. It is now the only series left at
kommuner_with_value = 0 (down from 8). KOSfritidredleie0000 returns nothing while SSB holds
1,812 values across 2015–2018 — 408 kommuner in 2018 alone — none of which reached the fact
table. A different defect from the one just fixed, and not explained by it. Open with atlas on
urb-agents#1351.
🔴 v20260922-d077fd7: a missing value is not a zero, and reading it as one moves help away from small places
This is the most consequential thing on this page and it is not about the catalogue. FHI suppresses small counts: it hides a cell under roughly 6 cases, and hides the whole series once more than 20% of its cells would be suppressed — explicitly so that a partial series cannot give a skewed impression of that kommune.
🔴 So a consumer that reads a gap as "no need here" produces exactly the skew the suppression exists to prevent — and it does so in the direction that moves help away from the smallest places. A need index built on those series cannot tell suppressed from zero unless something tells it, and the demo consumer said it could not.
Both surfaces now say so:
| field | before | after |
|---|---|---|
indicator_summary.properties.kommuner_with_null.description | 145 chars | 1,788 — SUPPRESSION present |
indicator_missing_kommuner.description | 714 chars | 1,357 — LOW NEED present |
⚠️ And the limit that stops the rule being misapplied: SSB does NOT suppress — it
ZERO-FILLS. Same column, two publishers, opposite conventions, and neither reading transfers.
A zero from SSB is a measurement; a gap from FHI is a withheld measurement; and the page that
already says "a zero is not a null" about latest_year is making the same distinction from the
other direction. Check which publisher a series comes from before interpreting an absence —
/meta_sources names it.
✅ v20260922-d077fd7 also landed a release that had never been deployed
One publish_api_v1 covered two releases: d077fd7 itself and 3072f1f, which had been built
and never deployed. That second one is why both defects this page reported in operational.upgrade
are already fixed here — the dangling operational.crons pointer now reads operational.cadence
below, and transform_checks is declared under a new operational.sensor_triggered with an
after: and a why: instead of appearing only in prose. 🔵 Verified by parsing this
artifact, not by reading the claim: operational.crons appears nowhere in it and no crons key
exists.
operational.sensor_triggered also states the chain a transform actually sets off, which this page
had only implied: publish_api_v1 after transform_and_publish ("dbt drops the api_v1 views
by CASCADE when it swaps the marts tables, so they exist again only after this runs"), then
api_v1_checks, then transform_checks — the last with the fact worth knowing on its own:
transform_and_publish deliberately excludes tests, so a green transform contains none.
🔴 v20260922-6cb28f4: the OpenAPI now warns you about count=exact before you use it
The field docs for brreg_enhet grew from 958 to 1,730 characters, and the 772 added characters
are a latency warning that had existed only in bus messages and a website page:
| before | after | |
|---|---|---|
definitions.brreg_enhet.description | 958 chars | 1,730 |
count=exact, Prefer, count=planned, CORS | all absent | all present |
- A filtered
count=exacttakes 12–33 s, against sub-second without the header. - ⚠️ Inside a browser
fetchthat is a timeout, not a slow answer — in the consumer's own words, a request that never completes is indistinguishable from a blocked one, so this reads as CORS or a dead endpoint and gets debugged as the wrong problem. count=plannedis 50× faster and was 6× wrong — 118 against an actual 716.
🔵 This is the one pin so far whose entire deliverable lives in the database, which is why
it needed publish_api_v1 and no transform and no checks. It is the cheap upgrade path this page
has described since fcf78e6, being the whole of a release rather than a footnote to one.
🔴 RETRACTED: the "49–88 s historical band" this page quoted for publish_api_v1 is
withdrawn, and no band replaces it. The figure was inherited through nominations and nobody
recorded which clock it measured. There are two, and they disagree by 20–40% on every run:
| run | tool clock | run-storage clock |
|---|---|---|
6cb28f4 | 72.9 s | 103.8 s |
d077fd7 | 107.4 s | 150.3 s |
⚠️ So the band cannot be checked, only chosen. If it was tool-clock, 6cb28f4 was inside
it; if storage-clock, 6cb28f4 was already over and this page said it was inside. ops-dev retired
the number rather than defend it (urb-agents#1382) and asked that this page drop it, which it has.
🔵 A duration with no named clock is the same class of claim as a measurement with no named
build — and this page has now shipped one of each.
✅ v20260922-ff164d5: published ranges now match the rows they sit beside
min_value and max_value were computed over every kommune the fact table holds, while the counts
printed next to them were scoped to active non-sentinel kommuner. So 33 of 222 series published
a range over kommuner the relation does not serve — a number that looked like data and described a
different population from the one it was labelled with.
| before 00:43Z | after 00:57Z | |
|---|---|---|
| series where published min/max disagreed with the rows | 33 | 0 |
| series | before | after |
|---|---|---|
ssb-06913 Folkemengde | 0 .. 728714 | 219 .. 728714 |
ssb-06913 Dode | 0 .. 4033 | 2 .. 4033 |
ssb-09429 PersonerProsent_01 | 0 .. 49.1 | 15.1 .. 49.1 |
ssb-crime-tables 0__AnmLovbrPer1000 | 3.9 .. 236 | 5.4 .. 57.3 |
Measured from outside by paging all 79,254 fact rows with the count asserted, folding per series and comparing against all 222 — and independently on the host by the operator.
🔵 The two things that could have gone wrong and did not, which are the interesting part:
ssb-06913Levendestill hasmin = 0, max 9287, 357 kommuner. A fix that excluded zeros along with sentinels would have produced a tidier table, passed a casual read, and silently deleted the fact that a kommune had no live births. The consumer checkedLevendebefore flagging every zero, and that restraint became the acceptance test.ssb-12063KOSfritidredleie0000keeps its row, withminNULL,maxNULL and count 0 — present and empty, not dropped. An absent row and an empty row answer different questions.
⚠️ And "the other 189 series are unchanged" here is a COMPARISON, not an absence of
noticing. All 222 were snapshotted before the deploy and diffed numerically after, so 13 and
13.0 could not read as a change. That distinction is why the claim is repeated on this page at all.
✅ v20260922-6cb28f4 was measured the same way, and more strictly: the 222-series snapshot
taken before the job was diffed byte for byte against the one after — all 222 rows identical,
"not a value, not a count, not a year." 🔵 A snapshot taken before is a different class of
evidence from a spot-check taken after, and it is the difference between "nothing moved" and "I
did not see anything move".
⚠️ Two things this pin must NOT be read as fixing
1. #394 is not observable on the deployed host, and this page does not assert its effect.
89a774e is titled for the six two-digit codes claiming to be fylker and is in this range — but
after install, klass_refresh and transform, those six still violate with exactly 3,648 rows:
21 22 23 25 26 88. A re-ingest cannot help: raw.ssb_klass_fylker carries 01..20, 30..56
and 99, and none of the six. 🔵 That is not a claim the commit is broken — only that its
stated effect is not visible where it was looked for, which is a different statement and the one the
evidence supports.
2. 9999 "Uoppgitt" is an active kommune, and it is NOT a regression in this release. A code
whose name means unspecified carries is_active = true, because both raw rows have NULL validity
and an open end reads as current. Norway has 357; any denominator of "active kommuner" is 358.
It was reported as introduced by the Klass re-ingest and it was not — the pre-rebuild measurement
settles it:
| when | dim_kommune | is_active = true |
|---|---|---|
| 22:25Z, before the rebuild | 1,158 codes | 358 |
| 00:30Z, after it | 1,170 codes | 358, unchanged |
So the twelve new codes arrived inactive (1,170 = 358 + 812) and 9999 was active beforehand. ✅
A pre-existing defect found while verifying a release is not a defect in it, and that distinction
is the difference between pinning this and holding it. It does not touch the numbers above:
indicator_summary filters not f.kommune_is_sentinel on both counts, so the sentinel cannot reach
kommuner_with_value or kommuner_with_null. Both with atlas.
Nothing regressed: ssb-crime-tables 18 series at 2014 and 14 at 2025, zero empty, coverage
64–83; kommune_befolkning_alder 357 rows; served_as = [] the same five; mixed sources exactly
two; and the inverse defect absent — Folkemengde held at 2026, Oslo 728,714.
🔴 After upgrading to this pin, two things look broken and are not
Both self-heal, both are documented, and an upgrader who does not know them will think the upgrade failed. Item 1 was measured on a real upgrade over a loaded install, not a cold one. Item 2's exit code is read from the script's source and has not been measured on this build — the distinction is kept because the last time this section blurred it, a single measurement was carried across four pins as though it were current.
1. The served documentation lags until you publish. COMMENTs live in the database, so the
OpenAPI field docs track the last build that published. 🔴 If you are coming from anything
older than v20260921-fcf78e6, this item is the whole point of taking the pin: the window-year
descriptions arrived in fcf78e6 and live in the DATABASE, so installing without running the publish
serves the OLD text with every signal green until the 05:00 publish — up to 24 hours. (That framing
said "this pin is largely a descriptions release" while pinned to fcf78e6, and was carried
unchanged into 1a569dc, which is not one — the descriptions are inherited, not new here.) Immediately after installing, the api_v1
checks measured 2 pass / 2 fail:
uis dagster run publish_api_v1
After that, 4 of 4 succeeded.
⚠️ Every per-job duration on this page is an ORDER OF MAGNITUDE, not a tripwire. They were
quoted from bus nominations and none of them names the clock it was read from; publish_api_v1
alone has been reported here at ~67 s, 72.9 s and 107.4 s, and the two clocks differ by 20–40% on
the same run. Do not gate anything on them. The cold-install total further up is different — the
artifact states its own method for that one ("1772 s wall, measured end to end on a factory-reset
cluster") — and what actually settles "did something else run?" is a before-and-after diff of
indicator_summary, not a stopwatch.
2. Until the first transform runs, the check reports a fault — and it self-heals.
marts.mart_source_freshness does not exist until a transform builds it, so the check has nothing to
read. Let one transform_and_publish run (~376 s) and it returns exit 0 — measured at 29
bounded sources, 29 within cadence.
On an upgrade, where marts were built by an earlier build but the view is missing, that is
WARN, not CANNOT: a fault waiting on a transform is not a host where nothing has ever run. The
old code made no such distinction, resting on an assertion that a missing view "means no transform
has ever run here" — which is false on every upgrade. ⚠️ Read the WARN as what the
source says, not as a measurement: nobody has run the check on this build inside the pre-transform
window. The one live run available is EXIT=0 on a healthy install, which does not exercise this
case, and the operator who produced it said so rather than offering it as the answer.
✅ The "exit 2, NOTHING WAS CHECKED" claim that stood here is RETIRED — and it had been stale
for five days and four pins. The check has had three states since 2026-09-16: OK, WARN
and CANNOT, combined by a worst() rule — "CANNOT dominates WARN dominates OK; not-asked is OK,
asked-and-unanswerable is CANNOT" — with WARN reachable from six distinct sites in the script.
🔴 How the claim went stale is the part worth keeping. The exit 2 figure was measured
once, on v20260916-e439668, and then carried unchanged through 1709934, 6e10058, 2d88589
and fcf78e6, still introduced by the words "on the currently pinned build only" — false four
times over by the end. The tri-state rework landed the same day, in a later build, and this page
documented one of its consequences while still saying the split had not demonstrably landed: the
dark-Automation exit 2 → exit 0 described below is that rework. The contradiction sat in two
sections of this page for four pins. A measurement carried forward is a quotation, not a
measurement, and it needs the build it was taken on written beside it. The retirement finally came
from reading the script — which was possible the whole time, and dated in its own comment.
🔵 Item 1's cause was established by intervention. Item 2's was established by reading the script — stronger than the behavioural test this page had been waiting for, and available five days earlier than it was used.
🔴 Taking this pin is THREE steps, and the middle one is not optional
install v20260922-d077fd7 → uis dagster run klass_refresh → uis dagster run transform_and_publish
🔴 Skip the middle step and the release does nothing, with every signal green. The whole
point of this build is 3e72760, which widens the Klass ingest window from 1960 back to 1950 —
and the twelve orphan kommune codes all dissolved on or before 1960, so the old window could
never see them. The ingest was not failing; it was not looking far enough back.
transform_and_publish rebuilds mart_dim_kommune from raw.ssb_klass_kommuner but never
re-fetches it, so deploy-then-transform rebuilds the same stale rows. In the operator's words:
"two of those three, and it does nothing."
With the re-ingest in the right order:
| before | after | |
|---|---|---|
raw Klass codes | 1,327 | 2,073 |
dim_kommune | 1,158 | 1,170 |
ssb-06913 kommune FK violations | 7,296 rows / 12 codes | 0 / 0 |
🔴 LEFT UNRUN, THE MIDDLE STEP DOES NOT STAY MISSING — IT SELF-HEALS, WHICH IS WORSE.
klass_refresh has a cron of 0 1 1 * *: 01:00 on the 1st of each month. So 3e72760 was not
inert forever. It was inert for nine days, and on 1 October the 7,296-row FK failure would
have cleared on its own, with nobody having changed anything and no record of why. ⚠️ A
defect that repairs itself on a schedule is not a mild defect — it is one that cannot be found by
noticing it stopped happening. atlas had been treating the missed re-ingest as a one-off ordering
mistake it kept repeating; the class self-heals, so it only became visible because four parties
looked at the same source on the same night. That is not a repeatable detection method, which is
why the trigger now lives in the artifact rather than only the remedy (self_healing_warning, urb-agents#1373).
🔵 Both halves of that were already on this page, 360 lines apart. The cadence table above
says klass_refresh runs on the 1st at 01:00; this section says the fix needs klass_refresh. The
conclusion — "then an operator who skips it gets a silent repair up to a month later" — needed no
new information and was not drawn. Same shape as the check-note contradiction three pins ago: two
sections that together state something neither states alone.
✅ THE HAND-CARRY IS OVER — this section is now derived, not carried. For three pins the
sequence above existed only in bus messages, and this page said it would stop "when a build
carrying operational.upgrade is PINNED". That build is pinned. The rule below mirrors
operational.upgrade in the artifact at pin v20261003-37769d0, unchanged since d077fd7:
| the artifact's rule | |
|---|---|
| default for an upgrade | transform_and_publish alone — "an upgrade already has data" |
| the exception | a release that changes what an ingest fetches — its date window, its filters, its dimensions |
| why | "the transform cannot recover rows that were never requested, so that source's ingest job must run FIRST" |
| then | the owning ingest job → transform_and_publish → transform_checks |
| if you skip it | "an ingest-window change left unrun is repaired by that source's next scheduled cron — up to a month for klass_refresh. The symptom disappears on its own and the cause is never recorded. Re-run it deliberately." |
🔵 That is a RULE with a stated trigger, not another job list — which is why it will outlive this pin. A per-release list of jobs has to be maintained by the person most likely to have just forgotten the step; a rule asking "did this release change what an ingest fetches?" keeps working on releases nobody has written yet. The Klass sequence above is now an instance of this rule rather than a special case, which is the whole reason it was worth waiting for.
⚠️ Two pointers in that field do not resolve, and this page substitutes its own. The field
says "operational.crons above maps every job to what it covers" — there is no
operational.crons key; the map is operational.cadence, which is the cadence table further up
this page. And it ends the sequence with transform_checks, which is not in that map at all (nor
in manual_only or unscheduled). Both verified by walking the parsed artifact. Reported on
urb-agents#1377 — neither changes what an operator should do, and both would send a reader looking
for something that is not there.
🔴 Kept as the record: this page was wrong about the artifact for three pins. It said
"the artifact does not contain it … nothing in the install definition mentions klass_refresh."
The artifact named it twice all along — first_data.jobs lists it second, before
transform_and_publish, and the monthly cadence row names it, so a first install following
first_data.jobs already got the right order. This repository's own verifier prints that job list
on every pin run, so the refutation was on screen each time the claim was repeated. What was
genuinely missing was narrower: no concept of an UPGRADE at all — first_data describes a
first install and nothing addressed a host that already has data. Corrected on urb-agents#1373.
⚠️ The --full-refresh requirement below is SEPARATE and still applies. It arrived with
v20260914-72999a4; an operator who has not yet taken 72999a4 needs everything in this section as
well, and this pin does not retire it.
⚠️ Whether a case of the same kind is now in play is STILL an open question, and the honest
answer is that this page does not know. atlas described v20260921-fcf78e6 as adding a
window_years column, and everything in fcf78e6 is in the pin above — so an operator taking
1a569dc over a loaded install inherits that column whether or not they ever installed fcf78e6. A
column added to an incrementally-materialised model with on_schema_change='fail' is precisely the
failure documented below. The artifact says nothing about window_years in any of the three builds,
and "atlas shipped no new upgrade instruction" and "no new instruction is needed" look exactly
the same from here. Asked on urb-agents#1341 and not yet answered — carried rather than quietly
dropped, because an unanswered question that disappears at the next pin reads as an answer. If you
are upgrading a loaded install, assume a full refresh may be needed and read the failure signature
below before concluding the release is broken.
The pins through 2026-09-13 were text-only or additive — you re-installed and the data was untouched.
This one is not. It adds a column to an incrementally-materialised model, and dim_brreg_enhet
runs with on_schema_change='fail'.
1. A full refresh. Without it the transform fails with
Database Error in model dim_brreg_enhet
column "snapshot_loaded_at" does not exist
LINE 209: select coalesce(max(snapshot_loaded_at), '-infinity'…
which is the model doing exactly what it is configured to do, not a broken release.
dbt build --full-refresh --select dim_brreg_enhet+
⚠️ Note the trailing +: descendants, not ancestors. dim_brreg_enhet+ rebuilds what depends on
the changed table, which is what you need. +dim_brreg_enhet is the opposite selector and does not.
⚠️ Run it as the atlas database user, not as a superuser. A full refresh drops and recreates, so
the tables take the running user's ownership — and a superuser leaves the next run with "permission
denied".
Measured cost for this selector: 528 s dbt-reported, 536 s wall (imac, on a real upgrade;
PASS=30 WARN=1 ERROR=0). ⚠️ A figure of ~256 s or "about four minutes" refers to the model alone.
dim_brreg_enhet+ additionally runs 29 data tests and a view model, so it is roughly twice that. The
number and the selector have to travel together.
2. A publish — and it is a different operation, not part of the refresh. This release changes
COMMENTs, and comments live in the database, not in the image. Without a publish the served
OpenAPI field documentation stays as it was while the build that produced it has moved on.
🔴 There is no CLI affordance for materialising a single asset. uis dagster run takes jobs,
not assets, so uis dagster run api_v1 answers "No job named 'api_v1'". From the host you have two
options and neither is the one-liner you might expect:
uis dagster run transform_and_publish— works, and rebuilds every mart rather than just publishing, so it costs more than the operation needs; or- materialise the
api_v1asset in the Dagster UI, which is the narrow operation.
Both. A refresh is not a publish.
⚠️ A UIS 1.6.84 floor, and what happens below it
The current pin declares env_from_services, which hands a consumer Atlas's API address resolved
from the named service rather than copied from a host-facing export.
env_from_services:
ATLAS_POSTGREST_URL: postgrest # a SERVICE id, resolved in-cluster
This is the fix for a real defect, and it was measured inside a running pod before being pinned:
ATLAS_POSTGREST_URL | result | |
|---|---|---|
| before | http://api-atlas.localhost | exit 2, CANNOT — .localhost is loopback (RFC 6761), so the pod dialled itself |
| after | http://atlas-postgrest.postgrest.svc.cluster.local:3000 | script exit 0, uis template check atlas → healthy |
⚠️ Port 3000 is the data API. 3001 is the admin server and 404s a table read.
exports.api-url is unchanged and still host-facing — correctly, because it is for hosts.
🔴 What this makes worse for a host below 1.6.84
env_from_services needs UIS 1.6.84 to be read at all, and the old env_from_exports key is
gone. So an un-upgraded host loses the variable entirely rather than getting a wrong one:
| UIS | what the host gets |
|---|---|
| 1.6.84+ | the in-cluster address — fixed |
| 1.6.83 and below | key ignored, and there is no fallback key any more → ATLAS_POSTGREST_URL unset → the check reports CANNOT, naming the variable |
For an un-upgraded host this is "wrong value, explained" becoming "no value, explained". Both are CANNOT, neither is a health regression, and the variable is named either way — which is why the previous pin, a diagnostic and not a fix, was worth taking: the "explained" half arrived with it.
⚠️ There is a population outside this fleet. The registry is fetched unauthenticated and lists
atlas today; nobody can make those hosts upgrade. ./uis pull moves a host to latest.
⚠️ What has not been observed
- A run pod's rendered environment. The value is declared correctly in the container context and has been read from a pod — but never from inside a run pod.
- A plain install on a host that took this catalogue's word for the pin. Until this pin moved that
was impossible:
uis template install <id>takes an id only, with no--tagor--digest, so a nominee cannot be installed through the supported surface until it is already pinned. The verification behind this pin used a locally repointed registry cache — the install was real, the catalogue read was not — and that was disclosed rather than glossed. - Scope. One application, one cluster: k3s v1.25.16 on Rancher Desktop, zero IngressRoutes.
What uis template check atlas actually reports
The check asks whether a deletion Brreg has published has been applied by the transform, not whether one is merely outstanding:
| situation | verdict |
|---|---|
deletions pending, awaiting the next transform at :10/:40 | OK — "not a fault" |
| a deletion applied and the organisation still served by the API | WARN — a real fault, at any age |
It reports both at once when both are true, labelled differently, so a real fault is not hidden by
routine pending work. The question has no clock in it, so it cannot drift when the :10/:40 offset
is tuned — and that offset is deliberate and therefore tunable.
✅ The check now tells you when nothing is running
This used to be a false all-clear. With the pipeline switched off, the check reported healthy, exit 0, and said "awaiting the next transform" when no transform was scheduled — the reassuring half of the table above printed for the one reason it could not cover. Fixed as of this pin, and verified in both the total and the partial case:
state what it says all five instigators stopped names them, other instigators running 0, "Nothing is scheduled to reconcile the register"one stopped, four running "…not yet applied — brreg_transform_half_hourlyis STOPPED, nothing will apply them",other instigators running 4⚠️ The partial case is the one a whole-fleet check would have missed — it names the single stopped instigator while four others still run. That is the case that had no fix at all on the previous pin.
Enable automation with
uis dagster automation --start.uis dagster automationlists the instigators and their state.
⚠️ If you gate on the check's exit code, it changed — on 2026-09-16, not in this build
A "dark" Automation block — where the check cannot reach Dagster to ask — returns exit 0. It
returned exit 2 up to and including v20260916-e439668. This heading said "in this build" for
four pins after the change, which is how a contract change reads as news long after anyone could
still act on it: if you are coming from any build from 2026-09-16 onward you already have this,
and if you are coming from an earlier one it is a change you have not yet absorbed.
The three states an exit code can carry, since that same rework:
| exit | state | means |
|---|---|---|
| 0 | OK | asked and answered, or not asked at all |
| 1 | WARN | a real fault, including one that self-heals on the next transform |
| 2 | CANNOT | asked and unanswerable — the check could not establish the answer |
CANNOT dominates WARN dominates OK, so one unanswerable block sets the whole exit code.
That is deliberate: an exit code reporting the tool's own wiring is not a health gate. But it is a contract change, and anything of yours that treats non-zero as "unhealthy" will now pass where it previously failed.
✅ The build says so when it happens — the printed output names the condition at the moment it
occurs, and the EXIT CODES docstring names the supported way to tighten it if you need the old
behaviour. Check the printed phrase on the raw stream if you grep for it; colour sequences can
break a naive match.
⚠️ The check is never proactive — it answers, it does not warn
| how you reach it | does it run? |
|---|---|
uis template check atlas | yes, immediately |
| a shell in the pod | yes |
| anything on a schedule — Dagster, a log, a sweep | no |
The only invoker is the check command. Nothing runs it on a clock, so this is never "a log nobody reads" — and it is also never something that tells you first. Do not read "better diagnostics" as "I will be told." It reaches a human exactly when a human asks.
🔴 And what the check asks is not everything worth asking. Three scope defects were found
on 2026-09-21–22 — counts scoped differently from the range beside them, a null filter scoped
differently from its subject, and latest_year scoped differently from the counts. All three were
found from outside, by consumers. None was caught by a gate, and the suite reported 990 pass / 18
fail before and after each one. ⚠️ A green check means the questions it asks were answered
well, which is a narrower statement than "the data is right". A gate for the class is being written
upstream; until it exists, treat a consumer noticing something as the detection mechanism it has
actually been.
Why the earlier releases being silent is the hazard
Every atlas upgrade before this one required no operator action. ⚠️ That is a property of those releases, not of atlas upgrades — and it is the more dangerous half of this page, because three silent upgrades in a row teach an expectation that the fourth violates. An operator who meets the schema error without warning reads it as a broken release.
Two surfaces answer two different questions, and this is the earlier one:
| you are asking | read |
|---|---|
| should I take this upgrade, and what will it cost? | this page, before you re-install |
| a run just failed — what now? | uis template info atlas, which renders the artifact's troubleshooting block, and the install output itself |
When a release needs operator action, this section will say so and name the cost, because by the time the install output tells you, you have already committed to it.
What this directory is, and is not
This directory is a catalogue pointer, not the application. There is no atlas source
code here. The install definition is the OCI artifact named in template-info.yaml:
ghcr.io/terchris/atlas-data/uis:<tag>@<digest>
The tag and digest are deliberately not repeated here. They live in source: in
template-info.yaml and nowhere else on this page, because a second hand-maintained copy of a
digest is a second thing to forget: this block sat on v20260909-853c696 for twelve days and
several releases, reading like the current pin to anyone who trusted it.
UIS pulls the digest. The tag is shown to people and never pulled by, because a tag can
be re-pointed at a registry and a digest cannot. Bumping atlas therefore means editing
source.digest in a reviewed change — that review is the only thing establishing that a
human approved this artifact.
The full reasoning lives in uis-applications/README.md in the dev-templates repository —
referenced as a path rather than a link, because this page is rendered on the documentation
site where a relative link to a repository file does not resolve.
params: and provides: are not listed here on purpose. They live in the artifact's
own template-info.yaml; there is one source of truth for what atlas installs and it is
not this repository.
Provenance of this entry
The artifact coordinates above are atlas's published values as verified by imac against
the real registry (urb-agents #481) — the 853c696 artifact, which superseded 4b11f3f
partway through that test round.
The three services listed above are not inferred: they are the plan
uis template install atlas --dry-run produces from this pin, as run by tor-agent against the
published registry (urb-agents #486) — deploy postgresql, configure postgresql,
configure postgrest --schemas api_v1 --url-prefix api-atlas, deploy postgrest, deploy dagster,
the atlas-data code location, then deploy dagster again.
The entry's description and abstract are atlas's own words, supplied on urb-agents #489.
Change them only on atlas's word.
⚠️ Two earlier versions of this description were wrong, and the sequence is worth keeping:
dev-templatesfirst wrote "Norwegian business-register data", inferred from thebrreg_enhetertable in the install records.- Corrected to "the Norwegian NGO sector, in one place" from the repository's own one-liner — better, but still not the framing atlas uses.
- atlas supplied the text above and explained why the first attempt was worst:
brreg_enheteris one raw table out of 47. FHI contributes 21 and SSB 17. Brønnøysund supplies organisation identity — how an NGO gets a stableorgnr— so leading with it described the smallest source as though it were the subject.
The lesson recorded rather than the words: a domain inferred from a table name reads plausibly and is not evidence. The prose in this repository is a stand-in until the owning application supplies its own.