1.x releases
Every 1.x release, newest first. Other series: 1.x, 0.x; back to the current releases. Rendered from CHANGELOG.md, the file GitHub and the release tooling read.
1.5.0 — 2026-09-24
Every finding carried forward from the 2026-09-23 audit is closed. The guiding rule: a whistleblower can never be locked out, deanonymised by a file, a timestamp or a Redis dump, or promised more than the software does. 124 mutations over this release's and v1.4.0's guards, all caught.
Upgrade notes
- Retention is on by default (
RETENTION_ENABLED=true): closed reports are deleted 1095 days after closing (HinSchG §11 Abs. 5). OpenWhistle dates from 2026, so nothing is old enough to be deleted yet. Setfalseto opt out. - Notifications are batched (
NOTIFICATION_BATCH_MINUTES=60) and the generic webhook payload changed to{"event": "new_activity", "new_reports": [...], "new_messages": [...]}with no timestamp. Update receivers that parse the oldnew_reportevent.0restores immediate delivery. - Logout is a POST with a CSRF token, for admins and whistleblowers; a GET no longer logs anyone out.
- OIDC now requires the provider to return an
id_token(all compliant providers do); it is verified against the provider's JWKS. - Drafts in progress restart after the upgrade (new encrypted draft format).
- Redis in
docker-compose.prod.ymland the Ansible role now runs with--maxmemory 1gb --maxmemory-policy noeviction. - Migration
003encrypts existing attachment filenames and widens the column. - New settings:
NOTIFICATION_BATCH_MINUTES,DRAFT_REDIS_MEMORY_PERCENT,ADMIN_FAILED_LOGIN_ALERT_THRESHOLD,ADMIN_FAILED_LOGIN_ALERT_WINDOW_MINUTES,LDAP_START_TLS.
Security
- A whistleblower can no longer be locked out of their own case. The status lockout was keyed by the 5-digit case number, so anyone could block a case by typing wrong PINs. Now a correct case number and PIN always open the case; wrong attempts are still counted and answered with a wait notice. Unknown case numbers take the same time as known ones (no timing oracle). The
/replyfallback shares the same check instead of trusting a client-supplied token. - Password spraying is detected. Failed admin passwords are counted instance-wide (no IP, no usernames); crossing the threshold raises one alert per window by email/webhook and in the audit log. MFA remains the barrier.
- OIDC: PKCE (S256), a verified
nonce, and a verifiedid_token(signature,iss,aud,exp,azp); identity is taken from the token. - CSRF cookie gets
Secure; logouts are CSRF-protected POSTs. - Setup wizard is atomic (advisory lock) — two first-run requests cannot create two admins; migrations are serialised across replicas.
- One password policy for the wizard, admin-created users and the reset script; passwords or PINs over 72 bytes no longer cause a 500 (bcrypt 5).
- LDAP StartTLS (
LDAP_START_TLS) with certificate verification. t()marks text as HTML only for.htmllocale keys; SSO error pages no longer echo the provider'serrorparameter.
Privacy
- Attachment filenames are encrypted with the report key (they can carry a name), and new S3 object keys no longer contain the filename.
- Office files lose comment and tracked-change authors (DOCX/XLSX), their thumbnails, and zip timestamps and uid/gid.
- Submission drafts are encrypted with a key that exists only in the whistleblower's cookie; a Redis dump alone reveals nothing. Draft attachments are capped, and refused when Redis is nearly full.
- Notifications no longer reveal submission times (batched digest).
- The app never sees client addresses: IP headers and the peer address are removed from every request after the proxy check.
- Pages are not cached (
Cache-Control: no-store) — a shared office PC keeps no PIN or report page. - Helm ingress turns the nginx access log off by default.
Changed
- Renovate replaces Dependabot: patch updates merge themselves behind the required checks; minor/major, the Python runtime and security-relevant libraries wait for review. GitHub Actions are pinned to digests. Tests fail if a Renovate manager reaches nothing or a tool carries two versions. Renovate merges its own patch PRs (
platformAutomerge: false), because the repository's "Allow auto-merge" setting is off. - Dashboard search by case number. Report content is encrypted and is deliberately not searchable.
- Readable audit log: every action has a translated label; details are shown as text. The CSV keeps machine codes and adds a label column.
- Copy: sentence case; no absolute promises ("completely protected"); plain language instead of "Two-Factor Access / UUID4"; every error says what happened and what to do. All user-facing strings, including upload errors, are translated.
Fixed
- Errors are tied to their fields (
aria-invalid,aria-describedby) on every form; blank admin login fields answered with a JSON 422. - The authenticator setup page showed raw locale keys in en, de and fr.
- Creating a location was logged as
category.created. - Wrong copy: "no cookies" (functional cookies exist) and confidential data "only visible to the assigned admin" (every admin with access sees it).
1.4.0 — 2026-09-24
Attachments no longer identify the whistleblower, multi-tenant installs keep organisations apart, and every page passes an automated contrast and layout check on a phone and in both themes. Found and verified with a mutation audit of every guard this release adds (28 of 28 caught).
Upgrade notes: migration 002 runs on start. The default BRAND_PRIMARY_COLOR is now #0c7253 (was #0e7c5a, 4.4:1 on tinted backgrounds); installs that set their own colour are unaffected. E2E and demo logins for the demo accounts use the static code 000000; real TOTP codes are single-use for every account.
Fixed
- Cleaned PDFs still contained their XMP metadata. Unlinking it from the catalog left the stream in the file as an orphaned object; orphans are now removed. PDFs restricted by an owner password only are accepted and cleaned instead of refused.
- Every page passes axe at impact serious and critical, in both themes. Fixed: accent colour on tinted backgrounds (4.4 → 5.0:1), white on amber in the dark demo badges (2.3:1), dark-theme secondary text (3.9 → 4.8:1), role badges (2.1 and 3.9:1), footer links distinguishable only by colour, inactive categories and locations faded below AA, an unlabelled role selector and link field, scrollable tables unreachable by keyboard.
- Six more admin pages scrolled sideways on a phone (users, categories, locations, statistics, retention, system — up to 569 px), and long audit entries on the report view.
- No page scrolls sideways on a phone any more. At 390 px the navigation (12 items in the admin) now wraps instead of running off-screen, the dashboard toolbar and the report view (two columns, no breakpoint) fit the width. An E2E test checks
/submit,/statusand/admin/loginat 390 px. - Confidential mode works without JavaScript (Tor Browser "Safest"): the name/contact fields are shown by CSS
:has()instead of a script. - The language picker works without JavaScript and no longer misuses
listbox/optionroles: it is a native<details>list of forms. - Contrast: input borders 1.25:1 → ≥ 3:1; a visible focus outline on inputs and on the submission-mode cards (focused and selected looked identical); secondary text 4.37:1 → 4.98:1 on cards; alert titles no longer dimmed.
Changed
- Stricter tests. The axe checks fail on serious violations, not only critical ones (contrast failures used to ship green). A new UI check visits every public and admin page in both themes at 390 and 1440 px and fails on axe violations, console errors or sideways scrolling. Every guard of the release is mutation-tested (
scripts/mutation_audit.py). - Maintainer documentation moved to
docs-tech/(release procedure, invariants, performance baseline); a test keeps it out of the published site. - Images are built once and published identically to all three registries. Each platform builds on a native runner (arm64 no longer under QEMU) and is pushed to GHCR by digest; one multi-arch index is then written under every tag to GHCR, Docker Hub and Quay.io. Docker Hub and Quay now get the same provenance, SBOM and cosign signature as GHCR (before: separate unsigned builds). The job fails unless every tag and every platform manifest behind it is pullable. Quay.io stays best-effort with a warning.
- Dependencies are locked in
uv.lock. The image, CI and the E2E/perf workflows install exactly the locked versions; CI fails if the lock is out of date. Dependabot now uses theuvecosystem — the oldpipentry only saw>=floors and had never opened a pull request. - The production image carries runtime dependencies only (no pytest, mypy or ruff), uv is taken from its official image (0.6.0 → 0.12.18), and the fonts are copied from
docs/fontsinstead of downloaded unverified at build time. python-josereplaced by PyJWT, which dropsecdsa(PYSEC-2026-1325, no fix planned). Unusedauthlibandaiofilesremoved;cryptographyis now a declared dependency instead of an accidental transitive one.
Security
- Multi-tenancy: an org admin now sees and manages only their own organisation. The users page, role changes, (de)activation, the assignment picker and target, the audit log and its CSV export, and the dashboard and statistics counts were unscoped; new users now join the creator's organisation. Single-organisation installs are unaffected.
DEMO_MODEno longer weakens a real installation. The static TOTP000000is accepted only for the seeded demo accounts, and demo data is not seeded into a database that completed the setup wizard and has no demo account.- Internal admin notes are encrypted at rest with the report's data key, like descriptions and messages. Existing notes are shown as stored.
- Attachments no longer carry identifying metadata. EXIF/GPS and camera data (JPEG, PNG, WebP, GIF), PDF document info and XMP, and DOCX/XLSX author and company properties are removed on upload — before the file reaches the draft store. A file that cannot be parsed for cleaning is refused instead of stored as-is. The upload step tells the whistleblower what is and is not cleaned.
- Attachments are encrypted at rest with the report's own data key, in PostgreSQL and in S3. Rows from before this release are served as stored (migration
002addsattachments.encrypted).
1.3.1 — 2026-09-23
Security
- OIDC logins now require TOTP. The OIDC callback issued a session directly, so SSO accounts skipped the mandatory second factor. All three login paths (local, LDAP, OIDC) now go through the same MFA step; SSO users enrol TOTP on their next login. Deactivated accounts are also rejected on the OIDC path.
- LDAP first login provisions a Case Manager, not an Admin. Any directory entry matching
LDAP_USER_FILTERpreviously got full admin access. - LDAP username is escaped before it is placed into the search filter (filter injection), and LDAPS now verifies the server certificate (
CERT_NONEbefore). Private CAs: setSSL_CERT_FILE. - Deleting a report removes its S3 objects. Manual deletion, 4-eyes deletion and the retention job only removed database rows; attachment files in the bucket were kept forever.
- nginx no longer logs client IPs.
error_logran atwarn, and nginx prefixes rate-limit (429) and body-size (413) errors with the client address. It now logs atcritonly. - "Start over" in the submission wizard is now CSRF-protected.
Fixed
- Uploads over 1 MB failed behind the bundled nginx (default
client_max_body_size). Set to 55 MB (5 × 10 MB attachments). - Fresh production installs could not start PostgreSQL 18. The 18 image refuses a volume at
/var/lib/postgresql/dataunlessPGDATApoints there;docker-compose.prod.ymland the Ansible template now set it. Existing data is unaffected. - GHCR images were unpullable (
manifest unknown): the weekly cleanup job deleted the untagged per-platform manifests that every multi-arch tag points to. GHCR cleanup is removed; Docker Hub and Quay.io were not affected. - Helm chart deployed v0.5.0 by default —
appVersionwas never bumped. It now tracks the app version, enforced by a test. - "Start over" on the review step returned 405 (GET link to a POST-only route).
- Footer text had 1.8:1 contrast on the dark footer; now 7.7:1.
- Animations now respect
prefers-reduced-motion. - HinSchG citations: the 3-year deletion rule is §11 Abs. 5, not §12 Abs. 3, and it is a deletion deadline, not a minimum retention period.
- "Back" in the submission wizard no longer triggers validation. The double-submit guard disabled the form's first submit button — which on every wizard step is "Back" — while the browser was still building the submitted entry list. Disabled controls are excluded from that list, so
action=backnever reached the server and the step was processed as a "Next", rejecting an empty description instead of navigating back. The guard now targets the button the user actually clicked and applies after the form data is collected.
1.3.0 — 2026-07-15
The "Signal" design system — a ground-up visual redesign that unifies the app and the public site under one identity, plus a documented design specification.
Added
DESIGN.md— a canonical design system ("Signal") in the google/design.md format: front-matter design tokens (colour, typography, spacing, radii, elevation) and prose covering every component, with first-class light and dark themes.- Design token foundation in the stylesheet: a
--space-*spacing scale, a--text-*type scale, and shared.anim-in/.delay-*animation utilities.
Changed
- Full "Signal" restyle of the application — a monochrome warm-neutral ground with a single emerald accent, Sora for display and body text, and JetBrains Mono for case numbers, PINs, timestamps and deadlines. Dark mode is now a warm near-black rather than a cold navy.
- Self-hosted fonts reduced to two (Sora + JetBrains Mono); the Docker image downloads exactly those, replacing the previous three-font set. The default
BRAND_PRIMARY_COLORis now emerald (#0e7c5a). - Public marketing and documentation pages (openwhistle.net) converted from their separate serif/gold look onto the same Signal tokens.
- Stylesheet and template cleanup: consolidated duplicated components (
.info-banner→.alert,.env-table→table, three admin grids →.admin-split-grid, inline-form wrappers →.inline-form), rebuilt the admin report page's 56 numbered one-off classes into semantic classes on the type scale, and unified the scattered animation-delay helpers.
Fixed
- The intended body typeface never loaded — its
@font-facepointed at font files that did not exist, so the app silently fell back tosystem-ui. A real self-hosted font now ships. - Dark mode ignored the configured brand colour (the accent was hardcoded); it now derives from
BRAND_PRIMARY_COLORin both themes. - The public report-status page's status pills were unstyled (they referenced CSS classes that were never defined); they now use the themed badge styles.
- Removed references to several undefined CSS custom properties.
1.2.1 — 2026-07-14
Follow-up hardening release resolving the remaining bug-bounty findings (#42–#46).
Security
- CSRF protection extended to the two remaining state-changing admin POST endpoints (
/admin/ip-warning/dismiss,/admin/demo/reset). AJAX requests authenticate via anX-CSRF-Tokenheader (read from a<meta>tag, since the double-submit cookie is HttpOnly) (#44). - Case numbers now use a random 5-digit suffix instead of a global sequence, so a new report no longer reveals aggregate cross-tenant report volume. Format is unchanged (
OW-YYYY-NNNNN) and existing numbers stay valid (#42). - Attachment uploads are now verified by magic number: the file's leading bytes must match its extension (PDF, JPEG, PNG, GIF, WebP, DOCX/XLSX, DOC/XLS), so a file cannot lie about its type (e.g. HTML bytes disguised as a
.png). Text formats have no signature and are unaffected (#43). - Reverse-proxy flood protection for the submission channel: the bundled nginx configs now apply a per-IP
limit_reqto dynamic endpoints. The IP is used only for in-memory throttling — never logged or forwarded upstream — so whistleblower anonymity is preserved (#46). - Removed a dead, unreachable "this account uses Single Sign-On" login branch; SSO-only accounts already receive the generic "invalid credentials" error, which avoids leaking account existence / auth method (#46).
Fixed
- Downloading an attachment whose S3 object is missing now returns 404 instead of an unhandled 500; genuine backend errors still surface as 5xx (#45).
1.2.0 — 2026-07-13
Added
- Admin System page + opt-in update check: a new Admin → System page shows the installed version and, when
UPDATE_CHECK_ENABLED=true, whether a newer release is available on GitHub. The check is off by default, runs as a daily background job (result cached in Redis, ETag-conditional), and sends no instance data to GitHub — only a standard request. The installed version is also shown in the footer. - File integrity check on the Admin → System page: verifies the shipped application files against a SHA-256 manifest generated at Docker build time and reports any missing, modified, or unexpected files. Purely local (no external calls); detects accidental modification, incomplete deployments and corruption (not tamper-proof against an attacker who can also rewrite the manifest — the manifest's own hash is shown for optional out-of-band verification).
1.1.1 — 2026-07-13
Security release: four privately-reported advisories plus an internal adversarial "bug-bounty" audit that fixed ~25 further edge-case defects. All users of 1.1.0 should upgrade.
Breaking
SECRET_KEYmust now be at least 32 characters. The application refuses to start with a shorter key.SECRET_KEYis the root secret for admin authentication and for encrypting confidential whistleblower identities, so a weak key undermines the platform's core protection. Generate a strong one withpython -c 'import secrets; print(secrets.token_urlsafe(48))'. Note: rotatingSECRET_KEYmakes previously-encrypted confidential fields unreadable — set a strong key from the start.
Security
- Report deanonymization / IDOR (GHSA-q3v3-5xf4-xjqr, High): every
/admin/reports/{id}*endpoint now enforces object-level authorization. Case managers can only access reports assigned to them; admins are scoped to their own organisation (superadmins span all) when multi-tenancy is enabled. The dashboard list and the confidential-identity block are scoped the same way, so an unassigned case manager can no longer read a confidential whistleblower's identity. - Privilege escalation (GHSA-g3xj-3929-r45h, High): the role-assignment endpoints now enforce privilege tiers. Only a superadmin may grant or modify the superadmin role, an account can no longer change its own role, and the last active administrator can no longer be demoted away.
- Stored XSS (GHSA-24hg-pf84-jj7x, High): admin usernames and organisation names are no longer interpolated into inline
onclickhandlers; confirmation prompts moved to a safedata-confirmattribute. Locally-created usernames are validated against a strict allowlist. - Weak / duplicated HTTP security headers (GHSA-gh23-4h5j-cqj8, Medium): security headers are now emitted by a single authoritative layer (the application middleware); the bundled nginx template no longer re-emits them, removing the duplicated/conflicting
Strict-Transport-Security,X-Content-Type-OptionsandX-Frame-Optionsheaders. The Content-Security- Policy no longer uses'unsafe-inline': it is now a strict, per-response nonce-based policy for both scripts and styles.
Reported by @openblow.
Fixed (internal bug-bounty audit)
Real defects found by an adversarial audit, each covered by a regression test in tests/test_bug_bounty_v111.py:
- Retention could delete reopened cases early:
closed_atwas never refreshed when a case was reopened and re-closed, so the auto-deletion job could remove reports far before the statutory retention period had elapsed since their actual closure. It is now cleared on reopen and re-stamped on re-close. - Superadmin lockout: a plain admin could deactivate a superadmin, and the last active privileged account could be deactivated/demoted, leaving no one able to administer the instance. Both are now blocked.
- Attachment downloads with non-Latin-1 filenames (CJK, Cyrillic, emoji) raised
UnicodeEncodeErrorand 500'd — the evidence became permanently undownloadable.Content-Dispositionnow uses RFC 5987 encoding. PDF export no longer crashes on non-Latin-1 note authors either. - MFA brute-force: TOTP guessing is now rate-limited, and a valid code is one-time-use within its window (blocks AiTM replay into a second session).
acknowledgeis now idempotent so the statutory feedback deadline cannot be pushed out by re-invoking it.- Concurrent submissions no longer 500 on a case-number collision (retry).
- Reports can no longer be assigned to a deactivated user (orphaned cases).
- Linked-report metadata is filtered through the object-level authz check.
- SLA reminder de-duplication now covers the full warn window (was re-firing every ~hour for days); per-report failures are isolated.
SUBMISSION_MODE_ENABLED=falsenow actually forces anonymous submissions, and confidential PII is purged from the session when switching to anonymous.- The whistleblower PIN lockout is keyed on the case number, so it can no longer be bypassed by fetching a fresh anonymous session token before each guess.
- Login now runs a constant dummy password hash for unknown users (removes a username-enumeration timing side-channel).
- Background scheduler jobs take a Redis lock so a scaled/stateless deployment does not run them once per replica (duplicate audit entries / notifications).
- 4-eyes delete confirmation re-checks the request under a row lock, so a concurrent cancel cannot be raced into deleting a withdrawn report.
- The submission wizard rejects out-of-order steps (blocks jumping straight to the attachment step to stash blobs in Redis) and no longer adopts a client-supplied session id with no server-side state (session fixation).
- Case-insensitive duplicate-username check; empty decrypted bodies no longer fall back to raw ciphertext; oversized uploads are rejected without buffering the whole body; relinking already-linked cases returns 409 instead of 500; decryption failures are logged rather than silently shown as blank.
Remaining lower-severity findings are tracked in GitHub issues #42–#46.
Changed
- All Python dependency floors raised to their current major versions (notably redis 8, bcrypt 5, SQLAlchemy 2.0.51, uvicorn 0.51, FastAPI 0.139, mypy 2, pytest 9, pytest-asyncio 1.x). GitHub Actions
actions/checkoutandcodecov/codecov-actionbumped to v7.
1.1.0 — 2026-04-28
Added
- Playwright E2E test suite (
tests/e2e/): 13 test modules covering every critical user journey — admin login (incl. MFA), setup wizard redirect behaviour, whistleblower anonymous/confidential/file-attachment submissions, status page with deadline display, admin workflow (acknowledge → reply → status transitions), 4-eyes deletion flow, language switcher persistence, PDF export download, session expiry, user management RBAC, category and location management lifecycle - Automated accessibility tests (
tests/e2e/test_accessibility.py): axe-core injected into 8 pages;run_axehelper filters to critical/serious violations and fails on any finding; CDN-unavailable skips gracefully; keyboard navigation smoke-test (skip link, tab order, form labels) - Locust performance test suite (
tests/perf/locustfile.py): three user classes (WhistleblowerUser,AdminUserwith TOTP login inon_start,StatusChecker); configurable concurrency;tests/perf/README.mdwith thresholds and run instructions - OpenAPI contract tests (
tests/test_openapi_contract.py): validates OpenAPI 3.x structure, required paths (/health,/status,/submit), admin route auth enforcement (7 routes assert 3xx for unauthenticated requests), and snapshot regression detection viatests/fixtures/openapi_snapshot.json - E2E CI workflow (
.github/workflows/e2e.yml): buildsopenwhistle:e2eimage, starts full Docker Compose stack withDEMO_MODE=true, waits for/health, runs Playwright tests with Chromium headless, uploads trace on failure - Performance CI workflow (
.github/workflows/perf.yml): manualworkflow_dispatchwith configurable users/run-time/host; uploads HTML + CSV Locust artifacts - Performance baseline (
docs/performance-baseline.md): SLO thresholds (/healthp95 < 50 ms,/statusp95 < 200 ms,/admin/dashboardp95 < 400 ms) and user mix ratios for reproducible benchmarks
Changed
pyproject.toml: new[e2e]and[perf]optional dependency groups;e2eandperfpytest markers registered; mypy overrides forplaywright.*andlocust.*; ruffper-file-ignoresextended to covertests/e2e/andtests/perf/
1.0.0 — 2026-04-27
Added
- Envelope encryption at rest: every new report is encrypted on write with a per-report Data Encryption Key (DEK) wrapped via AES-256 (Fernet); the DEK is encrypted with a Master Encryption Key (MEK) derived from
SECRET_KEYusing HKDF-SHA256; MEK is never stored; report description and all message bodies are encrypted; pre-encryption rows are readable without decryption (backward compat) - Data retention (GDPR / HinSchG):
RETENTION_ENABLED=trueactivates a daily job (03:00 UTC) that permanently deletes closed reports older thanRETENTION_DAYS(default 1095 = 3 years — HinSchG §12 Abs. 3 minimum); each deletion writes an immutable audit-log entry (report.auto_deleted) recording the case number, closure date, and legal basis - Multi-tenancy:
MULTI_TENANCY_ENABLED=trueactivates multi-organisation support;Organisationmodel withname,slug,is_active, andbrandingJSON; all reports, users, categories, locations, and audit entries carry anorg_idforeign key; per-org unique constraints on category slugs and location codes; superadmin role manages organisations via/admin/organisations - Superadmin role: new
superadminrole aboveadmin;require_superadmindependency guards the organisation management endpoints; existingadminrole retains all previous permissions; role added toAdminRoleenum viaALTER TYPE adminrole ADD VALUE IF NOT EXISTS 'superadmin' - Telephone reporting channel guide (
/admin/telephone-channel): compliance page covering HinSchG §16 requirements, implementation options (internal hotline vs. external ombudsman), §10 recording prohibition, and a compliance checklist - Data retention admin page (
/admin/retention): shows current retention config, next scheduled run, legal basis (GDPR Art. 5/17, HinSchG §12), and configuration reference table - Organisation management page (
/admin/organisations): superadmin-only page to create and deactivate organisations (default org cannot be deactivated)
Changed
- Report description and message content are now stored encrypted; existing plaintext rows are transparently decrypted on first read (backward compat via
decrypt_field_safe) - Admin report detail page and whistleblower status page now render decrypted content instead of raw ciphertext
- Scheduler refactored: both SLA reminders and retention cleanup share a single
AsyncIOSchedulerinstance; previous per-feature scheduler creation eliminated ReportCategory.slugandLocation.codeunique constraints changed from global to per-organisation composite (slug + org_id,code + org_id)- Nav bar in all admin templates updated with links to Telephone Channel, Retention, and Organisations pages
Migrations
- 012 — Creates
organisationstable; addsorg_idFK andencrypted_dekcolumn to all data-bearing tables; addssuperadmintoadminroleenum - 013 — Data migration: backfills
org_idwith default org; makesorg_idNOT NULL; encrypts all existing report descriptions and message bodies; makesencrypted_dekNOT NULL - 014 — Replaces global unique constraints on
report_categories.slugandlocations.codewith per-org composite unique constraints - 015 — Reverts
admin_users.org_idto nullable to support superadmin accounts (org_id = NULL means cross-organisation scope) and direct AdminUser creation in external tooling without a prior org lookup