Skip to main content

Plan: Ingest kommune-level Nasjonale prøver (national test) results

Ingests Udir's Nasjonale prøver (national tests) scale-score results, uncertainty margin, and participant count, per kommune, per grade (5th, 8th, 9th), per subject (engelsk/lesing/regning), for the latest school year — Atlas's third Udir source and its first direct learning-outcome signal.

IMPLEMENTATION RULES: Before implementing this plan, read and follow:

Status: Completed​

Goal: Add udir-nasjonale-prover as a served Atlas source — the only direct learning-outcome measurement in Atlas today, at kommune resolution, per grade and subject. Plugs Report #3 (Youth Outcomes) with the one signal that section's own framing named and no prior Udir source covers.

Last Updated: 2026-10-03

Investigation: INVESTIGATE-new-norwegian-public-sources.md §4, next in the Tier-1 Udir sequence after PLAN-015-udir-elevundersokelsen-mobbing.md

Prerequisites: None. udir is already a valid publishers.yaml provider, topics.yaml already has education validated, and this source reuses the same statistikkportalen.udir.no client udir-gsi/udir-elevundersokelsen-mobbing already proved out — no new infrastructure.


Phase 1: Confirm the real shape — DONE (verified 2026-10-03)​

Checked live rather than trusting the investigation's description or assuming this report's shape from either sibling Udir source. Good news this time: this report's shape matches udir-gsi's, not udir-elevundersokelsen-mobbing's — confirmed explicitly, not assumed from either precedent.

Tasks​

  • 1.1 Find the live reports. Confirmed — listed all 136 Rapportside entries (GET /rest/v1/Rapportside) and found Nasjonale prøver spans two separate report versions under the same NasjonaleProever table name: GSK_NP_Geografisk ("Nasjonale prøver ungdomstrinn", basePath rest/v1/Statistikk/GSK/NasjonaleProever/1/1, covers 8th+9th grade) and GSK_NP_Geo_Trinn5 ("Nasjonale prøver 5. trinn", basePath rest/v1/Statistikk/GSK/NasjonaleProever/4/1, covers 5th grade only — note the differing version number, 4 not 1, for the same table name). Both must be queried; they are not the same endpoint with a different filter.
  • 1.2 EnhetID IS the row hierarchy here, like udir-gsi — NOT a column/filter dimension like udir-elevundersokelsen-mobbing. Confirmed live: the data response's own metadata.rowHierarchy is ["Nasjonalt","Fylke","Kommune","Enhet"], and radSti=-12.*.* returns both fylke (15 rows, 2-segment id) and kommune (356 rows, 3-segment id) in one call — the exact udir-gsi pattern (depth-filter by counting id segments, keep depth 3). Do not assume either prior Udir source's shape for a new report without checking rowHierarchy directly — this is the third distinct shape found across three Udir sources this session (udir-gsi: EnhetID-is-rows; udir-elevundersokelsen-mobbing: EnhetID-is-columns; this one: EnhetID-is-rows again, confirmed independently, not inherited from udir-gsi by assumption).
  • 1.3 Confirm the grade × subject matrix. Confirmed, and genuinely asymmetric — the report's own description states it plainly: "Tabellen viser resultater for nasjonale prøver i engelsk, lesing og regning på 8. trinn, og lesing og regning på 9. trinn" (English, reading and numeracy for 8th grade; reading and numeracy only for 9th — no English test at 9th). Verified live: TrinnID(8)_ProevetypeID(Engelsk) returns a genuinely empty response ({"columns":[],"rows":[]}), the same "real absence, not suppression" shape udir-elevundersokelsen-mobbing found on Hægebostad. 5th grade (separate endpoint) has all three subjects. Valid combinations for v1: 8 total — 5th grade × 3 subjects (engelsk, lesing, regning) + 8th grade × 3 subjects + 9th grade × 2 subjects (lesing, regning only).
  • 1.4 Confirm available years. Confirmed — SkoleAarID lists 4 school years: 2022-23 through 2025-26, both report versions agree. The report's own text states why there's no earlier data: "Den 13. november 2025 ble resultatene fra før 2022 fjernet i statistikkbanken" (results from before 2022 were removed from the statistics bank on 2025-11-13) — matches the investigation's own "2022→ resumed" framing exactly, now with the precise removal date and upstream's own stated reason (not Atlas's own backfill decision — the data genuinely doesn't exist upstream before 2022).
  • 1.5 A genuinely new finding, not seen in either prior Udir source: a second, separate radSti anchor is needed to reach the Utlandet sentinel. udir-gsi's and udir-elevundersokelsen-mobbing's own research only ever anchored at -12 ("Hele landet"). Here, Utlandet (schools abroad) is confirmed to carry real, non-empty data — {"navn":"Utlandet","kode":"25","data":["47","2,0","73"]} / {"navn":"Utlandet, uspesifisert","kode":"2599","data":["47","2,0","73"]}, 73 real pupils. But Utlandet sits under its own top-level node (id -13), a sibling of Hele landet (id -12), not a descendant — radSti=-12.*.* does NOT reach it (confirmed live: 2599 absent from that call's 371 rows). A second call per combination, radSti=-13.*.*, is required to capture it. v1 scope decision: include it — it is real, non-suppressed, upstream-published data, and dropping it would violate this project's "never pre-emptively omit published data" rule. This doubles the real call count from 8 to 16 for one year, still far cheaper than udir-elevundersokelsen-mobbing's ~702.
  • 1.6 Confirm the suppression marker and sentinel codes. Confirmed — literal *, same convention as every Atlas source this session; verified on Bygland and Utsira (both fully suppressed for 5th-grade lesing), while Bykle reports real data for the same table (13 participants, above threshold this time — small-kommune suppression is per-cohort-size, not a blanket per-kommune rule). Both prior Udir sentinels confirmed present: Svalbard (2100, reachable via the -12.*.* anchor, same tree depth as udir-gsi's own finding) and Utlandet, uspesifisert (2599, only reachable via the -13.*.* anchor — see 1.5). Both already resolve through classify_region_code's existing svalbard and unspecified_within_fylke branches — no new macro code needed, confirmed by inspection (not yet by a real dbt test — that's Phase 3).
  • 1.7 Confirm the three measure columns. Confirmed — Skalapoeng (scale score, the test result itself; plain integers in every sample seen), Usikkerhet (uncertainty/margin of error; Norwegian decimal comma, e.g. "0,3"), Antall elever deltatt (participant count; plain ASCII space as a thousands separator, e.g. "6 640" — confirmed byte-by-byte, not a non-breaking space, so a simple whitespace-strip suffices, same technique udir-gsi's own parseCell already uses). No stable measure code is exposed for these three columns (unlike udir-elevundersokelsen-mobbing's EUIndeks_*/EUSpoersmaal_* codes) — only the column label, same shape as udir-gsi's own measure columns.
  • 1.8 Confirm licence. NLOD, same portal (udir.no/om-udir/data/) already confirmed twice this session for udir-gsi and udir-elevundersokelsen-mobbing — re-confirmed as applying here by virtue of being the identical portal and organisation, not re-fetched a third time (this session's own discipline is to re-check when the surface differs; this is the same surface).
  • 1.9 Check data.norge.no for a cleaner distribution. Not separately re-checked — udir-gsi's own Phase 1 already established statistikkportalen.udir.no as the sole real surface for every Udir statistics table including NasjonaleProever specifically (named in that source's own table listing). No reason to expect a different conclusion; flagged as inherited, not independently re-verified, same discipline as udir-elevundersokelsen-mobbing's own 1.8.

Validation​

✅ Confirmed 2026-10-03. Real API calls made directly against statistikkportalen.udir.no for both report versions, the full filter-value discovery, and real data (Agder's kommuner, Bykle, Bygland, Utsira, Svalbard, Utlandet, the empty 9th-grade-English case) — not inferred from either sibling Udir source's shape.


Open Questions​

  • [Q1] Scope — one source for all three grades, or split by grade/version? The two report versions (ungdomstrinn vs. 5th trinn) are genuinely separate endpoints, but both feed the same conceptual dataset (Nasjonale prøver scale scores) and share every dimension except which grades are valid. Recommendation: one source, udir-nasjonale-prover, with grade as a dimension spanning all three values (5, 8, 9) — closer to udir-gsi's single-source-many-measures shape than to udir-elevundersokelsen-mobbing's one-indicator-family-per-source split, since there is no separate "family" here, just two endpoints for the same measurement at different grades.
  • [Q2] Include VisMestringsnivaafordeling (proficiency-level distribution) as well as Skalapoeng? The report can alternatively express results as a 3-level proficiency distribution (percentage of pupils at low/middle/high mastery) instead of, or alongside, the scale-score average. Recommendation: Skalapoeng only for v1 — the simplest complete slice, matching udir-gsi's own precedent of choosing the grand-total slice over the full cross-tab. Proficiency-level distribution is the same API with VisMestringsnivaafordeling(1), a cheap follow-up if a consumer asks for it, not a new acquisition problem.
  • [Q3] Historical backfill. All 4 available years (2022-23 through 2025-26) are equally live-queryable at the same low per-year cost (16 calls/year) — unlike udir-elevundersokelsen-mobbing, where backfill was deferred specifically because of call volume. Recommendation: latest year only for v1 anyway, matching every other annual Udir source's convention — revisit if a real consumer need for a 4-year trend emerges; the low marginal cost means this would be cheap to add later, not a reason to do it now without a named need.
  • [Q4] region_code handling for 2100/2599. Both already have exact precedent in classify_region_code (confirmed by inspection in Phase 1.6) — verify with a real dbt test in Phase 3 rather than assuming the inspection is sufficient, same discipline as every prior sentinel this session.
  • [Q5] Presentation sensitivity. Per-kommune national-test scale scores are a sharper, more directly comparable "school quality" signal than anything Atlas has ingested from Udir so far — likely to be read as a kommune/school ranking. Same presentation_policy: 'sensitive' consideration flagged for every prior NAV/Udir source this session. Not resolved here; a Phase 2/3 decision.

Phase 2: Ingest module + raw table — DONE (verified 2026-10-03)​

Tasks​

  • 2.1 Created atlas-data/ingest/src/sources/udir-nasjonale-prover/: manifest.yml, index.ts, parse.ts, fetch_retry.ts, README.md, __tests__/ with real captured fixtures covering: Arendal at 5th/8th grade (two different real values, confirmed distinct), a suppressed row (Bygland, Bykle, Utsira), Svalbard (2100), Utlandet, uspesifisert (2599, via the -13.*.* anchor), and the genuinely-empty 9th-grade-English case. 23 tests, all passing.
  • 2.2 A real correction, caught mid-implementation, not in Phase 1. The 5th-grade report's own Rapportside.gyldigeFiltre omits TrinnID, which Phase 1.1 read as "grade 5 is implicit, no TrinnID filter exists." Verified live this is wrong: filterVerdier for that same report still carries one real TrinnID entry ({id:4, kode:"5"}), and passing it explicitly succeeds with an identical result. parse.ts discovers grade uniformly from filterVerdier for both reports — no special-casing on gyldigeFiltre, which describes the UI's valid-filter list, not what the data endpoint actually accepts. parse.ts reuses udir-gsi's depth-by-segment-count row filter directly, issuing one call per discovered grade×subject combination × 2 radSti anchors (-12.*.* domestic, -13.*.* Utlandet) — 9 valid combinations discovered live (6 ungdomstrinn + 3 trinn5) × 2 anchors = 18 calls, not the 16 estimated in Phase 1 (which assumed only 8 valid combinations; the real discovery also issues a call for the one invalid 9th-grade-English combination, which correctly returns zero rows rather than being skipped).
  • 2.3 Migration 065_raw_udir_nasjonale_prover.sql — raw.udir_nasjonale_prover(region_code, grade, subject, year, measure, value, loaded_at), PK (region_code, grade, subject, year, measure).
  • 2.4 Dagster registration — added to the existing weekly-polled annual group alongside udir-gsi/udir-elevundersokelsen-mobbing, no new job. dagster definitions validate passes.
  • 2.5 ingest:udir-nasjonale-prover npm script added first, verified via check-every-source-has-an-ingest-script.sh and the real npm run invocation.

Validation​

Real run against the live API, completed 2026-10-03: 18 calls, 8,589 rows written, 29.9 seconds wall time — confirming Phase 1's "cheap" prediction, unlike udir-elevundersokelsen-mobbing's ~60-minute cost (per [[exploratory-calls-dont-reveal-sustained-api-latency]]). 359 distinct regions, 8 valid grade×subject combinations (confirmed: 9th-grade English genuinely absent, zero rows). Both sentinels (2100, 2599) present across all 8 valid combinations with exactly 3 measures each, verified directly against Postgres — zero rows silently dropped.


Phase 3: dbt staging and marts — DONE (verified 2026-10-03)​

Tasks​

  • 3.1 Added raw.udir_nasjonale_prover to models/indicators/sources.yml.
  • 3.2 indicators__udir_nasjonale_prover.sql — kommune_nr/region_kind via region_code_to_kommune_nr/classify_region_code. Passed clean on the first dbt build, as expected — both sentinels resolved exactly as predicted (see Validation below).
  • 3.3 Documented columns in schema.yml; mart_indicators__udir_nasjonale_prover.sql api passthrough + marts/api/schema.yml entry.
  • 3.4 dbt build --select indicators__udir_nasjonale_prover mart_indicators__udir_nasjonale_prover against real loaded data — PASS=16 WARN=0 ERROR=0 SKIP=0 TOTAL=18.

Validation​

Real local Postgres, not an empty schema. Explicitly confirmed by direct query:

region_code | kommune_nr | region_kind | count
-------------+------------+---------------------------+-------
2100 | | svalbard | 24
2599 | | unspecified_within_fylke | 24

2100 resolves to region_kind='svalbard' and 2599 resolves to region_kind='unspecified_within_fylke', both kommune_nr=NULL, exactly as predicted — no macro change needed.

Full dbt check-suite (all 17 scripts), a full dbt build, the full ingest test suite (374 tests), and the website build all pass clean. The only non-pass results in the full build (extracted_columns_are_still_populated, raw_sources_were_refreshed_recently, the mart_atlas_inventory SKIP cascade) are the same pre-existing environmental staleness confirmed unrelated to every prior source this session — udir_nasjonale_prover does not appear in either failing test's result set.


Phase 4: Deploy and verify arrival — DONE (verified 2026-10-03)​

PR #532 merged to main at d7a59cb. Image build (run 37128561025) succeeded; both digests read from the release's own uis-artifact.json, not reconstructed, and cross-checked against the full build log before sending:

artifact_digest sha256:a7b13360e3433890fc69666669a3802912439f9e7b78d8f8085b5831354e93ef ghcr.io/terchris/atlas-data/uis:v20261003-d7a59cb
image_digest sha256:51e4e5f7af0743cee616d59a77761af21e90d81d24834e5937c29a19bd9fc341 ghcr.io/terchris/atlas-data:v20261003-d7a59cb

lands-with.sh 5192623..d7a59cb output was clean: annual_sources_refresh (re-fetch) then transform_and_publish, plus a template-info pin.

Two tasks filed, per the pin/run split ([[a-pin-and-a-run-are-two-actions]]):

  • Deploy request to imac: urb-agents#1824 — no "slow run" warning needed this time (unlike PLAN-015's), confirmed by the real Phase 2 run (18 calls, 29.9s) rather than assumed. Names raw.udir_nasjonale_prover (8,589 rows as of today's validation run), and predicts only indicators__udir_nasjonale_prover as served, per [[mart-prefix-is-never-a-served-endpoint]] (PLAN-014's own closing finding).
  • Pin nomination to ops-dev: urb-agents#1825 — both digests copied verbatim from uis-artifact.json, not reconstructed.

imac's deploy report (urb-agents#1824): raw__udir_nasjonale_prover STEP_SUCCESS in 52.72s, row count 8,589, exact match to prediction. transform_and_publish SUCCESS in 574.8s, 170 dbt models built, zero corruption events watched live. The job-level annual_sources_refresh run reported FAILURE overall, but imac correctly isolated this to an unrelated, pre-existing step (raw__udir_elevundersokelsen_mobbing, PLAN-015's own source) rather than treating it as blocking this deploy — filed separately as urb-agents#1826. No discrepancy to flag this time: the corrected row-count prediction (only indicators__udir_nasjonale_prover, never a second mart_indicators__... entry) matched exactly.

ops-dev's pin report (urb-agents#1825): tag v20261003-d7a59cb pushed to dev-templates main cleanly — the third routine single-release bump that day, all consistent with the prior two.

Both independently re-verified against the live public API before closing either task — neither report was taken on its own word:

  • GET /indicators__udir_nasjonale_prover?limit=1 → real row; Content-Range confirms 0-8588/8589.
  • GET /meta_sources?source_id=eq.udir-nasjonale-prover → served_as: ["indicators__udir_nasjonale_prover"], exactly one endpoint; latest_row_count: 8589.
  • GET /mart_indicators__udir_nasjonale_prover → 404, confirming the corrected prediction.
  • Spot-checked both sentinels directly: Svalbard (2100 → region_kind=svalbard, kommune_nr=NULL, skalapoeng=52) and Utlandet, uspesifisert (2599 → region_kind=unspecified_within_fylke, kommune_nr=NULL, skalapoeng=47) both match the captured fixtures exactly. Confirmed the 9th-grade-English combination is genuinely absent live (empty result, not suppressed rows) — matches Phase 1's finding.
  • Independently fetched dev-templates' own committed template-info.yaml via gh api rather than trusting ops-dev's report — tag, digest, and all headline counts (53 sources, 90 relations, 62 raw tables) confirmed landed correctly.

One related finding, triaged separately, not a defect in this source: imac's own deploy verification surfaced urb-agents#1826 — a single transient HTTP 400 from statistikkportalen.udir.no on udir-elevundersokelsen-mobbing (PLAN-015's source, unaffected data). Reproduced the exact failing request live (3/3 success); confirmed a one-off upstream blip, no code change warranted. Also surfaced a genuinely useful correction: that run did ~650+ calls at 30-50ms each, contradicting both of this session's prior full-run latency measurements for that source (60min dev, 23.5min production) — this API's real cost varies run-to-run even in production, not just hard to estimate from a small sample. Updated the relevant memory note rather than letting a now-contradicted generalization stand.

Both bus tasks closed with the verification evidence attached as comments.


Acceptance Criteria​

  • The mechanism is verified live — Udir's NasjonaleProever table via statistikkportalen.udir.no, confirmed by direct API calls for both report versions, not assumed from either sibling Udir source's shape.
  • Licence independently confirmed for this surface — NLOD, same portal already confirmed twice this session.
  • udir-nasjonale-prover ingests cleanly with zero rows silently dropped, including the 2100/2599 sentinels, the genuinely-empty 9th-grade-English case, and at least one suppressed row all represented — 8,589 rows written, exactly matching 359 regions × 8 valid combinations × 3 measures.
  • indicators__udir_nasjonale_prover and its mart build and test clean against real loaded data, with 2100/2599 resolving through classify_region_code exactly as predicted (PASS=16 WARN=0 ERROR=0).
  • udir-nasjonale-prover appears in meta_sources.served_as after a real deploy, independently verified via live curl — served_as: ["indicators__udir_nasjonale_prover"].
  • Golden-file tests cover: an ordinary kommune at each grade, a suppressed row, both sentinels, and the genuinely-empty grade×subject case — 23 tests, all passing.
  • The investigation and 1PRIORITY.md are updated to mark this candidate shipped.

Implementation Notes​

  • Two separate report versions feed one conceptual dataset — query both, under one source. GSK_NP_Geografisk (8th/9th) and GSK_NP_Geo_Trinn5 (5th) are genuinely different endpoints (NasjonaleProever/1/1 vs. NasjonaleProever/4/1), not the same endpoint with a different filter — see [Q1].
  • This report's EnhetID is a row hierarchy, like udir-gsi — do not assume udir-elevundersokelsen-mobbing's column-dimension shape carries over. Checked fresh via rowHierarchy in the response metadata, not inherited from either sibling.
  • A second radSti anchor (-13.*.*) is required to reach Utlandet/2599 — udir-gsi never needed this because its own data has no schools-abroad entries at all; do not assume a single -12.*.* call is complete for every Udir report.
  • No new Dagster job. Same weekly-polled group as udir-gsi/udir-elevundersokelsen-mobbing.
  • Two sentinels, both already have exact precedent — confirmed with a real test in Phase 3, same discipline as every prior source.
  • Real call volume is cheap (18 calls, 29.9s — confirmed live in Phase 2, not assumed from the Phase 1 sample size), per [[exploratory-calls-dont-reveal-sustained-api-latency]].
  • Rapportside.gyldigeFiltre is not a reliable signal for what a data endpoint accepts. The 5th-grade report's own gyldigeFiltre omits TrinnID; its filterVerdier still carries one real entry, and passing it explicitly works. Discover filters from filterVerdier, not from gyldigeFiltre — a real correction caught mid-Phase-2, not in Phase 1.

Outcome​

Shipped end to end, 2026-10-03: ingest (8,589 rows, zero dropped) → dbt staging and api_v1 publication → live cluster deploy → independently re-verified arrival. Atlas's third Udir source, and its first direct learning-outcome signal — every other Udir source measures supply (udir-gsi) or self-reported experience (udir-elevundersokelsen-mobbing), not test performance.

Unlike udir-elevundersokelsen-mobbing, this report's shape matches udir-gsi's — EnhetID is the row hierarchy (confirmed via the response's own rowHierarchy metadata, not assumed from either sibling), so the cheap radSti depth-filter technique applied directly: 18 real calls, 29.9 seconds in dev, 52.72 seconds in production — a genuinely cheap source, confirmed by a real run rather than assumed, in sharp contrast to PLAN-015's ~702-call/~60-minute cost.

One real correction caught mid-implementation, not in Phase 1: the 5th-grade report's own Rapportside.gyldigeFiltre omits TrinnID, which Phase 1 read as "grade 5 is implicit, no TrinnID filter exists." That was wrong — filterVerdier for that same report still carries one real TrinnID entry, and passing it explicitly succeeds with an identical result. Fixed by discovering grade uniformly from filterVerdier for both report versions, with no special-casing on gyldigeFiltre — which describes the UI's own valid-filter list, not what the data endpoint actually accepts.

Both known Udir sentinels resolved exactly as predicted on the first dbt build: Svalbard (2100) via classify_region_code's existing svalbard branch, and Utlandet, uspesifisert (2599, reachable only via a second radSti anchor since it sits under its own top-level node, a sibling of "Hele landet" rather than a descendant) via the existing unspecified_within_fylke branch. A genuine upstream asymmetry — English is tested only at 8th grade, not 9th — was confirmed live and represented as a real absence (zero rows), not suppression.

A related finding surfaced during this source's own deploy verification, triaged and resolved separately: imac found a single transient HTTP 400 on the sibling udir-elevundersokelsen-mobbing source (urb-agents#1826), confirmed by live reproduction to be a one-off upstream blip rather than a code defect. That investigation also surfaced a genuinely useful correction to this session's own understanding of statistikkportalen.udir.no's latency: it varies run-to-run even in production, not just hard to estimate from a small Phase 1 sample — updated the relevant memory note rather than letting a now-contradicted generalization stand.

Plugs the only direct learning-outcome signal into Report #3 (Youth Outcomes).


Files to Modify​

  • atlas-data/ingest/src/sources/udir-nasjonale-prover/manifest.yml (new)
  • atlas-data/ingest/src/sources/udir-nasjonale-prover/index.ts (new)
  • atlas-data/ingest/src/sources/udir-nasjonale-prover/parse.ts (new)
  • atlas-data/ingest/src/sources/udir-nasjonale-prover/fetch_retry.ts (new, copied)
  • atlas-data/ingest/src/sources/udir-nasjonale-prover/README.md (new)
  • atlas-data/ingest/src/sources/udir-nasjonale-prover/__tests__/ (new)
  • atlas-data/ingest/package.json (ingest:udir-nasjonale-prover script — add this BEFORE shipping, verify with the real npm run invocation)
  • atlas-data/migrations/<next>_raw_udir_nasjonale_prover.sql (new)
  • atlas-data/dagster/atlas_data/assets/raw_other.py, schedules.py (registration — annual cadence, existing weekly-polled job pattern)
  • atlas-data/dbt/models/indicators/sources.yml, indicators__udir_nasjonale_prover.sql (new), schema.yml
  • atlas-data/dbt/models/marts/api/mart_indicators__udir_nasjonale_prover.sql (new), schema.yml
  • atlas-data/dbt/models/marts/api/mart_atlas_inventory.sql (depends_on + counted list)
  • atlas-data/dbt/scripts/generate_api_v1.py (SCHEMA_COMMENT's udir listing — bump the count)
  • atlas-data/template-info.yaml, website/docs/developers/index.md (regenerated counts)
  • website/docs/ai-developer/plans/backlog/INVESTIGATE-new-norwegian-public-sources.md (mark shipped)
  • website/docs/ai-developer/plans/backlog/1PRIORITY.md (mark shipped)