Files
school_compare/docs/superpowers/plans/2026-08-23-feature-flags.md
T
TudorandClaude Opus 5 c339c2f1a1 docs(flags): implementation plan, eight tasks
Task 1 is the Unleash stack and ends with a human step — the Portainer
deploy and the token generation cannot be automated from here. Nothing
else blocks on it: an unset UNLEASH_URL means every flag is False, which
is what local development and CI get, so the whole suite runs without a
flag server existing.

Self-review caught three defects in the plan itself. get_supplementary_data
takes (db, urn), not (urn), and the test DataFrame was minimised to the
point where the endpoint would have failed for reasons unrelated to
flags — both now copy the known-good shape from test_school_details.py.
The proxy test needs the node jest environment, since NextRequest wants
Fetch API globals jsdom does not provide. And the e2e off-state check
hardcoded a URN, so a 404 page would have satisfied it without proving
anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-23 10:46:15 +01:00

39 KiB
Raw Blame History

Feature Flags (Unleash) 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: Let a merged feature reach production without becoming visible, and be switched on later from a UI.

Architecture: A self-hosted Unleash instance in its own Portainer stack holds flag state. FastAPI is the only service with an Unleash SDK; it declares every flag in a code registry, evaluates them fail-closed, and exposes them at an internal-only /api/flags. Next reads that endpoint server-side. No webhook — time-based ISR already propagates a flip within five minutes on school pages.

Tech Stack: Unleash OSS (Apache-2.0), UnleashClient (Python), FastAPI, Next.js 15 App Router, Playwright, pytest, Jest.

Spec: docs/superpowers/specs/2026-08-23-feature-flags-design.md

Global Constraints

  • Every flag defaults to False. There is no per-flag default field. A flag that defaults on is a kill switch, which this design does not offer.
  • One string per flag, used unchanged as the registry key, the Unleash flag name, and the JSON key in /api/flags. snake_case, matching the API's existing convention. No case transformation anywhere.
  • Fail closed. Unleash unreachable, unconfigured, or erroring ⇒ every flag is False. Never raise out of a flag evaluation.
  • /api/flags must never be publicly reachable. It names unreleased features.
  • An API field that is off is absent, not null, and withheld at the source. /api/schools/ is public and unauthenticated (precedent: commit c9a1892).
  • Never flag content on a force-static page. app/admissions/page.tsx never revalidates.
  • Reading flags in a route pins that route to a 300s ISR floor — Next uses the lowest revalidate among a route's fetches.
  • Backend tests run via: uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q
  • Never push to main. Branch, PR, let checks pass.
  • Do not start a local server to test the application; it does not work.

File Structure

File Responsibility
docker-compose.portainer.unleash.yml (new) The Unleash stack: server + its own Postgres. Owned by neither app stack.
backend/flags.py (new) The whole flag layer: registry, client lifecycle, is_enabled, all_flags. One module, ~90 lines.
backend/config.py (modify) Four unleash_* settings.
backend/app.py (modify) /api/flags endpoint; flags.init() in lifespan; the admission_distance gate.
backend/tests/test_flags.py (new) Registry shape, fail-closed evaluation, staleness tripwire, endpoint.
nextjs-app/lib/flags.ts (new) getFlags() — one server-side fetch, typed, never throws.
nextjs-app/app/api/[...path]/route.ts (modify) Internal-only denylist so the public proxy will not forward /api/flags.
nextjs-app/__tests__/lib/flags.test.ts (new) getFlags fallback behaviour.
nextjs-app/__tests__/api/proxyDenylist.test.ts (new) The proxy 404s internal paths.
e2e/tests/journeys.spec.ts (modify) Flag-aware journeys; the explicit-gate rule from spec §7.
requirements.txt (modify) UnleashClient.
docker-compose.yml, docker-compose.portainer.yml, docker-compose.portainer.staging.yml (modify) UNLEASH_* env for the backend service.

Task 1: The Unleash stack

Files:

  • Create: docker-compose.portainer.unleash.yml
  • Modify: docs/DEPLOY.md

Interfaces:

  • Consumes: nothing.
  • Produces: a reachable Unleash server, and two client tokens — one scoped to the development environment, one to production — for Task 2's UNLEASH_API_TOKEN.

This task ends with a human step. The stack must be deployed in Portainer and the tokens generated by hand; neither can be automated from here. Every later task works without it — flags simply evaluate False when UNLEASH_URL is unset, which is the correct local and CI behaviour.

  • Step 1: Write the stack file
# Portainer Stack Definition for School Compare — UNLEASH (feature flags)
#
# Deploy as a *separate* Portainer stack ("schoolcompare-unleash"), alongside
# the production and staging stacks. It deliberately belongs to neither: a
# staging redeploy must not be able to disturb production's flag state.
#
# One instance serves both environments. Open-source Unleash ships with
# `development` and `production` environments and environment-scoped client
# tokens, so the same flag holds independent state in each.
#
# Portainer environment variables (set in Portainer UI -> Stack -> Environment):
#   UNLEASH_DB_PASSWORD    — PostgreSQL password for the Unleash database
#   UNLEASH_ADMIN_PASSWORD — initial admin password for the Unleash UI
#   UNLEASH_IP             — macvlan IP for the Unleash server (default 10.0.1.152)

services:

  unleash_db:
    container_name: sc_unleash_postgres
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: unleash
      POSTGRES_PASSWORD: ${UNLEASH_DB_PASSWORD}
      POSTGRES_DB: unleash
    volumes:
      - unleash_postgres_data:/var/lib/postgresql/data
    networks:
      - unleash
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U unleash"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s
    restart: unless-stopped

  unleash:
    container_name: sc_unleash
    image: unleashorg/unleash-server:6
    environment:
      DATABASE_URL: postgres://unleash:${UNLEASH_DB_PASSWORD}@unleash_db:5432/unleash
      DATABASE_SSL: "false"
      INIT_ADMIN_API_TOKENS: ""
      UNLEASH_DEFAULT_ADMIN_PASSWORD: ${UNLEASH_ADMIN_PASSWORD}
    depends_on:
      unleash_db:
        condition: service_healthy
    networks:
      unleash: {}
      macvlan:
        ipv4_address: ${UNLEASH_IP:-10.0.1.152}
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:4242/health || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s
    restart: unless-stopped

networks:
  unleash:
    driver: bridge
  macvlan:
    external: true
    name: macvlan

volumes:
  unleash_postgres_data:
  • Step 2: Document the runbook

Append to docs/DEPLOY.md:

## Feature flags (Unleash)

Flag state lives in a self-hosted Unleash instance, deployed as its own
Portainer stack from `docker-compose.portainer.unleash.yml`. It is separate
from the application stacks on purpose — redeploying staging must not be able
to disturb production's flags.

### First-time setup

1. Deploy the stack in Portainer. Set `UNLEASH_DB_PASSWORD`,
   `UNLEASH_ADMIN_PASSWORD` and (optionally) `UNLEASH_IP`.
2. Log in to the UI at `http://<UNLEASH_IP>:4242` as `admin`.
3. Create one **client** API token per environment:
   - `schoolcompare-staging`, environment **development**
   - `schoolcompare-prod`, environment **production**
   Client tokens, not admin tokens — the backend only reads.
4. Put each token in the matching Portainer stack's `UNLEASH_API_TOKEN`
   variable, and set `UNLEASH_URL` to `http://<UNLEASH_IP>:4242/api`.
5. Redeploy the application stacks.

### Turning a feature on

Flags are declared in `backend/flags.py` and appear in the Unleash UI on the
first evaluation after the backend starts. Toggle the flag in the environment
you want. A flip reaches school pages within about five minutes and place pages
within the hour — Next's ISR does the propagating, there is no webhook.

If `UNLEASH_URL` is unset, every flag evaluates to `False`. That is the correct
behaviour for local development and CI, and means nothing here is required to
run the tests.
  • Step 3: Commit
git add docker-compose.portainer.unleash.yml docs/DEPLOY.md
git commit -m "feat(flags): add the Unleash stack and its runbook"
  • Step 4: Hand off the manual step

Tell the human: the stack file is ready, and Steps 1–5 of the runbook are theirs to run. Note that the remaining tasks do not block on it.


Task 2: The flag registry and client

Files:

  • Create: backend/flags.py
  • Create: backend/tests/test_flags.py
  • Modify: backend/config.py
  • Modify: requirements.txt

Interfaces:

  • Consumes: settings from backend/config.py.

  • Produces:

    • backend.flags.Flag — frozen dataclass with name: str, description: str, added: date
    • backend.flags.REGISTRY: dict[str, Flag]
    • backend.flags.MAX_FLAG_AGE_DAYS: int (= 90)
    • backend.flags.init() -> None
    • backend.flags.is_enabled(name: str) -> bool
    • backend.flags.all_flags() -> dict[str, bool]
  • Step 1: Write the failing tests

Create backend/tests/test_flags.py:

"""Tests for the feature flag layer (spec 2026-08-23).

None of these need a running Unleash. That is the point: an unset UNLEASH_URL
means every flag is False, which is what local development and CI get.
"""

from datetime import date, timedelta

import pytest

from backend import flags


def test_every_declared_flag_is_keyed_by_its_own_name():
    # One string is the registry key, the Unleash flag name and the JSON key.
    # A mismatch here would mean the UI toggles a flag the code never reads.
    for key, flag in flags.REGISTRY.items():
        assert key == flag.name


def test_flag_names_are_snake_case():
    # Matches the API's existing convention (admission_distance,
    # rwm_expected_pct) so no case transformation exists to get wrong.
    for name in flags.REGISTRY:
        assert name == name.lower()
        assert "-" not in name and " " not in name


def test_an_unconfigured_client_evaluates_every_flag_false(monkeypatch):
    monkeypatch.setattr(flags, "_client", None)
    for name in flags.REGISTRY:
        assert flags.is_enabled(name) is False


def test_an_undeclared_flag_is_false_rather_than_an_error(monkeypatch):
    # A typo'd flag name must not raise in a request path. It is logged as an
    # error, because an undeclared flag is always a bug.
    monkeypatch.setattr(flags, "_client", None)
    assert flags.is_enabled("no_such_flag") is False


def test_an_exploding_client_is_false_rather_than_a_500(monkeypatch):
    class Boom:
        def is_enabled(self, *a, **kw):
            raise RuntimeError("unleash is on fire")

    monkeypatch.setattr(flags, "_client", Boom())
    name = next(iter(flags.REGISTRY))
    assert flags.is_enabled(name) is False


def test_all_flags_reports_every_declared_flag(monkeypatch):
    monkeypatch.setattr(flags, "_client", None)
    assert set(flags.all_flags()) == set(flags.REGISTRY)
    assert all(v is False for v in flags.all_flags().values())


def test_a_flag_older_than_the_limit_fails_this_test():
    """A tripwire, not an assertion about correctness.

    Flags are temporary scaffolding and the failure mode of every flag system
    is accumulation. This fails on the day a flag turns 90, on whatever PR
    happens to be open — which is the point: someone has to decide.

    To fix: delete the flag and the branches that read it, or, if it genuinely
    still needs to exist, move its `added` date and say why in the commit.
    """
    stale = [
        f.name for f in flags.REGISTRY.values()
        if date.today() - f.added > timedelta(days=flags.MAX_FLAG_AGE_DAYS)
    ]
    assert not stale, (
        f"Flags older than {flags.MAX_FLAG_AGE_DAYS} days: {stale}. "
        "Remove the flag and the code branches it guards, or move its `added` "
        "date deliberately."
    )
  • Step 2: Run the tests to verify they fail

Run: uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_flags.py -q

Expected: FAIL — ModuleNotFoundError: No module named 'backend.flags'

  • Step 3: Add the dependency

Append to requirements.txt:

UnleashClient==6.0.1
  • Step 4: Add the settings

In backend/config.py, inside class Settings, after the Typesense block:

    # Feature flags (Unleash). An empty unleash_url disables flags entirely and
    # every flag evaluates False — the correct behaviour for local development
    # and CI, and the reason no test here needs a running Unleash.
    unleash_url: str = ""
    unleash_api_token: str = ""
    unleash_app_name: str = "schoolcompare-backend"
    # On a named volume, so a restart during an Unleash outage keeps
    # last-known state instead of reverting a released feature to dark.
    unleash_cache_directory: str = "/app/.unleash"
  • Step 5: Write the module

Create backend/flags.py:

"""Feature flags: what can be switched, and what is switched right now.

Ship-dark, not a kill switch. Flags let work merge and deploy without becoming
visible; they are expected to flip about monthly, by a person, deliberately.
Nothing here does percentage rollouts or user targeting — the site has no user
identity to target.

Unleash holds the state. It does not hold the list. REGISTRY below is that
list, and it exists for three reasons: the SDK evaluates an unknown flag to
False, so without a registry that is an *undeclared* False, indistinguishable
from a typo; /api/flags needs a key set to return when Unleash is unreachable;
and a flag in the UI but not in the registry is orphaned and should be visibly
so rather than quietly authoritative.

Every flag defaults to False. There is no per-flag default, because a flag that
defaults on is a kill switch, and this is not one.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import date

from .config import settings

logger = logging.getLogger(__name__)

# A flag is temporary scaffolding. See test_a_flag_older_than_the_limit.
MAX_FLAG_AGE_DAYS = 90


@dataclass(frozen=True)
class Flag:
    # One string: the registry key, the Unleash flag name, and the JSON key in
    # /api/flags. snake_case, matching the API's existing convention. No case
    # transformation anywhere, so there is no mapping layer to get wrong.
    name: str
    description: str          # one line: what turning this on reveals
    added: date               # for the staleness tripwire


REGISTRY: dict[str, Flag] = {
    f.name: f for f in (
        Flag(
            name="admission_distance",
            description=(
                "The last-distance-offered figure on the Admissions tile and "
                "the 'How far away are you?' section on school pages."
            ),
            added=date(2026, 8, 23),
        ),
    )
}


_client = None


def init() -> None:
    """Start the Unleash client, or log why flags are all off.

    Called once from the app lifespan. Never raises: a flag system that can
    stop the API from booting is worse than one that is switched off.
    """
    global _client
    if not settings.unleash_url or not settings.unleash_api_token:
        logger.warning(
            "Unleash is not configured (UNLEASH_URL / UNLEASH_API_TOKEN); "
            "every feature flag evaluates to False.")
        return

    try:
        from UnleashClient import UnleashClient

        _client = UnleashClient(
            url=settings.unleash_url,
            app_name=settings.unleash_app_name,
            custom_headers={"Authorization": settings.unleash_api_token},
            cache_directory=settings.unleash_cache_directory,
            refresh_interval=15,
        )
        _client.initialize_client()
        logger.info("Unleash client initialised against %s", settings.unleash_url)
    except Exception:
        # Fail closed and keep serving. The SDK also evaluates everything False
        # until its first successful sync, so this is the same direction.
        _client = None
        logger.exception("Unleash client failed to start; flags are all False.")


def is_enabled(name: str) -> bool:
    """Whether `name` is on. False for anything unknown, unreachable or broken."""
    if name not in REGISTRY:
        logger.error(
            "undeclared feature flag %r was evaluated; returning False. "
            "Add it to backend/flags.py REGISTRY or fix the name.", name)
        return False
    if _client is None:
        return False
    try:
        return bool(_client.is_enabled(
            name, fallback_function=lambda feature_name, context: False))
    except Exception:
        logger.exception("flag %r failed to evaluate; returning False", name)
        return False


def all_flags() -> dict[str, bool]:
    """Every declared flag and its current value. Serves /api/flags."""
    return {name: is_enabled(name) for name in REGISTRY}
  • Step 6: Run the tests to verify they pass

Run: uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_flags.py -q

Expected: PASS, 7 tests.

  • Step 7: Run the whole backend suite

Run: uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q

Expected: all pass. Nothing else imports flags yet.

  • Step 8: Commit
git add backend/flags.py backend/tests/test_flags.py backend/config.py requirements.txt
git commit -m "feat(flags): the registry and a fail-closed Unleash client"

Task 3: /api/flags, and keeping it off the public internet

Files:

  • Modify: backend/app.py
  • Modify: nextjs-app/app/api/[...path]/route.ts
  • Modify: backend/tests/test_flags.py
  • Create: nextjs-app/__tests__/api/proxyDenylist.test.ts

Interfaces:

  • Consumes: backend.flags.init, backend.flags.all_flags.
  • Produces: GET /api/flags → {"<flag_name>": bool, ...}, reachable only from inside the Docker network.

The endpoint and its exposure control ship together on purpose. The moment /api/flags exists, the public proxy at app/api/[...path]/route.ts will forward it, publishing the name and state of every unreleased feature.

  • Step 1: Write the failing backend test

Append to backend/tests/test_flags.py:

def _client():
    from fastapi.testclient import TestClient
    from backend import app as app_module
    return TestClient(app_module.app, raise_server_exceptions=False)


def test_the_flags_endpoint_lists_every_declared_flag(monkeypatch):
    monkeypatch.setattr(flags, "_client", None)
    body = _client().get("/api/flags").json()
    assert set(body) == set(flags.REGISTRY)


def test_the_flags_endpoint_answers_false_when_unleash_is_unreachable(monkeypatch):
    # The endpoint must still answer. A frontend that cannot read flags renders
    # everything dark, which is right; one that gets a 500 renders nothing.
    monkeypatch.setattr(flags, "_client", None)
    res = _client().get("/api/flags")
    assert res.status_code == 200
    assert all(v is False for v in res.json().values())
  • Step 2: Run it to verify it fails

Run: uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_flags.py -q

Expected: FAIL — the endpoint 404s, so set(body) is {"detail"}.

  • Step 3: Add the import and the lifespan call

In backend/app.py, add to the existing relative imports (after from .data_loader import get_data_info as get_db_info):

from . import flags

Inside async def lifespan(app: FastAPI):, immediately after the print("Loading school data from marts...") line:

    flags.init()
  • Step 4: Add the endpoint

In backend/app.py, directly above @app.get("/api/data-info"):

@app.get("/api/flags")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_feature_flags(request: Request):
    """Every declared flag and its current value.

    Internal only. The Next proxy denies this path, because the response names
    every unreleased feature the codebase knows about — which is exactly what
    shipping dark is meant to keep quiet.
    """
    return flags.all_flags()
  • Step 5: Run the backend tests to verify they pass

Run: uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q

Expected: all pass.

  • Step 6: Write the failing proxy test

Create nextjs-app/__tests__/api/proxyDenylist.test.ts:

/**
 * The /api/* proxy is public. Anything it forwards is on the internet.
 *
 * @jest-environment node
 */
// The docblock above is load-bearing. jest.config.js sets jsdom globally, and
// NextRequest/NextResponse need the Web Fetch API globals that only the node
// environment provides — under jsdom this suite fails on import, not on an
// assertion.
import { NextRequest } from 'next/server';
import { GET } from '@/app/api/[...path]/route';

function request(path: string) {
  return new NextRequest(`http://localhost:3000/api/${path}`);
}

describe('public API proxy', () => {
  it('refuses to forward internal-only paths', async () => {
    // /api/flags names every unreleased feature and its state. Forwarding it
    // publishes the thing shipping dark exists to keep quiet.
    const res = await GET(request('flags'), { params: Promise.resolve({ path: ['flags'] }) });
    expect(res.status).toBe(404);
  });

  it('does not deny a path that merely starts with the same letters', async () => {
    // A prefix match would take /api/flagship down with /api/flags.
    const res = await GET(
      request('flagship'), { params: Promise.resolve({ path: ['flagship'] }) });
    expect(res.status).not.toBe(404);
  });
});
  • Step 7: Run it to verify it fails

Run: cd nextjs-app && npx jest __tests__/api/proxyDenylist.test.ts

Expected: FAIL — the first case returns 502 (no backend running in the test env), not 404.

  • Step 8: Add the denylist

In nextjs-app/app/api/[...path]/route.ts, after the STRIPPED_RESPONSE_HEADERS constant:

/*
 * API paths this public proxy must not forward.
 *
 * Matched on the first segment, exactly — a prefix match would take
 * /api/flagship down with /api/flags.
 *
 * `flags` is here because GET /api/flags names every unreleased feature the
 * codebase knows about, along with whether it is on. Publishing that defeats
 * the point of shipping dark. Next reads it server-side via FASTAPI_URL, on
 * the Docker network, which never transits this route.
 *
 * Anything else internal-only belongs here too.
 */
const INTERNAL_ONLY_SEGMENTS = new Set(['flags']);

And at the top of handler, immediately after const { path } = await ctx.params;:

  if (INTERNAL_ONLY_SEGMENTS.has(path[0])) {
    return NextResponse.json({ detail: 'Not Found' }, { status: 404 });
  }
  • Step 9: Run the frontend tests

Run: cd nextjs-app && npx jest && npx tsc --noEmit

Expected: all pass, typecheck clean.

  • Step 10: Commit
git add backend/app.py backend/tests/test_flags.py 'nextjs-app/app/api/[...path]/route.ts' nextjs-app/__tests__/api/proxyDenylist.test.ts
git commit -m "feat(flags): serve /api/flags, and keep the public proxy off it"

Task 4: Reading flags from Next

Files:

  • Create: nextjs-app/lib/flags.ts
  • Create: nextjs-app/__tests__/lib/flags.test.ts

Interfaces:

  • Consumes: GET /api/flags.
  • Produces: getFlags(): Promise<Flags> where type Flags = Record<string, boolean>, and FLAGS_REVALIDATE = 300.

This ships with no consumer. The first flag needs none — the backend withholds the field and the frontend follows (Task 5). It is built anyway because "UI elements on existing pages" is one of the three surfaces this capability is for, and a flag layer that cannot gate a UI element is incomplete. Keep it minimal; do not add helpers nothing calls.

  • Step 1: Write the failing test

Create nextjs-app/__tests__/lib/flags.test.ts:

import { getFlags } from '@/lib/flags';

describe('getFlags', () => {
  afterEach(() => { jest.restoreAllMocks(); });

  it('returns the flags the API reports', async () => {
    jest.spyOn(global, 'fetch').mockResolvedValue({
      ok: true,
      json: async () => ({ admission_distance: true }),
    } as Response);
    await expect(getFlags()).resolves.toEqual({ admission_distance: true });
  });

  it('returns no flags rather than throwing when the API is down', async () => {
    // A page that cannot read flags must render everything dark, not 500.
    // Fail-closed is the same direction as the backend's default.
    jest.spyOn(global, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
    await expect(getFlags()).resolves.toEqual({});
  });

  it('returns no flags rather than throwing on a non-200', async () => {
    jest.spyOn(global, 'fetch').mockResolvedValue({
      ok: false, status: 503,
    } as Response);
    await expect(getFlags()).resolves.toEqual({});
  });
});
  • Step 2: Run it to verify it fails

Run: cd nextjs-app && npx jest __tests__/lib/flags.test.ts

Expected: FAIL — Cannot find module '@/lib/flags'.

  • Step 3: Write the module

Create nextjs-app/lib/flags.ts:

/**
 * Reading feature flags.
 *
 * Server-side only. No flag value reaches the browser bundle, and there is no
 * Unleash dependency in package.json — the SDK lives in FastAPI, which already
 * owns every other piece of data this app renders.
 *
 * Flags are declared in backend/flags.py. A purely front-end flag still has to
 * be declared there; it is a flat data edit, and the return is that one list
 * answers "what flags exist" for the whole system.
 */

export type Flags = Record<string, boolean>;

/*
 * Reading flags pins the calling route to this ISR floor: Next uses the LOWEST
 * revalidate among a route's fetches to set the whole route's revalidation
 * frequency. 300s matches what /school/[slug] already sits at, so a page that
 * reads flags is no more dynamic than a school page already is.
 *
 * It is also what makes a flip propagate without a webhook: five minutes on
 * school pages, an hour on place pages, against flags that flip monthly.
 */
export const FLAGS_REVALIDATE = 300;

const API = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL
  || 'http://localhost:8000/api';

/** Every flag and its value. Never throws: an unreadable flag is a dark one. */
export async function getFlags(): Promise<Flags> {
  try {
    const res = await fetch(`${API}/flags`, {
      next: { revalidate: FLAGS_REVALIDATE },
    });
    if (!res.ok) return {};
    return await res.json();
  } catch {
    return {};
  }
}
  • Step 4: Run the tests to verify they pass

Run: cd nextjs-app && npx jest __tests__/lib/flags.test.ts && npx tsc --noEmit

Expected: PASS, 3 tests, typecheck clean.

  • Step 5: Commit
git add nextjs-app/lib/flags.ts nextjs-app/__tests__/lib/flags.test.ts
git commit -m "feat(flags): server-side getFlags for the frontend"

Task 5: Put admission_distance behind the flag

Files:

  • Modify: backend/app.py:809
  • Modify: backend/tests/test_flags.py

Interfaces:

  • Consumes: backend.flags.is_enabled.
  • Produces: /api/schools/{urn} omits admission_distance unless the flag is on.

One gate, at the source. The frontend needs no change: DistanceSection already returns null when admissionDistance?.distance_m == null, and PrimarySchoolSections/SecondarySchoolSections already condition the admissions block on (admissions || admissionDistance). Only 57 local authorities publish cut-offs, so the off-path is the commonest path on the site and is well covered already.

  • Step 1: Write the failing tests

Append to backend/tests/test_flags.py:

def _school_payload(monkeypatch, *, flag_on: bool):
    """Fetch one school's payload with the distance flag forced on or off.

    The DataFrame shape is copied from test_school_details.py rather than
    minimised: the endpoint reads a wide set of GIAS columns, and a trimmed
    frame fails for reasons that have nothing to do with flags.
    """
    import numpy as np
    import pandas as pd
    from fastapi.testclient import TestClient
    from backend import app as app_module

    df = pd.DataFrame([{
        "urn": 150275,
        "school_name": "West London Performing Arts Academy",
        "phase": "Secondary",
        "school_type": "Special post 16 institution",
        "trust_name": None,
        "religious_denomination": "Does not apply",
        "gender": None,
        "age_range": "16-25",
        "admissions_policy": None,
        "capacity": np.nan,
        "gias_total_pupils": np.nan,
        "headteacher_name": None,
        "website": None,
        "ofsted_grade": np.nan,
        "local_authority": "Ealing",
        "address": "268 Northfield Avenue, London, W5 4UB",
        "postcode": "W5 4UB",
        "latitude": 51.4986,
        "longitude": -0.3148,
        "year": np.nan,
        "total_pupils": np.nan,
        "eligible_pupils": np.nan,
        "rwm_expected_pct": np.nan,
    }])

    monkeypatch.setattr(app_module, "load_school_data", lambda: df)
    # Two arguments: get_supplementary_data(db, urn). See backend/app.py:763.
    monkeypatch.setattr(
        app_module, "get_supplementary_data",
        lambda db, urn: {"admission_distance": {"distance_m": 772.49,
                                                "year": 2024}})
    monkeypatch.setattr(flags, "is_enabled", lambda name: flag_on)

    client = TestClient(app_module.app, raise_server_exceptions=False)
    res = client.get("/api/schools/150275")
    assert res.status_code == 200, res.text
    return res.json()


def test_the_distance_field_is_absent_when_the_flag_is_off(monkeypatch):
    """Absent, not null, and withheld at the source.

    /api/schools/ is public and unauthenticated. Leaving a withheld field in
    the payload while declining to render it hands the record to anyone who
    opens the network tab — the reasoning already recorded in c9a1892.
    """
    body = _school_payload(monkeypatch, flag_on=False)
    assert "admission_distance" not in body


def test_the_distance_field_is_present_when_the_flag_is_on(monkeypatch):
    body = _school_payload(monkeypatch, flag_on=True)
    assert body["admission_distance"]["distance_m"] == 772.49
  • Step 2: Run them to verify they fail

Run: uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests/test_flags.py -q

Expected: FAIL on test_the_distance_field_is_absent_when_the_flag_is_off — the key is present with value None.

  • Step 3: Gate the field

In backend/app.py, replace line 809:

        "admission_distance": supplementary.get("admission_distance"),

with:

        # Behind a flag, and withheld at the source rather than rendered-but-
        # hidden: this endpoint is public and unauthenticated, so a field left
        # in the payload is a published field. The key is absent, not null —
        # null would state that this school has no cut-off, which is a
        # different claim from "we are not publishing cut-offs".
        **({"admission_distance": supplementary.get("admission_distance")}
           if flags.is_enabled("admission_distance") else {}),
  • Step 4: Run the backend suite

Run: uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q

Expected: all pass.

  • Step 5: Check the frontend type tolerates an absent key

Run: cd nextjs-app && npx tsc --noEmit

lib/types.ts:364 declares admission_distance: SchoolAdmissionDistance | null. An absent key is undefined, which that type does not admit. Change it to:

  /**
   * Absent — not null — when the admission_distance flag is off. Null means
   * "this school has no published cut-off"; absent means "cut-offs are not
   * being published at all". They are different claims and the type says so.
   */
  admission_distance?: SchoolAdmissionDistance | null;

app/school/[slug]/page.tsx already reads it as admission_distance ?? null, so no call site changes.

  • Step 6: Run the frontend suite

Run: cd nextjs-app && npx jest && npx tsc --noEmit && npm run build

Expected: all pass, build green.

  • Step 7: Commit
git add backend/app.py backend/tests/test_flags.py nextjs-app/lib/types.ts
git commit -m "feat(flags): ship last-distance-offered dark behind a flag"

Task 6: Flag-aware E2E journeys

Files:

  • Modify: e2e/tests/journeys.spec.ts

Interfaces:

  • Consumes: GET /api/flags (reachable from Playwright, which runs against the frontend's own origin — note that the proxy denies it, so these read it through the same denial and must handle that; see Step 1).

  • Step 1: Write the journeys

The existing distance journeys at e2e/tests/journeys.spec.ts:1214–1310 use schoolWithCutoff(), which returns null and skips when no school has a figure. With the flag off they would skip silently and the suite would go green — a flag accidentally off in staging would look like a pass.

/api/flags is denied by the public proxy, so Playwright cannot read it directly. The flag's observable effect is what the tests assert on instead: whether the field appears in a school payload at all.

Insert directly above the schoolWithCutoff helper:

/**
 * Whether the last-distance-offered feature is switched on here.
 *
 * Read from the data rather than from /api/flags, which the public proxy
 * denies on purpose — the endpoint names unreleased features. The observable
 * effect is the field's presence: the flag is off iff no candidate school
 * carries an `admission_distance` key at all.
 *
 * The distinction that matters: `admission_distance: null` means this school
 * has no published cut-off, and the key being ABSENT means cut-offs are not
 * being published at all.
 */
async function distanceFeatureIsOn(page: Page): Promise<boolean> {
  for (const urn of CUTOFF_CANDIDATE_URNS) {
    const res = await page.request.get(`/api/schools/${urn}`);
    if (!res.ok()) continue;
    if ('admission_distance' in (await res.json())) return true;
  }
  return false;
}

Then add, directly after the schoolWithCutoff helper:

test('when the distance feature is on, a school with a cut-off is findable', async ({ page }) => {
  /*
   * The gate that stops the other distance journeys passing vacuously.
   *
   * They all skip when schoolWithCutoff() finds nothing, which is right when
   * the feature is off — but it means a feature that is *supposed* to be on
   * and is silently broken shows up as a green run full of skips. This test
   * fails in that case.
   */
  test.skip(!(await distanceFeatureIsOn(page)),
    'the admission_distance flag is off in this environment');

  expect(await schoolWithCutoff(page),
    'the distance feature is on, but no candidate school has a cut-off — '
    + 'the flag is on and the data or the query behind it is broken')
    .not.toBeNull();
});

test('with the distance feature off, the section is absent rather than empty', async ({ page }) => {
  // Shipping dark means the page renders as it did before the feature existed,
  // not as a feature with its content removed.
  test.skip(await distanceFeatureIsOn(page),
    'the admission_distance flag is on in this environment');

  // A school that exists, found rather than hardcoded — a 404 page would
  // satisfy the absent-heading assertion without proving anything.
  //
  // A plain loop, not Array.find: find's predicate is synchronous, so an async
  // one returns a Promise, every Promise is truthy, and it would always hand
  // back the first URN whether or not that school exists.
  let urn: number | null = null;
  for (const candidate of CUTOFF_CANDIDATE_URNS) {
    if ((await page.request.get(`/api/schools/${candidate}`)).ok()) {
      urn = candidate;
      break;
    }
  }
  expect(urn, 'no candidate school resolves in this environment').not.toBeNull();

  await page.goto(`/school/${urn}`);
  await expect(page.locator('h1')).toBeVisible();

  await expect(page.getByRole('heading', { name: /How far away are you\?/ }))
    .toHaveCount(0);
});

test('/api/flags is not reachable from the public internet', async ({ page }) => {
  // It names every unreleased feature and whether it is on. Next reads it
  // server-side over the Docker network; the public proxy must deny it.
  const res = await page.request.get('/api/flags');
  expect(res.status()).toBe(404);
});
  • Step 2: Verify the spec compiles and the tests are collected

Run: cd e2e && npx playwright test --list

Expected: the three new titles appear, total count rises by 3.

  • Step 3: Commit
git add e2e/tests/journeys.spec.ts
git commit -m "test(e2e): make the distance journeys fail loudly, not skip quietly"

Task 7: Wire the environment variables

Files:

  • Modify: docker-compose.yml
  • Modify: docker-compose.portainer.yml
  • Modify: docker-compose.portainer.staging.yml

Interfaces:

  • Consumes: the tokens from Task 1.

  • Produces: a backend that can reach Unleash in each environment.

  • Step 1: Add the variables to all three stacks

In each file, in the backend service's environment block, after the TYPESENSE_API_KEY line, add:

      UNLEASH_URL: ${UNLEASH_URL:-}
      UNLEASH_API_TOKEN: ${UNLEASH_API_TOKEN:-}

Unset by default on purpose: an environment without them has every flag off, which is the correct dark state rather than a failure.

  • Step 2: Add the cache volume to the two Portainer stacks

The SDK's disk cache must survive a container restart, or a restart during an Unleash outage reverts a released feature to dark.

In docker-compose.portainer.yml and docker-compose.portainer.staging.yml, add to the backend service:

    volumes:
      - unleash_cache:/app/.unleash

(If the backend service already has a volumes: block, add the one line to it rather than a second block.)

And to each file's top-level volumes: block:

  unleash_cache:
  • Step 3: Document the Portainer variables

In docker-compose.portainer.staging.yml, add to the header comment list:

#   UNLEASH_URL            — http://<unleash-ip>:4242/api (empty = all flags off)
#   UNLEASH_API_TOKEN      — Unleash *client* token, environment: development

And the same in docker-compose.portainer.yml with environment: production.

  • Step 4: Commit
git add docker-compose.yml docker-compose.portainer.yml docker-compose.portainer.staging.yml
git commit -m "chore(flags): wire UNLEASH_URL and the SDK cache volume into the stacks"

Task 8: Open the PR

  • Step 1: Run everything
uv run --quiet --with-requirements requirements.txt --with pytest --with "httpx==0.27.0" python -m pytest backend/tests -q
cd nextjs-app && npx jest && npx tsc --noEmit && npm run build
cd ../e2e && npx playwright test --list

Expected: backend green, frontend green, typecheck clean, build green, e2e collects.

  • Step 2: Open the PR against main

Body must state: the flag defaults off, so merging changes nothing visible in any environment until someone toggles it in Unleash; and that Task 1's manual Portainer steps are a prerequisite for the flag to be togglable at all.

Notes for the executor

Nothing here needs a running Unleash. Every test passes with UNLEASH_URL unset, which is also what CI gets. Task 1's manual steps only decide whether the flag can be turned on, not whether the code works.

The staleness test will fail on 2026-11-21 if admission_distance is still declared. That is intended — see the docstring. It fails on whatever PR is open at the time, which is the mechanism, not a bug in it.

Do not add a webhook. An earlier draft of the spec had one; §4 records why it was removed and why time-based revalidation is enough.