diff --git a/docs/superpowers/specs/2026-08-28-site-overhead-monthly-and-oneoff-design.md b/docs/superpowers/specs/2026-08-28-site-overhead-monthly-and-oneoff-design.md
new file mode 100644
index 0000000..3b8ad96
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-28-site-overhead-monthly-and-oneoff-design.md
@@ -0,0 +1,236 @@
+# Site overhead — monthly recurring vs one-off
+
+**Date:** 2026-08-28
+**Status:** Approved (brainstorming)
+**Scope:** Dashboard site detail page. Single-card split for monthly vs one-off overhead entries. Manual add surface for both types.
+
+---
+
+## Problem
+
+The site detail page today has a single "Site P&G overheads" card that lists every `fixed_costs` row and treats it as a **monthly** cost — daily equivalent is always `cost / 21` working days.
+
+In practice, additional overhead often shows up **after** a project has started: things like a one-off compliance certificate, an emergency repair, a penalty, a survey fee. These need to be:
+
+- Logged against the same site (so they show up in the site's "Total spent")
+- Distinguished from monthly overheads so the P&G category in the burn bar isn't inflated
+- Addable from the dashboard (today, only n8n OCR writes to `fixed_costs`)
+
+There is no field on `fixed_costs`, no UI toggle, and no API route that distinguishes recurring from one-off.
+
+---
+
+## Goals
+
+- Users can add a new overhead entry directly from the site detail page.
+- Each entry is classified as either **monthly recurring** or **one-off** at entry time.
+- Both types count toward the site's "Total spent" KPI and the sites-list roll-up.
+- Existing rows are unchanged (backward-compatible).
+- n8n OCR pipelines keep writing to the same collection with no changes.
+
+---
+
+## Non-goals
+
+- Editing or deleting entries from the dashboard (manual entries today; can be added later).
+- A new "audit" field tracking `logged_by: ocr | manual` (deferred).
+- Renaming the card or moving it elsewhere on the page.
+- Bulk import of one-off costs.
+- Per-site reporting history or roll-back.
+
+---
+
+## Design
+
+### 1. Architecture & data layer
+
+**Schema** — `scripts/pb-bootstrap-schema.py` (`fixed_costs` collection, around lines 147–159):
+
+```python
+{"name": "is_recurring", "type": "bool", "required": False, "options": {"default": True}}
+```
+
+- Type `bool`, not required, default `true`.
+- Existing rows behave identically without backfill (default true → still monthly).
+- PocketBase collection default means n8n OCR writes (which don't set the field) get `true` automatically.
+
+**Type** — `src/lib/pb-types.ts` (`FixedCostRecord`, around lines 53–62):
+
+```ts
+is_recurring?: boolean // default true. false = one-off.
+```
+
+The `PnGItem` interface in `src/app/dashboard/sites/[id]/site-detail-view.tsx` (lines 31–38) gains one field:
+
+```ts
+is_recurring?: boolean
+```
+
+**New API route** — `src/app/api/sites/[id]/fixed-costs/route.ts`:
+
+- `POST` only (matches existing site route style).
+- Path-scoped to site id; server derives `site` and `project_code` from the path.
+- Body schema:
+ ```ts
+ {
+ description: string // required, 1–200 chars
+ cost: number // required, > 0
+ date_logged: string // required, ISO YYYY-MM-DD
+ notes?: string // optional, ≤500 chars
+ is_recurring: boolean // required
+ }
+ ```
+- `201` + new record on success. `400` with `{ error: "validation", field?, message? }` on bad input. `404` if site id doesn't resolve. `500` if PB write throws.
+
+### 2. UI components
+
+**Modified card** — `src/app/dashboard/sites/[id]/site-detail-view.tsx` (currently lines 393–469):
+
+```
+┌─ Site P&G overheads ─────────────────────── [+ Add]┐
+│ Monthly preliminaries & general running costs. │
+├─────────────────────────────────────────────────────┤
+│ ▾ Monthly overheads N items │
+│ Description │ Notes │ Date │ Monthly │ Daily │ % │
+│ … existing rows … │
+│ │
+│ ▾ One-off costs M items │
+│ Description │ Notes │ Date │ Cost │ Daily │ % │
+│ … new rows … │
+└─────────────────────────────────────────────────────┘
+```
+
+- Two stacked `
` blocks inside the same `Card`. Each section is collapsible (default open) with a count chip.
+- One-off section columns: Description / Notes / Date / **Cost** / Daily (= cost ÷ 21, shown for parity only — never used in math) / % of P&G total.
+- Monthly section keeps the exact columns it has today.
+- Single `+ Add` button at top-right. Opens a dialog.
+
+**Dialog** — new file `src/components/fixed-cost-dialog.tsx`:
+
+```
+┌─ Add overhead cost ────────────────────────�
+│ Type: ( ) Monthly recurring │
+│ (•) One-off │
+│ │
+│ Description [____________________] │
+│ Date [YYYY-MM-DD] │
+│ Cost [R ______] │
+│ Notes [____________________] │
+│ │
+│ [Cancel] [Save] │
+└───────────────────────────────────────────┘
+```
+
+- Type radio first — drives the entry's `is_recurring` value on submit.
+- `Cost` input is `R-prefixed` (matches existing dashboard currency formatting).
+- On submit: `POST /api/sites//fixed-costs`. Optimistic close + `router.refresh()`.
+
+### 3. Data flow
+
+**Read path** — `src/app/dashboard/sites/[id]/page.tsx` (server component, lines 49–97):
+
+```
+fetch fixed_costs WHERE site =
+ │
+ ▼
+partition into:
+ monthlyItems = rows.filter(r => r.is_recurring !== false)
+ oneOffItems = rows.filter(r => r.is_recurring === false)
+ │
+ ▼
+fixedSum = Σ monthlyItems.cost (unchanged)
+oneOffSum = Σ oneOffItems.cost (NEW)
+ │
+ ▼
+totalSpent = fixedSum + oneOffSum + payrollBurn + materialSum + fuelBurn
+```
+
+- `dailyCost` remains `cost / 21` for display, applied identically to both sections so the UI is consistent.
+- `% of P&G` in both tables uses `cost / (fixedSum + oneOffSum)` so it sums to 100% across the whole card.
+
+**Sites list roll-up** — `src/app/dashboard/sites/page.tsx` (lines 31–82): same change — include `oneOffSum` in `spent` so site-card "Spent" totals stay consistent with the detail page.
+
+**Write path** (manual, new):
+
+```
+[+ Add] → Dialog → POST /api/sites/[id]/fixed-costs
+ │
+ ▼
+ pbAdmin().collection("fixed_costs").create({...})
+ │
+ ▼
+ revalidatePath(`/dashboard/sites/${id}`)
+ router.refresh() on client
+```
+
+**Write path** (n8n OCR, unchanged): n8n keeps writing to `fixed_costs` directly via `pbAdmin()`. New rows without `is_recurring` default to `true`.
+
+**Empty states:**
+- Both sections empty: today's single empty state ("No fixed costs logged for this site yet.") — unchanged.
+- Only monthly empty: "No monthly overheads yet."
+- Only one-off empty: "No one-off costs logged yet."
+
+### 4. Error handling
+
+| Condition | Response |
+|---|---|
+| Site id from path doesn't resolve | `404 { error: "site_not_found" }` |
+| Body fails validation | `400 { error: "validation", field, message }` |
+| `cost <= 0` | `400 { error: "validation", field: "cost", message: "Cost must be greater than zero." }` |
+| `description` empty / > 200 chars | `400 { error: "validation", field: "description", message }` |
+| `date_logged` not ISO YYYY-MM-DD | `400 { error: "validation", field: "date_logged", message }` |
+| PocketBase write throws | `500 { error: "write_failed" }` — log full error server-side |
+
+- Inline field-level error from `400` responses (display under the offending field in the dialog).
+- Generic "Couldn't save overhead" banner for `500`.
+- Disable Save button while in-flight; show spinner on the Save button only.
+- Closing the dialog mid-submit doesn't cancel the request; on success the dialog closes; on failure it stays open with the error.
+- Server is source of truth for `site` + `project_code` (derived from path).
+- `cost` stored as `number` (PocketBase `number`). No floating-point rounding in the API.
+
+### 5. Testing
+
+**Unit tests** (Vitest/Jest — match repo conventions):
+
+- `src/app/api/sites/[id]/fixed-costs/route.test.ts`:
+ - ✅ `201` with valid body — record returned
+ - ✅ `404` when site id missing
+ - ✅ `400` for each validation failure (description empty, cost 0, cost negative, date wrong format, is_recurring missing)
+ - ✅ `500` when PocketBase write throws (mock failure)
+
+- `src/lib/__tests__/partition-fixed-costs.test.ts` (pure function extracted from the page server component):
+ - ✅ Empty list → both arrays empty
+ - ✅ All `is_recurring: true` (or undefined) → monthly section only
+ - ✅ All `is_recurring: false` → one-off section only
+ - ✅ Mixed → correctly partitioned
+ - ✅ Totals match expected sums
+
+**Manual smoke test:**
+
+1. Open a site detail page.
+2. Click `+ Add` → choose "One-off" → fill → save → row appears in "One-off costs" section.
+3. Click `+ Add` → choose "Monthly recurring" → fill → save → row appears in "Monthly overheads" section.
+4. Reload page — both rows persist.
+5. Confirm "Total spent" KPI increased by the sum of both costs.
+6. Confirm sites-list card for this site shows the same increased "Spent" total.
+7. Confirm `Burn by category` stacked bar still uses `fixedSum` for the P&G segment (unchanged) but the bottom-line "Total spent" is correct (includes one-offs).
+
+---
+
+## Files to create / modify
+
+- **Modify** `scripts/pb-bootstrap-schema.py` — add `is_recurring` field to `fixed_costs`.
+- **Modify** `src/lib/pb-types.ts` — add `is_recurring?: boolean` to `FixedCostRecord`.
+- **Create** `src/app/api/sites/[id]/fixed-costs/route.ts` — POST endpoint.
+- **Create** `src/components/fixed-cost-dialog.tsx` — controlled dialog component.
+- **Modify** `src/app/dashboard/sites/[id]/page.tsx` — partition fixed_costs into monthly/one-off, add `oneOffSum`, include in `totalSpent`.
+- **Modify** `src/app/dashboard/sites/[id]/site-detail-view.tsx` — split card into two stacked sections, wire `+ Add` button + dialog, update `PnGItem` interface.
+- **Modify** `src/app/dashboard/sites/page.tsx` — include `oneOffSum` in sites-list roll-up.
+- **Create** `src/app/api/sites/[id]/fixed-costs/route.test.ts` — API route tests.
+- **Create** `src/lib/__tests__/partition-fixed-costs.test.ts` — partition helper tests.
+
+---
+
+## Open questions
+
+None — all design decisions resolved during brainstorming.