' link by parsing its date,
+ never matches[0].
+ """
+ resp = requests.get(GOV_UK_PAGE, timeout=30)
+ resp.raise_for_status()
+ csv_links = re.findall(
+ r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.csv)"',
+ resp.text,
+ )
+
+ months = {
+ 'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6,
+ 'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12,
+ 'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'jun': 6,
+ 'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12,
+ }
+ parsed_links = []
+ for link in csv_links:
+ normalized = link.lower().replace('-', '_')
+ if 'latest_inspections_as_at' not in normalized:
+ continue
+ match = re.search(r'as_at_(\d{1,2})_([a-z]+)_(\d{4})', normalized)
+ if match:
+ day, month_str, year = match.groups()
+ month = months.get(month_str)
+ if month:
+ try:
+ parsed_links.append((datetime(int(year), month, int(day)), link))
+ except ValueError:
+ continue
+ parsed_links.sort(reverse=True)
+ if parsed_links:
+ return parsed_links[0][1]
+ if csv_links:
+ return csv_links[-1]
+ matches = re.findall(
+ r'href="(https://assets\.publishing\.service\.gov\.uk/[^"]+\.ods)"',
+ resp.text,
+ )
+ return matches[0] if matches else None
+```
+
+(`mi_url` config still wins when set — `self.config.get("mi_url") or discover_csv_url()` is unchanged.)
+
+- [ ] **Step 3: Parse and guard the date in staging**
+
+In `stg_ofsted_inspections.sql`, inside the `renamed` CTE after the `rc_sixth_form` line, add:
+
+```sql
+ -- Start date of the latest FULL inspection (the report-card
+ -- inspection in the renewed framework). Guarded below: only kept
+ -- when the row actually carries report-card grades, because in
+ -- legacy-format files this column is the legacy inspection date.
+ to_date(nullif(trim(rc_inspection_date), 'NULL'), 'DD/MM/YYYY') as rc_inspection_date_raw,
+```
+
+and replace the final select:
+
+```sql
+select
+ *,
+ case
+ when rc_safeguarding_met is not null
+ or rc_inclusion is not null
+ or rc_curriculum_teaching is not null
+ or rc_achievement is not null
+ or rc_attendance_behaviour is not null
+ or rc_personal_development is not null
+ or rc_leadership_governance is not null
+ then rc_inspection_date_raw
+ end as rc_inspection_date
+from renamed
+where inspection_date is not null
+```
+
+- [ ] **Step 4: Propagate through int + mart**
+
+Add `rc_inspection_date,` to the explicit column lists of `int_ofsted_latest.sql` and `fact_ofsted_inspection.sql` (after `rc_sixth_form`). Do NOT propagate `rc_inspection_date_raw`.
+
+- [ ] **Step 5: Parse-check dbt**
+
+Run: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .`
+Expected: parse OK, no compilation errors.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add pipeline/plugins/extractors/tap-uk-ofsted pipeline/transform/models
+git commit -m "feat(pipeline): carry the report-card inspection's own date; pick newest MI file in discovery"
+```
+
+---
+
+### Task 3: Report-card date in the API and UI
+
+**Files:**
+- Modify: `backend/models.py` (FactOfstedInspection)
+- Modify: `backend/data_loader.py` (`_ofsted_block`)
+- Test: `backend/tests/test_supplementary_enrichment.py` (extend the existing `_ofsted_block` tests)
+- Modify: `nextjs-app/lib/types.ts` (OfstedInspection)
+- Modify: `nextjs-app/components/compare/CompareOfsted.tsx` ("Inspected" measure)
+- Test: `nextjs-app/__tests__/components/CompareOfsted.test.tsx`
+
+**Interfaces:**
+- Consumes: `marts.fact_ofsted_inspection.rc_inspection_date` (Task 2).
+- Produces: API `ofsted.rc_inspection_date: string | null` (ISO date). UI rule: report-card displays are dated with `rc_inspection_date` only; when null, show "—" (never the legacy date).
+
+- [ ] **Step 1: Failing backend test**
+
+In `backend/tests/test_supplementary_enrichment.py`, alongside the existing `_ofsted_block` tests, add (reuse the file's existing fake-row helper/style):
+
+```python
+def test_ofsted_block_carries_rc_inspection_date():
+ o = _fake_ofsted_row( # use this file's existing fake/stub construction
+ overall_effectiveness=None,
+ ungraded_grade=2,
+ rc_achievement=1,
+ rc_inspection_date=date(2026, 2, 3),
+ inspection_date=date(2021, 10, 7),
+ )
+ block = _ofsted_block(o, 138690)
+ assert block["rc_inspection_date"] == "2026-02-03"
+ # The legacy inspection date is still present, unchanged.
+ assert block["inspection_date"] == "2021-10-07"
+
+
+def test_ofsted_block_rc_inspection_date_none_when_absent():
+ o = _fake_ofsted_row(overall_effectiveness=1, inspection_date=date(2021, 10, 13))
+ block = _ofsted_block(o, 136276)
+ assert block["rc_inspection_date"] is None
+```
+
+Run: `uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_supplementary_enrichment.py -q`
+Expected: FAIL (KeyError / AttributeError on `rc_inspection_date`).
+
+- [ ] **Step 2: Backend implementation**
+
+`backend/models.py`, in `FactOfstedInspection` after `rc_sixth_form`:
+
+```python
+ # Start date of the report-card inspection itself (renewed framework,
+ # Nov 2025+). Null for rows without report-card grades.
+ rc_inspection_date = Column(Date)
+```
+
+`backend/data_loader.py` `_ofsted_block`, after the `"inspection_date"` entry:
+
+```python
+ "rc_inspection_date": (
+ o.rc_inspection_date.isoformat()
+ if getattr(o, "rc_inspection_date", None)
+ else None
+ ),
+```
+
+(`getattr` default keeps old test stubs working.) Run the backend suite; expected: PASS.
+
+- [ ] **Step 3: Failing frontend test**
+
+`nextjs-app/lib/types.ts`, in `OfstedInspection`, after `inspection_date`:
+
+```ts
+ /** Start date of the report-card inspection itself (Nov 2025+); null otherwise. */
+ rc_inspection_date?: string | null;
+```
+
+In `nextjs-app/__tests__/components/CompareOfsted.test.tsx`, add to the existing suite (reusing its fixture style):
+
+```tsx
+it('dates a report card with the report-card inspection date, never the legacy date', () => {
+ const ofsted = reportCardOfsted({
+ inspection_date: '2021-10-07',
+ rc_inspection_date: '2026-02-03',
+ });
+ render();
+ expect(screen.getByText(/3 Feb 2026/)).toBeInTheDocument();
+ expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
+ expect(screen.queryByText('4+ years ago')).toBeNull();
+});
+
+it('shows an em dash when a report card has no rc_inspection_date yet', () => {
+ const ofsted = reportCardOfsted({ inspection_date: '2021-10-07', rc_inspection_date: null });
+ render();
+ expect(screen.getByText('—')).toBeInTheDocument();
+ expect(screen.queryByText(/7 Oct 2021/)).toBeNull();
+});
+```
+
+(`reportCardOfsted` = the file's existing report-card fixture builder, or build inline matching its other tests.) Run just this file; expected: FAIL.
+
+- [ ] **Step 4: Frontend implementation**
+
+In `CompareOfsted.tsx`, replace the body of the "Inspected" measure's map:
+
+```tsx
+ {schools.map((school, i) => {
+ const ofsted = data[String(school.urn)]?.ofsted;
+ // A report card is dated by its OWN inspection date. The legacy
+ // inspection_date belongs to an older inspection and must never
+ // be shown against a report card (report cards exist only from
+ // Nov 2025).
+ const dateIso =
+ displays[i].kind === 'report_card'
+ ? ofsted?.rc_inspection_date ?? null
+ : ofsted?.inspection_date ?? null;
+ const age = yearsSince(dateIso);
+ return (
+ |
+ {formatInspectionDate(dateIso)}{' '}
+ {age != null && age > 4 && 4+ years ago}
+ |
+ );
+ })}
+```
+
+- [ ] **Step 5: Run frontend checks**
+
+Run: `cd nextjs-app && npx tsc --noEmit && npm test`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/models.py backend/data_loader.py backend/tests nextjs-app/lib/types.ts nextjs-app/components/compare/CompareOfsted.tsx nextjs-app/__tests__/components/CompareOfsted.test.tsx
+git commit -m "fix(compare): date report cards with their own inspection date, never the legacy one"
+```
+
+---
+
+### Task 4: Census-based context benchmarks; kill the FSM fallback
+
+**Files:**
+- Create: `pipeline/transform/models/marts/fact_census_benchmarks.sql`
+- Modify: `pipeline/transform/models/marts/_marts_schema.yml`
+- Modify: `backend/models.py` (new `CensusBenchmark` model)
+- Modify: `backend/data_loader.py` (`compute_benchmarks`)
+- Modify: `backend/app.py` (compare endpoint call site, only if the signature change requires it)
+- Test: `backend/tests/test_benchmarks.py`
+- Modify: `nextjs-app/components/compare/CompareCommunity.tsx:36`
+- Test: `nextjs-app/__tests__/lib/compareLogic.test.ts` or the community section's existing test home (add a fallback-removal test where the FSM chip logic is tested today)
+
+**Interfaces:**
+- Consumes: `marts.fact_pupil_characteristics` (urn, year, phase_type_grouping, total_pupils, fsm_pct, eal_pct).
+- Produces: `marts.fact_census_benchmarks` — one row per phase (`'primary'`/`'secondary'`), columns `phase, year, fsm_pct, eal_pct, median_pupils`. `fsm_pct`/`eal_pct` are **pupil-weighted means** (so they approximate the national pupil-level rate, answering the expert's objection to school-median anchors). API `benchmarks.{primary,secondary}` keeps its existing keys; `fsm_pct`/`eal_pct`/`median_pupils` now come from this mart; `disadvantaged_pct` becomes primary-only (the KS2-column median was junk for secondary).
+
+- [ ] **Step 1: dbt mart**
+
+Create `pipeline/transform/models/marts/fact_census_benchmarks.sql`:
+
+```sql
+{{ config(materialized='table') }}
+
+-- Mart: state-school context benchmarks from the pupil census — one row per
+-- phase, latest census year. Computed at import time (never per request).
+-- fsm_pct / eal_pct are pupil-weighted means, i.e. "what % of pupils", not
+-- "the median school" — this matches how DfE quotes national FSM/EAL rates.
+-- Consumers must label these "state-school average (computed from our
+-- dataset)" (spec §8.6), never "England average".
+
+with latest as (
+ select max(year) as year from {{ ref('fact_pupil_characteristics') }}
+),
+
+classified as (
+ select
+ case
+ when p.phase_type_grouping ilike '%primary%' then 'primary'
+ when p.phase_type_grouping ilike '%secondary%' then 'secondary'
+ end as phase,
+ p.total_pupils,
+ p.fsm_pct,
+ p.eal_pct,
+ l.year
+ from {{ ref('fact_pupil_characteristics') }} p
+ join latest l on p.year = l.year
+ where p.total_pupils is not null and p.total_pupils > 0
+)
+
+select
+ phase,
+ max(year) as year,
+ round((sum(fsm_pct * total_pupils) filter (where fsm_pct is not null)
+ / nullif(sum(total_pupils) filter (where fsm_pct is not null), 0))::numeric, 1) as fsm_pct,
+ round((sum(eal_pct * total_pupils) filter (where eal_pct is not null)
+ / nullif(sum(total_pupils) filter (where eal_pct is not null), 0))::numeric, 1) as eal_pct,
+ round(percentile_cont(0.5) within group (order by total_pupils))::integer as median_pupils
+from classified
+where phase is not null
+group by phase
+```
+
+Add a `fact_census_benchmarks` entry to `_marts_schema.yml` in the file's existing style (name + description; column tests only if sibling marts have them).
+
+Run: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` — expected PASS.
+
+- [ ] **Step 2: Failing backend test**
+
+In `backend/tests/test_benchmarks.py` add:
+
+```python
+def test_benchmarks_use_census_mart_for_context(monkeypatch):
+ census = {
+ "primary": {"year": 202425, "fsm_pct": 25.3, "eal_pct": 21.8, "median_pupils": 240},
+ "secondary": {"year": 202425, "fsm_pct": 24.1, "eal_pct": 18.9, "median_pupils": 980},
+ }
+ result = compute_benchmarks(_sample_df(), census_benchmarks=census)
+ assert result["primary"]["fsm_pct"] == 25.3
+ assert result["secondary"]["eal_pct"] == 18.9
+ assert result["secondary"]["median_pupils"] == 980
+ # KS2-only columns must not produce a fake secondary disadvantaged anchor.
+ assert result["secondary"]["disadvantaged_pct"] is None
+
+
+def test_benchmarks_context_none_when_mart_missing():
+ result = compute_benchmarks(_sample_df(), census_benchmarks=None)
+ assert result["primary"]["fsm_pct"] is None # never silently fall back
+```
+
+(`_sample_df()` = this file's existing dataframe fixture.) Run the file; expected: FAIL (unexpected keyword `census_benchmarks`).
+
+- [ ] **Step 3: Backend implementation**
+
+`backend/models.py` (next to the national-average models):
+
+```python
+class CensusBenchmark(Base):
+ """State-school context benchmarks from the pupil census — one row per phase."""
+ __tablename__ = "fact_census_benchmarks"
+ __table_args__ = MARTS
+
+ phase = Column(String(20), primary_key=True)
+ year = Column(Integer)
+ fsm_pct = Column(Float) # pupil-weighted mean
+ eal_pct = Column(Float) # pupil-weighted mean
+ median_pupils = Column(Integer)
+```
+
+`backend/data_loader.py` — change the signature and `_block`:
+
+```python
+def compute_benchmarks(df: pd.DataFrame, census_benchmarks: dict | None = None) -> dict:
+```
+
+Inside, keep `_median` and `_weighted_disadvantaged` as-is, and replace `_block` with:
+
+```python
+ def _block(sub, phase, with_disadvantaged):
+ census = (census_benchmarks or {}).get(phase) or {}
+ block = {
+ # Context measures come from the census mart (pupil-weighted):
+ # the performance df has no fsm_pct, and its eal/disadvantaged
+ # columns are KS2-only — medianing them for "secondary" produced
+ # junk anchors from the handful of all-through schools.
+ "eal_pct": census.get("eal_pct"),
+ "sen_support_pct": _median(sub, "sen_support_pct"),
+ "disadvantaged_pct": _median(sub, "disadvantaged_pct") if with_disadvantaged else None,
+ "fsm_pct": census.get("fsm_pct"),
+ "median_pupils": census.get("median_pupils"),
+ }
+ if with_disadvantaged:
+ block["disadvantaged_rwm_expected_pct"] = _weighted_disadvantaged(sub)
+ return block
+```
+
+and the return:
+
+```python
+ return {
+ "source": "state-school average (computed from our dataset)",
+ "year": int(latest_year),
+ "primary": _block(prim, "primary", with_disadvantaged=True),
+ "secondary": _block(sec, "secondary", with_disadvantaged=False),
+ }
+```
+
+In `backend/app.py`'s compare endpoint, load the mart and pass it (same defensive style as the national-averages queries):
+
+```python
+ census_benchmarks = None
+ try:
+ rows = db.query(CensusBenchmark).all()
+ if rows:
+ census_benchmarks = {
+ r.phase: {
+ "year": r.year,
+ "fsm_pct": r.fsm_pct,
+ "eal_pct": r.eal_pct,
+ "median_pupils": r.median_pupils,
+ }
+ for r in rows
+ }
+ except Exception:
+ db.rollback()
+ ...
+ "benchmarks": compute_benchmarks(df, census_benchmarks=census_benchmarks),
+```
+
+(Import `CensusBenchmark`; use the endpoint's existing db session pattern.) Run the backend suite; expected: PASS (update any existing benchmark tests that asserted the old median-sourced fsm/eal values).
+
+- [ ] **Step 4: Frontend — remove the cross-definition fallback**
+
+`nextjs-app/components/compare/CompareCommunity.tsx:36`:
+
+```tsx
+ const anchor = bench?.fsm_pct ?? null;
+```
+
+If the FSM chip has unit coverage, update/add the case: `anchor` null ⇒ no verdict chip rendered (bare value only). Run `cd nextjs-app && npx tsc --noEmit && npm test` — expected PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add pipeline/transform/models/marts backend/models.py backend/data_loader.py backend/app.py backend/tests/test_benchmarks.py nextjs-app/components/compare/CompareCommunity.tsx nextjs-app/__tests__
+git commit -m "fix(compare): census-sourced FSM/EAL benchmarks; never fall back across measure definitions"
+```
+
+---
+
+### Task 5: Official KS4 national averages
+
+**Files:**
+- Modify: `pipeline/plugins/extractors/tap-uk-ees/tap_uk_ees/tap.py` (new stream, registered in `discover_streams`)
+- Create: `pipeline/transform/models/staging/stg_ees_ks4_national.sql`
+- Modify: `pipeline/transform/models/staging/_stg_sources.yml` (add raw table `ees_ks4_national`)
+- Modify: `pipeline/transform/models/marts/fact_ks4_national_averages.sql` (rewrite)
+- Modify: `backend/models.py` (Ks4NationalAverage docstring), `backend/app.py` (`_national_averages_payload` — remove the computed fallback)
+- Test: `backend/tests/test_national_averages_marts.py`
+
+**Interfaces:**
+- Consumes: EES data-catalogue CSV `https://explore-education-statistics.service.gov.uk/data-catalogue/data-set/1b649e16-01e8-435b-a814-56be2faf9054/csv` (columns verified: `time_period, geographic_level, establishment_type_group, breakdown_topic, breakdown, attainment8_average, progress8_average, engmath_95_percent, engmath_94_percent, ebacc_entering_percent, ebacc_95_percent, ebacc_94_percent, ebacc_aps_average, …`).
+- Produces: `marts.fact_ks4_national_averages` with the SAME columns as today (so `Ks4NationalAverage` needs no schema change), now holding official DfE figures; `gcse_grade_91_pct` is NULL (not in the official series — the England anchor for that measure disappears, which is correct: it was noise).
+
+- [ ] **Step 1: Tap stream**
+
+In `tap.py`, after the KS2 national stream, add:
+
+```python
+# ── KS4 National Headlines (national level only — one row per year) ──────────
+# Dataset: "National characteristics summary data" (Key stage 4 performance).
+# Official England state-funded headline measures, 2018/19 → latest.
+# Suppressed values ('z', 'x') → NULL downstream. Progress 8 is legitimately
+# absent in years with no KS2 baseline (e.g. 2024/25) — that is DfE policy,
+# not missing data.
+
+_KS4_NATIONAL_CSV_URL = (
+ "https://explore-education-statistics.service.gov.uk/data-catalogue/"
+ "data-set/1b649e16-01e8-435b-a814-56be2faf9054/csv"
+)
+
+_KS4_NATIONAL_COL_MAP = {
+ "attainment8_average": "attainment_8_score",
+ "progress8_average": "progress_8_score",
+ "engmath_94_percent": "english_maths_standard_pass_pct",
+ "engmath_95_percent": "english_maths_strong_pass_pct",
+ "ebacc_entering_percent": "ebacc_entry_pct",
+ "ebacc_94_percent": "ebacc_standard_pass_pct",
+ "ebacc_95_percent": "ebacc_strong_pass_pct",
+ "ebacc_aps_average": "ebacc_avg_score",
+}
+
+
+class EESKs4NationalStream(Stream):
+ """National KS4 headline averages — one row per academic year.
+
+ Filters to geographic_level == 'National', establishment_type_group ==
+ 'All state-funded', breakdown_topic == 'Total', breakdown == 'Total'
+ so only the England-wide all-pupils row per year is emitted.
+ """
+
+ name = "ees_ks4_national"
+ primary_keys = ["time_period"]
+ replication_key = None
+
+ schema = th.PropertiesList(
+ th.Property("time_period", th.StringType, required=True),
+ *[th.Property(out, th.StringType) for out in _KS4_NATIONAL_COL_MAP.values()],
+ ).to_dict()
+
+ def get_records(self, context):
+ import pandas as pd
+
+ self.logger.info("Downloading KS4 national headlines: %s", _KS4_NATIONAL_CSV_URL)
+ resp = requests.get(_KS4_NATIONAL_CSV_URL, timeout=60)
+ resp.raise_for_status()
+
+ df = pd.read_csv(io.BytesIO(resp.content), dtype=str, keep_default_na=False)
+ df.columns = [c.strip().lower() for c in df.columns]
+
+ for col, want in [
+ ("geographic_level", "national"),
+ ("establishment_type_group", "all state-funded"),
+ ("breakdown_topic", "total"),
+ ("breakdown", "total"),
+ ]:
+ if col in df.columns:
+ df = df[df[col].str.strip().str.lower() == want]
+
+ self.logger.info("Emitting %d national KS4 rows", len(df))
+ for _, row in df.iterrows():
+ record = {"time_period": row.get("time_period", "").strip()}
+ for src, out in _KS4_NATIONAL_COL_MAP.items():
+ record[out] = row.get(src, "")
+ yield record
+```
+
+Register `EESKs4NationalStream(self)` in `discover_streams` next to the KS2 national stream.
+
+- [ ] **Step 2: Raw source + staging model**
+
+Add to `_stg_sources.yml` under the raw source, matching the `ees_ks2_national` entry's style:
+
+```yaml
+ - name: ees_ks4_national
+ description: Official DfE KS4 national headline averages (EES data catalogue)
+```
+
+Create `pipeline/transform/models/staging/stg_ees_ks4_national.sql`:
+
+```sql
+{{ config(materialized='table') }}
+
+-- Staging model: official DfE KS4 national headline averages — one row per
+-- academic year (England, all state-funded, all pupils). Source: EES data
+-- catalogue "National characteristics summary data". Suppressed values
+-- ('z', 'x') are coerced to NULL by safe_numeric — Progress 8 is 'z' in
+-- years with no KS2 baseline (e.g. 2024/25): legitimately unpublished.
+
+select
+ cast(trim(time_period) as integer) as year,
+ {{ safe_numeric('attainment_8_score') }} as attainment_8_score,
+ {{ safe_numeric('progress_8_score') }} as progress_8_score,
+ {{ safe_numeric('english_maths_standard_pass_pct') }} as english_maths_standard_pass_pct,
+ {{ safe_numeric('english_maths_strong_pass_pct') }} as english_maths_strong_pass_pct,
+ {{ safe_numeric('ebacc_entry_pct') }} as ebacc_entry_pct,
+ {{ safe_numeric('ebacc_standard_pass_pct') }} as ebacc_standard_pass_pct,
+ {{ safe_numeric('ebacc_strong_pass_pct') }} as ebacc_strong_pass_pct,
+ {{ safe_numeric('ebacc_avg_score') }} as ebacc_avg_score
+from {{ source('raw', 'ees_ks4_national') }}
+where time_period ~ '^[0-9]+$'
+```
+
+- [ ] **Step 3: Rewrite the mart**
+
+Replace the entire body of `fact_ks4_national_averages.sql`:
+
+```sql
+{{ config(materialized='table') }}
+
+-- Mart: OFFICIAL DfE KS4 national headline averages — one row per academic
+-- year (England, state-funded, all pupils), from the EES national dataset.
+-- Replaces the previous unweighted school-level means, which were 7–15
+-- points off every headline measure and produced an arithmetically
+-- impossible national Progress 8. gcse_grade_91_pct has no official
+-- national series and is NULL (schema kept for the API model).
+
+select
+ year,
+ attainment_8_score,
+ progress_8_score,
+ english_maths_standard_pass_pct,
+ english_maths_strong_pass_pct,
+ ebacc_entry_pct,
+ ebacc_standard_pass_pct,
+ ebacc_strong_pass_pct,
+ ebacc_avg_score,
+ cast(null as double precision) as gcse_grade_91_pct
+from {{ ref('stg_ees_ks4_national') }}
+order by year
+```
+
+Run: `cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir .` — expected PASS.
+
+- [ ] **Step 4: Backend — official provenance, no computed fallback**
+
+`backend/models.py`: change the `Ks4NationalAverage` docstring to `"""Official DfE KS4 national headline averages — one row per academic year."""`.
+
+`backend/app.py` `_national_averages_payload`: delete the entire `if not any(secondary_by_year.values()):` fallback block (it computes dataset means that the UI footnote then labels official). Update the function docstring's KS4 sentence to: `official DfE KS4 figures (fact_ks4_national_averages). If the KS4 mart hasn't been built yet, the secondary series is empty — never a computed stand-in, because the UI labels these figures as official.`
+
+Update `backend/tests/test_national_averages_marts.py`: the test that exercised the fallback now asserts the opposite —
+
+```python
+def test_ks4_secondary_empty_when_mart_missing(...):
+ # No computed stand-in: the UI labels national figures as official DfE
+ # data, so an empty mart must yield an empty secondary series.
+ payload = _national_averages_payload(df)
+ assert all(not e["secondary"] for e in payload["by_year"])
+```
+
+(adapt to the file's existing fixtures/monkeypatching). Run the backend suite — expected PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add pipeline/plugins/extractors/tap-uk-ees pipeline/transform backend
+git commit -m "fix(data): official DfE KS4 national headline averages; drop mislabelled computed means"
+```
+
+---
+
+### Task 6: Honest 2021/22 footnote
+
+**Files:**
+- Modify: `nextjs-app/components/ComparisonChart.tsx:243-247`
+- Modify: `nextjs-app/lib/compareChartData.ts` (comment lines 7, 53–55 — comments only, no logic)
+- Modify: `nextjs-app/__tests__/lib/compareChartData.test.ts` (test name/comment wording only)
+- Modify: `docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md` §8.1
+
+**Interfaces:** none — copy and docs only. This is the one place the plan changes reviewed copy, because the reviewed copy is factually wrong (Global Constraints exception).
+
+- [ ] **Step 1: Fix the user-facing copy**
+
+In `ComparisonChart.tsx` replace the note:
+
+```tsx
+ {built.showUnpublished202122Note && (
+
+ No national tests were held in 2019/20 and 2020/21 (COVID), and our dataset doesn't
+ yet include school-level figures for 2021/22 — the England average is shown for that
+ year.
+
+ )}
+```
+
+- [ ] **Step 2: Fix the lying comments**
+
+In `compareChartData.ts`, update the header comment (line 7) and the `showUnpublished202122Note` doc comment (lines 53–55) to say the 2021/22 school-level figures are *absent from our dataset* (DfE published them in Dec 2022; ingesting them is a backlog pipeline task), not "unpublished". Rename nothing (the flag name stays — pure rename churn). In `compareChartData.test.ts`, adjust the test description/comment wording the same way.
+
+- [ ] **Step 3: Correct spec §8.1**
+
+In the spec's §8.1, replace any wording that calls 2021/22 school-level KS2 a "permanent DfE gap" with: DfE published school-level KS2 results for 2021/22 in December 2022 (with comparability caveats); they are not yet ingested — loading them remains an open pipeline task, and the chart footnote says "our dataset doesn't yet include" accordingly.
+
+- [ ] **Step 4: Verify + commit**
+
+Run: `cd nextjs-app && npx tsc --noEmit && npm test` — expected PASS.
+
+```bash
+git add nextjs-app docs/superpowers/specs/2026-07-11-compare-screen-redesign-design.md
+git commit -m "fix(compare): stop attributing the missing 2021/22 school-level year to DfE"
+```
+
+---
+
+### Task 7: Full verification, PR, and post-deploy checklist
+
+**Files:** none new (verification + PR).
+
+- [ ] **Step 1: Run everything**
+
+```bash
+uv run --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q
+cd nextjs-app && npx tsc --noEmit && npm test && cd ..
+cd pipeline/transform && uv run --with dbt-postgres python -m dbt.cli.main parse --profiles-dir . && cd ../..
+```
+
+Expected: all PASS.
+
+- [ ] **Step 2: Open the PR**
+
+Push `fix/compare-final-review-mustfix`; open a PR via the Gitea API using `git credential fill` basic auth (token-header auth 401s). PR body: summarize the five findings and fixes, link the expert review, end with the standard Claude Code attribution + session URL. Note in the body that findings 2 and 4 also need a **DAG run after the staging deploy** before the UI shows corrected data.
+
+- [ ] **Step 3: Post-merge staging verification (after the user merges and the daily DAG runs — record results, do not promote)**
+
+```bash
+# Report card dated by its own inspection (Barclay): expect 2026-02-03
+curl -sk "https://stx.schoolcompare.co.uk/api/compare?urns=138690" | python3 -c "import json,sys; o=json.load(sys.stdin)['comparison']['138690']['ofsted']; print(o['rc_inspection_date'], o['inspection_date'])"
+# Stale Watford rc grades cleared by the fresh extract: expect report_card == {}
+curl -sk "https://stx.schoolcompare.co.uk/api/compare?urns=136276" | python3 -c "import json,sys; print(json.load(sys.stdin)['comparison']['136276']['ofsted']['report_card'])"
+# Official KS4 nationals: expect A8 46.0 for 202425, progress_8_score absent
+curl -sk "https://stx.schoolcompare.co.uk/api/national-averages" | python3 -c "import json,sys; print(json.load(sys.stdin)['secondary'])"
+# FSM benchmark real (~24-26), secondary disadvantaged_pct gone
+curl -sk "https://stx.schoolcompare.co.uk/api/compare?urns=138690,136276" | python3 -c "import json,sys; print(json.load(sys.stdin)['benchmarks'])"
+```
+
+Then re-screenshot both phase views (desktop + mobile, "More measures" expanded, Watford Grammar in the secondary set) and hand them to the Ofsted expert agent for the sign-off pass it said it expects. Production promotion remains the human's manual call.
+
+---
+
+## Out of Scope (expert should-fix/minor — separate follow-ups)
+
+- 137086-style interim state (subgrades without an overall from an RI reinspection) rendering treatment (finding 6).
+- Disadvantaged cohort sizes on the attainment row (finding 7, spec §8.5).
+- SEN/EAL "typical school" labelling and secondary SEN benchmark (finding 8) — note Task 4 already upgrades EAL to a pupil-weighted census figure.
+- Selective-school admissions copy variant (finding 9).
+- Removing/relabelling `gcse_grade_91_pct` as a compare measure (finding 10) — Task 5 already removes its false England anchor.
+- Palette deviation (11), trends picker label (12), "More measures" expanded-state verification (13).
+- Actually ingesting the 2021/22 school-level KS2 release (the copy in Task 6 says "doesn't *yet* include").