# GIAS OfficialSixthForm Flag Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ingest GIAS's authoritative `OfficialSixthForm` flag into `marts.dim_school.has_sixth_form` and replace every `age_range contains "18"` heuristic in the backend and frontend with it.

**Architecture:** Data flows tap → raw → dbt staging → dbt mart → backend SQL → API payload → Next.js components. The GIAS Singer tap must declare the new CSV column (target-postgres only persists declared columns); the dbt staging model renames it; `dim_school` derives a boolean (with a statutory-age fallback for blank GIAS values); the backend exposes it on list + detail payloads and uses it for the `has_sixth_form=yes|no` filter; the frontend badge/note/filter-labels switch from the age-range substring check to the flag.

**Tech Stack:** Singer SDK (tap), dbt (Postgres), FastAPI + pandas, Next.js + TypeScript, pytest, Jest/RTL.

**Spec:** `docs/superpowers/specs/2026-07-07-exam-phase-taxonomy-design.md` §3.

## Global Constraints

- A school **has a sixth form** iff GIAS `OfficialSixthForm (name)` = `"Has a sixth form"`. `"Does not have a sixth form"` and `"Not applicable"` → false. Blank/NULL (rare) → fall back to `statutory_high_age >= 18`.
- The public API filter parameter stays `has_sixth_form=yes|no` (unchanged contract).
- Filter dropdown labels must drop the age-range parentheticals: "With sixth form" / "Without sixth form" (sixth form ≠ age range).
- Never push to `main`; work stays on branch `feat/gias-sixth-form-flag` (create from `docs/exam-phase-taxonomy` so the spec is included, or from `main` if that branch has merged).
- The dbt models cannot be run locally (no pipeline DB); dbt changes are verified by review + `python -c` schema asserts + existing CI. Do NOT attempt to start a local server.
- The backend marts tables are dbt `table` materializations — rebuilt on every pipeline run, so **no ALTER TABLE migration is needed** for `marts.dim_school`.
- Deployment ordering: the tap must run before dbt on the first pipeline run after deploy (this is already the DAG order: extract → transform). Until that run happens, `has_sixth_form` is absent from the DB; the backend must treat a missing column as "flag false / fallback", never crash.

---

### Task 1: Ingest `OfficialSixthForm (name)` — tap schema + dbt staging

**Files:**
- Modify: `pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py:31-66` (Singer schema)
- Modify: `pipeline/transform/models/staging/stg_gias_establishments.sql` (add renamed column)

**Interfaces:**
- Produces: raw column `"OfficialSixthForm (name)"` in `raw.gias_establishments`; staging column `official_sixth_form` (text: `Has a sixth form` / `Does not have a sixth form` / `Not applicable` / NULL) consumed by Task 2.

- [ ] **Step 1: Add the property to the Singer schema**

In `tap.py`, inside `GIASEstablishmentsStream.schema = th.PropertiesList(...)`, add after the `th.Property("PhaseOfEducation (name)", th.StringType),` line:

```python
        th.Property("OfficialSixthForm (name)", th.StringType),
```

- [ ] **Step 2: Verify the tap module still imports and declares the column**

Run:
```bash
cd /Users/tudor/projects/school_compare/pipeline/plugins/extractors/tap-uk-gias && \
python3 -c "
import ast, sys
src = open('tap_uk_gias/tap.py').read()
ast.parse(src)
assert '\"OfficialSixthForm (name)\"' in src.replace(\"'\", '\"')
print('OK: tap declares OfficialSixthForm (name)')
"
```
Expected: `OK: tap declares OfficialSixthForm (name)`
(Uses `ast.parse` instead of importing because `singer_sdk` is not installed locally.)

- [ ] **Step 3: Add the column to the staging model**

In `stg_gias_establishments.sql`, in the `renamed` CTE, add after the `"PhaseOfEducation (name)" as phase,` line:

```sql
        nullif(trim("OfficialSixthForm (name)"), '')    as official_sixth_form,
```

- [ ] **Step 4: Sanity-check the SQL edit**

Run:
```bash
grep -n "official_sixth_form" /Users/tudor/projects/school_compare/pipeline/transform/models/staging/stg_gias_establishments.sql
```
Expected: one line showing the new column inside the `renamed` CTE (before `from source`).

- [ ] **Step 5: Commit**

```bash
git add pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py pipeline/transform/models/staging/stg_gias_establishments.sql
git commit -m "feat(pipeline): ingest GIAS OfficialSixthForm into staging

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```

---

### Task 2: Derive `dim_school.has_sixth_form` (dbt mart + schema tests + SQLAlchemy model)

**Files:**
- Modify: `pipeline/transform/models/marts/dim_school.sql` (add derived column)
- Modify: `pipeline/transform/models/marts/_marts_schema.yml` (document + test the column)
- Modify: `backend/models.py:13-38` (`DimSchool` — add column)

**Interfaces:**
- Consumes: `official_sixth_form` text column from Task 1's staging model.
- Produces: `marts.dim_school.has_sixth_form boolean not null`, and `DimSchool.has_sixth_form = Column(Boolean)` for the backend. Task 3 selects it as `s.has_sixth_form`.

- [ ] **Step 1: Add the derived column to `dim_school.sql`**

In the `select`, add after the `s.age_range` line (`s.statutory_low_age || '-' || s.statutory_high_age as age_range,`):

```sql
    -- Authoritative sixth-form flag (spec §3): GIAS OfficialSixthForm.
    -- "Not applicable" (nurseries, primaries, PRUs) => false. Blank GIAS
    -- value (rare, new establishments) falls back to the statutory age range.
    case
        when s.official_sixth_form = 'Has a sixth form' then true
        when s.official_sixth_form in ('Does not have a sixth form', 'Not applicable') then false
        else coalesce(s.statutory_high_age >= 18, false)
    end as has_sixth_form,
```

- [ ] **Step 2: Add schema documentation + tests in `_marts_schema.yml`**

Under `- name: dim_school` → `columns:`, add after the `phase` column block:

```yaml
      - name: has_sixth_form
        description: >
          Authoritative sixth-form flag from GIAS OfficialSixthForm.
          "Has a sixth form" => true; "Does not have a sixth form" and
          "Not applicable" => false; blank GIAS value falls back to
          statutory_high_age >= 18. Replaces the age_range-contains-"18"
          heuristic (spec 2026-07-07 §3).
        tests:
          - not_null
          - accepted_values:
              values: [true, false]
```

- [ ] **Step 3: Add the column to the `DimSchool` SQLAlchemy model**

In `backend/models.py`, in `class DimSchool`, add after `age_range = Column(String(20))`:

```python
    has_sixth_form = Column(Boolean)
```

- [ ] **Step 4: Verify SQL/YAML/Python all parse**

Run:
```bash
cd /Users/tudor/projects/school_compare && \
python3 -c "
import yaml
y = yaml.safe_load(open('pipeline/transform/models/marts/_marts_schema.yml'))
dim = [m for m in y['models'] if m['name'] == 'dim_school'][0]
cols = [c['name'] for c in dim['columns']]
assert 'has_sixth_form' in cols, cols
print('OK: schema yml documents has_sixth_form')
" && \
grep -c "has_sixth_form" pipeline/transform/models/marts/dim_school.sql && \
python3 -c "import ast; ast.parse(open('backend/models.py').read()); print('OK: models.py parses')"
```
Expected: `OK: schema yml documents has_sixth_form`, grep count `>= 1`, `OK: models.py parses`.

- [ ] **Step 5: Commit**

```bash
git add pipeline/transform/models/marts/dim_school.sql pipeline/transform/models/marts/_marts_schema.yml backend/models.py
git commit -m "feat(pipeline): derive dim_school.has_sixth_form from GIAS flag

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```

---

### Task 3: Backend — expose `has_sixth_form` and replace the filter heuristic

**Files:**
- Modify: `backend/data_loader.py:117-215` (`_MAIN_QUERY` — select the column)
- Modify: `backend/schemas.py:536-553` (`SCHOOL_COLUMNS` — include in list payloads)
- Modify: `backend/app.py:419-422` (filter) and `backend/app.py:589-610` (detail `school_info`)
- Test: `backend/tests/test_sixth_form_flag.py` (new)

**Interfaces:**
- Consumes: `marts.dim_school.has_sixth_form` (Task 2).
- Produces: `has_sixth_form: bool | null` field on `GET /api/schools` items and on `GET /api/schools/{urn}` → `school_info`. Filter `GET /api/schools?has_sixth_form=yes|no` now driven by the flag. Frontend (Task 4) reads `school.has_sixth_form`.

- [ ] **Step 1: Write the failing tests**

Create `backend/tests/test_sixth_form_flag.py`:

```python
"""Tests for the GIAS-driven has_sixth_form flag (spec 2026-07-07 §3).

The filter and payloads must use dim_school.has_sixth_form, not the old
age_range-contains-"18" substring heuristic. The key regression case is a
16-19 sixth-form college: flag true, but "16-19" contains no "18".
"""

import numpy as np
import pandas as pd
import pytest
from fastapi.testclient import TestClient


def _schools_df() -> pd.DataFrame:
    """Latest-year snapshot rows as produced by load_latest_school_data."""
    base = {
        "local_authority": "Testshire",
        "school_type": "Academy",
        "phase": "Secondary",
        "address": "1 Test Street",
        "town": "Testtown",
        "postcode": "TS1 1AA",
        "religious_denomination": None,
        "gender": "Mixed",
        "admissions_policy": None,
        "ofsted_grade": np.nan,
        "ofsted_date": None,
        "ofsted_framework": None,
        "latitude": 51.5,
        "longitude": -0.1,
        "year": 202425,
        "total_pupils": 1000,
        "rwm_expected_pct": np.nan,
        "attainment_8_score": 50.0,
    }
    return pd.DataFrame(
        [
            # 11-18 school WITH a registered sixth form
            {**base, "urn": 100001, "school_name": "Alpha High",
             "age_range": "11-18", "has_sixth_form": True},
            # 16-19 college: old heuristic said NO ("16-19" has no "18"),
            # GIAS flag says YES — must appear in the yes-filter results
            {**base, "urn": 100002, "school_name": "Beta Sixth Form College",
             "age_range": "16-19", "has_sixth_form": True},
            # 11-18 age range on paper but NO registered sixth form:
            # old heuristic said YES, GIAS flag says NO
            {**base, "urn": 100003, "school_name": "Gamma Academy",
             "age_range": "11-18", "has_sixth_form": False},
            # Missing flag (pipeline not yet re-run) — must not crash,
            # must not match the yes-filter
            {**base, "urn": 100004, "school_name": "Delta School",
             "age_range": "11-16", "has_sixth_form": None},
        ]
    )


@pytest.fixture()
def client(monkeypatch):
    from backend import app as app_module

    monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
    monkeypatch.setattr(app_module, "load_school_data", _schools_df)
    monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
    return TestClient(app_module.app, raise_server_exceptions=False)


def _urns(resp):
    return sorted(s["urn"] for s in resp.json()["schools"])


def test_filter_yes_uses_flag_not_age_range(client):
    resp = client.get("/api/schools?has_sixth_form=yes")
    assert resp.status_code == 200, resp.text
    # 16-19 college included; 11-18-without-sixth-form excluded
    assert _urns(resp) == [100001, 100002]


def test_filter_no_uses_flag_not_age_range(client):
    resp = client.get("/api/schools?has_sixth_form=no")
    assert resp.status_code == 200, resp.text
    # Gamma (flag false) and Delta (flag missing => not true)
    assert _urns(resp) == [100003, 100004]


def test_list_payload_includes_flag(client):
    resp = client.get("/api/schools")
    assert resp.status_code == 200, resp.text
    by_urn = {s["urn"]: s for s in resp.json()["schools"]}
    assert by_urn[100002]["has_sixth_form"] is True
    assert by_urn[100003]["has_sixth_form"] is False
    assert by_urn[100004]["has_sixth_form"] is None


def test_detail_payload_includes_flag(client):
    resp = client.get("/api/schools/100002")
    assert resp.status_code == 200, resp.text
    assert resp.json()["school_info"]["has_sixth_form"] is True
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /Users/tudor/projects/school_compare && python3 -m pytest backend/tests/test_sixth_form_flag.py -v`
Expected: FAIL — `test_filter_yes_uses_flag_not_age_range` asserts `[100001, 100002]` but the age-range heuristic returns `[100001, 100003]`; the payload tests fail with `KeyError: 'has_sixth_form'`.

- [ ] **Step 3: Select the column in `_MAIN_QUERY`**

In `backend/data_loader.py`, in `_MAIN_QUERY`, add after `s.age_range,`:

```sql
        s.has_sixth_form,
```

- [ ] **Step 4: Include it in list payloads**

In `backend/schemas.py`, in `SCHOOL_COLUMNS`, add after `"age_range",`:

```python
    "has_sixth_form",
```

(`app.py` builds list responses from `SCHOOL_COLUMNS ∩ df.columns`, so a DB that predates the pipeline re-run simply omits the field — no crash.)

- [ ] **Step 5: Replace the filter heuristic in `app.py`**

Replace lines 419-422:

```python
    if has_sixth_form == "yes":
        df_latest = df_latest[df_latest["age_range"].str.contains("18", na=False)]
    elif has_sixth_form == "no":
        df_latest = df_latest[~df_latest["age_range"].str.contains("18", na=False)]
```

with:

```python
    # GIAS OfficialSixthForm flag (dim_school.has_sixth_form). NULL (flag not
    # yet populated by the pipeline) is treated as "no sixth form".
    if has_sixth_form in ("yes", "no"):
        if "has_sixth_form" in df_latest.columns:
            flag = df_latest["has_sixth_form"].eq(True)
        else:  # DB predates the pipeline re-run — fall back to age range
            flag = df_latest["age_range"].str.contains("18", na=False)
        df_latest = df_latest[flag if has_sixth_form == "yes" else ~flag]
```

- [ ] **Step 6: Add the flag to the detail payload**

In `backend/app.py` `school_info` dict (line ~598), add after `"age_range": latest.get("age_range", ""),`:

```python
            "has_sixth_form": latest.get("has_sixth_form"),
```

(`convert_to_native` already maps NaN/None → null and numpy bools → bool.)

- [ ] **Step 7: Run the new tests**

Run: `cd /Users/tudor/projects/school_compare && python3 -m pytest backend/tests/test_sixth_form_flag.py -v`
Expected: 4 passed.

- [ ] **Step 8: Run the full backend suite**

Run: `cd /Users/tudor/projects/school_compare && python3 -m pytest backend/tests -v`
Expected: all pass (the pre-existing `test_school_details.py` df has no `has_sixth_form` column — `latest.get()` returns None, serialized as null).

- [ ] **Step 9: Commit**

```bash
git add backend/data_loader.py backend/schemas.py backend/app.py backend/tests/test_sixth_form_flag.py
git commit -m "feat(api): drive has_sixth_form filter and payloads from GIAS flag

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```

---

### Task 4: Frontend — badge, note, row tag, and filter labels use the flag

**Files:**
- Modify: `nextjs-app/lib/types.ts:10-30` (`School` interface)
- Modify: `nextjs-app/components/SecondarySchoolDetailView.tsx:101` (badge + coming-soon note)
- Modify: `nextjs-app/components/SecondarySchoolRow.tsx:25-27` (row tag)
- Modify: `nextjs-app/components/FilterBar.tsx:370-372` (labels only — param name unchanged)
- Test: `nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx` (new)

**Interfaces:**
- Consumes: `has_sixth_form: boolean | null` on both list items and `school_info` (Task 3; both are typed as `School`).
- Produces: no new exports — behavior change only.

- [ ] **Step 1: Write the failing test**

Create `nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx`:

```tsx
/**
 * SecondarySchoolRow — sixth-form tag must come from the GIAS
 * has_sixth_form flag, not the age_range-contains-"18" heuristic.
 */

import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { SecondarySchoolRow } from '@/components/SecondarySchoolRow';
import type { School } from '@/lib/types';

const base = {
  urn: 100002,
  school_name: 'Beta Sixth Form College',
  local_authority: 'Testshire',
  school_type: 'Academy',
  phase: 'Secondary',
  gender: 'Mixed',
  attainment_8_score: 50.0,
} as unknown as School;

describe('SecondarySchoolRow sixth-form tag', () => {
  it('shows the tag for a 16-19 college with the GIAS flag set', () => {
    render(
      <SecondarySchoolRow
        school={{ ...base, age_range: '16-19', has_sixth_form: true }}
      />,
    );
    expect(screen.getByText('Sixth form')).toBeInTheDocument();
  });

  it('hides the tag for an 11-18 school without a registered sixth form', () => {
    render(
      <SecondarySchoolRow
        school={{ ...base, age_range: '11-18', has_sixth_form: false }}
      />,
    );
    expect(screen.queryByText('Sixth form')).not.toBeInTheDocument();
  });

  it('hides the tag when the flag is missing (pipeline not yet re-run)', () => {
    render(
      <SecondarySchoolRow school={{ ...base, age_range: '11-18' }} />,
    );
    expect(screen.queryByText('Sixth form')).not.toBeInTheDocument();
  });
});
```

- [ ] **Step 2: Run it to verify it fails**

Run: `cd /Users/tudor/projects/school_compare/nextjs-app && npx jest __tests__/components/SecondarySchoolRow.test.tsx`
Expected: FAIL — first test can't find "Sixth form" ("16-19" fails the substring check), second test finds an unexpected "Sixth form" tag. (If TS complains that `has_sixth_form` is not on `School`, that is the same failure — proceed.)

- [ ] **Step 3: Add the field to the `School` type**

In `nextjs-app/lib/types.ts`, in `export interface School`, add after `age_range: string | null;`:

```ts
  has_sixth_form?: boolean | null;
```

- [ ] **Step 4: Switch `SecondarySchoolRow` to the flag**

Replace the helper at `SecondarySchoolRow.tsx:25-27`:

```ts
function hasSixthForm(school: School): boolean {
  return school.age_range?.includes('18') ?? false;
}
```

with:

```ts
function hasSixthForm(school: School): boolean {
  // GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
  return school.has_sixth_form ?? false;
}
```

- [ ] **Step 5: Switch `SecondarySchoolDetailView` to the flag**

Replace line 101:

```ts
  const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false;
```

with:

```ts
  // GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
  const hasSixthForm = schoolInfo.has_sixth_form ?? false;
```

(This drives both the header "Sixth form" badge at line ~230 and the "Post-16 destination data coming soon" note at line ~715 — no changes needed there.)

- [ ] **Step 6: Fix the filter labels in `FilterBar.tsx`**

Replace:

```tsx
                    <option value="yes">With sixth form (11-18)</option>
                    <option value="no">Without sixth form (11-16)</option>
```

with:

```tsx
                    <option value="yes">With sixth form</option>
                    <option value="no">Without sixth form</option>
```

- [ ] **Step 7: Run the new test and verify it passes**

Run: `cd /Users/tudor/projects/school_compare/nextjs-app && npx jest __tests__/components/SecondarySchoolRow.test.tsx`
Expected: 3 passed.

- [ ] **Step 8: Run the full frontend checks**

Run: `cd /Users/tudor/projects/school_compare/nextjs-app && npx tsc --noEmit && npx jest`
Expected: typecheck clean, all Jest suites pass.

- [ ] **Step 9: Verify no heuristic remains**

Run:
```bash
grep -rn "includes('18')\|contains(\"18\")" /Users/tudor/projects/school_compare/nextjs-app/components /Users/tudor/projects/school_compare/backend --include="*.tsx" --include="*.ts" --include="*.py" | grep -v test
```
Expected: only the documented fallback inside `app.py` (DB-predates-pipeline branch); no other hits.

- [ ] **Step 10: Commit**

```bash
git add nextjs-app/lib/types.ts nextjs-app/components/SecondarySchoolRow.tsx nextjs-app/components/SecondarySchoolDetailView.tsx nextjs-app/components/FilterBar.tsx nextjs-app/__tests__/components/SecondarySchoolRow.test.tsx
git commit -m "feat(ui): sixth-form badge, note and filter labels use GIAS flag

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```

---

### Task 5: Update the spec status + PR

**Files:**
- Modify: `docs/superpowers/specs/2026-07-07-exam-phase-taxonomy-design.md` (§3 "Pipeline change (future work)" → implemented)

**Interfaces:**
- Consumes: everything above merged into the branch.
- Produces: PR ready for review; e2e journeys are the promotion gate (no journey currently exercises the sixth-form filter, and the API contract is unchanged, so no e2e change is required — state this in the PR body).

- [ ] **Step 1: Mark spec §3 pipeline change as implemented**

In the spec, change the §3 heading `### Pipeline change (future work)` to `### Pipeline change (implemented 2026-07-07)` and append one line at the end of that subsection:

```markdown
Implemented in `feat/gias-sixth-form-flag` — see
`docs/superpowers/plans/2026-07-07-gias-sixth-form-flag.md`.
```

- [ ] **Step 2: Commit**

```bash
git add docs/superpowers/specs/2026-07-07-exam-phase-taxonomy-design.md
git commit -m "docs: mark sixth-form flag pipeline change implemented

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```

- [ ] **Step 3: Push and open the PR (Gitea)**

Push the branch, then create the PR against `main` using the Gitea API via the git credential helper (token-header auth 401s on this Gitea; basic auth from `git credential fill` works):

```bash
git push -u origin feat/gias-sixth-form-flag
```

PR title: `feat: drive sixth-form separation from GIAS OfficialSixthForm flag`
PR body must note: (1) API contract unchanged (`has_sixth_form=yes|no`), (2) flag is NULL until the next pipeline run — backend and frontend degrade to "no sixth form" / age-range fallback, (3) no e2e journey change needed, and end with the standard generation footer.

- [ ] **Step 4: Verify CI passes**

Watch the PR checks (typecheck, tests, builds, AI review). All must pass before merge; merging deploys to staging automatically.
