Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

- When investigating a discrepant Microcosm calibration target, follow [the shared investigation workflow](docs/ai/workflows/investigate-microcosm-target.md) and its linked role-specific reviews.
- Before starting the application for Cross-dataset work, configure exactly one DIR or URL artifact location for each country you will use, as described in [the Cross-dataset application configuration](docs/cross-dataset-api.md#configure-the-application).
- Adding a Microcosm country is one entry in `frontend/lib/microcosm/countries.ts`; pages are gated by the registration's capabilities and the release artifact's `country` block, never by country code (see [the spec-driven countries note](docs/spec-driven-countries.md)).
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

- When investigating a discrepant Microcosm calibration target, follow [the shared investigation workflow](docs/ai/workflows/investigate-microcosm-target.md) and its linked role-specific reviews.
- Before starting the application for Cross-dataset work, configure exactly one DIR or URL artifact location for each country you will use, as described in [the Cross-dataset application configuration](docs/cross-dataset-api.md#configure-the-application).
- Adding a Microcosm country is one entry in `frontend/lib/microcosm/countries.ts`; pages are gated by the registration's capabilities and the release artifact's `country` block, never by country code (see [the spec-driven countries note](docs/spec-driven-countries.md)).
53 changes: 47 additions & 6 deletions docs/spec-driven-countries.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ from `cross_dataset.frontend_bundle.v1`.

## Code-owned metadata that remains

`COUNTRY_REPO` should be the only required registration point, but today adding a
country also touches closed country unions/parsers and selector lists, dataset and
national-geography labels, overview and target-browser copy, public/private link
behavior, and feature checks such as US-only staging and navigation. Publisher
`COUNTRY_REGISTRY` in `frontend/lib/microcosm/countries.ts` is the only required
registration point: country unions and parsers, selector lists, dataset and
national-geography labels, public/private link behavior, and page gating read
the registration or the artifact `country` block. Overview and target-browser
copy still sit in legacy per-country tables with a generic fallback. Publisher
display names remain in a shared TypeScript map. Target decomposition still has a
generic filter-pattern table plus legacy US name grammar (FIPS/state, filing
status, return type, income band, and qualifying-child rules).
Expand All @@ -36,8 +37,9 @@ should not grow another country branch.
`frontend/lib/microcosm/third-country-conformance.test.ts` defines a synthetic
fourth country, `zz`, with four targets, filter-coded facets, a release
description, and Chronicle record IDs whose publisher prefix is unknown to the
label map. Registering its repository in `COUNTRY_REPO` must be the only country
specific code change. The same builders used by US/UK/BE must then produce:
label map. Registering its repository in `COUNTRY_REGISTRY` must be the only
country specific code change. The same builders used by US/UK/BE must then
produce:

- the normal overview and targets response shapes and section order;
- the artifact description in the existing provenance-note slot;
Expand All @@ -59,6 +61,45 @@ be enabled without adding `zz` conditionals or tables.
| Filter-pattern decomposition, region/sex/age value maps, and legacy US target-name parsing | A `dimensions` dictionary in `calibration_diagnostics` (label, semantic role, value labels, ordering) plus `targets[].dimensions` values. Geography dimensions also declare their level/id so no country geography fallback is needed. |
| Source/variable guesses from flat target names | Structured `targets[].source` and `targets[].variable` identifiers, with the publisher still traceable to `metadata.chronicle_record_ids`. |

### Implemented: the `country` block

`release_manifest.country` is read by `releaseCountry` in
`frontend/lib/microcosm/latest-artifact.ts` and served as `country` on the
overview summary and the target-diagnostics page (client type
`MicrocosmArtifactCountry`). Registration lives in
`frontend/lib/microcosm/countries.ts`; adding a country is one entry there.

```json
{
"country": {
"code": "be",
"label": "Belgium",
"geography_id": null,
"geography_label": "Belgium",
"repository_visibility": "private",
"capabilities": ["calibration", "targets", "compare", "cross_dataset"]
}
}
```

Merge rule: every field defaults to the registration; a well-typed string
field in the block overrides it (`label`, `geography_id`, `geography_label`,
and `repository_visibility` as `"public"` or `"private"`). `code`, when
present, must equal the selected country after lower-casing, otherwise the whole
block is ignored: the dashboard is selected by registry and an artifact cannot
re-route it. `capabilities` is filtered to the enumerated set and intersected
with the registration, so an artifact can narrow what a deployment serves but
never widen it. Unknown keys are ignored. The resolved `geography_label` is the
national geography for rows that carry none.

Capabilities: `calibration`, `targets`, `compare`, `cross_dataset`, `staging`,
`model_coverage`, `pipeline`, `variables`, `external_checks`. Navigation, the
staging loaders and hooks, and the staging page gate on capability membership.

A registration with `fixture: true` (the conformance country `zz`) is a valid
country for parsers and builders but is never listed in selectors or the
release-alert allowlist.

Schema readers must remain backward-compatible while published releases migrate.
After migration, name/filter parsing is a legacy adapter selected by artifact
schema version, never by country.
24 changes: 13 additions & 11 deletions frontend/app/api/hf-webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { timingSafeEqual } from "node:crypto";

import { NextResponse } from "next/server";

import type { MicrocosmCountry } from "@/lib/microcosm/latest-artifact";
import { selectableCountries, type MicrocosmCountry } from "@/lib/microcosm/countries";
import { microcosmRepo } from "@/lib/microcosm/latest-artifact";
import { postReleaseAlert } from "@/lib/slack";

export const runtime = "nodejs";
Expand All @@ -26,18 +27,19 @@ interface WebhookPayload {

const TAG_PREFIX = "refs/tags/";

// Only these repos may trigger a release alert. The webhook secret is shared
// across US and UK, so without an allowlist a valid caller could spoof an
// arbitrary repo name into either Slack channel.
const ALLOWED_REPOS: Record<string, MicrocosmCountry> = {
// Deprecated upstream identifiers: Hugging Face webhook payloads still use
// the former Populace dataset repository names.
"policyengine/populace-us": "us",
"policyengine/populace-uk-private": "uk",
};
// Only the registered country repositories may trigger a release alert. The
// webhook secret is shared across countries, so without an allowlist a valid
// caller could spoof an arbitrary repo name into any Slack channel. Fixture
// registrations are never allowlisted. Repositories are the env-resolved ones
// the dashboard actually reads (a POPULACE_*_HF_REPO override moves the
// allowlist with it); Hugging Face webhook payloads carry the repositories'
// former Populace names, as registered.
const ALLOWED_REPOS = new Map<string, MicrocosmCountry>(
selectableCountries().map((country) => [microcosmRepo(country).toLowerCase(), country]),
);

function countryForRepo(repoName: string): MicrocosmCountry | null {
return ALLOWED_REPOS[repoName.toLowerCase()] ?? null;
return ALLOWED_REPOS.get(repoName.toLowerCase()) ?? null;
}

// Constant-time secret check. HF sends the configured secret as the
Expand Down
5 changes: 3 additions & 2 deletions frontend/app/api/microcosm/staging/compare/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";

import { hasCapability } from "@/lib/microcosm/countries";
import {
loadPointerReleaseId,
parseCountry,
Expand All @@ -17,11 +18,11 @@ export async function GET(request: Request) {
const country = parseCountry(url.searchParams.get("country"));
const runId = url.searchParams.get("run")?.trim();
let release = url.searchParams.get("release")?.trim() || "latest";
if (!runId && country === "us") {
if (!runId && hasCapability(country, "staging")) {
return NextResponse.json({ detail: "Provide a staging run id via ?run=." }, { status: 400 });
}
try {
if (release === "latest" && country === "us") {
if (release === "latest" && hasCapability(country, "staging")) {
release = (await loadPointerReleaseId(300, country)).release_id;
}
return NextResponse.json(
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/api/microcosm/staging/run/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";

import { hasCapability } from "@/lib/microcosm/countries";
import { parseCountry, scrub } from "@/lib/microcosm/latest-artifact";
import { loadStagingRun } from "@/lib/microcosm/staging-artifact";

Expand All @@ -12,7 +13,7 @@ export async function GET(request: Request) {
const params = new URL(request.url).searchParams;
const country = parseCountry(params.get("country"));
const runId = params.get("id")?.trim();
if (!runId && country === "us") {
if (!runId && hasCapability(country, "staging")) {
return NextResponse.json({ detail: "Provide a staging run id via ?id=." }, { status: 400 });
}
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";

import { hasCapability } from "@/lib/microcosm/countries";
import { parseCountry, scrub } from "@/lib/microcosm/latest-artifact";
import { loadStagingTargetDiagnostics } from "@/lib/microcosm/staging-artifact";

Expand All @@ -12,7 +13,7 @@ export async function GET(request: Request) {
const params = new URL(request.url).searchParams;
const country = parseCountry(params.get("country"));
const runId = params.get("id")?.trim();
if (!runId && country === "us") {
if (!runId && hasCapability(country, "staging")) {
return NextResponse.json({ detail: "Provide a staging run id via ?id=." }, { status: 400 });
}
try {
Expand Down
13 changes: 12 additions & 1 deletion frontend/components/layout/country-context.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test";

import { countrySwitchUrl } from "./country-context";
import { countrySwitchUrl, isCountry } from "./country-context";

test("country switching clears bundle-specific Cross-dataset state", () => {
const switched = new URL(
Expand All @@ -25,3 +25,14 @@ test("country switching preserves route state outside Cross-dataset", () => {
expect(switched.pathname).toBe("/microcosm/targets");
expect(switched.searchParams.toString()).toBe("country=uk&variable=income_tax");
});

test("the client country parser accepts every registered country and nothing else", () => {
expect(isCountry("us")).toBe(true);
expect(isCountry("uk")).toBe(true);
expect(isCountry("be")).toBe(true);
expect(isCountry("zz")).toBe(true);
expect(isCountry("US")).toBe(false);
expect(isCountry("fr")).toBe(false);
expect(isCountry("")).toBe(false);
expect(isCountry(null)).toBe(false);
});
16 changes: 9 additions & 7 deletions frontend/components/layout/country-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import {
type ReactNode,
} from "react";

export type Country = "us" | "uk" | "be";
import {
DEFAULT_COUNTRY,
isCountry,
type MicrocosmCountry,
} from "@/lib/microcosm/countries";

const COUNTRIES = new Set<Country>(["us", "uk", "be"]);
export type Country = MicrocosmCountry;

export function isCountry(value: string | null): value is Country {
return value != null && COUNTRIES.has(value as Country);
}
export { isCountry };

const STORAGE_KEY = "microcosm-country";

Expand All @@ -41,12 +43,12 @@ interface CountryContextValue {
}

const CountryContext = createContext<CountryContextValue>({
country: "us",
country: DEFAULT_COUNTRY,
setCountry: () => {},
});

export function CountryProvider({ children }: { children: ReactNode }) {
const [country, setCountryState] = useState<Country>("us");
const [country, setCountryState] = useState<Country>(DEFAULT_COUNTRY);

useEffect(() => {
const requested = new URLSearchParams(window.location.search).get("country");
Expand Down
40 changes: 37 additions & 3 deletions frontend/components/layout/nav-items.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, test } from "bun:test";

import { hasCapability, selectableCountries } from "@/lib/microcosm/countries";

import {
isActive,
navGroupsForCountry,
Expand Down Expand Up @@ -78,19 +80,36 @@ test("preserves the Cross-dataset navigation label and route", () => {
expect(item).toEqual({
href: "/microcosm/datasets",
label: "Cross-dataset",
capability: "cross_dataset",
});
expect(isActive("/microcosm/datasets", item!)).toBe(true);
});

test("Belgium navigation keeps country-ready pages and hides US-only tools", () => {
test("every navigation item is gated by a capability, never by a country code", () => {
const items = NAV_GROUPS.flatMap((group) => group.items);
expect(items.every((item) => item.capability != null)).toBe(true);
expect(items.find((item) => item.href === "/microcosm/staging")?.capability).toBe("staging");
expect(items.find((item) => item.href === "/microcosm/model-coverage")?.capability).toBe(
"model_coverage",
);
expect(items.find((item) => item.href === "/microcosm/pipeline")?.capability).toBe("pipeline");
expect(items.find((item) => item.href === "/microcosm/variables")?.capability).toBe(
"variables",
);
expect(items.find((item) => item.label === "External checks")?.capability).toBe(
"external_checks",
);
});

test("Belgium navigation keeps country-ready pages and hides pages it lacks capabilities for", () => {
const items = navGroupsForCountry("be").flatMap((group) => group.items);
expect(items.map((item) => item.href)).toEqual([
"/microcosm",
"/microcosm/targets",
"/microcosm/datasets",
"/microcosm/compare",
]);
expect(items.every((item) => item.usOnly !== true)).toBe(true);
expect(items.every((item) => hasCapability("be", item.capability!))).toBe(true);
expect(items.map((item) => navItemHref(item, "be"))).toEqual([
"/microcosm?country=be",
"/microcosm/targets?country=be",
Expand All @@ -99,8 +118,23 @@ test("Belgium navigation keeps country-ready pages and hides US-only tools", ()
]);
});

test("US navigation lists every page", () => {
expect(navGroupsForCountry("us").flatMap((group) => group.items)).toEqual(
NAV_GROUPS.flatMap((group) => group.items),
);
});

test("artifact-narrowed capabilities hide pages the release does not serve", () => {
const groups = navGroupsForCountry("us", ["calibration", "targets"]);
expect(groups.map((group) => group.label)).toEqual(["Dataset accuracy"]);
expect(groups[0].items.map((item) => item.href)).toEqual([
"/microcosm",
"/microcosm/targets",
]);
});

test("shows Cross-dataset navigation for every selectable country", () => {
for (const country of ["us", "uk", "be"] as const) {
for (const country of selectableCountries()) {
expect(
navGroupsForCountry(country)
.flatMap((group) => group.items)
Expand Down
43 changes: 29 additions & 14 deletions frontend/components/layout/nav-items.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import type { Country } from "@/components/layout/country-context";
import {
countryCapabilities,
type CountryCapability,
} from "@/lib/microcosm/countries";

// usOnly pages run on US-specific data/runtimes (JCT scores, the PolicyEngine-US
// variable runtime) and aren't wired for UK yet.
// A page is shown when the country (or the release artifact, which can narrow
// a registration) serves its capability. Pages that run on country-specific
// data/runtimes (JCT scores, the PolicyEngine-US variable runtime, the staging
// repository) are granted only to the countries wired for them.
export interface NavItem {
href: string;
label: string;
usOnly?: boolean;
capability?: CountryCapability;
external?: boolean;
// Extra path prefixes that keep this item highlighted (drill-down views).
also?: string[];
Expand All @@ -17,41 +23,50 @@ export const NAV_GROUPS: { label: string; items: NavItem[] }[] = [
{
label: "Dataset accuracy",
items: [
{ href: "/microcosm", label: "Calibration fit" },
{ href: "/microcosm/targets", label: "Calibration targets" },
{ href: "/microcosm/model-coverage", label: "Validation reach", usOnly: true },
{ href: "/microcosm", label: "Calibration fit", capability: "calibration" },
{ href: "/microcosm/targets", label: "Calibration targets", capability: "targets" },
{
href: "/microcosm/model-coverage",
label: "Validation reach",
capability: "model_coverage",
},
// External checks (reform scores vs JCT/fiscal notes/admin actuals)
// moved to the PolicyEngine scorecard, which owns all external
// comparisons; per-release history was ingested there (issue #15).
{ href: "/microcosm/datasets", label: "Cross-dataset" },
{ href: "/microcosm/datasets", label: "Cross-dataset", capability: "cross_dataset" },
{
href: "https://www.policyengine.org/scorecard",
label: "External checks",
usOnly: true,
capability: "external_checks",
external: true,
},
],
},
{
label: "Releases",
items: [
{ href: "/microcosm/compare", label: "Compare versions" },
{ href: "/microcosm/staging", label: "Staging candidates", usOnly: true },
{ href: "/microcosm/compare", label: "Compare versions", capability: "compare" },
{ href: "/microcosm/staging", label: "Staging candidates", capability: "staging" },
],
},
{
label: "Reference",
items: [
{ href: "/microcosm/pipeline", label: "Pipeline", usOnly: true },
{ href: "/microcosm/variables", label: "Variable lookup", usOnly: true },
{ href: "/microcosm/pipeline", label: "Pipeline", capability: "pipeline" },
{ href: "/microcosm/variables", label: "Variable lookup", capability: "variables" },
],
},
];

export function navGroupsForCountry(country: Country) {
export function navGroupsForCountry(
country: Country,
capabilities: readonly CountryCapability[] = countryCapabilities(country),
) {
return NAV_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => country === "us" || !item.usOnly),
items: group.items.filter(
(item) => item.capability == null || capabilities.includes(item.capability),
),
})).filter((group) => group.items.length > 0);
}

Expand Down
Loading
Loading