audit-sectors · git:20260723.e9058ad · 2026-07-23 · sha256 4b4d2c8b92e262b9

audit-sectors git:20260723.e9058adA

Immutable. This exact content never changes and is served at /api/v1/blob/4b4d2c8b92e262b9.

---
name: audit-sectors
description: Audit a government-sector dashboard (the /governance/sectors hub tile + its /sector/:id or bespoke screen) against the raw sources. Verifies the hub headline KPI reconciles to its declared basis (budget/payout/procurement/headcount/score), the sector's awarder EIK-set is honest (no wrong-EIK leakage like the МВР €370M-into-defense error, no missing sibling body, every EIK real and correctly attributed), the four EIK-set copies stay in lockstep (generator SECTOR_EIKS ↔ SECTOR_DASHBOARDS.members ↔ *_SECTOR_EIKS reference data ↔ SECTOR_BROWSE_PACKS), and the derived/bespoke tiles reconcile (category split sums to header, sum==100%, HHI denominator guard, no double-count); then adds PG-backed regression tests and fixes the issues. Use when the user asks to audit / verify / fact-check a sector dashboard or its hub tile, after building a new sector, or when a sector's headline or awarder list looks off. Prefers generic-engine and generator fixes over per-sector hacks; treats a new basis / new sector-hub entry / an EIK-inclusion-boundary judgment call as a design decision to confirm, and otherwise auto-fixes via /implement-plan.
allowed-tools:
  - Read
  - Bash
  - Edit
  - Write
  - Agent
  - Skill
---

# Audit Sectors skill

A **government sector** (e.g. `defense`, `regional`, `water`) is presented in two
places that must both be honest:

1. **The hub tile** on `/governance/sectors` — a single headline € (or score /
   headcount) number with a one-word caption. The number is precomputed per scope
   into `data/procurement/derived/sector_stats.json` and carries a `basis`
   (`budget | payout | procurement | headcount | score`) that decides both the
   caption and which source it came from.
2. **The sector screen** — either the generic `/sector/:id`
   (`SectorDashboardScreen`, config in `src/screens/sector/sectorDashboards.ts`)
   which rolls a curated **awarder EIK-set** through `awarder_group_model`, or a
   bespoke vanity path (`/defense`, `/judiciary`, `/water`, `/culture`,
   `/pensions`, `/education`, `/sector/administration`) that mixes contract-derived
   tiles with curated JSON data files.

A sector is only as honest as (a) its headline reconciling to its declared basis,
(b) its EIK allowlist including exactly the right bodies, and (c) those two views
agreeing. A wrong EIK inflates the total (the defense audit's near-miss: МВР
directorates ДУССД `129010157` €301M + ДКИС `129010698` €70M would have been a
€370M error); a missing sibling understates it; a moved JSON field silently zeros
a tile; a budget *function* mislabels unrelated spend as the sector. This skill
audits all of that against the raw data and fixes it.

Worked precedents this skill is distilled from: the **Отбрана (МО) pack audit**
(3 adversarial agents — EIK-allowlist leakage, name-regex false positives, budget
function ≠ defense, per-platform aggregate not reconciling to header, alias-EIK
double-count) and the **sectors-hub KPI-basis rework** (procurement-only headline
understated Култура €3k vs a €234M budget → per-basis honest headline).

## The four EIK-set copies (keep in lockstep — Failure mode E)

The same sector's awarder allowlist is declared in up to four places. They must
match (modulo intentional single-member collapses):

| Copy | File | Shape |
|------|------|-------|
| **Reference data** (canonical) | `src/lib/<domain>ReferenceData.ts` | `<D>_ENTITIES[]` (per-entity name + universe + alias flag) → `<D>_SECTOR_EIKS` |
| **Hub generator** (procurement basis only) | `scripts/db/gen_procurement/sector_stats.ts` | `SECTOR_EIKS: Record<id, string[]>` — imports the `<D>_SECTOR_EIKS` |
| **Dashboard config** | `src/screens/sector/sectorDashboards.ts` | `SECTOR_DASHBOARDS[id].members[]` (+ `leadEik`) — maps `<D>_ENTITIES` |
| **Browse pack** | `src/screens/components/procurement/sectorPacks.tsx` | `SECTOR_BROWSE_PACKS[id].eiks` |

**Reference data is the single source of truth** — every other copy should derive
from `<D>_SECTOR_EIKS` / `<D>_ENTITIES`, never re-hardcode digits. A copy that
hardcodes its own list is the drift bug to fix (point it back at the export).
Curate the allowlist **by EIK, never by name regex** — a name sweep false-positives
(`7-МО Основно училище` matched "МО"; the town of Раковски matched the Раковски
military academy; EIK prefix `1290*` is the whole security-services range, mostly
МВР — two МВР directorates sit adjacent to МО and would be a €370M error).

## Basis → source of truth (what the hub headline must reconcile to)

`sector_stats.json[scope][id] = { kind, basis, value, year?, note? }`, generated by
`scripts/db/gen_procurement/sector_stats.ts` (`npm run db:gen-sector-stats`; reads
PG after `db:refresh` + the bespoke JSON files). Per basis:

| Basis | Sectors | Source the value MUST equal | Owner skill (to refresh the source) |
|-------|---------|-----------------------------|-------------------------------------|
| **procurement** (windowed Σ `contracts.amount_eur`) | roads, water, transport, energy, environment | PG `contracts` for `SECTOR_EIKS[id]` in the scope window | update-procurement |
| **budget** (annual приет expenditure) | defense, security, justice, culture, edu, tourism, social, regional | `data/budget/ministries/<node>.json` `years[].expenditure.amountEur` | update-budget |
| **budget** (annual, `note:'adjusted'`) | revenue (НАП), customs (АМ) | `data/budget/agencies/{nap,customs}.json` (own годишен уточнен план) | update-budget |
| **payout** (annual) | pension (ДОО), health (НЗОК), agri (ДФЗ CAP) | `budget/noi/funds.json`, `budget/nzok/execution_history.json`, `agri_payloads` (kind=overview) | update-noi / update-nzok / update-agri |
| **score** (annual) | schools | `data/indicators.json` `series.dzi` (unweighted oblast mean) | update-indicators |
| **headcount** (annual) | administration | `data/budget/personnel.json` `national[year].positions.filled` | update-administration |

The generator warns (not errors) when a bespoke source produces no data — an
all-zero or stale tile is a silent bug, not a legitimate 0. **The basis choice is
editorial:** a pass-through ministry (МРРБ controls ~€1.06bn but procures ~€100M)
must front its *budget*, not its thin procurement line. Never front a budget
*function* — "Отбрана и сигурност" bundles police+courts+prisons (~12%); use the
administrative ПРБ **node**, Eurostat COFOG, or the МО line for defense-only.

## Setup

```bash
docker ps --format '{{.Names}} {{.Ports}}' | grep postgres   # electionsbg-pg :5433 must be up
PG="postgres://postgres:postgres@localhost:5433/electionsbg"
SECTOR=<id>                                                   # e.g. defense, regional, water
```

Identify the sector's `id`, `to` route, `agency`, and cluster from
`src/screens/governance/sectorRegistry.ts`; its basis + source from the table
above; its EIK-set from the four copies. Never trust `sector_stats.json` as the
source of truth for *correctness* — it is the OUTPUT of the generator; regenerating
it cannot catch a wrong-EIK / wrong-basis / wrong-node bug on its own.

## Phase 1 — Reproduce the hub headline against its source

Pick a concrete scope (start with `all`, then the current parliament `ns:<election>`
and a `y:<year>`). Read the emitted value, then reproduce it from the declared
source and compare.

```bash
# What the tile shows (per scope). scopeKey = all | ns:<election> | y:<year>.
node -e "const d=require('./data/procurement/derived/sector_stats.json');console.log(JSON.stringify(d['all']['$SECTOR'],null,2))"
```

- **procurement basis** — reproduce the windowed sum for the EIK-set:
  ```bash
  psql "$PG" -tAc "
  select coalesce(round(sum(amount_eur)),0) eur
  from contracts
  where tag='contract' and awarder_eik in (<SECTOR_EIKS[id]>)
    and date >= '<from>' and date < '<to>';"   # ns/all/y bounds; all → no date filter
  ```
  Must equal `value`. If HIGHER, a wrong EIK is leaking (Failure mode C); if LOWER,
  a member is missing (Failure mode D).
- **budget basis** — `value` must equal the source file's `years[year].expenditure.amountEur`
  for the resolved `year` (the scope's year if present, else latest). Confirm the
  `<node>`/agency file is the right body and that `year` matches the caption.
- **payout / score / headcount** — reconcile `value` against the named source file's
  latest (or scope-year) point. A `0` here almost always means a moved field
  (Failure mode B) — grep the source JSON for the field the generator reads.

## Phase 2 — Validate the EIK-set and the tiles (the core audit)

### EIK-set membership (procurement/group sectors)

Per member EIK, ask "is this genuinely THIS sector, and is anything missing?"

```bash
# Per-EIK contract € + the awarder_name(s) the corpus knows it by — spot leakage
# (a wrong body) and name-variant EIK confusion.
psql "$PG" -F$'\t' -tAc "
select awarder_eik, min(awarder_name), count(*), round(sum(amount_eur))
from contracts where tag='contract' and awarder_eik in (<eiks>)
group by awarder_eik order by 4 desc nulls last;"
```

Then diff the four EIK-set copies (Failure mode E):

```bash
# Print each copy and confirm they're equal (or an intentional single-member collapse).
grep -n "SECTOR_EIKS\|<D>_SECTOR_EIKS" scripts/db/gen_procurement/sector_stats.ts
grep -n "eik:" src/screens/sector/sectorDashboards.ts        # SECTOR_DASHBOARDS[id].members
grep -n "$SECTOR" src/screens/components/procurement/sectorPacks.tsx
sed -n '/<D>_SECTOR_EIKS\|<D>_ENTITIES/p' src/lib/<domain>ReferenceData.ts
```

### Failure modes (each seen in a real audit)

| # | Symptom | Detect | Root cause / fix |
|---|---------|--------|------------------|
| **A** | Basis understates/mislabels the sector (Култура €3k; a pass-through ministry fronting its thin tender line) | Compare headline to the body's real budget/payout | Wrong `basis` — move to budget/payout in the generator. **Editorial → confirm.** |
| **B** | Tile is `0` / stale despite a live source | grep the source JSON for the field the generator reads (`expenditure.amountEur`, `series.dzi`, `positions.filled`, agri `headline.totalEur`) | Moved/renamed field or unloaded table; the generator only *warns*. Fix the field path or re-run the owner update-* skill. |
| **C** | Group total >> Σ(true members); a wrong body inflates it | per-EIK € + `awarder_name` (above) — an unexpected big-value body | Wrong EIK in `<D>_ENTITIES`. Remove it (defense near-miss: МВР ДУССД/ДКИС; ДА „Държавен резерв" 831913661). |
| **D** | Total < reality; a sibling body's spend absent | free-text the parent/ownership name across ALL awarders: `... where awarder_name ilike '%<term>%' group by awarder_eik order by 4 desc` — a big related buyer not in the set | Add the missing EIK to `<D>_ENTITIES` (Българска армия units, ВМА). |
| **E** | The four EIK-set copies disagree | diff them (above) | A copy hardcodes digits instead of importing `<D>_SECTOR_EIKS`. Repoint it at the export. |
| **F** | A dominant sub-entity distorts a category/HHI tile; a parent+child EIK double-count; €0 consortium members counted | segment by universe; check the HHI denominator is Σ suppliers WITH eik (not headline); look for parent EIK whose children are also listed | Add a universe Select (ВМА = 46% of the МО group → segmentable); keep the alias-EIK / consortium guards; never let both a parent and its own contracts' EIK sum twice. |
| **G** | Caption year/period ≠ the value's actual year | check `year` in the emitted stat vs the scope | `annual()` resolves to latest when the scope year is absent — confirm that's intended; procurement carries no year (scope window names it). |
| **H** | A budget *function* labelled as the sector | is the node an administrative ПРБ or a function? | "Отбрана и сигурност" ≠ defense. Use the admin node / COFOG. |
| **I** | A bespoke tile's parts don't sum to its header (aviation_sustainment €374.7M/195 ≠ header 376/219); category split ≠ 100% | recompute the tile's aggregate from its JSON; assert Σ==header, Σpct==100 | Add the missing bucket ("Двигатели и друго" €1.3M/24) / fix the parser; pin with an assert in the data file's generator. |

### Bespoke-screen data files

For a vanity-path sector (`/defense`, `/judiciary`, …), each curated JSON under
`data/<domain>/*.json` is owned by an `update-<domain>` skill. Reconcile each tile's
internal totals (Failure mode I), confirm no leva post-2026 (EUR only), and confirm
the file is fresh via its `source`/date. Don't hand-edit a scraped file — re-run its
skill; hand-fix only a genuine parser/aggregation bug.

## Phase 3 — Classify fixes

Prefer the highest tier that solves the **class** of problem — never a per-sector
hack for a systemic bug.

1. **Data-source / generator level** — the honest default for most findings:
   - fix a wrong/missing EIK in `src/lib/<domain>ReferenceData.ts` `<D>_ENTITIES`
     (propagates to all four copies if they import it);
   - repoint a hardcoded EIK-set copy back at `<D>_SECTOR_EIKS`;
   - fix the generator's `<node>`/agency file, basis assignment, or a moved
     source-field path;
   - re-run the owning `update-<domain>` skill to refresh a stale/zero bespoke source.
2. **Generic engine** — when the bug is a class shared by every sector, fix once +
   test: the `awarder_group_model` SQL (`scripts/db/schema/pg/061_awarder_group_model.sql`),
   `buildAwarderModelFromAggregates` / a pack classifier
   (`src/lib/awarderModel.ts`), the shared HHI/single-bid/caption helpers, or the
   generator's `annual()`/scope-window logic. Keep the SQL, the endpoint
   (`functions/db_routes.js`), and the client fold in lockstep; re-apply `061` to
   Cloud SQL when the SQL changes.
3. **Registry / editorial decision** — **STOP and propose to the user.** These are
   design calls, not bug fixes:
   - a **new basis** or changing a sector's basis (procurement ↔ budget ↔ payout);
   - **adding/removing a sector** from `sectorRegistry.ts` or a cluster;
   - an **EIK-inclusion boundary** judgment ("is ВМА part of *defense*?", "does
     transport include Метрополитен?") — where the answer changes the headline
     materially;
   - a **new universe segmentation** or a new hub-wide narrowing control.
   Present the specific choice + its EUR impact; don't hardcode it silently.

## Phase 4 — Regression tests (always)

Create/extend a PG-backed data test `scripts/db/tests/sector_stats.data.test.ts`
(model on `scripts/db/tests/procurement_dossiers.data.test.ts`; auto-skips when
Postgres is down). For the audited sector, pin with **bands and inequalities**
(never exact equality — the corpus grows fortnightly, budgets get new years):

- headline € **band** for a fixed scope (ceiling catches EIK re-leakage / re-expansion,
  floor catches an over-trim or a zeroed source);
- **the four EIK-set copies are equal** — assert `SECTOR_EIKS[id]`,
  `SECTOR_DASHBOARDS[id].members.map(m=>m.eik)`, `<D>_SECTOR_EIKS`, and
  `SECTOR_BROWSE_PACKS[id].eiks` are the same set (this is the drift tripwire);
- a **removed wrong EIK is absent** from `<D>_ENTITIES` (e.g. МВР `129010157` not in
  defense);
- a **signature true-member EIK is present** and its per-EIK € > floor;
- for a budget/payout sector: `sector_stats.json[scope][id].basis === '<expected>'`
  and `value` within a band of the source file's resolved year;
- a bespoke tile's `Σ(parts) === header` assert where Failure mode I bit.

EIKs and node slugs are stable — use them as anchors.

## Phase 5 — Apply, regenerate, verify

- Regenerate the hub blob: `npm run db:gen-sector-stats` (needs PG up + `db:refresh`
  current). Confirm the audited sector moved the expected way and that no other
  sector shifted unexpectedly (diff old vs new `sector_stats.json`).
- Gates: `npx tsc -b` (NOT `tsc --noEmit -p tsconfig.json` — that compiles nothing),
  `npm run lint`, the touched vitest suites, and
  `npx vitest run scripts/db/tests/sector_stats.data.test.ts`.
- Live-check: `/governance/sectors` (the tile's number + caption) and the sector's
  screen (`to` route) on the dev server — KPI, awarders list, and any tile you
  touched.
- `data/procurement/derived/sector_stats.json` is **bucket-served** (in the
  `bucket:sync` include-list alongside `hub_stats.json`), not Firebase-hosted.
  Deploy the blob with
  `npm run bucket:sync:paths -- procurement/derived/sector_stats.json`
  (plus any refreshed bespoke `data/<domain>/*.json`). Code changes (reference data,
  generator, screens) need the frontend deploy (`npm run deploy`); an engine/SQL
  change also needs the functions deploy (`firebase deploy --only functions`) and
  the `061` re-apply to Cloud SQL.

## Decision: auto-fix vs confirm

Produce a concise findings summary first (each issue: EUR + count impact, root
cause, proposed fix tier). Then:

- **If any fix is a Phase 3 tier-3 editorial/registry decision** — a new/changed
  basis, adding/removing a sector, an EIK-inclusion-boundary judgment, a new
  segmentation — **STOP and ask**, presenting the specific choice and its impact.
- **Otherwise** (clear bug fixes: a wrong/missing EIK, a hardcoded copy to repoint,
  a moved source-field, a lockstep drift, the regression tests) — **automatically
  proceed to fix via `/implement-plan`**, treating the audit's recommended fixes as
  the plan. Each fix is one step (implement → `/code-review` in a subagent →
  `/code-repair` → commit); the regression tests are their own step; regenerate +
  commit `sector_stats.json` alongside the code that produced it.

One sector fix = one focused change; keep the four EIK-set copies in lockstep and
commit the regenerated `sector_stats.json` with the code that changed it.