diff --git a/CLAUDE.md b/CLAUDE.md
index 9b5f2cd..5a503ce 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -194,6 +194,52 @@ python manage.py check # System check
pair of body vars for dark mode).
- **Email/PDF payslips**: Worker name dominant — do NOT add prominent FoxFitt branding
+## Static Assets & Cache-Busting (Cloudflare is in front)
+
+Production traffic reaches the Flatlogic VM through **Cloudflare** (response headers
+include `cf-ray`, `cf-cache-status`, and a `cache-control: max-age=14400`). Static
+assets — including `custom.css` — are cached at Cloudflare's edge for up to 4 hours
+per unique URL. This is great for performance and bad for deploys if the URL doesn't
+change when the file does.
+
+### How cache-busting works now
+`base.html` loads CSS as:
+
+```html
+
+```
+
+`deployment_timestamp` comes from `core/context_processors.py::project_context` as
+`int(time.time())` — meaning every Django request generates a new query-string value.
+Cloudflare treats each new `?v=...` value as a new URL → `cf-cache-status: MISS` →
+fresh fetch from the VM. Users always see the latest CSS as soon as the Django
+process restarts.
+
+**Trade-off**: because the timestamp changes every second, CDN cache-hit rate on
+CSS is effectively zero. For a low-traffic app this is fine. If traffic grows,
+consider switching to a file-mtime-based token so the URL only changes when the
+CSS actually changes.
+
+### The pitfall this replaced
+Pre-Apr 2026, the template used `{{ request.timestamp|default:'1.0' }}`. But
+`request.timestamp` is **not** a Django request attribute — the variable always
+fell back to the literal `'1.0'`. Every deploy's CSS URL resolved to the same
+`custom.css?v=1.0`, so Cloudflare held onto a pre-redesign copy for hours while
+the VM served the new one. Symptom was "the deploy worked but the page looks wrong"
+that only a hard refresh in incognito temporarily fixed. Never use `request.timestamp`
+in templates — it doesn't exist.
+
+### When CSS changes don't appear on production
+1. Confirm Django is rendering the new URL: `curl -s https://foxlog.flatlogic.app/ | grep -oE 'custom\.css\?v=[^"]+'` — the `v=` number should change per request (or at least per restart)
+2. Confirm the CDN honours it: `curl -sI "https://foxlog.flatlogic.app/static/css/custom.css?cb=test$(date +%s)" | grep -i cache` — expect `cf-cache-status: MISS` then `HIT` on repeat
+3. If the Django URL still looks like `?v=1.0` (constant), `deployment_timestamp` isn't being injected — check that `core.context_processors.project_context` is listed in `TEMPLATES[0]['OPTIONS']['context_processors']` in `config/settings.py`
+
+### `collectstatic` is required after CSS/JS changes on production
+Flatlogic's rebuild does NOT automatically run `collectstatic`. If new CSS is on
+disk but the VM's `staticfiles/` hasn't been refreshed, Apache serves the old
+collected copy. Have Gemini run `python3 manage.py collectstatic --noinput`
+after any PR that touches `static/`.
+
## PDF Generation (WeasyPrint)
Migrated from xhtml2pdf in Nov 2026. WeasyPrint is a browser-grade HTML→PDF renderer — it supports real CSS (flexbox, grid, `@font-face`, shadows, `border-radius`, proper cascade) that xhtml2pdf could not handle.
@@ -503,23 +549,83 @@ python manage.py restore_data backup.json
## Environment Variables
```
-DJANGO_SECRET_KEY, DJANGO_DEBUG, HOST_FQDN, CSRF_TRUSTED_ORIGIN
+DJANGO_SECRET_KEY # required in prod — startup fails without it
+DJANGO_DEBUG # "true"/"false"; defaults to false; keep false in prod
+HOST_FQDN, CSRF_TRUSTED_ORIGIN # trusted hostnames (scheme-less ok, auto-prefixed https://)
DB_NAME, DB_USER, DB_PASS, DB_HOST (default: 127.0.0.1), DB_PORT (default: 3306)
-USE_SQLITE # "true" → use SQLite instead of MySQL
-EMAIL_HOST_USER, EMAIL_HOST_PASSWORD (Gmail App Password — 16 chars)
-DEFAULT_FROM_EMAIL, SPARK_RECEIPT_EMAIL
+USE_SQLITE # "true" → use SQLite instead of MySQL (local dev only)
+EMAIL_HOST_USER # Gmail address — required for any outbound email
+EMAIL_HOST_PASSWORD # Gmail App Password (16 chars, no spaces/non-breaking-space)
+DEFAULT_FROM_EMAIL # Optional — falls back to EMAIL_HOST_USER if unset
+SPARK_RECEIPT_EMAIL # Optional — defaults to FoxFitt's Spark Receipt address
PROJECT_DESCRIPTION, PROJECT_IMAGE_URL # Flatlogic branding
```
+### Email fallback behaviour
+`DEFAULT_FROM_EMAIL` is not strictly required — `config/settings.py` sets it as:
+
+```python
+DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "") or EMAIL_HOST_USER
+```
+
+…so if the env var is unset or empty, the "From" address on every outbound email
+falls back to the authenticated Gmail address (which is always valid since we
+send AS that account). Without this fallback, receipt and payslip emails fail
+with `Invalid address ""`. If you want to send FROM a different display address
+than the authenticated one (e.g. "FoxFitt Payroll "),
+set `DEFAULT_FROM_EMAIL` explicitly — but Gmail will likely rewrite it to the
+authenticated user anyway unless you've configured a "Send mail as" alias.
+
+### Where env vars live on Flatlogic
+Flatlogic's platform has no env-var UI. Values are set in a `.env` file at
+`BASE_DIR.parent / ".env"` on the VM (one level up from the repo). Edit via
+Gemini/shell — the user cannot modify via Flatlogic's web editor because
+`.env` is outside the project tree. The file is loaded by
+`python-dotenv` in `config/settings.py` before any `os.getenv()` calls.
+
## Flatlogic/AppWizzy Deployment
- **Branches**: `ai-dev` = development (Flatlogic AI + Claude Code). `master` = deploy target.
- **Workflow**: Push to `ai-dev` → Flatlogic auto-detects → "Pull Latest" → app rebuilds (~5 min)
- **Deploy from Git** (Settings): Full rebuild from `master` — use for production
- **Migrations**: Sometimes run automatically during rebuild, but NOT always reliable. If you get "Unknown column" errors after pulling latest, visit `/run-migrate/` in the browser to apply pending migrations manually. This endpoint runs `python manage.py migrate` on the production MySQL database.
+- **Static files**: Flatlogic's rebuild does NOT auto-run `collectstatic`. After CSS/JS changes have Gemini run `python3 manage.py collectstatic --noinput` + restart the service, otherwise Apache keeps serving the previously-collected copy.
+- **Service**: The Django app runs as `django-dev.service` (systemd). Gemini restarts it via `sudo systemctl restart django-dev.service`. It runs `python manage.py runserver 0.0.0.0:8000` — a **development server**, not gunicorn/uwsgi (Flatlogic default, works fine at this scale).
+- **CDN**: All production traffic goes through Cloudflare. Response headers show `cf-ray`/`cf-cache-status`. Static assets are cached at the edge for 4h — see "Static Assets & Cache-Busting" section for how the `deployment_timestamp` token breaks stale caches.
- **Never edit `ai-dev` directly on GitHub** — Flatlogic pushes overwrite it
- **Gemini gotcha**: Flatlogic's Gemini AI reads `__pycache__/*.pyc` and gets confused. Tell it: "Do NOT read .pyc files. Only work with .py source files."
- **Sequential workflow**: Don't edit in Flatlogic and Claude Code at the same time
+### Git remotes on the VM
+The Flatlogic VM has TWO git remotes, both kept in sync:
+- `github` → `https://github.com/Konradzar/LabourPay_v5.git` (our canonical source)
+- `gitea` → `https://gitea.flatlogic.app/admin/-vm.git` (Flatlogic's internal mirror — the one the platform UI watches)
+
+Any push the VM makes must go to BOTH: `git push github ai-dev && git push gitea ai-dev`.
+If the two diverge, Flatlogic's dashboard can show a different commit than GitHub,
+which silently confuses deploys. Flatlogic's UI occasionally commits as
+`Flatlogic Bot ` (autosaves from the in-browser file editor) —
+those commits land on gitea but don't propagate to GitHub unless someone pushes.
+
+### VM-local safety branches
+When doing risky deploys (model migrations, branch resets, history rewrites), we
+create a safety branch on the VM at the pre-deploy HEAD so Gemini can
+`git reset --hard ` + service-restart to roll back in ~60 seconds:
+
+```
+git branch pre--YYYYMMDD HEAD
+git branch --list "pre-*"
+```
+
+Safety branches are VM-local — not pushed to GitHub by default. They're
+single-use rollback anchors; delete after 7 days of confirmed stability via
+`git branch -D pre--YYYYMMDD`.
+
+### Workflow options going forward
+Either works — pick one and stick to it per change to avoid divergence:
+1. **Claude → GitHub → Flatlogic pulls**: Claude pushes to origin/ai-dev; you click "Pull Latest" in the Flatlogic UI (or ask Gemini to `git pull + push gitea + restart`).
+2. **Flatlogic UI → GitHub**: edit in Flatlogic's file editor; click "Push to GitHub" in their UI; Claude pulls locally with `git pull origin ai-dev`.
+**Don't mix** paths in the same change — that's how divergence (and the "Ver XX.YY screeeewup" commits) happen.
+
## Security Notes
- Production: `SESSION_COOKIE_SECURE=True`, `CSRF_COOKIE_SECURE=True`, `SameSite=None` (cross-origin for Flatlogic iframe)
- Local dev: Secure cookies disabled when `USE_SQLITE=true`