Skip to main content

Give feedback

Back to all releases
Changelog

1.5.6

Released 2026-07-14

Changed

  • Translation cleanup now closes the mobile and contextual admin-help ratchets. All 14,904 missing mobile locale keys are filled across the six maintained non-English mobile locales, with unambiguous web translations reused before machine translation and a new CI check enforcing mobile interpolation-variable parity. The 653 English-only contextual admin-help entries now load from a dedicated admin_help namespace across all ten web locales instead of a bundled TypeScript registry, while preserving lazy loading and route structure. Translation files pass exact key and interpolation parity; these machine-assisted translations remain explicitly queued for native-speaker review.

  • Maintained React translations now have zero actionable fallback gaps across all ten non-English locales. The remaining 1,991 English fallbacks were machine-translated with key and interpolation parity preserved, while the gap audit now distinguishes explicit language-invariant protocol, product, currency, and keyboard labels from untranslated prose and can print exact residual keys for review.

  • The primary React frontend now reports CSP violations instead of silently enforcing its static policy. Every production and fallback nginx SPA response advertises the bounded API reporting endpoint through both report-uri and report-to/Reporting-Endpoints; regression coverage requires the canonical policy and reporting header to remain paired across all maintained React vhosts.

  • Voice-message erasure now preserves both migration compatibility and private-storage locking guarantees. GDPR deletion safely resolves historical tenant-scoped public recording pointers as well as current private media, retains failed-delete pointers for retry, and the MariaDB concurrency regression exercises the private storage contract so a send cannot commit after account erasure. The production migration also recognizes the original absolute attachment paths and tenant-slug voice paths, moving those files into tenant-private storage instead of leaving historical raw media in the web root.

  • The platform audit now enforces private sensitive-media, native-release, and infrastructure boundaries. Insurance is metadata-only (type, provider, expiry and reminders) across React, API, administration, and the accessible frontend; raw documents and sensitive policy fields are rejected, safe-column reads have regression coverage, and existing stored values/files are purged by migration. Direct-message attachments and voice recordings move outside the web root and are delivered only through tenant- and participant-authorized, no-store endpoints, including authenticated React/mobile playback and erasure coverage. Android now generates a certificate-pinned, cleartext-disabled network policy, restricts deep links and authenticated downloads, binds OTA updates to versioned staging/production channels, and has an authoritative native release gate. Horizon receives a five-queue, 2 GiB, hourly-recycling baseline; legacy raw-SQL migrations require a pre-mutation snapshot; the prerender cron installer validates intervals with a blocking runtime test; and dependency update coverage spans Composer, npm, Actions, and Docker.

  • Release and abuse controls now cover every maintained delivery surface. The API has tenant/actor-scoped minute and sustained global envelopes, trusted browser devices use Secure HttpOnly cookies while native clients retain an explicit stateless path, CSP violations report to a bounded sanitized endpoint, and federated OIDC sessions may satisfy local MFA only with explicit validated amr evidence or an allow-listed ACR. CI adds blocking Android and accessible-frontend jobs plus one aggregate Release Gate, while the insurance workflow and its safeguards are translated across all maintained locales.

  • Authentication sessions now use one short, server-enforced lifetime and revocation policy across maintained clients and sign-in methods. Password, TOTP, passkey, social OAuth/SSO, React, and the accessible frontend now issue 15-minute access JWTs plus 30-day absolute refresh-token families regardless of caller-controlled platform headers. Refresh credentials rotate on every use and persist only hashed identifiers; unique token/direct-successor constraints plus a composite user/tenant foreign key prevent branching and cross-tenant rows. A duplicate within five seconds is tolerated only when its active direct successor exists and receives a credential-free conflict, while older, branching, revoked, or expired replay revokes the family. Refresh and credential issuance serialize with logout-all and user-locked password change/reset/admin mutations, which roll back if session revocation fails; API and accessible account-deletion requests revoke every session before confirmation. Pending password/TOTP and passkey ceremonies carry their authentication start and atomically consume one challenge before issuance. Social OAuth applies tenant admission/orchestration and signed tenant state, OAuth/SSO callbacks recheck account gates under the shared issuance lock and fail closed for untrusted local TOTP, and OIDC discovery/token/JWKS requests use pinned public-IP, no-redirect clients. Tenant OIDC domain authorization, email linking, and auto-provisioning now require a signed literal-boolean email_verified assertion; existing tenant-bound subjects remain usable without a domain gate, but unverified email metadata cannot rebind them. Every social login/register/link and tenant OIDC SSO start also creates a per-tab high-entropy verifier retained only in sessionStorage; only its SHA-256 challenge enters signed/cache state, callback codes bind that challenge, and final exchange verifies the verifier atomically under the callback-code lock before returning credentials. A wrong or missing verifier cannot consume the valid code, closing login-CSRF/session swapping, and maintained OAuth/SSO frontend requests use the configured API_BASE. The accessible frontend rotates through Secure-in-production HttpOnly cookies; React holds a Web Lock for the full refresh request, rechecks token generation and tenant context after waiting, and aborts refresh on timeout or tenant change. Personal-access bearer sessions, pre-v2 or overlong access tokens, and pre-rotation refresh tokens are deliberately rejected, so existing sessions must sign in again at rollout.

  • Groups configuration now distinguishes tenant policy from day-to-day administration. The Module Configuration dialog exposes only settings with real runtime enforcement, adds the previously omitted maximum description length, links directly to the full Groups administration area for approvals, moderation, types, organisation, recommendations, ranking, analytics, and group records, and uses a responsive management card on mobile and desktop. The Groups configuration API now rejects unknown keys, malformed booleans, invalid visibility values, out-of-range limits, and contradictory description bounds instead of silently coercing or ignoring them.

  • Events enterprise CI now exercises the authoritative notification outbox. The destructive lifecycle journey drains the disposable CI app's Events outbox before verifying recipient-visible cancellation notifications and targets HeroUI v3 data grids by their actual accessible role.

  • Podcasts now enforce one tenant-safe, fail-closed contract across React, the accessible frontend, distribution feeds, moderation, storage, search, and privacy workflows. Public RSS/transcript/chapter and hosted-media delivery use explicit tenant identity and capability signatures; unsafe or unprocessed media cannot publish, stream, or leak through feed cards; creator edits reset moderation when material; report resolution is per report; hosted artwork is tenant-owned; listen analytics record real progress without trusting client completion; creator/admin studios expose full metadata and operational review controls; global search, feed lifecycle, GDPR export/erasure, OpenAPI coverage, and accessible authoring now match the canonical Laravel API. Locale parity, legacy cache handling, durable failed-file cleanup, and focused tenant/authorization/media regressions are included.

  • Marketplace checkout and money movement are now server-authoritative, tenant-safe, and retry-safe. Orders bind a durable idempotency key to their canonical listing, quantity, shipping, coupon, loyalty, and payment-method inputs; inventory, offers, coupon use, loyalty reservations, free orders, time-credit settlement, pickup reservations, and cancellation/expiry recovery are atomic and reversible. Stripe Connect now distinguishes destination charges from delayed separate transfers, holds eligible payouts until delivery, reconciles partial/full refunds and external refunds exactly once, handles active/won/lost chargebacks (including seller transfer reversal and reimbursement), blocks payout while disputes are active, preserves reduced partial-refund balances, and reports seller balances by currency instead of summing incompatible currencies. Delivered non-escrow orders now auto-complete on schedule, and migrations add the uniqueness, ledger, dispute, enforcement, and browse indexes required by these guarantees.

  • Marketplace privacy, trust, discovery, and moderation now fail closed. Upload extensions come from detected MIME, public originals are re-encoded to remove EXIF/GPS metadata, Apache blocks executable files below public storage, and public coordinates are rounded while owners retain precise edit coordinates. Category/collection/discovery queries reject cross-tenant references; public reads hide pending, removed, suspended, and expired inventory; material listing edits return to moderation; save counters are idempotent; paid promotion products are hidden until a real payment lifecycle exists while explicitly free promotions remain supported. User reports, seller notices, appeals, admin evidence review, dispute resolution, enforcement snapshots, and enforcement-specific restoration are complete and recipient-locale aware, with transactional decision claims preventing competing admin resolutions.

  • Marketplace checkout and case-management parity now spans React, native mobile, and the accessible frontend. All three clients support cash, free, time-credit, and explicit hybrid payment-method choice; use server-priced shipping; recover safely from pickup/payment failures; and expose user report/appeal flows. React adds translated user report pages and an evidence-rich admin report/dispute workspace, mobile uses stable list keys and idempotent checkout recovery, and the accessible frontend provides HTML-first purchase parity. Marketplace API/OpenAPI contracts, the checked-in MariaDB schema, all 11 PHP/React locales, all 7 mobile locales, focused financial/tenant/privacy regressions, and a Chromium browser journey are refreshed together.

  • Marketplace policy, publication, and concurrency controls now fail closed end to end. Tenant switches for shipping, free items, business sellers, hybrid pricing, community delivery, card payments, promotions, and moderation are enforced in services and rechecked against locked listings at checkout; configuration read failures no longer auto-approve content. Suspended/inactive sellers are excluded from every maintained public discovery path, per-seller listing quotas serialize, accepted offers expire and release reservations safely, cancelled offer checkout can restart without reusing financial ledgers, and shipping/delivery transitions cannot overwrite a concurrent dispute or refund. Stripe account creation is idempotent, provider-bound fee economics survive later config changes, webhook refunds/disputes share the payout mutex, offer notifications are translated, and marketplace migrations are idempotent and ownership-safe under partial MariaDB DDL.

  • Page-builder image uploads now use polished, responsive spacing by default. Images inserted or replaced through the GrapesJS page builder receive a dedicated Nexus presentation class with responsive sizing, rounded corners, and consistent separation from following content; a narrowly scoped compatibility rule also repairs existing custom pages where a bare uploaded image directly touches the following Nexus card grid.

  • All existing numeric route throttles now use explicit tenant-, actor-, and endpoint-scoped policies across the maintained API and accessible frontend. Legacy numeric middleware declarations have been replaced without changing their request ceilings, unrelated routes no longer consume one another's endpoint-specific allowance, and every policy tier retains a broader IP abuse envelope. Database-backed email login buckets are now tenant-scoped, case-normalised, and stored as digests rather than raw addresses; regression gates prevent numeric throttles from returning.

  • Platform-audit enforcement now covers the surfaces that previously escaped release gates. Hardcoded-string checks include React administration and native mobile runtime code, known admin-help and mobile locale gaps are ratcheted, Expo permission copy is localised, and web-push/mobile network errors use translations. The test-skip budget is lower, the mobile Jest baseline no longer depends on expired dates, removed settings, or uncommitted generated native files, dependency licence classification distinguishes UNLICENSED metadata and documents the reviewed getID3 MPL election, E2E workflow permissions are read-only, vulnerable transitive packages are overridden, production licence inventories are refreshed, and private root env/secret inputs are excluded from the Docker build context. The frontend manifest and lockfile declare the Node 22/npm 10 CI toolchain so clean installs retain HeroUI CLI's optional peer dependency. Performance-sensitive Events and Marketplace modules use focused HeroUI imports, while OpenStreetMap rendering now uses Leaflet directly instead of the use-restricted React wrapper dependency.

  • PHP test isolation now takes effect before Laravel providers boot. The PHPUnit cache driver forcibly uses the intended in-memory store even inside the Docker image, preserving named route-limiter registrations while preventing rate-limit counters from leaking across tests or runs. Accessible blog SEO coverage now verifies that its JSON-LD nonce matches the response CSP, proxy tests preserve the hardened Cloudflare-chain requirement and reject spoofed forwarding headers, and the message-service mock matches Eloquent's real collection contract.

Fixed

  • Tenant Hub capability is now managed from one guarded control. Edit forms direct administrators to Hub Settings instead of submitting the protected capability field, Hub deactivation is blocked until all child tenants are moved or deleted, disabling an empty Hub requires explicit confirmation and explains the tenant-super-admin impact, maximum hierarchy depth is described consistently, and the shared HeroUI Switch wrapper now follows the v3 clickable-content anatomy while explicitly reflecting controlled on/off state in its visible track and thumb.

  • Security scanning now distinguishes Symfony components instead of treating the framework as one monolithic CPE. OWASP Dependency-Check no longer assigns component-specific Symfony advisories to unrelated packages such as Clock, UID, Translation, or VarDumper; the suppression is constrained to the exact false-positive package/CVE pairs while Composer Audit, the PHP security checker, and Trivy remain blocking package-aware gates.

  • Partner Timebanks settings now keep the save action beside the editable controls. The settings page no longer hides its only Save button in the page header above the long guidance content; all three partnership toggles and the partnership limit now share a clearly visible save action at the bottom of the settings card, with regression coverage verifying that every toggle is included in the persisted API payload.

  • Partner Directory requests now reflect existing relationships. Communities with a pending or active partnership no longer appear to accept a duplicate request, and rejected request attempts display the API's specific reason instead of an unhelpful generic failure.

  • Super-admin tenant edits can be saved again without unrelated 422 validation failures. The tenant form now sends a partial update containing only changed fields, so unchanged legacy contact values and protected platform routing hosts do not block ordinary edits; the backend also accepts an already-assigned protected host while continuing to reject new assignments and avoids treating unchanged routing values as routing changes.

  • Events load correctly on custom tenant domains and tenant PWA manifests remain valid, tenant-scoped JSON. CORS now permits and exposes the negotiated X-Events-Contract header across Laravel's primary and fallback response paths, while both React nginx configurations proxy same-origin /api/* resources to Laravel instead of returning the SPA HTML shell and preserve the browser-facing host for manifest tenant resolution; regression coverage locks both boundaries.

  • Blue-green deployments now terminate Horizon through the root-owned container process and expose IndexNow verification on every tenant host. The deploy script detects Horizon's soft signal failure and falls back immediately instead of waiting through the former five-minute shutdown path, while both maintained React nginx configurations serve the shipped verification key as exact plain text ahead of the SPA catch-all; focused regressions lock both production contracts.

  • Open message conversations now reflect a member's withdrawn safeguarding contact preference promptly, including empty threads. A blocked conversation independently rechecks the server-side contact policy every five seconds while visible instead of relying on message-cursor polling, which never started when the two members had no existing messages. The member-side control now says “Request review of my vetting” so it cannot be mistaken for changing the other participant's status. The revoke API remains synchronous and a regression proves the next conversation preflight and direct-message write are immediately allowed.

  • Safeguarding Options no longer exposes internal codes or untranslated trigger keys. Broker-facing option cards now translate preset sources such as england_wales, show localized labels for every behavioural trigger, reuse localized trigger explanations in the editor, and hide inactive options' internal storage keys such as has_vetting.

  • Safeguarding policy setup no longer replays existing member selections as new onboarding disclosures. Configuring a tenant contact policy, refreshing a country preset, maintaining an option, or revoking one preference still invalidates caches and recomputes every enforcement trigger, but only a fresh member preference save can dispatch the onboarding safeguarding alert. This prevents an England-and-Wales policy transition from emailing brokers about every historical active selection while preserving policy-review notices and the dedicated consent-withdrawal notification.

  • The live accessible frontend now enforces its CSP and HTML boundaries consistently. GOV.UK bootstrap and JSON-LD scripts carry the per-request nonce, the event credential print action uses the compiled progressive-enhancement handler instead of blocked inline JavaScript, and legal, knowledge-base, and blog CMS bodies are sanitised at render time. Static regression coverage scans every accessible Blade template for nonce and inline-handler regressions.

  • Production origin, proxy trust, and browser policies now match the platform's security controls. PHP and React origin ports bind to loopback, the PHP image derives its remote address from Apache's right-appended X-Forwarded-For chain instead of trusting caller-selected CF-Connecting-IP across a Docker hop, and application fallback logic accepts Cloudflare identity only when that chain proves a Cloudflare edge. The PHP image no longer adds a second static CSP that overrode Laravel's per-request nonce; Laravel is the single nonce-bearing CSP authority for API and accessible responses. Both React Nginx configurations now share one canonical SPA policy with the maintained media, map, worker, document, payment, and embed origins, preventing route-specific policy drift.

  • Voice transcription and merchant coupon QR display no longer depend on stale or external paths. Both voice-upload controllers transcribe the server-owned file after it has moved into tenant storage and never re-fetch a public URL or expose the local path. React and native mobile generate redemption QR codes locally, so opaque coupon tokens are no longer sent to api.qrserver.com or exposed through a fallback link.

  • The audited frontend content and scanner sink findings now fail closed at their browser boundaries. Page-builder HTML uses DOMPurify's parser-backed fragment output, CSS and URL allowlists reject container escapes, active schemes, global selectors, and unsafe declarations, and image previews accept only normalised HTTP(S) or explicitly opted-in browser blob URLs. Podcast listen sessions require cryptographic randomness, dynamic test URLs escape complete regular-expression syntax, Markdown audit output escapes backslashes, and uploaded voice-file cleanup proves canonical tenant-directory containment before touching the filesystem.

  • Direct-message reactions no longer expand into rows of numbered controls. The API now returns an allowlisted emoji-to-count map from canonical reaction rows, safely falls back to legacy JSON, and never exposes the legacy per-user map or reactor IDs (including from the batch endpoint); reaction reads and toggles are tenant-scoped throughout, while the React message bubble also rejects malformed strings, arrays, metadata, and invalid counts during staggered deployments.

  • Safeguarding checkbox responses now distinguish an explicit “No” from an active protection. Checkbox values are normalised to 1/0, and only affirmative, non-revoked selections can activate cached, bulk, or transaction-locked vetting and messaging gates. Negative responses remain in consent and GDPR history but are excluded consistently from React and accessible member settings, administrator lists and counts, staff summaries, annual reviews, policy-change reviews, and member safeguarding flags.

  • Legacy personal safeguarding preferences can no longer authorise broker access to message content. The copy engine rejects and repairs the exact historical onboarding-monitoring marker, an idempotent migration clears existing rows without weakening independent messaging restrictions or audit fields, and the generic review notice is now controlled only by tenant-wide broker-visibility policy without exposing either participant’s monitoring status.

  • Sensitive administrator actions and logout now preserve their privilege and session boundaries under concurrency. Email, role, status, approval, suspension, ban, reactivation, deletion, password replacement, password-reset dispatch, 2FA reset, and impersonation enforce one actor-above-target hierarchy that includes legacy is_admin accounts; sensitive mutations recheck that hierarchy while actor and target rows are locked, and administrator password replacement also consumes the target tenant's outstanding reset links transactionally. WebAuthn password/TOTP/backup-code confirmation now holds the tenant-user issuance lock through factor verification and proof creation, so a concurrent password reset cannot mint a fresh passkey-enrolment proof from an old factor. Refreshed access JWTs are bound to their refresh family, making family logout invalidate delayed SPA or accessible-cookie responses, while React marks and Web-Lock-serializes logout so an in-flight refresh cannot repopulate browser storage.

  • Social sign-in and identity linking now require explicit tenant, intent, browser, and provider proof at every mutation boundary. Login intent can no longer provision a member, and missing or malformed tenant provider opt-in fails closed. Google automatic email ownership is limited to Gmail or a matching signed Workspace hosted-domain assertion; Facebook email metadata cannot automatically link or provision an account; and Apple remains unavailable until a vetted driver exists. Verified-email and SSO identity links are deferred until the initiating browser proves its verifier and current account gates are rechecked, callback processing rechecks the provider kill switch, and unlinking a provider revokes every session in the same transaction or rolls the identity removal back.

  • Passkey enrolment, password reset links, backup codes, and two-factor removal now share the account's locked revocation boundary. Passkey registration revalidates the current security confirmation under the tenant user lock immediately before credential persistence, rejecting ceremonies invalidated by a password change or logout-all. An authenticated password change consumes every reset token for that locked tenant/email inside the same password/history/session transaction. Backup-code use is a tenant/user-scoped conditional update with exactly one successful consumer, and disabling TOTP re-reads the password under the user lock, revokes every session with the factor removal, and restores the factor if revocation cannot persist.

  • Two-factor login can no longer lose its tenant, lifetime, or single-use boundary during cross-community administration. Login and setup challenges bind the account's home tenant and password-authentication start; session and stateless completion atomically consume the same cache-backed challenge with one absolute five-minute deadline and a bounded attempt count before credential issuance, which is serialized against password changes and logout-all. Verification reads only the bound tenant's TOTP secret, backup codes, trusted devices, and attempt history; tenant administrators can authenticate only through their own community, while platform-level administrators retain cross-tenant access without bypassing an enabled second factor. Forced first-time administrator setup remains default-disabled and must receive the same authentication-start revocation check before it is enabled.

  • Voice-message cleanup can no longer follow an untrusted database or API path outside the owning tenant's recording directory. Generic message sends reject client-supplied voice pointers, maintained clients persist audio only through the dedicated server upload flow with 128-bit random filenames, and GDPR erasure canonicalizes every tenant message pointer before unlinking. Traversal, remote, missing, symlink-escape, and cross-tenant paths are scrubbed without being followed; recordings referenced by another sender are preserved, failed unlinks retain their retry pointer and keep the erasure request open, and rotating refresh-session records are removed with the erased account. API and accessible account-deletion requests also revoke all active sessions before confirming the request; private storage and authenticated delivery of sensitive media remain separate conditional work.

  • Account erasure and message creation now share one tenant-user serialization boundary. Erasure locks the account before establishing its database snapshot, and message persistence locks and rechecks that the sender is exactly active and not deleted immediately before insertion. A request authenticated before deletion therefore cannot commit a text, attachment, or voice message after the successful erasure scan; real two-process MariaDB coverage exercises the race.

  • Events People bulk operations no longer share a numeric rate-limit bucket with unrelated API traffic. The existing 30-per-minute allowance now uses a named limiter scoped to the tenant and authenticated actor (with an IP fallback), so ordinary smoke-test or application traffic cannot starve the organizer bulk workflow while its own limit still returns 429 correctly.

  • Pending podcast media no longer leaks a cloud CDN URL. Hosted uploads persist the fail-closed in-app media proxy until processing and scanning complete; legacy cloud rows are also normalized to that proxy whenever unready media is projected. Ready public episodes may then use the configured CDN while restricted episodes receive signed proxy capabilities.

  • The full backend CI suite now matches the hardened commerce and event lifecycles. Pickup fixtures declare pickup delivery and paid QR-scan state, loyalty edge cases apply pending reservations to an order before reversal assertions, podcast upload tests isolate the synchronous media processor, and the Ed25519 tamper test always changes significant signature bits instead of occasionally changing only Base64URL padding bits.

  • Marketplace OpenAPI descriptions now remain structurally valid when they contain commas. Flow-style YAML descriptions for checkout validation, time-credit refunds, and historical dispute refunds are quoted so the synchronized JSON contracts no longer emit sentence fragments as illegal schema properties and the Redocly documentation gate passes.

  • Fresh schema imports no longer retain a production-only MariaDB trigger definer. Schema refreshes strip account-specific DEFINER clauses so CI, tests, and new installations create triggers under their own import account instead of failing writes with error 1449 when nexus@% does not exist.

Added

  • Events now has a real enterprise tenant-configuration workspace instead of placeholder “Configure” options. The dedicated, translated admin page separates tenant policy from per-Event and member-owned settings, reports rollout/schema readiness for registration forms, invitations, ticketing, agendas, offline sync, broadcasts, safety, analytics and federation, previews active-record impact, and exposes versioned audit history. Tenant-scoped creation, moderation, capacity, registration, guests, waitlists/timed offers, recurrence creation, reminders, broadcasts, offline check-in, calendars, federation, safety and notification-delivery policies are enforced in canonical services and projections with safe withdrawal/revocation paths. Changes require a reason and optimistic version, disruptive disables require server-confirmed impact review, section/all restoration removes only selected overrides, reminder shutdown cancels pending rows, and federation shutdown queues ordered tombstones for existing shares. The Events module card now navigates directly to this workspace; desktop/mobile browser coverage verifies configuration, confirmation, restoration, module navigation and the organizer People workspace.

  • Events clients can now discover recurrence capabilities from an authoritative runtime contract. Authenticated clients inside the tenant Events feature boundary can call GET /api/v2/events/recurrence-capabilities to select the legacy or V2 engine, supported frequencies/end types and bounded occurrence cap without guessing from a deployment version. Rolling-never, effective-revision and definition-blueprint support fail closed when rollout flags, configuration or required schema are unavailable; the exact non-sensitive resource, tenant/auth boundary, OpenAPI schema and Events test-harness membership are regression-tested.

  • Events is now a full enterprise operations module across React, the accessible frontend, and native mobile. Events now use an authenticated, tenant-feature-gated canonical contract with separate publication/operational lifecycle, moderation history, capability-scoped staff, server-paginated People operations, race-safe registration/waitlist offers, encrypted versioned forms and answers, invitations/campaigns, guests and retention, timezone-safe RFC 5545 recurrence and calendars, preview/commit recurrence revisions, immutable definition-only future-occurrence blueprints, attendee/session agenda registration, independent reminders and notification preferences, scheduled audience-frozen broadcasts, signed offline check-in with device/manifest/conflict workflows, safety and guardian-consent controls, versioned templates, bounded free/time-credit ticket definitions with free-only entitlement ledgers and atomic release on registration exit, privacy-thresholded analytics, and reliable federation upserts/tombstones. Notification delivery is outbox-authoritative by default for fresh installs, rechecks current event/category/global consent immediately before deferred sends, renders per recipient locale, and retains audited retry evidence. Privacy, tenant, lifecycle, capacity, idempotency, migration rollback and maintained-client parity are covered by the isolated Events test harness. Destructive RSVP and waitlist exits now require explicit confirmation, and the checked-in schema baseline is refreshed through migration 000071 with a verified no-pending-migration round trip. Attendance remains separate from finance: automatic attendance credits, cash processing, and unapproved time-credit settlement stay fail-closed.

  • Module Configuration now includes lockout-safe two-factor authentication and passkey controls. Super-admins can configure trusted-device availability and duration, future recovery-code counts, passive passkey autofill, passkey enrolment, and the maximum credentials per member. The Passkey / biometric login switch is now a real tenant-wide authentication kill switch: disabling it requires an impact confirmation that reports affected and passkey-only members, removes passkey login from tenant bootstrap/login UI, and blocks every public passkey challenge/verification route; an environment-level emergency switch provides the same fail-closed platform response. New-enrolment policy remains independent so it can be paused without disabling existing sign-in. The full passkey path now requires user verification and discoverable credentials, binds ceremonies to the exact tenant origin, RP ID, user handle, credential allow-list, and account state, consumes challenges atomically, pads account-bound challenge results, stores complete case-sensitive credential/RP/authenticator metadata, and revokes stale credentials on tenant moves. Authenticator registration, rename, and removal require short-lived password/TOTP/backup-code or recent passkey/federated step-up; removal revokes every active session, while final-sign-in-method guards count only usable passwords and enabled OAuth/SSO providers. The React and accessible settings flows enforce the same sensitive-change boundary, and focused abuse, replay, tenant-isolation, schema, migration, and successful-cryptographic-path tests replace the former 500-accepting coverage.

  • Safeguarding vetting now supports one United Kingdom policy package across England, Wales, Scotland and Northern Ireland. An authorised broker can record one community contact clearance backed by any controlled combination of Enhanced DBS, PVG and AccessNI, together with an encrypted scope summary, encrypted private operational notes, a mandatory community review date, and the PVG membership expiry date when applicable. The expandable broker detail view shows exactly what the community certified; review or authority expiry closes safeguarded contact until renewal. Active brokers and administrators receive translated email and in-app reminders at 90, 30 and 7 days, on the due date, and after expiry. Certificates, reference numbers, disclosure results, identity documents and criminal-record information remain prohibited, and a contact clearance is never reused as volunteering, employment or regulated-role clearance. One central fail-closed policy continues to guard maintained messaging, connection, exchange, matching, caring, volunteering and job-contact paths before writes or notifications, while cancel/withdraw/decline exits remain available. React and the accessible frontend retain private status, empty-body broker-review requests, policy-review acknowledgement, coordinator help and live message rechecks; safeguarding preferences do not grant brokers message-content monitoring. Dry-run-first commands still cover narrowly proven legacy metadata import, inferred-jurisdiction correction, unsupported listing flags and content-blind DPO-authorised evidence cleanup.

  • The protected-contact boundary now covers every maintained alternate interaction path found in the remediation sweep. Write-time checks now include directed comments and mentions, reactions/shares/votes/favourites/support, group membership plus discussion/announcement/Q&A/file/media/team content, events and waitlists, marketplace orders/community delivery, linked or sub-account relationships, legacy scheduled matching, volunteering emergency/waitlist transitions, and podcast reactions; removal, withdrawal, decline, cancellation, unreact and unfavourite exits remain available. Live protective options cannot be silently disabled, deleted, replaced, or revoked by an admin policy transition: incompatible/custom/unconfigured transitions preserve the member selection and return policy unavailable pending review. Broker decisions serialize with policy rotation; definitive direct-message decisions use a common tenant/policy/preference/option/attestation lock order, bypass shared caches, and serialize preference reactivation before persistence. DPO-authorised cleanup now also redacts prohibited legacy certificate references, dates, notes, rejection text and evidence-derived role flags, setting a content-free marker that permanently excludes the row from automatic import. Automated credential cleanup is limited to explicit vetting aliases and cleanup tombstones, while unknown/custom historical types remain private, non-authoritative, and available for manual review/member deletion. Legacy-decision apply requires explicit tenant/all-tenant scope, an exact acknowledgement, active authorised verifier provenance, and an unredacted legacy row.

  • The pre-render admin now has an authoritative “Reset and rebuild all” workflow. Platform super-admins get a typed-confirmation danger action that preflights every active tenant's complete static + sitemap route plan, fences older queue work, and schedules one high-priority fresh rebuild while retaining the last known-good snapshots if rendering or validation fails. The control panel now reports actual tenant-specific coverage, unexpected snapshots, read/write cache health, route-plan failures, inventory truncation, authenticated metrics/CSV downloads, complete missing-route lists, safer destructive confirmations, request-race-safe inspection, and honest live-page links. Destructive pattern and unexpected-snapshot purges require a short-lived server-issued preview token and delete only the exact cache paths in that preview; changed/expired previews fail closed. The reset, inventory, coverage, tenant-safety, sitemap, audit, and freshness copy is translated across all 11 frontend locales.

  • The accessible (GOV.UK) frontend now renders styled error pages instead of bare Laravel defaults. Aborting inside the accessible route group (403 module gates, 404s, expired form sessions, rate limits) previously fell through to the unstyled framework error pages: no GOV.UK layout, no skip link, no way back, and — a hard project requirement — no AGPL Section 7(b) attribution (2026-07-10 accessible-frontend audit, crawler finding W2). A new exception renderable (App\Support\AccessibleErrorPage, registered first in bootstrap/app.php so accessible requests are skinned before the API JSON renderables claim them) renders a standalone accessible-frontend::error view for 403/404/419/429/503 with translated title/body (govuk_alpha.error_pages.*, all 11 locales), a link back to the tenant's accessible home, and the attribution footer. The view deliberately does not extend the main layout so a second failure while rendering the error page is impossible. Also fixed from the same crawl: govuk_alpha.actions.back/actions.cancel were referenced by the AI-chat and listing-report pages but existed in no locale, rendering as literal key names (crawler finding W1) — added to all 11 locales.

  • The Explore / Discover page is now a gateable per-tenant feature that admins can turn on or off in Module Configuration. Previously the /explore curated discovery page was hardcoded-visible: its navbar link (desktop, mobile drawer, and collapsed overflow menu) rendered unconditionally and the route had no gate, so a tenant that didn't want it had no way to hide it. explore is now a first-class tenant feature (TenantFeatureConfig::FEATURE_DEFAULTS + TenantFeatures/defaultFeatures, defaulting ON so existing tenants are unaffected) with a card in the admin Module Configuration panel (moduleRegistry FEATURE_MODULES). When an admin toggles it off, the navbar Explore link disappears in all three surfaces (Navbar desktop link + overflow megamenu gated on hasFeature('explore'), MobileDrawer item carries feature: 'explore') and the route is wrapped in <FeatureGate feature="explore" redirect="/"> so direct-URL access redirects home — matching how sibling features (events, groups, volunteering) are gated frontend-side. No new user-facing strings (reuses existing nav.explore / nav_desc.explore keys); the public /api/v2/explore endpoints keep the codebase-wide convention of frontend-only feature gating.

  • The master tenant (tenant 1) home page is now a searchable "Choose your community" directory instead of a normal-looking landing page. The master tenant is the platform root, not a working community — but its home rendered the same LandingPageRenderer as every real tenant, so a visitor who got redirected there by an error saw a page indistinguishable from their own community with no obvious way back. HomePage now renders a new MasterTenantChooser when useTenant().tenant?.id === 1: a card grid of every active community (from the existing public, Redis-cached GET /v2/tenants, which already excludes master) with a client-side search box, each card a full-document <a href> — the tenant's custom domain when it has one, else /{slug} — so the app cleanly re-bootstraps into the chosen community (the same reason TenantShell's "Community not found" screen uses <a href>). Mirrors the accessible frontend's tenant-chooser (AlphaController::tenantChooser + tenant-chooser.blade.php). Strictly gated to id 1 — every other tenant renders the unchanged landing page — and touches no tenant-resolution or routing code, so it cannot affect any other tenant. New community_chooser.* keys added to public.json across all 11 locales.

Changed

  • The accessible frontend's public URL prefix is now /{tenantSlug}/accessible/... — the /alpha slug is retired. The track has been Beta for a while, but the URL still said alpha. The route prefix, the custom-domain slug-stripping middleware, the React "WCAG 2.2 AA Version" utility-bar/mobile-drawer link builder, the accessible-frontend dev-server redirects, the a11y E2E page list, ~1,577 test URLs across 52 test files, and the docs all move to /accessible. Old URLs keep working: /{slug}/alpha/{path} permanently redirects (301 for GET/HEAD, method-preserving 308 otherwise, query string preserved), and on dedicated accessible custom domains legacy /alpha/{path} deep links redirect to the bare slug-less path. That custom-domain redirect also fixes a real pre-existing bug: buildAccessibleFrontendUrl emitted https://{custom-domain}/alpha/{path} deep links, which always 404'd because custom accessible domains serve bare slug-less routes — the builder now emits bare paths (and /{slug}/accessible on the shared domain). Internal code-path names (GovukAlpha controllers, govuk_alpha translations, govuk-alpha.* route names, routes/govuk-alpha*.php) deliberately keep their names until a separate namespace migration; AGENTS.md/READMEs updated to say so.
  • Merged the tenant_admin role into admin; tenant super-admin is now purely the is_tenant_super_admin flag, granted via the dedicated toggle. An audit found role='tenant_admin' and role='admin' were treated identically by every authorization gate — the real tenant-super-admin power lives in the separate is_tenant_super_admin column, which was set independently of the role. So a tenant_admin without the flag was actually weaker than an admin (it couldn't assign elevated roles — the "only super admins" toast), and AdminUsersController::setSuperAdmin revoke left a role='tenant_admin' with the flag cleared — a powerless "zombie tenant_admin". tenant_admin is removed as an assignable role from all five role pickers (UserEdit, UserCreate, super-panel TenantShow add-admin, SuperUserList filter, and SuperUserForm create/edit) and the four backend allow-lists (AdminUsersController::update/store, AdminSuperController::userCreate/userUpdate) — attempting to assign it now returns 422 pointing to the super-admin toggle. The three grant paths (setSuperAdmin, AdminSuperController::promoteAndMove + bulk move) now write role='admin' alongside is_tenant_super_admin=1, so revoking the flag can never strand a tenant_admin role again (fixes the zombie bug). Data migration 2026_07_09_000002_merge_tenant_admin_into_admin converts existing tenant_admin users to admin while preserving is_tenant_super_admin (real tenant super-admins keep every power via the flag; flagless zombies become the plain admins they already were). tenant_admin is deliberately retained in the authorization accept-lists (requireAdmin/callerIsAdminTier/EnsureIsAdmin, frontend isAdminTier/hasAdminPanelAccess) as an inert legacy alias, so any pre-migration row still resolves to admin access; the frontend min_role menu hierarchy now treats tenant_admin as equal to admin (so a nav item gated min_role: 'tenant_admin' stays visible to migrated tenant super-admins rather than vanishing); inert role_tenant_admin labels are left in place. Net: the redundant, mislabeled tenant_admin role is gone from the UI — assignable roles are Member/Broker/Admin, and tenant super-admin is an explicit flag toggle.

Removed

  • Deleted three dead NewsletterService methods (getABTestResults, selectABWinner, resendToNonOpeners). They had no callers — the live admin resend/A-B workflow is implemented tenant-scoped in AdminNewsletterController — and they mutated newsletters by bare id with no tenant filter, a cross-tenant IDOR waiting to happen the moment one was wired to a route (2026-07-09 platform audit P3).
  • Removed the two non-functional placeholder user roles, "Moderator" and "Newsletter Admin", from the admin user role selector. Both appeared in the Create/Edit User role dropdowns and were storable on users.role, but neither was ever wired to a backend capability: moderator only granted cosmetic client-side entry to /admin/* routes (whose API calls still 403'd) plus inclusion in two notification/assignee queries, and newsletter_admin granted nothing at all (the newsletter endpoints require a true admin). They are now gone from both selectors (UserCreate, UserEdit), the two backend allow-lists (AdminUsersController::update/store — assigning either now returns VALIDATION_ERROR), the dead moderator clauses in ProtectedRoute, roles.ts::canModerateContent, the UserPermissions badge-colour map and the UserList search-hint map, and the two role IN (...) staff queries (MunicipalImpactReportService, AdminCrmController). A data migration (2026_07_09_000001_normalize_retired_user_roles) downgrades any existing moderator/newsletter_admin users to member — the role they already behaved as — so no orphaned values remain. The User['role'] TypeScript union and Zod enums intentionally keep moderator: it is also a distinct group-member role (GroupMember extends User) and can still appear in pre-migration legacy data, so the type layer stays a superset while the assignment paths do the enforcing. The now-unused role_moderator / role_newsletter_admin locale label keys are left in place (inert). The four real platform roles — Member, Broker, Admin, Tenant Admin — are unchanged.

Fixed

  • Mobile bottom navigation and radio controls now remain clear in constrained phone layouts. All five tabs receive equal flexible widths and share one fixed label baseline, the raised Create action no longer pushes its label toward or beyond the viewport edge in landscape, and left/right safe-area insets keep navigation clear of notches and system controls. The shared HeroUI v3 radio wrapper now restores a contrast-safe one-pixel boundary for unselected controls in dark and light themes while preserving the accent-filled selected state; focused component regressions cover both contracts.

  • Profile settings now fail safely and save consistently. Discarding edits restores the last server-backed values instead of retaining hidden changes, privacy and notification controls remain unavailable until their real values load, browser history stays synchronized with the selected tab, and tab-specific APIs load only when needed. Notification, match, and digest preferences now save through one validated database transaction with canonical values returned to React. The same hardening closes arbitrary identity-column writes through legacy notification preferences and ensures GDPR consent/request operations use the request tenant rather than a service constructed before tenant resolution. Focused settings coverage now exercises rollback, lazy loading, URL navigation, failed-load protection, identity verification, skills, passkeys, availability, linked accounts, and all maintained settings tabs.

  • Events lifecycle compatibility resolution now applies the blank-status normalization consistently. Canonical-axis consistency checks use the same legacy-active interpretation as the publication and operational enum mappers, allowing historical empty-string rows to complete guarded lifecycle backfill without weakening rejection of other unknown values.

  • Events lifecycle rollout now preserves blank legacy statuses as active compatibility rows. Historical Events installations can contain empty-string events.status values with the same meaning as the existing nullable compatibility state; the dual-axis lifecycle migration now maps both forms to published/scheduled while continuing to fail closed on every other unknown status.

  • Accessible Event cover-upload cleanup now loads its tenant-safe image helper in production. The accessible Events controller imports the shared uploader used by failed-create and failed-edit cleanup paths, preserving the upload lifecycle fix under PHP static analysis and at runtime.

  • Accessible Event creation now preserves cover uploads across immediate moderation freeze and renders recurrence limits from the live capability contract. Cover files are ingested into the creation payload before a new Event can enter pending review, failed creations clean up their tenant-scoped upload, and edit-time upload failures no longer leave orphaned files. The GOV.UK form and regression coverage now keep rolling-never hidden while its guarded writers are disabled and use the server-advertised maximum occurrence count instead of assuming the V2 ceiling.

  • CI schema regressions now exercise the authoritative Events lifecycle and stable WebAuthn dump semantics. The moderation regression fixture supplies a pending-review Event and an authorised admin before asserting the legacy active compatibility mirror, while the WebAuthn identity test accepts mysqldump's optional comma spacing without weakening its composite tenant foreign-key invariant.

  • Accessible Events moderation now exposes its subtype-preserving response helper to PHPStan correctly. The generic response annotation uses a standard multiline contract, preventing CI from treating the helper's template type as unreferenced while retaining precise Response and RedirectResponse inference.

  • Non-Events CI regressions now match the hardened authentication, federation, Groups, privacy, and queue contracts. Authentication fixtures use valid password-backed recovery methods; outbound federation fixtures provide explicit HMAC platform identifiers; Groups feed and course fixtures respect member-only child-resource visibility; accessible federation message counts stay scoped to the active conversation; authenticated responses emit one complete Vary: Authorization, Cookie field; and Horizon consumes the declared webhooks queue.

  • Event detail pages no longer flash a false “Event Not Found” state or wait for the People roster before rendering a valid event. Overlapping detail loads (including development StrictMode and fast route transitions) aborted the older request correctly, but that stale request's unconditional finally cleared the shared loading flags while its replacement was still in flight; the empty terminal branch then rendered for several seconds before the successful response replaced it. Detail and roster loads now use independent request-generation, controller-ownership, loading and error lifecycles, so only the current non-aborted request can publish its result and the authoritative event renders as soon as its detail contract resolves. Genuine unsuccessful responses still render the established terminal error state, while deferred replacement-detail, slow-roster and stale-roster regressions pin the races.

  • React modals now stay above fixed application navigation and inside safe viewport insets. The shared HeroUI v3 wrapper previously inherited the library's z-50 backdrop even though the platform header uses the --z-fixed layer at 300, allowing the Events organizer message title and close control (and sibling dialogs) to sit underneath the header on shorter desktop viewports. Shared modal backdrops and containers now use the existing --z-modal-backdrop and --z-modal tokens plus safe-area-aware HeroUI container insets; full-screen dialogs retain their edge-to-edge contract, and shared/Events composer regressions pin the layer and viewport guarantees.

  • Event management tabs remain usable on narrow mobile viewports. The installed HeroUI v3 tab styles gave both the horizontal list and every tab w-full; combined with the management strip's intrinsic minimum width, each control expanded to the width of the entire strip and pushed later sections thousands of pixels off-screen at 390px. Every permission- and capability-gated tab now uses intrinsic non-growing sizing, the tablist is the single horizontal scroll owner, and a deep-linked section is revealed from its stable controlled key once the loading workspace has mounted and scrollable layout has settled instead of depending on React Aria's later aria-selected timing. A 390px regression holds the staff request through the early scheduling window, delays both ARIA and scrollable layout after mount, proves horizontal movement, and covers the complete organizer strip including the rolling-recurrence Future setup tab.

  • Accessible event registration now renders every attendee and organiser workflow through the maintained GOV.UK layout. The registration overview, form editor, and private answer-review pages no longer reference a nonexistent nested layout, and their complete registration/error vocabulary is available in all 11 PHP locales. Arabic passkey impact messages also retain their required count interpolation, while focused regression coverage keeps both mocked and database-backed group exchange creation paths.

  • Group detail navigation is compact again instead of rendering every module as an oversized scrolling tab. Desktop now keeps the six core group sections in a bounded 36px tab row and places collaboration/admin sections in a single translated More menu; mobile retains its one-control section selector. Keyboard navigation, tenant tab configuration, feature gates, member/admin visibility, and selected-panel semantics remain covered by focused regression tests.

  • CI coverage now matches the privacy and safeguarding contracts introduced by the latest hardening release. Member-identity controller tests authenticate before exercising protected payloads, denied direct-message attempts emit the staff safeguarding event at the write preflight, external federation transfers fail closed until a partner trust contract exists, locale files stay structurally aligned, and PWA offline checks target only the identity-free public shell. Service/unit fixtures now cover the definitive safeguarding queries and metadata-only GDPR cleanup, while the root dependency lock updates immutable to 5.1.9 and tar to 7.5.19 to clear the current OWASP findings.

  • Passkey/WebAuthn authentication now fails closed across account, tenant, domain, and session boundaries. Inactive or unverified members can no longer bypass normal login gates; assertion signatures are verified before account-policy responses; and signed-out login always uses discoverable, account-agnostic credentials so email-bound descriptor padding cannot become a response or timing oracle. Authenticator backup flags and offered COSE algorithms are enforced, registration limits are race-safe, production loopback origins are rejected, and RP/domain or hierarchy changes that would strand current, legacy, or inheriting-child credentials return a recoverable impact report. Registration challenges carry a fresh routing fingerprint, while every routing editor and enrolment share the same tenant/user locks and re-check impact inside the mutation transaction; real concurrent-registration coverage verifies the boundary. Credential removal and tenant moves atomically revoke JWT and Sanctum sessions, same-second revocation cutoffs advance monotonically without invalidating a freshly issued replacement token, passkey-only user moves return a recoverable 409 until password recovery exists, and unused low-level hard deletion is disabled in favour of the audited purge workflow. The hardening migration aborts before DDL instead of deleting ambiguous ownership data. The settings UI now prevents duplicate sensitive actions, translates default device names, exposes credential-domain mismatches and configured limits, flags legacy or unknown-discoverability credentials for re-enrolment, restores session-expiry warnings after conditional passkey login, reports unknown disable impact as unsafe, and exposes step-up selection to assistive technology; signed-out recovery guidance directs affected legacy users through password reset without restoring the email enumeration oracle.

  • Groups authorization, lifecycle, membership, and challenge rewards are now fail-closed and tenant-safe. One canonical lifecycle state controls discovery, child resources, recommendations, jobs, admin transitions, and the compatibility mirror; disabled Groups routes now fail closed across every maintained legacy and current endpoint. Invitations are tenant/email-bound and atomic, join/leave/approval/role/removal paths preserve owners and member counts, and challenge completion/cancellation uses bounded rewards with an idempotent ledger. Adjacent-ID, cross-tenant, private-group, upload, rate-limit, and audit-secret boundaries are covered explicitly.

  • Groups collaboration, storage, exports, and automation now use durable bounded contracts. Members, events, discussions, announcements, Q&A, wiki, analytics, files, media, channels, tasks, and subgroups use deterministic cursor pagination and parent-group access rules. Private file/media migration is dry-run-first with checksum verification and storage compensation; exports are queued and authenticated; scheduled posts and webhooks use idempotent claim/outbox flows. Destructive, role, lifecycle, image, pin, and economy mutations write redacted audit events in the same transaction, while unsafe synchronous export, unfinished custom fields, ownership transfer, clone, merge, and misleading type-scoped policies are no longer reachable.

  • Groups navigation and administration have received an extensive responsive/accessibility pass. ?tab= is canonical for deep links and browser history, stale requests cannot cross group boundaries, desktop uses semantic HeroUI v3 Tabs, and mobile uses the official single-selection Dropdown below the real header offset. The sticky desktop tab bar now clears the fixed header reliably, analytics uses the translated Files label, and the language preference API accepts the same Arabic locale that the application middleware supports. Destructive actions use typed HeroUI confirmations; challenge cancellation, server-side member search, event continuation, notifications, RTL, forced colours, reduced motion, keyboard paths, and 320–1280 px layouts are covered. Admin Groups now uses canonical lifecycle actions, translated audit filtering/paging, safe type/tag/rule/configuration workflows, and reversible fixture-backed browser journeys; module configuration no longer requests an empty translation key and its card actions, responsive grid, and search semantics remain usable at narrow desktop widths. Deterministic Groups browser coverage now exercises Chromium, Firefox, mobile Chrome, mobile Safari, and the Chromium admin surface with run-scoped fixtures and cleanup verification.

  • Member identities are no longer exposed through signed-out community-content pages, APIs, accessible HTML, indexing, or caches. Explore, groups and rosters, events and attendees, listings, jobs, courses, podcasts/RSS/media, marketplace sellers, volunteering and organisations, resources/knowledge base, ideation, certificates, and municipality surveys now require a valid same-tenant member session before identity-bearing reads; React and the accessible frontend redirect to tenant-aware login before mounting or querying those surfaces. The blog remains public, indexable, sitemap-discoverable, and prerenderable for SEO, but its anonymous API, React, accessible HTML, and structured data use an author-free editorial projection with the tenant/community as publisher instead of exposing the linked member account; blog comments, reactions, authoring, and administration remain authenticated. Explore now enforces publication, schedule, audience, member-privacy, and private-group rules; event detail no longer embeds RSVP users and event/group rosters require a relevant relationship or role; marketplace and ideation detail lookups cannot enumerate unpublished records. Protected URLs are removed from public sitemaps and both backend/build-time prerender plans, authenticated API responses are private, no-store, the PWA never caches private APIs or protected navigations and purges legacy HTML/thumbnail caches, and the public CMS fails closed on legacy member-grid blocks, account profile links, and account avatars. Static project contributors, editorial testimonials, organisation-only directories/calendars, aggregate statistics, and identity-free job/listen feeds remain intentional public content.

  • Production nginx now serves runtime translation catalogs as JSON instead of the SPA shell. The hardened pre-render fallback treated /locales/<language>/<namespace>.json like an unknown client-side route and returned _spa.html with text/html; i18next retried the catalogs indefinitely, leaving React's root Suspense boundary blank and causing the candidate journey gate to fail every rendered page before cutover. /locales/ is now an exact static namespace with a fail-closed 404 fallback and bounded caching, covered by both nginx contract and real-container HTTP tests.

  • Pre-render reset fencing and maintenance cutover are now rolling-deploy safe. Authoritative reset intent is stored durably in additive fence_state/fence_ready_at columns without modifying the live job-status enum: old workers see a non-claimable row, new workers expose pending_fence, verify the database defaults required by old writers, recover the fence before honoring a tripped circuit breaker, and defer lock-contended activation quickly instead of holding the HTTP request for up to two minutes. The accepted intent and its success audit (including the actual job ID) now commit atomically; an audit storage failure rolls back the intent and returns 503. Once activated, the replacement cannot be canceled after it has superseded older jobs. Snapshot ownership now has a checksummed _tenant.json sidecar and a transactional .tenant-identity-v1 rollout marker; nginx serves the live SPA until the first fully verified identity generation exists. Maintenance rendering uses a rotated root-only Basic credential accepted only for the exact tenant origin, revokes and rolls back credentials transactionally, preserves truthful 503 status, and rebuilds before reopening. Shared-volume ACLs are normalized for root, PHP, and nginx without allowing PHP container startup to cross the nested pre-render mount or expose the private credential.

  • The pre-render engine is tenant-aware and fail-closed end to end. The route planner now includes each tenant's real feature/module gates, custom pages, blog entries, custom-domain or parent-domain path, and landing-page configuration instead of applying one generic route set; malformed, empty, unsafe, or capped plans are rejected rather than silently degrading to static routes. Each render uses an isolated browser context and verifies the exact tenant id/slug, final URL, canonical, readiness, status, visible content, API errors, assets, and public-network destination before publication. Landing-page, SEO, feature/module, blog, custom-page, menu, resource-library, marketplace listing/category, and listing-image writes now invalidate or reconcile the correct tenant; disabled-feature, retired-host, deleted-tenant, and wrong-tenant leftovers are removed by exact-plan drift reconciliation. Queue claiming/finalisation is claim-token fenced, long jobs have a real heartbeat lease plus runtime/resource limits, blue/green workers resolve the actual active color, stale-job reaping is compare-and-swap safe, and drift detection bypasses stale sitemap response caches and yields to active authoritative global work. Also fixed the broken shell JSON ingestion, custom-domain root-prefix loss, read-only cache mount, parent/child cache attribution, stale status/checksum sidecars, misleading >100% coverage, sitemap date precision, inaccessible external invalidation webhook, cron self-deadlock, and silent partial/truncated maintenance passes.

  • Authoritative pre-render rebuilds now publish validated, rollback-safe host-tree batches. A complete reset replaces each host tree only after every tenant route has rendered and validated, journals old/new ownership, rolls back on publication errors, and restores interrupted mutations when the next successful render reaches publication; custom-domain and shared-host tenants therefore cannot be left persistently on route-level mixed generations. Lock takeover is fenced by flock, PID start time, a random owner token, and a matching Docker label, so PID reuse or a foreign container can never be killed by name alone. Tenant domain, slug, hierarchy, activation, and deletion changes schedule a global authoritative generation to remove former host paths, while ordinary tenant branding/configuration changes refresh only that tenant. Healthy long jobs survive queue repair through heartbeat-aware leases, cancelled claims cannot start, cached redirect documents are rejected instead of being served incorrectly as HTTP 200, and the Playwright container now drops Linux capabilities and forbids privilege escalation. Startup recovery and a request-atomic immutable-generation serving pointer remain the next architecture steps and are documented in the audit report.

  • Pre-render publication, maintenance status, and cancellation are now fail-closed across blue/green operation. Route aliases that redirect (/development-status, tenant-only hOUR pages, and the build-dependent marketplace map) are no longer planned for the wrong tenants; malformed or partially rejected sitemap locations stop an authoritative reset. Root and query-string maintenance snapshots retain HTTP 503 with Retry-After, the status map is persisted beside snapshots on the shared volume and verified before color cutover, targeted jobs cannot change status-bearing snapshots, rejected authoritative output reports zero published pages, and owner-qualified lease files let reset/reaper revoke a publisher before waiting on the mutation barrier. Path traversal, symlink escape, unsafe manifest/output, and spreadsheet-formula export cases are rejected.

  • Courses now participate fully in tenant pre-rendering. Feature-gated course list/detail routes are included in sitemaps and complete rebuilds, with course/section/lesson observers and drift timestamps keeping snapshots current. Podcast routes were subsequently removed from public sitemaps and pre-rendering when same-tenant authentication became mandatory, preventing identity-bearing member content from entering anonymous caches.

  • Render-affecting tenant settings now commit with durable rebuild intent. General settings validate every field before the first write; reserved or duplicate slugs and malformed/duplicate domains are rejected through the shared hierarchy validator. Branding/content settings—including tenant landing-page layouts, feature/module gates, SEO metadata, header colours/logos, partner marks, and powered-by images—commit with a tenant rebuild job, while slug/domain/maintenance/hierarchy/activation/deletion changes commit with an authoritative rebuild job in the same database transaction. If the queue intent cannot be written, the setting or routing mutation rolls back instead of returning success with stale snapshots. Tenant routing identity is now platform-super-admin-only and core platform hosts cannot be claimed as tenant domains. Request-time CREATE TABLE IF NOT EXISTS was removed because MySQL's implicit DDL commit could previously defeat that rollback; missing settings schema now fails explicitly and must be repaired through migrations.

  • The E2E smoke suite no longer fails on a login rate-limit. Its primeApiAuth helper logged in per test (an admin and a user each), firing ~40 POST /api/auth/login requests within a minute from the CI runner's single IP and tripping the login limiter (route throttle:30,1 plus the App\Core\RateLimiter brute-force check) — roughly 11 succeeded and the rest 429'd with rate_limited, failing the smoke deploy gate. The helper now caches tokens per role (one login per role per worker, reused across every test) and honours the throttle's retry_after with a bounded retry as a backstop. Test-harness only — no production rate limits were changed.

  • The in-app "Report a problem" button no longer crashes the error-boundary fallback. ReportProblemButton (and its floating wrapper) called useAuth(), which throws when rendered with no AuthProvider ancestor — but the top-level ErrorBoundary (App.tsx) sits above the per-route AuthProvider (provided inside TenantShell), so any recovery fallback that surfaced the report button re-crashed and escalated to the bare root boundary, defeating the recovery UI exactly when it was needed. A new non-throwing useAuthOptional() hook returns null outside a provider instead of throwing; the button now reads useAuthOptional()?.isAuthenticated ?? false. Regular feature code keeps using useAuth(). Covered by component and context tests.

  • Restored the react-frontend npm lockfile to a state npm ci accepts. A hand-edit to react-frontend/package-lock.json had dropped the nested heroui-cli/node_modules/rxjs@7.8.2 entry (and inconsistently bumped two transitive deps) while changing no real dependencies — the package.json diff was scripts-only — so every npm ci step failed with Missing: rxjs@7.8.2 from lock file, turning the CI Pipeline React build, Lighthouse CI, and the Security Scan OWASP audit red. Restored the byte-exact lockfile from the last commit whose npm ci passed in CI; verified the committed file carries all four rxjs entries.

  • Fixed the accessible (GOV.UK) volunteering parity test after the govuk-tabs → semantic <nav> change. The audit-polish batch correctly replaced the volunteering section switcher's govuk-tabs markup with an aria-labelled <nav> of links (govuk-tabs is reserved for in-page JS panel switching), but the inherited GovukAlphaFrontendTest::test_volunteering_pages_render_opportunity_detail_and_application_flow still asserted class="govuk-tabs ", so it failed across all six subclasses that inherit it and turned the PHPUnit suite red. The assertion now targets the new sub-nav (tabs_title aria-label + tab label); the /alpha → /accessible slug rename and routes were already correct.

  • React frontend audit remediation: closed the HeroUI v3 accessibility, nested-interactive, locale-formatting, admin-i18n, responsive-header, PWA, performance, and semantic design-token findings. Added blocking contracts for nested controls, browser-locale formatting, admin literals, and raw brand palettes; the authenticated live accessibility matrix now passes 8/8 with zero Axe violations.

  • Frontend localization and state reliability: completed all ten non-English locale catalogs with interpolation parity, translated genuine admin copy, preserved technical-literal suppressions narrowly, and replaced fabricated analytics zeroes with explicit loading/error/empty/stale states. Machine-assisted secondary translations remain flagged for native-speaker review.

  • Canonical platform legal pages now state their language policy: Platform Terms, Privacy, and Disclaimer retain authoritative English legal bodies while a translated notice explains that policy and says the English text controls if translations differ. No substantive legal text was machine-translated.

  • Accessible (GOV.UK) frontend audit pass — 46 polish and correctness fixes across ~40 pages (2026-07-10 full audit: 574-request crawl of all 287 routes, two blade-by-blade reviews of all 265 templates). The audit found zero 5xx errors, zero broken route references, and near-perfect i18n discipline; the fixes close the gaps it did find. Highlights: the premium page's monthly/yearly choice was JS-only — without JavaScript (this frontend's core audience) every subscribe silently posted monthly; each tier now renders its own no-JS interval radios and the inline script is gone, prices gain a currency symbol from the tenant currency, and the regression test asserts the radios and the script's absence. Organisation-jobs JSON-LD was HTML-escaped inside its <script> tag (invalid JSON, invisible to search engines) — now emitted with JSON_HEX_* flags. A nonexistent govuk-warning-text__assistive class (v6 removed it) rendered the "Warning" prefix visibly on the notifications and feed pages — replaced with govuk-visually-hidden. Every remaining one-click destructive action gained the no-JS <details>+warning confirmation pattern (job alert/posting deletes — which previously relied on a JS-only confirm(), listing delete, poll deletes, connection remove/cancel ×4, group-member removal, passkey removal), and credit-moving forms (wallet transfer, wallet-manage, member-profile transfer) now require a native-required confirmation checkbox. Help-centre FAQ answers are sanitized at render (HtmlSanitizer::sanitizeCms) as defence-in-depth instead of trusting write-time sanitization alone. Ratings no longer pre-select 5 stars (profile, reviews) and the exchange rating select starts on a "Choose a rating" placeholder; message delete defaults to "Delete for you" instead of "Delete for everyone"; RSVP radios no longer pre-select "Going". The listings "Load more" link preserves the near distance filter; login failures now mark the email field with a proper inline GOV.UK error; course enrolment no longer renders two <h1>s; group and group-exchange detail pages gained back links; the profile-delete error summary moved above the heading; wallet and volunteering cross-page "tabs" became real <nav> sub-navigation; session device types and community-project statuses are translated (new govuk_alpha.ux.* keys, all 11 locales); the jobs bias-audit back link pointed at a route that never existed (/alpha/admin) and now returns to the jobs index; plus table captions, @php block-form conversions per the project's Blade hazard rule, dead-code removal, poll-percentage rounding, dashboard feed permalinks, and screen-reader context for rating values and goal progress bars.

  • The later Settings tabs are reachable on mobile again — Security, Skills, Availability, Linked/Connected Accounts, Safeguarding and Translation were all trapped off-screen on a phone. The Settings page has 10 tabs but only ~3 fit a phone width; the strip was meant to scroll sideways, but its overflow-x-auto/scrollbar-hide classes were applied to HeroUI's inner .tabs__list (which is min-w-max and never scrolls itself) instead of the real scroller, .tabs__list-container — and with no visible scrollbar and only a faint edge gradient, a touch swipe on the 32px strip was routinely lost to vertical page scroll. The user report was concrete: a member could not scroll past Profile/Notifications/Privacy to reach the Safeguarding tab and revoke a safeguarding preference (e.g. "only be contacted by DBS/vetted members"). The shared Tabs wrapper gains an opt-in scrollAffordance prop, enabled on Settings: edge ‹/› chevron buttons that appear only when there is more to scroll (and hide at each end), plus the selected tab is auto-scrolled into view so deep links like ?tab=safeguarding land visibly. The buttons are aria-hidden and non-focusable — keyboard users already get React Aria's arrow-key navigation with scroll-into-view, so they add no duplicate tab stops and no new translatable strings. Fixing it surfaced a second latent bug caught in a real browser: the new scroll wrapper is a flex item of HeroUI's column .tabs flexbox, so without min-w-0 its min-width: auto expanded to the full tab-strip content width and defeated the inner overflow scroller entirely (the un-wrapped container had dodged this only because its own overflow gives it min-width: 0). Verified at 375px — all 10 tabs reachable, the last tab fully visible at the end, chevrons toggling correctly at start/middle/end — with 6 new regression tests on the shared Tabs component (including a guard on the load-bearing min-w-0).

  • Passkey (biometric) login and setup now work on tenant custom domains. The WebAuthn RP ID was a single platform-wide value (WEBAUTHN_RP_ID=project-nexus.ie), but five production tenants serve the React app from their own domains (e.g. hour-timebank.ie) — and WebAuthn requires the RP ID to be a registrable suffix of the page's domain, so on every custom-domain tenant the browser rejected the ceremony before it started (The RP ID "project-nexus.ie" is invalid for this domain) for both passkey registration and login; the HTTP_HOST code fallback could never help because production API calls are cross-origin (api.project-nexus.ie). WebAuthnController::getRpId() now derives the RP ID from the request's Origin header validated against the tenant's registered domains (tenants.domain, tenants.accessible_domain) plus the configured platform default — a forged Origin cannot mint tokens because lbuchs/WebAuthn checks the browser-signed clientDataJSON.origin against the same RP ID at verify time, and credentials stay scoped to the RP ID they were registered under. Compounding fixes from the same audit: the login page silently swallowed every passkey failure except "cancelled"/"not found" (an RP ID failure looked like the button doing nothing) — unexpected failures now surface a translated toast; the settings page toasted the raw untranslated browser exception (the exact toast in the user report) — known failure classes now map to translated messages (new passkey_error_domain / passkey_login_failed / passkey_cancelled keys, all 11 locales) with the raw error logged for diagnostics; frontend error classification uses SimpleWebAuthn's structured error codes (ERROR_INVALID_RP_ID etc.) instead of fragile message substrings; WebAuthn challenge tenant-binding now actually engages for the stateless React flows (the store read $_SESSION['tenant_id'], which is unset there, so the cross-tenant replay guard silently never fired — it now uses TenantContext::getId()); the per-user registration-challenge rate limit of 3 per 10 minutes (a user retrying a failing setup locked themselves out after three clicks) is raised to 10; and the stale AGENTS.md claim that production compose derives the RP ID from an HTTP_ORIGIN fallback is corrected. Slug-only sub-tenants served at a parent's custom domain (e.g. stratford at uk.timebank.global/stratford — no domain of their own, parent_id set) inherit the parent tenant's domains as valid RP IDs, mirroring TenantContext::getFrontendUrl()'s parent lookup; credentials remain tenant-scoped in webauthn_credentials, so a shared RP ID cannot cross-authenticate tenants. Regression tests pin per-tenant RP ID derivation (custom domain, multi-label custom domain like uk.timebank.global, sub-tenant inheriting the parent domain, platform domain, unrecognised origin falls back to the platform default) and the frontend error classification. Note: passkeys are inherently scoped per domain — one registered on app.project-nexus.ie is a separate credential from one registered on a tenant's custom domain (custom-domain users had no working passkeys before, so nothing existing breaks).

  • The "Set Your Availability" grid in profile/settings is clickable again — every cell had collapsed to a 2px-wide sliver. Each day/time cell is a HeroUI Button, and HeroUI v3's base .button style applies w-fit (width: fit-content). The cells have no text content and p-0, so fit-content shrank each one to ~2px (just its 1px borders) — an unhittable click target, which rendered the whole grid as faint vertical lines instead of a grid of boxes (customer report: "cannot click on any of the times or days"). The appended utilities (h-6, p-0, min-w-0) already override HeroUI's default height/padding, but nothing overrode the width, so w-fit survived the HeroUI v3 button migration. Fix is a single class: the cell button now carries w-full, so it fills its 1fr grid track (~80px) and presents a full-size click target. Affects both the editable settings grid and the read-only profile display (which showed the same slivers). Verified in a real browser via coordinate-based hit-testing — pre-fix the cell centre resolved to no element and toggling never fired; post-fix a real pointer click flips the slot, turns it green, and reveals Save. No new strings; SPDX/i18n unaffected.

  • Internal federated credits are now visible in the receiver's wallet. A cross-tenant transfer (FederationV2Controller::sendTransaction) writes its canonical ledger row in the SENDER's tenant (transactions.tenant_id = sender tenant, is_federated=1), and the receiver's wallet reads through the Transaction model's tenant scope — so the receiver's balance rose with no ledger line to see, audit or dispute (2026-07-10 federation audit B1). Rather than dual-writing a federation_transactions row (which would double-count every internal transfer in FederationInternalLedgerService's admin totals, since that service deliberately sums both tables), WalletService gains a receiver-scoped read overlay: the transaction list's page-1 federation overlay, the balance summary's total_earned/count, and the single-transaction detail view now also surface internal federated inbound rows (scoped by receiver_id + receiver_tenant_id = viewer, recorded in another tenant, deleted_for_receiver respected), rendered with the sender's name and community like external inbound credits. Regression test pins that the receiver sees exactly one credit row (list, detail and total_earned) and the sender sees exactly one debit row with no duplicate overlay.

  • Federation browse endpoints no longer 500 on array-valued query params, and federated transfers reject fractional amounts instead of silently truncating them. ?partner_id[]=1 reached str_starts_with() as an array before the endpoints' try/catch (uncaught TypeError → HTTP 500 on members/listings/events/groups); a new queryScalar() coercion treats non-scalar partner_id/q/skills/service_reach/type input as "no filter". sendTransaction did (int) $amount before the range check, so "5.9" transferred 5 — rounding in the sender's favor; non-integer amounts are now rejected with a validation error (reusing the existing "whole hours" message). The receiver-credit UPDATE inside the transfer is now rowcount-guarded like the sender debit, rolling back if the receiver row vanished mid-transaction instead of debiting the sender with no matching credit. The mutating federation endpoints that had no rate limiting (opt-in/opt-out/setup, settings update, message mark-read single/batch, connection accept/reject/remove) now carry throttle middleware matching the style of the already-throttled send endpoints. The memberReviews hardcoded English 'Anonymous' is now __('api.fed_review_anonymous') (translated across all 11 locales), and FederationUserService::getTrustScore() now scopes reviews by receiver_tenant_id (with the legacy null fallback and status filter) exactly like the member profile's review list and reputation aggregate — previously the same profile could show disagreeing trust_score and reputation_score because the two paths selected different review rows (2026-07-10 federation audit B2–B7). Regression tests cover the array-param 200s, the fractional rejection, and the receiver-wallet visibility.

  • Credit agreements between communities now require genuine two-sided consent and follow a real state machine. The admin credit-agreement action endpoint mapped approve|reject|suspend|activate|reactivate|terminate straight to a raw UPDATE ... SET status with no current-state check — any state could jump to any state (including resurrecting a terminated agreement), and the tenant that CREATED an agreement could immediately "approve" it to active alone, unlocking the v1 federated-transfer gate without the counterparty ever consenting (2026-07-10 federation audit C1). The dual-consent logic already existed in FederationCreditService::approveAgreement() (per-party approved_by_from/approved_by_to columns, TOCTOU guards, row-locked activation) — the controller just never called it. action('approve') now routes through that service (so approval only records the acting tenant's consent and the agreement activates only when BOTH sides have approved), requires an active partnership between the two tenants before activation, and every other action passes a legal-transition guard (pending→terminated via reject, active⇄suspended, →terminated from any live state, terminated is final) returning 409 INVALID_TRANSITION otherwise. Also from the same audit tranche: partnership permission edits (updatePermissions) are now rejected on non-active partnerships, unknown permission keys fail loudly instead of being silently ignored, and no flag can be switched on beyond what the partnership's federation_level grants by default (a level-1 discovery partnership can no longer be handed transactions_enabled); the federation data-import only accepts partnership rows where the importing tenant is the initiating side, closing the path where a super-admin could import a fabricated "pending request from" another tenant and self-approve it to active (C3); the directory profile description is length-clamped like the sibling fields (C4); the platform-wide federation handlers (system controls, emergency lockdown, global whitelist, suspend/reactivate/terminate any partnership) now call requirePlatformSuperAdmin() in-controller as defense-in-depth beyond the route middleware (C5); and counter-proposed partnership levels are clamped to the system-wide max federation level (C6). Sixteen new regression tests pin the state machine, dual consent, partnership requirement, permission guards, and import scoping.

  • Federation React i18n pass: the admin Aggregates page, reviews panel, toasts and level labels now actually translate. The entire Federation Aggregates admin page referenced a federation_aggregates.* key block that existed in no locale — every label, chip, button, modal and toast rendered as raw keys like federation_aggregates.consent.title; the full block now exists in admin_federation.json across all 11 locales. FederationReviewsPanel referenced missing reviews.empty/reviews.load_error/reviews.verified keys (the empty state hits every federated profile with zero reviews) — added to all locales — and its 404 detection now tests the api client's code field (NOT_FOUND/HTTP_404) instead of a status field the failure envelope never carries plus a locale-fragile English regex. The settings toggle success toast interpolated hardcoded English 'enabled'/'disabled' into translated sentences (new settings.action_enabled/_disabled keys, all locales); the messages compose modal's 'Recipient'/`User #${id}` and the events card's 'Organizer' fallbacks are now translated keys; the Analytics "Recent Errors" chip rendered mojibake — (double-encoded em-dash, also fixed in two Verein federation cells); and partner federation-level chips across the hub, partners list/modal and partner detail now prefer the translated partners.level_* keys over the backend's always-English federation_level_name (external partners get the existing translated external key). Three pages read the federation opt-in status from fields the /v2/federation/status endpoint never returns (user_opted_in, status.user_optin) — a new shared lib/federationStatus.ts helper reads the real federation_optin/enabled fields and is used by the onboarding redirect, messages page and member profile (2026-07-10 federation audit A1–A8).

  • Federation React UX pass: opted-out users now get an opt-in CTA instead of fake empty states, plus a batch of small fixes. The members/listings/events/groups browse endpoints 403 (FEDERATION_NOT_ENABLED) for users who haven't opted into federation, but the four pages rendered "nothing found" (members: an un-retryable error) — compounded by the partner filter dropdown populating anyway, since /partners doesn't require opt-in. All four now detect the code and render a shared FederationOptInNotice linking to the onboarding wizard, exactly like the messages page already did (RF8). Also: the hub's three data fetches now honour its AbortController (the signal was created but never passed) and the effect aborts on unmount (RF1); the members grid hides "Send message" for members whose messaging_enabled is false instead of letting the backend 403 after composing (RF2); an inbound realtime message for the currently-open thread is now marked read immediately instead of sticking as unread until the next thread switch (RF3); the hub quick-links gain the routed-but-unreachable Groups and Connections pages (index-keyed labels shifted so Settings keeps its translations, new labels in all 11 locales) (RF6); the reviews panel no longer double-fetches when the i18n namespace loads (tRef pattern) (RF7); the super-admin FederationWhitelist page's loading state gains role="status"/aria-busy and a load failure now shows an error toast instead of silently rendering an empty whitelist (new failed_to_load key, all locales) (RF5); and four federation admin files' SPDX headers moved from below the imports to the top of the file (RF4).

  • Accessible (GOV.UK) frontend federation pass: honest hub stats, safe status params, and paginated connections. The hub's "Federated messages" stat counted both copies of the dual-insert (outbound sender copy + inbound receiver copy match the same sender/receiver columns), showing ~2× the React figure — it now applies the same direction filter as the React API (a member with 5 sent + 3 received sees 8, not 16; regression test) (B8). The ?status= query param was interpolated raw into __() on four pages (messages, conversation, transfer, connections), echoing arbitrary input back as a literal translation key inside a GOV.UK error summary — all four now whitelist the known status values exactly like the member page already did (AF2). The federated listing detail image now gets the same URL-scheme guard as the member avatar (AF3). And the connections list — previously a flat 100-row cap that silently dropped a member's older connections — now has GOV.UK previous/next pagination (50/page, wallet-history pattern, fed2.connections.pagination_* keys reusing each locale's existing pagination translations) (AF4).

  • Federation "Browse Members" from a partner community now filters to that community. The partner-detail page's "Browse Members" button links to /federation/members?partner_id=X, but FederationMembersPage never read useSearchParams, so the partner_id (and any q/skills/service_reach) in the URL was ignored and the page always listed members from every partner community — while the sibling "Browse Listings" worked because FederationListingsPage does initialise its filter from the URL (2026-07-10 federation audit). The members page now initialises its partner/search/skills/service-reach filters from the query string and syncs them back (so back/refresh preserves them), matching the listings page. No backend change was needed — the members/listings/events/groups endpoints already honour partner_id and gate it behind an active partnership, so the filter can only narrow within the partnership, never reach a non-partner tenant.

  • Federation partner-detail permission chips, level labels and the onboarding service-reach summary no longer render raw i18n keys. FederationPartnerDetailPage referenced partner_detail.permission_* / partner_detail.level_* keys that don't exist (the real keys live under the partners.* block, used correctly by the partners list) and FederationOnboardingPage referenced onboarding.reach_label_* (the keys are onboarding.reach_*), so the six permission chips, the level fallback label, and the onboarding confirmation summary showed literal strings like partner_detail.permission_profiles (2026-07-10 federation audit). All three now point at the existing keys — a code-only fix with no locale-file or translation-parity changes. The /v2/federation/ingest/* endpoints authenticate a federation_api_keys row, but the acting external-partner identity — and with it the per-partner allow_* permission flags — was chosen from the client-supplied X-Federation-Partner-ID header, constrained only to the same tenant. A key issued for partner A (e.g. volunteering disallowed) could claim partner B's id and write, overwrite, or mass-retract B's federated opportunities/listings/events under B's permissions (2026-07-10 volunteering audit M3; cross-tenant isolation was never affected). The partner identity is now a server-side binding: a new federation_api_keys.external_partner_id column links each key to its partner row, the ingest controller resolves the acting partner from the authenticated key only and ignores the header entirely, and an unlinked key resolves no partner so every permission flag fails closed. Admins can set the binding when creating a key (external_partner_id on POST /v2/admin/federation/api-keys, validated tenant-scoped) and see it in the key listing. Regression tests pin that a bound key acts only as its own partner regardless of the header and that an unlinked key claiming a partner via header is rejected with no shadow write.

  • Suspended volunteer organisations can no longer receive wallet deposits via the accessible frontend. The hard-freeze on non-approved orgs lived only in the React API controller, so the accessible (GOV.UK) frontend — which calls VolOrgWalletService::depositFromUser() directly after its owner/admin gate — let members keep moving time credits into a suspended or still-pending org's wallet (2026-07-10 volunteering audit M2). The freeze now lives inside depositFromUser() itself, checked on the locked org row before any balance moves, so every current and future caller inherits it; reuses the existing api.volunteer_org_not_active message. Regression test pins that deposits into suspended and pending orgs are rejected with no balance change and no transaction row.

  • Partially refunded Stripe donations can no longer over-claim Gift Aid or overstate giving-day totals. charge.refunded fires for partial refunds too, but the webhook handler ignored them entirely: the donation stayed completed at its full amount, the giving day's raised_amount stayed overstated, and a ready Gift Aid row was exported to HMRC at the full amount — claiming tax relief on money already returned to the donor (2026-07-10 volunteering audit M1). A new vol_donations.amount_refunded column (migration backfills fully refunded rows) now records Stripe's cumulative refund total, delta-applied under a row lock so replayed webhooks and successive partial refunds decrement the giving day exactly once per refunded slice; the donation deliberately stays completed (it is still partly a live gift). The Gift Aid export and the ops overview's ready_cents now claim only the retained amount (amount − amount_refunded, fully netted rows excluded), a partial refund of an already-claimed donation flags refund_after_claim for the next HMRC adjustment exactly like full refunds, the full-refund path decrements only the still-unaccounted remainder after earlier partial refunds, and annual receipt rows expose the refunded amount. Regression tests cover cumulative delta-idempotency, the netted export/overview, and the claimed-then-partially-refunded flag.

  • A minor volunteer can no longer grant their own guardian consent (safeguarding). Two coupled defects from the 2026-07-10 volunteering audit (C1 + H1): the minor's own consent list, GET /v2/volunteering/guardian-consents, did a bare ->get() and returned every column including consent_token — the very secret requestConsent() was hardened never to hand the minor — and the public verify endpoint performed the one-way pending → active grant on an unauthenticated GET, so anyone holding the URL (the minor, or a mail-scanner prefetching the guardian's email link) could record legal consent. Together they let a minor request consent with any email, read the token from their own list, hit the verify URL, and pass every checkConsent() safeguarding gate with zero guardian involvement. Fixes: getConsentsForMinor() now selects an explicit column list excluding consent_token/consent_ip (mirroring getConsentsForAdmin()); the grant is now POST-only (POST /v2/volunteering/guardian-consents/verify/{token}), with the GET on the same URL demoted to a read-only status lookup that never mutates; the React GuardianConsentVerifyPage keeps its explicit human "Confirm my approval" tap, now issuing the POST. No new locale keys (reuses api.vol_consent_invalid_token). Regression tests pin that the minor's create response and consent list contain no token, that GET verify leaves the row pending even with the real token, and that POST verify still grants.

  • Feed post saves and bookmarks work again after the 2026-07-09 cross-tenant fix mapped 'post' to the wrong table. That fix (2e28315e5) added an item-existence check to BookmarkService::toggle() and SavedCollectionService::saveItem(), but both type→table maps pointed 'post' at the legacy blog posts table instead of feed_posts. Every real feed save/bookmark — the React FeedCard bookmark button and the accessible frontend's storeFeedPostSave, which both send a feed_posts id — ran the check against the wrong table and failed with “Item not found”, which surfaced as the GOV.UK feed-save parity tests going red across four PHP shards in CI. Both maps now resolve 'post'feed_posts (preview/title from the post content); a regression test pins that a same-tenant feed post is bookmarkable.

  • Reviews can no longer be fabricated against transactions the reviewer wasn't part of. ReviewService::create() validated only that receiver_id and transaction_id each exist in the tenant — never that they were related or that the reviewer took part — so any member could attach a review of anyone to any tenant transaction id, and rotate through ids to bypass the 24-hour anti-spam window (review-bombing) (2026-07-10 accessible-frontend audit P2; shared with the React API — this fixes both). Creating a review with a transaction_id now requires the reviewer and receiver to be the two parties (sender_id/receiver_id) of that transaction.

  • A paid marketplace order can no longer be silently cancelled without a refund. MarketplaceOrderService::cancel() blocked shipped/delivered/completed/refunded orders but allowed a paid order to be cancelled — which voided the order and restored inventory while never returning the buyer's captured Stripe payment, leaving them charged with no goods and no refund (2026-07-10 accessible-frontend audit P3, money; shared — the React API cancel endpoint calls the same service, so both surfaces are fixed). Cancellation is now allowed only before payment; the accessible-frontend order pages no longer offer "cancel" on a paid order. Refunding a captured payment on cancellation is a separate payments feature (the refund engine MarketplacePaymentService::processRefund exists but is not yet wired to any user action and needs a staging Stripe test before launch). Unit tests pin that shipped/completed/refunded and now paid orders are rejected.

  • Ranked-choice poll ballots are now validated on submit. PollRankingService::submitRanking() inserted whatever option ids were posted, with no check that the poll was ranked-type, still open, or that the options belonged to it — so junk option ids could be inserted and inflate the voter count and per-option tallies in the results (2026-07-10 accessible-frontend audit P3; shared with the React PollsController::rank). It now rejects the ballot unless the poll is ranked-type and every submitted option belongs to that poll.

  • Accessible frontend: a drip-scheduled course lesson can no longer be completed before it unlocks. commerceCompleteLesson marked any lesson complete without the drip-availability check the React API enforces, letting a learner skip ahead of the schedule and drive course completion early (2026-07-10 accessible-frontend audit P3). It now mirrors CourseEnrollmentController — a locked lesson is refused with a "not yet available" notice instead of being completed.

  • Accessible frontend: federated member reviews now respect the reviewer's cross-tenant opt-out. The two federated-review queries (member review list + reputation average) matched every review targeting the viewing tenant, dropping the review_type = 'federated' and show_cross_tenant = 1 conditions the canonical Review::scopeWithFederated requires — so a reviewer who opted out of cross-tenant display still appeared, and counted, in a partner community's view (2026-07-10 accessible-frontend audit P3; parity with the same gap in FederationV2Controller). Both queries now carry the opt-out conditions.

  • Accessible (GOV.UK) frontend: group join/leave, goal create/progress/complete and organisation registration no longer 403 when a tenant disables the group-exchanges feature. These six accessible-frontend actions used geGuard() — a helper that also asserts hasFeature('group_exchanges') — as their auth guard, then applied their real gate (groups/goals/volunteering) on the next line, so a tenant with group-exchanges off but those modules on got a 403 while the pages still rendered the forms (2026-07-10 accessible-frontend audit P2; latent because the feature defaults on). A new feature-agnostic alphaAuthGuard() (slug assertion + currentUserId() + login redirect) now guards the six handlers; geGuard() stays reserved for the genuine group-exchange handlers.

  • Accessible frontend: resource admin controls (reorder, cross-member delete) now work for tenant admins. resourcesUserIsAdmin() checked Auth::user(), but the accessible frontend authenticates with a stateless auth_token cookie and never populates a Laravel guard, so it returned false for every user including admins — reorder always 403'd and the admin delete fallback never applied (2026-07-10 accessible-frontend audit P2). It now resolves the role via currentUserId() and a tenant-scoped users lookup, mirroring the working ideationIsAdmin().

  • Accessible frontend: the buyer marketplace "Active" orders tab is no longer always empty, and order tabs no longer hide delivered/refunded orders. Tab names were passed straight through as marketplace_orders.status values, but active/completed/cancelled are UI groupings, not enum statuses — the buyer Active tab matched zero rows, and Completed omitted delivered while Cancelled omitted refunded (2026-07-10 accessible-frontend audit P2/P3). A shared commerceOrderStatusFilter() now maps tabs to the status sets used by the React BuyerOrdersPage/SellerOrdersPage (active→paid,shipped; completed→completed,delivered; cancelled→cancelled,refunded), and the seller dashboard gains a Cancelled tab so cancelled/refunded sales are visible outside "All" (reuses existing orders.tab_cancelled key).

  • Accessible frontend: editing a listing no longer silently re-activates a sold, expired or removed listing. The shared listing-input builder sets status='active' (correct for create), but the edit path passed the same payload to MarketplaceListingService::update, so any edit of a sold listing flipped it back to active and re-purchasable, and resurrected expired/removed listings without the renew flow's expires_at extension (2026-07-10 accessible-frontend audit P3, money-adjacent). The update path now strips status from the payload; lifecycle status changes only through the explicit renew flow.

  • Accessible frontend: hardened currentUserId() so a stale session identity can't act cross-tenant. The tenant-membership + active + approval re-check (validatedTenantUserId()) ran only on the token auth branches; the Auth::user() and native $_SESSION['user_id'] branches returned the id unchecked. The live alpha path (token cookie) was already validated so there was no reachable exploit, but if either branch were ever populated on the shared host a tenant-A identity could act under tenant-B's slug and a suspended user with a live session wouldn't be re-blocked (2026-07-10 accessible-frontend audit P3, defense-in-depth). All three branches now route through validatedTenantUserId().

  • ~40 admin and member API route blocks now enforce auth at the middleware layer, not just inside controllers. The FADP compliance, residency-verification, ad-campaign, push-campaign, KI-agent, AI-module-docs, AI-trace-metrics, regional-analytics, pilot-inquiry and api-partner route blocks carried no route-level auth:sanctum/admin middleware — every controller self-checked identity/role, so there was no live bypass, but those helpers don't re-check account status or token↔tenant binding, meaning a suspended or banned user with a live session wasn't blocked, and any future forgotten controller guard would have been world-reachable (2026-07-09 platform audit P2). All /v2/admin/* blocks are now wrapped in ['auth:sanctum','admin'] and their /v2/me/* counterparts in auth:sanctum, with controller checks kept as a second layer. Deliberately-public endpoints (ad serving + impression/click beacons, survey listing, the throttled pilot-inquiry lead form) are annotated and untouched, as are the two blocks that must stay auth-only because non-admin roles legitimately use them (municipality announcers for surveys; safeguarding officers/coordinators/brokers for safeguarding). A 29-test regression suite pins 401 for anonymous and 403 for plain members on a representative route from every hardened block, and that the public endpoints stay public.

  • Members can no longer probe or drain the tenant's AI provider budget via POST /ai/test-provider. The endpoint only required a logged-in user and had no rate limit, so any member could enumerate which AI providers a tenant has configured and loop connection tests that spend real provider API credit (2026-07-09 platform audit P2). It now requires an admin (both requireAdmin() in the controller and admin middleware on the route) and is throttled to 10 requests/minute. Regression tests pin 401/403/200 for anonymous/member/admin.

  • Event categories can no longer cross tenants. EventService::resolveCategoryId() looked up a user-supplied category_name with no tenant filter (first match from any tenant won) and stored a caller-supplied category_id with no ownership check, so a foreign tenant's category name/colour could surface in this tenant's event joins (2026-07-09 platform audit P2). Both branches are now tenant-scoped — a foreign or nonexistent id/name resolves to null (uncategorized) instead of attaching foreign data — and the update path validates raw ids the same way. Regression tests cover foreign id, foreign name, same-name-in-both-tenants, and nonexistent id.

  • Creating, editing, deleting and renewing listings no longer fake success when the API rejects the request. The api client resolves 4xx as {success:false} without throwing, and these four call sites ignored the envelope: a 422/403/409/429 on create or edit still showed "created/updated successfully" and navigated away (silently losing the user's input), delete showed "deleted" while the listing still existed, and a failed renew just stopped the spinner with no feedback (2026-07-09 platform audit P2/P3). All four now check response.success, surface the API's error detail in an error toast, keep the form state, and stay on the page. Ten Vitest regression tests cover the failure and success paths.

  • Bookmark collections are no longer leaked between users on a shared browser. useBookmarkCollections cached the user's private collection names/counts in a module-global variable that survived logout, so after a user switch the next user saw the previous user's collections — and adding a bookmark could write into the previous user's collection id (2026-07-09 platform audit P2). The cache is now keyed by tenant+user, cleared on logout, and refetched when the authenticated user changes; the hook's public API is unchanged. Thirteen hook tests cover the user-switch, logout and cache-hit paths.

  • The organisation detail page no longer crashes when an opportunity has a category. It rendered {opp.category} directly, but the API returns categorized opportunities' category as an object {id,name,color} — React throws "Objects are not valid as a React child" and the error boundary replaced the whole page (2026-07-09 platform audit P2). A shared getOpportunityCategoryName() helper now unwraps string-or-object categories in OrganisationDetailPage, VolunteeringPage and OpportunityDetailPage (the latter had the same latent crash), with unit tests.

  • Seven pages no longer fail silently or show misleading empty states when the API errors. All shared the same root cause (4xx resolves {success:false} without throwing): Group detail's "generate invite link" did nothing on 403 (now shows the error toast); the Appreciation Wall, Blocked Users (privacy-critical — a failed load claimed "No blocked users", as if blocks had been cleared) and Matches pages rendered their empty states on failure (all three now render an explicit error state with a retry button, never the empty state); Match Preferences left the form editable with defaults after a failed load so saving silently overwrote the member's real preferences (the error screen now replaces the form entirely and saving is blocked until a load succeeds); Goals' "Load more" had no in-flight state so a double-click appended the same page twice with colliding keys (now guarded + spinner); and the Security tab labelled its working sessions endpoint "coming soon" and masked real API errors behind the same copy (now renders real data, a real error + retry, and an honest empty state — the misleading sessions_coming_soon key is gone from all 11 locales). Eleven new Vitest regression tests across the batch (2026-07-09 platform audit P3).

  • Event notification emails now render dates and location lines in the recipient's language. Every event reminder/cancelled/updated/created email formatted dates with PHP date() — English weekday/month names for every locale, even though the surrounding copy was correctly translated — and the reminder body glued hardcoded ' (Online)' / ' at {location}' connectors into translated strings (2026-07-09 platform audit P3). All five date sites now use Carbon isoFormat in the active locale, and the connectors are translation keys (notifications.event_reminder_location_online/_at) across all 11 locales. A regression test asserts a German-locale reminder renders a German weekday.

  • Three more hardcoded-English surfaces are now translatable: the wallet transfer category picker's label/placeholder, the composer template picker's template titles and bodies (post/listing/event/goal/poll), and the Regional Points history's transaction types, which rendered raw machine codes like earned_for_hours (now mapped through locale keys with a humanized fallback for unknown codes). Keys added across all 11 locales (2026-07-09 platform audit P3).

  • Approving under an hour of volunteering is now honest in the email channel too. The earlier VOL-BE-008 fix made the bell/push notification say "no time credit was added", but the email still used the celebratory generic "Hours Approved — thank you!" template. Sub-hour approvals now send the same honest no-credit note in the email body (emails_notifications.volunteering.hours_approved_no_credit_note, all 11 locales), with a regression test on both variants (2026-07-09 platform audit P4).

  • The "new group created" admin email renders its fallback in each admin's language. When the group creator couldn't be resolved, the fanout baked the English string "A member" into every admin's email regardless of their preferred_language; the translated fallback is now resolved per-recipient inside the locale-wrapped send (2026-07-09 platform audit P4).

  • Group deletion, gamification aggregates and group recommendations now carry tenant filters on their raw queries. Defense-in-depth from the audit's tenant pass: the group-deletion cascade deleted child rows by group_id alone (parent load was tenant-checked, so not exploitable — but the query shapes were unsafe to copy), gamification XP/badge aggregates counted likes/transactions across all tenants for users belonging to two communities (blending their stats), and two recommendation-engine reads were unscoped intermediates. All now filter by tenant_id; deliberately cross-tenant cron maintenance (CronJobRunner cleanups, identity-verification session expiry/purge, group-challenge expiry) is explicitly annotated as such so the pattern is never copied into request paths (2026-07-09 platform audit P3).

  • AI-chat source links now navigate in-app instead of reloading the whole SPA, the tool-result card's hardcoded "Result" fallback, the share sheet's "Email" label, and the link-preview card's screen-reader alt texts are now translated (all 11 locales), and the Smart Nudges admin chart uses the shared theme-aware chart colours instead of hardcoded light-mode greys that fell below AA contrast in dark mode (2026-07-09 platform audit P4).

  • Volunteering "Load more" buttons show a spinner and can't double-fire; My Collections validates blank names and distinguishes load errors from "no collections"; the Verein dues "Pay now" button is guarded against double-click (which could surface a spurious Stripe error toast mid-payment); and the Reviews page's dead unreachable error block was replaced by its live per-tab error states (2026-07-09 platform audit P4).

  • The marketplace seller "Reviews" tab and the admin user-permissions page no longer promise "coming soon" for things that aren't being built. The seller tab (which sat next to a rating chip advertising a review count) now shows honest neutral copy that reviews aren't available and points at the aggregate rating; the admin permissions page now says permissions are read-only there and directs to role management. Old *_coming_soon keys removed, new keys added across all 11 locales (2026-07-09 platform audit P4).

  • useApi latent hazards fixed: a null endpoint no longer leaves isLoading stuck true, and usePaginatedApi no longer jumps currentPage to total_pages when the response meta omits current_page (which killed pagination after page 1). No live consumers were affected; hook tests added (2026-07-09 platform audit P4).

  • 69 source files' copyright headers normalized to the mandated © 2024–2026 form — they carried ASCII (c) variants that passed the SPDX CI check because it only greps the license line (2026-07-09 platform audit P4).

  • Saved collections and bookmarks can no longer read another tenant's content. SavedCollectionService::attachPreviews() and BookmarkService::attachTitles() hydrated saved-item previews/titles with a bare WHERE id IN (…)no tenant_id filter — against tenant-scoped tables (posts, listings, events, groups, pages, marketplace_listings, job_vacancies, resources, group_discussions), and neither saveItem() nor toggle() validated that the id being saved existed in the caller's tenant. Any authenticated user could therefore save an arbitrary foreign-tenant id and read the hydrated preview.title across the tenant boundary — for posts the preview selects content as title, so that's the full post content, drafts included (2026-07-09 platform audit P1). Both layers are now fixed in both services: the hydration queries are tenant-scoped (AND tenant_id = ?, so even a poisoned pre-existing row hydrates null instead of foreign content), and save/bookmark creation rejects any (item_type, item_id) that doesn't exist in the caller's tenant with a translated validation error (api.saved_item_not_found, added across all 11 locales). Removal paths (unsave/un-toggle) deliberately skip the existence check so stale bookmarks pointing at since-deleted items can still be cleaned up. Regression tests cover the cross-tenant save/bookmark rejection, the nonexistent-id rejection, the poisoned-row null preview/title, and that same-tenant previews/titles still hydrate.

  • Custom-split group exchanges can no longer mint (or destroy) time credits. A split_type='custom' exchange stored each participant's hours verbatim with no cross-role reconciliation: complete() credited every provider and debited every receiver, so the net ledger change was Σ(provider hours) − Σ(receiver hours) — an organizer could set provider=100h / receiver=1h and mint +99 credits from nothing on completion, repeatably (2026-07-09 platform audit P1). GroupExchangeService now enforces the conservation invariant at both gates: start() (early feedback to the organizer, before participants are asked to confirm) and complete() (the authoritative gate, since the split can still change between start and complete — update() allows split_type edits). The credits the split would give providers must equal the debits it would take from receivers, computed exactly as complete() applies them (rounded to 2dp, ≤0 entries skipped) so a negative-hours row can't disguise an imbalance a plain SUM would miss. Unbalanced splits are rejected with a translated error naming both totals (api.group_exchange_split_unbalanced, added across all 11 locales); equal/weighted splits already conserved by construction and are untouched. Also fixed the adjacent completion-notification bug: per-participant hours were (int)-cast before the bell/push/email, so a 0.5h share moved real money with no notification at all and 1.5h read as "1" — hours now stay float and render as trimmed 2dp ("0.5", "1.5", "6"), so sub-hour shares notify. Regression tests cover the audit exploit at both gates, the negative-hours disguise, a balanced custom split still starting/completing, and the 0.5h notification.

  • Group Exchanges is a working module now — it was broken end-to-end at every step. The whole feature (create a multi-participant time exchange, add providers/receivers, split hours, confirm, complete → wallet settlement) had never been exercised end-to-end; its tests mocked an imagined contract the backend never returned, so they stayed green while production was dead. Fixed, in order of the user journey: (1) the reported bug — in the create wizard's "Add participants" step the search results rendered in an absolute-positioned dropdown, so when the search box sat low in the viewport the list fell below the fold with no way to scroll to it (only the tops of the green/amber role buttons peeked out — TimeBanking UK's CEO hit exactly this). Results now render inline in normal document flow, so the card grows and the page scrolls, with a capped-height internal scroll for long lists and a "no members found" state. (2) The list was always empty: GroupExchangeController::index() double-nested the envelope ({data:{data:[…]}}); the client unwraps one level, so Array.isArray(response.data) failed and every user saw the empty state even with exchanges — it now returns the collection as the top-level data array with has_more in meta. (3) "Start Exchange" was a silent no-op: it PUT {status}, but the service update allow-list drops status by design, so the exchange was stuck in draft forever and could never be confirmed or completed — added a dedicated POST /v2/group-exchanges/{id}/start action (organizer-only, requires ≥1 provider and ≥1 receiver) that transitions draft/pending_participantspending_confirmation, and wired the button to it. (4) Blank names/avatars: show()/listForUser() omitted organizer_name/organizer_avatar and returned participants as name/avatar_url while the React pages read organizer_name/user_name/user_avatar/user_email — the service now joins the organizer and returns both the legacy and the frontend field names, plus participant_count. (5) The detail "Hour Split" table rendered garbage: it expected a nested provider→receiver map but calculated_split is a flat per-participant list, so Object.entries produced nonsense rows — it now renders an honest per-participant credit (+) / debit (−) breakdown, which is exactly what the wallet ledger does on completion. (6) Participant search ignored the query: both pages sent ?search=, but the member directory filters on ?q=, so it returned an unfiltered member list regardless of what was typed — now sends q. Adds two api.php keys (group_exchange_cannot_start, group_exchange_start_needs_participants) and two group_exchanges.json toast keys (exchange_started, start_failed) across all 11 locales, a full HTTP end-to-end feature test (create → list → show contract → start → confirm → complete → wallet settlement, plus the start authorization/validation paths), and repairs the module's three Vitest suites (their @/lib/helpers mocks were missing resolveThumbnailUrl/cn, so the real Avatar/Modal crashed and the tests couldn't run). Starting an exchange now notifies every participant (bell + push, in each recipient's preferred_language via LocaleContext, deduped per user, fail-safe) that it's live and awaiting their confirmation — previously it silently changed status and relied on participants noticing the "Needs Confirmation" tab. Adds svc_notifications.group_exchange.needs_confirmation across all 11 locales and a group_exchange icon in the notifications UI; the E2E test asserts each participant is notified and the organizer is not. Both the start prompt and the completion credit/debit now also send an email (in addition to the bell + push) — rendered in the recipient's preferred_language, sent directly as a force-instant transactional message (bypassing the digest queue, which defaults to off, but still honouring an explicit email_transactions opt-out), and fail-safe so a mail error can never unwind the committed status/balance change. The completion email was previously bell + push only. Reuses the existing svc_notifications bodies + emails.common/emails.footer/emails.notification.view_wallet keys and adds a 5-key emails.group_exchange section (subjects/titles/CTA) across all 11 locales; email HTML + i18n resolution verified in en and fr.

  • Approving under an hour of volunteering no longer implies a credit was added. Time credits are whole hours, so approving a sub-hour log (e.g. 45 minutes) floors to 0 credits — nothing is minted and the org wallet isn't debited — yet the volunteer still got the generic "Your hours were approved!" notification, implying success. That case now sends an honest message ("…that is under an hour so no time credit was added"). Adds notifications.vol_hours_approved_no_credit_body across all 11 locales (English pending the translation pass). Regression test asserts the sub-hour approval mints nothing, records no transaction, and sends the honest message. (Audit VOL-BE-008.)

  • The webhook "Retry" button is now honestly labelled as a test event. On a failed volunteering webhook, the button (and its success toast) said "Retry", but it actually POSTs a synthetic test event — not a redelivery of the failed events — which could mislead operators into thinking failed deliveries had been re-sent. It now uses the "test event" label and toast; the real failed events are already retried automatically by the */5 cron (WebhookDispatchService::retryFailed). No new locale keys (reuses test_webhook / webhook_test_sent / webhook_test_failed). (Audit VOL-RX-008.)

  • Nine broken t() translation lookups across the app now resolve instead of silently falling back to the raw key or hardcoded English. The exhaustive i18n coverage gate (scripts/check-i18n-coverage.mjs, baseline 0) was red on main: nine t('…') calls referenced keys that existed in no locale JSON — the session-timeout "Log Out" button, the accent-colour swatch aria-label, the code-snippet copy button, the ideation tag-filter aria-label, three ad/marketplace loading aria-labels, the marketplace filter-sidebar aria-label, and the marketplace category-search placeholder. Seven were genuinely-missing keys and are now added to all 11 locales with real translations (common: auth.log_out, top-level copy_code, common.loading; marketplace: common.loading, aria.filter_panel, search.category_search; utility: top-level loading). Two were code mis-references and are fixed at the call site: IdeationPage used t('tags') where tags is an object, now t('tags.label'); ThemePicker used t('appearance_prefs.select_color', { ns: 'settings' }) with the ns option on a separate line — invisible to the line-based checker and (correctly) resolving in the settings namespace — now the equivalent t('settings:appearance_prefs.select_color', …) colon-prefix form. Coverage now reports 0 missing keys and the translation-drift check confirms all 11 locales share an identical key set, so main is green on i18n again. Regenerated react-frontend/src/resources.d.ts.

  • The "Community not found" page's "Go home" and "Find a community" buttons work now — they never had since day one. Both were SPA <Link>s (to="/" and to="/login"). TenantShell keeps a sticky tenant slug for the lifetime of the document (stickySlugRef, added to stop setSearchParams on tenant pages from momentarily flipping to the master tenant). A client-side <Link> navigation away from an unknown-tenant URL is not a fresh document load, so the bad slug stayed sticky: effectiveSlug fell back to it, TenantProvider kept bootstrapping the missing slug, notFoundSlug never cleared, and the page simply re-rendered itself — the URL bar changed but the "Community not found" card stayed, so the buttons looked dead. They are now full-document <a href> navigations (/ and /login), which is the fresh document load the sticky-slug design already assumed for any tenant/master switch — the sticky ref resets and the tenant re-resolves from the URL/Host. Verified live in-browser: Go home → master-tenant home, Find a community → the login page's community chooser. Regression test asserts both render as real anchors with the correct href.

  • Whole app modules (Courses, Podcasts, Premium, OAuth sign-in, invite links, and more) are reachable again wherever the first path segment is the route. When the tenant is identified by the domain/host — a custom domain like hour-timebank.ie, or localhost/shared-host path-based dev — the first path segment is the SPA route, but detectTenantFromUrl() (TRS-001) treats an unreserved first segment as a candidate tenant slug and calls tenant/bootstrap?slug=<segment>, which 404s and renders "Community not found". Many real top-level routes had never been added to the shared reserved-path lists, so hour-timebank.ie/courses and localhost:5173/premium — plus /podcasts, /coupons, /clubs, /me/..., /municipality-calendar, /advertise/..., /donations/..., /users/..., /join/<code> invite links, the /auth/oauth/callback OAuth redirect, /verify-identity-optional, and public /pilot-apply, /developers, /trust-and-safety, /regional-analytics, /partner-analytics, /pricing — were all silently unreachable. Both reserved-path lists — RESERVED_PATHS in react-frontend/src/lib/tenant-routing.ts and TenantContext::getReservedPaths() in PHP — now include every top-level route name (reserving a route name is always safe; a route name must never be a tenant slug), and a router-derived completeness test now parses the route files and fails CI if any future top-level route ships unreserved — so this class of bug (previously patched one route at a time: /saved, then /courses, then /premium) cannot regress again. A genuine parent-domain child (timebanking.uk/cardiff) still resolves. The bootstrap 404s were only ever recorded as performance transactions, never as errors, which is why no Sentry issue was raised.

  • Volunteer expenses now record the tenant's currency and enforce the monthly cap atomically. Expense submission stored currency from the client defaulting to a hardcoded 'EUR' (and the approval/paid emails fell back to ?? 'EUR'), and it read the monthly-cap running total then inserted outside any lock — so a first-time claimant on a non-euro tenant had their reimbursement mislabelled "EUR", and two concurrent submissions each individually within policy.max_monthly could jointly exceed it (check-then-insert TOCTOU). Submission now persists TenantContext::getCurrency() (never a euro literal; the email fallbacks resolve the tenant currency too) and serialises the cap read + insert under a per-volunteer/org/month Cache::lock, mirroring the dedupe guard in VolunteerService::logHours. Regression tests cover the tenant-currency default and the lock serialisation. (Audit VOL-BE-001, VOL-BE-002.)

  • Un-sharing a federated volunteer opportunity now retracts it from partners. Turning off "share to the federation network" (federated_visibility listednone) dispatched an update event carrying the already-unshared row; the push listener's opt-in gate returned before the retraction branch, so no action='deleted' was ever sent and partner sites kept displaying the withdrawn opportunity indefinitely. The VolunteerOpportunityUpdated event now carries the prior federated_visibility, and the listener treats a listed → un-shared transition as a retraction (pushes action='deleted' keyed on the opportunity id). The existing delete/close retraction (driven by is_active=0) is unchanged. Regression tests cover the un-share retraction and that a never-shared opportunity is not spuriously retracted. (Audit VOL-BE-004.)

  • Recovered volunteers no longer keep a stale "at-risk" wellbeing alert forever. The daily burnout assessment only ever wrote alerts (risk score ≥ 30) and had no path to clear one, so a volunteer flagged months ago who has since re-engaged kept an active critical alert carrying the old score/indicators indefinitely, polluting the coordinator safety panel. runTenantAssessment now auto-resolves a user's system-raised active alert once their recomputed score drops below the threshold (coordinator-managed acknowledged/dismissed states are left untouched). Regression tests cover resolution on recovery and retention while still at-risk. (Audit VOL-BE-005.)

  • Admin donation refunds can no longer double-decrement a giving-day total. createRefund() checked the donation status on an unlocked read taken before the Stripe call, then its transaction unconditionally set refunded and decremented raised_amount — so it could race the charge.refunded webhook it triggers and decrement the giving day twice (understating it, possibly negative). The refund-ledger step is now a single shared, row-locked, idempotent helper used by both the admin refund and the webhook: it re-reads the locked row, bails if already refunded, and decrements only a still-completed row, so whichever path commits first the total moves exactly once. Regression test drives both paths on the same donation and asserts a single decrement. (Audit VOL-BE-003.)

  • Guardian-consent and check-in token pages now announce their result to screen readers. Both pages swap between confirm / loading / success / error content inside a static card with no live region and no focus move, so a blind guardian or shift coordinator got no announcement of whether their consent approval or check-in/out succeeded (WCAG 2.1 AA 4.1.3). The result region is now an aria-live container (role="alert" assertive for errors, role="status" polite otherwise) that also receives focus when the async action resolves. Adds the first Vitest coverage for CheckInVerifyPage and asserts the alert region on both pages. (Audit VOL-RX-001.)

  • Right-to-erasure now clears gift_aid_country on unclaimed donations. The GDPR Article-17 scrub nulled every gift-aid declaration/address field on unclaimed rows except gift_aid_country, leaving e.g. 'GB' behind — a field the data-export code already classifies as the subject's personal data. gift_aid_country is now included in the scrub (claimed / refund-after-claim rows still retain it under the HMRC record-keeping carve-out), and the erasure regression test asserts it is nulled on unclaimed rows and retained on claimed ones. (Audit VOL-BE-014, VOL-XC-002.)

  • The volunteering bulk-config validation message is now translated. updateVolunteeringConfigBulk returned a hardcoded English "Settings array is required" (invisible to the i18n checker) when the settings body was missing or not an array; it now uses the existing translated api.missing_required_field key. Regression test asserts the 422 message resolves from the key, not the literal. (Audit VOL-BE-010.)

  • Volunteer notification dates now render in the recipient's language. Shift-reminder emails, donation receipts, admin donation alerts, the emergency-alert bell, and the shift-signup confirmation embedded the date via locale-insensitive date() / Carbon->format(), so inside an otherwise recipient-locale-translated notification the weekday/month/am-pm was always English (a French volunteer's reminder read "Mon, 14 Jul 2026"). Every such date now renders through Carbon::parse(...)->locale(app()->getLocale())->isoFormat(...); for the emergency-alert bell and the shift-signup confirmation the formatting was moved inside the per-recipient LocaleContext closure (it was previously computed once in the worker's default locale). No new locale keys. (Audit VOL-BE-012, VOL-XC-001.)

  • Volunteering data-contract and feature-gating cleanups. Six "my-organisations" / applications call sites hand-rolled a raw.data?.items ?? raw.items ?? Array.isArray(...) unwrap — several with permanently-dead branches and a comment falsely claiming a {data:{items}} envelope when the endpoint returns a bare array — and now use the tested extractCollectionItems helper (removing the silent-empty risk and the misleading comments); a new unit test covers the helper's array / {items} / {data:{items}} / fallback shapes. Separately, the guardian-consent verify route — the only volunteering route wrapped in ErrorBoundary alone — now sits behind <FeatureGate feature="volunteering" redirect="/"> like its siblings (still public / token-based). (Audit VOL-RX-002, VOL-RX-003, VOL-RX-005.)

  • Volunteering backend hardening: creator-id disclosure, monthly recurrence, waitlist-expiry scoping. (1) The public opportunities list serialised the creator's internal users.id; it is now stripped from the response (name + avatar remain), matching the org directory's owner-id stripping. (2) Monthly recurring-shift patterns anchored on day 29/30/31 used an exact day-of-month match, so a day-31 pattern never generated in Feb/Apr/Jun/Sep/Nov; the anchor day is now clamped to the last valid day of each month (matching EventService). (3) ShiftWaitlistService::expireStaleNotifications now scopes its status UPDATE by tenant_id (defence-in-depth). Regression tests cover the creator-id stripping and the September clamp. (Audit VOL-BE-011, VOL-BE-018, VOL-BE-019.)

  • Donation receipt and check-in QR failures now recover gracefully. A transient receipt-fetch failure dropped into a passive <p> with no role="alert" and no way to retry (only a full reload); it now renders an alert with a "Try again" button that re-fetches. And when the client-side qrcode chunk fails to load (stale deploy / offline), QrCodeImage left an aria-busy placeholder spinning forever with no fallback — it now renders a usable link to the check-in URL instead. Adds the first Vitest coverage for QrCodeImage. (Audit VOL-RX-006, VOL-RX-007.)

  • Declining a single volunteer application now asks for confirmation. The per-row Decline button in the admin approvals table fired declineApplication immediately on click — a misclick irreversibly rejected the applicant — unlike the bulk decline and the single-decline flows in Hours Audit / Swaps, which confirm first. It now routes through the same ConfirmModal. Regression test asserts the decline only POSTs after confirmation. (Audit VOL-RX-010.)

  • OrgHoursReviewTab no longer mistypes its array endpoint as an object. The pending-hours endpoint returns { data: [...], meta } (api.get unwraps one level, so response.data is the array and cursor/has_more live on response.meta), but the code typed api.get<PendingHoursResponse> with an { items, cursor, has_more } interface that contradicted its own runtime — a footgun where response.data.items would type-check yet be undefined. The generic is now PendingHourEntry[], the read goes through extractCollectionItems, and the misleading interface is removed (validated by tsc + the existing runtime test). (Audit VOL-RX-004.)

  • VolOrgWalletService::payVolunteer() no longer phantom-succeeds on sub-1.0 amounts. users.balance is whole hours, so a fractional amount below 1 (e.g. 0.5) floored to a 0-hour payment that moved nothing yet still recorded a zero-amount vol_org_transactions row, emailed "0 hours paid", and returned success. The method now rejects floor(amount) < 1 (reusing the existing amount-required message). It has no production caller today — real payouts run through verifyHours — so this hardens it before any future wiring. Regression test asserts a 0.5 payment is rejected with no ledger row. (Audit VOL-BE-009.)

  • Stripe donation currency-mismatch error and anonymous-receipt label are now translated. StripeDonationService::createPaymentIntent threw a hardcoded English "Donation currency must match the community currency." (surfaced to the React form on a mismatched-but-valid currency), and the receipt built an untranslated "Anonymous" donor label — unlike the sibling VolunteerDonationService which uses __(). Both now resolve from existing translated keys (api.vol_donation_currency_mismatch with the tenant currency, api.vol_activity_donor_anonymous); no new locale keys. (Audit VOL-BE-013.)

  • The accessible safeguarding error summary now links to the offending field. The GOV.UK error summary rendered errors as a bare <p> with no jump link, unlike the sibling accessible forms — a keyboard/screen-reader user could not jump from the summary to the control. It now renders a govuk-error-summary__list with an <a href="#field"> for each field-level status (training type/name/date, incident title/description); generic/server statuses stay as plain text. No new locale keys. Regression test asserts the summary links to #training_type. (Audit VOL-AF-002.)

  • Accessible warning text no longer announces "There is a problem" for advisory warnings. The govuk-warning-text visually-hidden prefix on the emergency-alert accept warning and the group-signup cancel warning reused shared.error_title ("There is a problem"), so screen-reader users heard an advisory warning framed as a validation error. Both now use the existing translated govuk_alpha.states.warning_prefix ("Warning"). No new locale keys. (Audit VOL-AF-003.)

  • The accessible donations page no longer hardcodes euro. The amount input showed a fixed prefix (aria-hidden, so screen-reader users got no currency cue), and the amount/currency hints read "in euro" / "Leave blank to use euro" — misleading for every non-eurozone tenant (donations are actually recorded in the tenant currency). The prefix now shows the tenant's configured currency code and is exposed to assistive tech, and the two hints are currency-neutral ("…for example 25 or 25.50" / "…leave blank to use the community currency") across all 11 locales (the keys were untranslated English placeholders, so the neutral value is applied uniformly). No new locale keys. Regression test asserts the donations page carries no &euro;/"in euro". (Audit VOL-AF-001.)

  • The admin reminder-settings save no longer hides partial failures. handleSave fired independent per-reminder PUTs with Promise.all and collapsed all outcomes into one boolean, so a partial failure persisted some settings and not others while showing only a generic error and leaving the form on stale optimistic values. It now uses Promise.allSettled, names the reminder key(s) that failed in the error toast, and always reloads from the server afterwards so the form reflects the true saved state. (Audit VOL-RX-009.)

  • Per-tenant reminder sweeps no longer scan every tenant's rows. In the runAll/forEachTenant cron path, sendPreShiftReminders($tenantId) (and the post-shift, lapsed-nudge, and credential/training-expiry sweeps) plucked the reminder settings and shift/record candidates for ALL tenants, then discarded all but the current one — an O(tenants) full scan per tenant per cycle. Each collection query is now scoped to $onlyTenantId when provided, so a per-tenant call reads only that tenant's rows (behaviour-preserving; restrictToTenant yields the same result). Regression test asserts the pre-shift settings scan carries the target tenant binding. (Audit VOL-BE-015.)

  • Pre-shift reminder loop no longer re-queries per shift and per recipient. The sweep selected only s.* from its shift↔opportunity join and then re-fetched the opportunity once per shift, and ran an exists() dedup query once per confirmed volunteer. It now selects the opportunity title/location from the join (removing the per-shift re-fetch) and loads the already-reminded user set once per shift (an in-memory check instead of a per-recipient query). Behaviour-preserving — same shifts, same dedup — verified by the reminder integration + unit suites. (Audit VOL-BE-016; the per-recipient user-fetch is left as a further optional optimization.)

  • Admin "Organisation Wallets" dashboard now shows real data instead of empty rows. The /admin/timebanking/org-wallets overview queried the abandoned community-org tables (org_wallets / org_transactions / org_type='community'), which nothing writes to, so it always rendered empty. It now reads the live volunteer-org wallet — vol_organizations.balance, the vol_org_transactions ledger (deposits and positive adjustments as money-in, payments and negative adjustments as money-out), and active org_type='volunteer' member counts. Regression test seeds an org with a balance, a deposit, a payment, and two members and asserts the dashboard reports them.

  • Removed the dead "community organisation" wallet controller. OrgWalletController served an abandoned second org subsystem that nothing writes to. Its balance()/transactions()/transfer() methods were unrouted and queried columns/tables that do not exist (org_wallets.org_id, org_wallets.currency, org_wallet_transactions) — a money-transfer method that would throw SQL errors the moment it was wired up — while its two routed endpoints (/organizations/{id}/members, /organizations/{id}/wallet/balance) always returned empty (no community members/wallets are ever created) and the React frontend never called them. The controller, its two routes, and its test are removed; route:list still loads cleanly. The live volunteer-org wallet under /v2/volunteering/... and /v2/admin/volunteering/... is unaffected.

  • Accessible federation onboarding wizard is now reachable from the hub. The 4-step guided onboarding wizard existed and was tested but nothing linked to it — the opted-out hub CTA pointed at the flat single-page opt-in form, so accessible-frontend members never got the guided, GDPR-explaining flow React users get. The hub's "join the network" button now points at govuk-alpha.federation.onboarding. Regression test asserts the opted-out hub links to the wizard.

  • Removed an unused, incomplete federation page barrel. react-frontend/src/pages/federation/index.ts re-exported only 10 of the 12 federation pages (omitting FederationGroupsPage and FederationMemberProfilePage) and was imported nowhere — routes lazy-import each page directly. Deleting it removes a misleading, incomplete export surface that would hand an undefined component to anyone who imported from it.

  • Suspending a volunteer organisation is now a hard freeze on value movement. A suspended org was hidden from public listings, but the owner could still deposit into its wallet and admins could still approve pending hours — minting new time credits into a suspended org. orgWalletDeposit and hour approval (verifyHours for the approve action) now reject when the org is not in an active/approved status (ORG_NOT_ACTIVE, HTTP 403). Declining pending hours stays allowed (no value movement) so admins can clear the queue. Adds api.volunteer_org_not_active across all locales; regression test covers approve-blocked / decline-allowed.

  • Federation emergency lockdown / global disable now truly halts inbound webhooks and native ingest. The outbound push listeners gated on FederationFeatureService, but the inbound webhook receiver and native-ingest controller never did — so during an emergency lockdown (or global disable, or after de-whitelisting a tenant) an active partner could keep POSTing transaction.completed/transaction.requested webhooks that mint local time-credit balances while operators believed federation was stopped. A single kill-switch check now sits at the top of the shared handleEvent() (covering both inbound paths): if the partner's tenant lacks the federation feature or isTenantFederationEnabled() is false (emergency lockdown, global disable, or not whitelisted), the event is rejected with HTTP 503 — so a conforming partner retries after re-enable — and nothing is persisted or credited. Regression test asserts a lockdown transaction webhook returns 503 and credits no balance.

  • Uploaded images now render from their original stored files unless thumbnails are explicitly enabled. The responsive media helper no longer routes avatars, listing images, or other uploaded content through /v2/media/thumbnail by default, because a thumbnail-source/path failure could make freshly uploaded originals appear broken across the member UI. The thumbnail proxy remains available behind VITE_ENABLE_MEDIA_THUMBNAILS=true for a safer re-enable after production verification.

  • HeroUI/Tailwind visual polish restored on sensitive frontend surfaces. Auth SSO/OAuth buttons, full-page loading, app/feature error fallbacks, the auth utility footer/language controls, and the admin page header are back on shared HeroUI-backed components instead of native fallback markup, while the admin header remains opaque so scrolled content cannot bleed through. A visual-contract smoke guard now fails if these high-sensitivity surfaces are simplified away from HeroUI primitives again.

  • Federation credential decryption now fails loud instead of silently using ciphertext. FederationExternalApiClient::decryptCredential and the webhook receiver's decryptSecret caught DecryptException and returned the raw stored value, so after an APP_KEY rotation every encrypted partner secret silently became a garbage signing key/API key — outbound calls signed with ciphertext and inbound HMAC compared against ciphertext, masquerading as a generic "partner down". Both now pass through genuine legacy plaintext (non-eyJpdiI6 values) but, when a ciphertext value will not decrypt, log a distinct APP_KEY/APP_PREVIOUS_KEYS error and treat the credential as unavailable (the client throws so the send path surfaces an auth error; the webhook returns null so the partner fails auth) rather than authenticating against the blob. Regression tests cover both.

  • Inbound federated transaction amounts now convert units deterministically per protocol. handleTransactionRequested guessed seconds-vs-hours with an amount > 100 heuristic while handleTransactionCompleted credited the amount verbatim, so a TimeOverflow partner (which sends seconds) over-credited by up to ~3600× and sub-100-second transfers were minted as whole hours. Unit normalization moved into TimeOverflowAdapter::normalizeWebhookPayload (seconds→hours for transaction events); both inbound handlers now consume already-normalized hours and the magnitude heuristic is removed. Regression tests assert a 60-second transfer credits 0.02h.

  • Organisation website validation now rejects non-http(s) schemes at every create/update sink. The public org page renders the website as an <a href>, but only UpdateOrganisationRequest restricted the scheme — CreateOrganisationRequest used a bare url rule and both VolunteerService::createOrganization (the shared sink for the React member form, both GOV.UK register paths, and admin create) and AdminVolunteerController::updateOrganization validated with filter_var(FILTER_VALIDATE_URL), which accepts javascript:/data: URLs — a stored link-injection vector. All sinks now enforce an http/https scheme allow-list. Regression tests cover rejection of a javascript: URL and acceptance of a valid https: URL.

  • Cross-Verein invitations now require the inviter to belong to the source club. VereinFederationService::sendCrossInvitation validated the invitee's membership but never the inviter's, so any authenticated user in a caring-community tenant could send invitations "from" a Verein they had no relationship with — fanning out in-app notifications and emails to that club's members — and use the invitee-membership check as a membership-disclosure oracle. The inviter is now verified as an active volunteer member of the source Verein before the invitation is created (and before the invitee lookup, which closes the oracle). Adds an inviter_not_member message across all locales; regression test added.

  • Federation partner logs now redact confidential credential keys and whole sensitive subtrees. FederationLogRedactor used array_walk_recursive, which only visited scalar leaves, so a secret nested as an object under a sensitive key (e.g. {"credential": {...}}) was persisted in full; its allow-list also omitted client_secret, private_key, and credential. Redaction now walks the payload manually and replaces the entire value under any sensitive key (scalar or nested), the allow-list adds the missing keys, and a substring heuristic redacts prefixed variants like partner_client_secret/webhook_token. Free-text redaction also now matches client_secret/private_key/credential.

  • Federation review/connection push listeners now tenant-scope their federated-identity lookups. PushReviewToFederatedPartner and PushConnectionAcceptedToFederatedPartner queried the deliberately non-auto-scoped FederatedIdentity model without a tenant_id filter, so a same-local_user_id identity row belonging to another tenant could receive the wrong network's review/connection push. Both listeners now filter tenant_id, closing the documented-invariant gap left when the sibling listeners were fixed in 60a1237 (defense-in-depth — currently mitigated by globally-unique user ids). Regression tests assert a foreign-tenant identity is never pushed to.

  • Avatar uploads now fail earlier and recover from stale Docker upload-volume permissions. Settings and onboarding avatar pickers now only offer the formats accepted by the backend image pipeline (JPG, PNG, GIF, and WebP), share one client-side validator for MIME type, extension, and 5 MB size checks, and reject HEIC/AVIF/SVG-style images with the existing translated invalid-file toast instead of sending them to /api/v2/users/me/avatar and surfacing a generic 400 upload failure. The PHP Docker images also pre-create and repair the tenant upload tree at startup so old root-owned named volumes cannot block Master Tenant profile avatar directories.

  • Saved-items navigation no longer resolves as an unknown community. The React and PHP tenant reserved-route lists now include /saved, so localhost:5173/saved and shared-host /saved URLs render the saved-items route instead of trying tenant/bootstrap?slug=saved; the public route registry also uses already-loaded navigation translations for blog, listings, courses, and podcasts feature labels to avoid noisy i18next missing-namespace warnings during startup.

  • Federation audit fixes now fail closed across ingest, reads, and logs. External webhook and native-ingest handlers enforce per-event partner capability flags, native ingest resolves real external partner permissions instead of permissive synthetic defaults, duplicate webhook signing-secret ambiguity is rejected unless a signed discriminator identifies one partner, and inbound/outbound federation logs now redact sensitive payload fields. Federation browse/read endpoints now require caller opt-in while keeping partner discovery explicit, public aggregate access is locked as public-but-consent-gated, member-review fetches require active permitted partners, shadow upserts and profile-update identity lookups are tenant-scoped, settings boolean normalization handles string booleans, accessible federation data/action screens redirect non-opted-in members to opt-in, and React federation toasts no longer display raw backend error text.

  • Secondary image surfaces now use responsive uploaded-media thumbnails. Explore rails, blog cards/detail heroes, event covers, group cards/headers/media grids, profile listing cards, federation listing/event cards, and selected admin preview/table surfaces now use the shared responsive thumbnail helper so uploaded content can render smaller browser-selected variants outside the primary listings/marketplace/feed hot paths. Branding/logo previews remain on original assets by design.

  • Database hot-path indexes now cover the remaining justified audit findings. The DB/index EXPLAIN pass added guarded composite indexes for event RSVP count aggregation, marketplace listing primary-image hydration, search-log trending range scans, and listing category slug resolution while leaving feed, message inbox, notification, and existing public listing/marketplace cursor paths unchanged because their current composite indexes already match the audited query plans.

  • Non-English locales caught up on ~10,800 untranslated interface strings. Recent admin feature batches (federation, content, system, podcasts, resources, and others) had added English strings to the en locale without translating them into the 10 other languages, leaving large swathes of the admin and member UI showing English to non-English users and turning the i18n gap-regression gate red (12,619 gaps vs a 1,843 baseline). All 10 locales (de, fr, it, pt, es, nl, pl, ja, ar, ga) were machine-translated for those gaps — 10,845 strings filled — with {{placeholder}} and markup integrity verified (0 variable mismatches across 1,214 files) and every locale file confirmed to parse. The remaining gaps are strings that are legitimately identical across languages (proper nouns, product terms like "Webhooks", short tokens). The gap baseline was refreshed to the new post-translation level. Machine translations are a first pass and the admin strings warrant a native-speaker review over time.

  • Platform audit follow-ups tightened accessible frontend, admin routing, and editor bundles. Accessible GOV.UK POST tests now exercise CSRF tokens for cookie/support/report flows, the accessible build no longer imports GOV.UK footer identity CSS or the unused crest asset, stale /admin-legacy redirects and maintenance bypasses now target the maintained React admin surface, and newsletter/page design editors lazy-load the asset library while GrapesJS and CodeMirror live in deferred vendor chunks. The test-skip ratchet now reflects the current schema-driven skip budget.

  • Viewing the volunteer wellbeing dashboard no longer writes to the database. Burnout detection (VolunteerWellbeingService::detectBurnoutRisk) used to upsert an active vol_wellbeing_alerts row as a side effect of every wellbeing dashboard / my-status GET, so a read mutated data. Detection now takes a $persist flag (default off) — the read endpoints compute and return live risk without writing, and the alert upsert moved to a new scheduled command, volunteering:assess-wellbeing (daily, per-tenant crash isolation), which the admin wellbeing panel's alerts are sourced from. Idempotent upsert (one active row per user) means the daily job refreshes rather than duplicates.

  • Admin navigation no longer corrupts the member header/footer CSS after returning to site. Admin, broker, caring, partner-timebank, and super-admin code still lazy-load as route chunks, but their Tailwind utilities are generated by the main stylesheet instead of late-loaded panel CSS files. This removes the cross-route cascade leak that could hide member header/footer labels after clicking Back to site while preserving responsive pairs such as hidden md:block and hidden sm:inline inside the admin shell. The desktop footer also no longer carries a conflicting base column span that pushed Legal onto a second row after admin CSS loaded.

  • The user-facing Timebanking navbar dropdown has its structured menu layout again. The desktop Timebanking menu now renders its icon, label, and description rows with explicit spacing, padding, and active/hover states, so the menu does not lose its formatting after the recent performance CSS loading changes.

  • Admin and panel headers now keep their left-side navigation controls visible. The main Tailwind bundle now safelists the shared responsive display and offset utilities used by lazy-loaded admin, broker, caring-community, partner-timebank, and super-admin shells, so Back to site, Back to admin, tenant names, mobile toggles, and desktop header links are no longer hidden by the lower-priority route utility layer.

  • Returning from admin to the member site no longer hides or distorts the header and footer. Lazy-loaded admin, broker, caring, partner-timebank, and super-admin Tailwind utility sheets now sit in a lower-priority cascade layer, so their late-loaded .hidden, spacing, and responsive utilities cannot override the main app navbar after client-side navigation back to /dashboard; the desktop footer also no longer carries a conflicting base column span that pushed Legal onto a second row after admin CSS loaded.

  • CI now recognises the responsive uploaded-image pipeline release note. The performance/media hotfix is recorded in the pushed changelog delta and the in-app changelog copy is refreshed so the release-note guard passes on main.

  • Volunteering performance indexes now pass the blue/green migration safety gate. The additive index migration no longer carries raw rollback DROP INDEX SQL in the pending migration file, so emergency deploys can proceed without maintenance-mode override.

  • Uploaded media now has a stronger responsive-image pipeline. Image uploads enforce a pixel-dimension cap, generate named card/square/detail variants with WebP and AVIF derivatives when the server supports them, and return structured variants/srcsets metadata. The media thumbnail endpoint now accepts an explicit image format while preserving cache headers, and high-traffic listing, marketplace, search, and feed image renderers use responsive srcSet props so browsers can choose smaller derivatives instead of downloading one oversized card/detail image everywhere. Bundle-budget guardrails now pin those hot uploaded-media surfaces to the responsive thumbnail helper.

  • Header and footer logos now render from their original branding assets again. Header tenant logos, footer partner logos, and the powered-by footer mark no longer pass through the uploaded-media thumbnail endpoint, preserving transparent PNG/JPEG/WebP light/dark variants and avoiding the API-host rewrite that broke static /images/... powered-by assets. The legacy PNG/JPEG public image filenames were restored as compatibility fallbacks for cached clients and older references.

  • Gated-page redirects to login no longer render stale application routes without their providers. TenantShell now tags the lazy-loaded route registry as auth, public, app, or provided, and refuses to render a registry from the previous surface during navigation. This prevents protected-page redirects into /login from briefly rendering the full app layout outside the menu/auth provider stack, which caused useMenuContext must be used within a MenuProvider and follow-on useAuth must be used within an AuthProvider crashes.

  • Emergency deploy gate fixes now keep donation and admin surfaces stable. Stripe donation currency conversion now declares its zero-/three-decimal currency tables before the static-analysis deploy gate runs, and the admin header uses an opaque token-backed surface so page content cannot bleed through during light/dark mode scrolling.

  • Vitest no longer hangs at 100% CPU on tests that mock @/components/ui. The shared src/test/uiMock.tsx proxy returned a truthy stub for every property access — including then — which made the mocked ES-module namespace look like a thenable. When Vitest linked the mocked barrel for any component doing import { X } from '@/components/ui' (e.g. PublicPageHero, PublicEmptyState, VerificationBadge), the module-resolution interop awaited namespace.then(...), which never settled, and the worker spun forever (uninterruptible by testTimeout). The mock's get/has traps now report then as absent, so the namespace is not mistaken for a promise. This unblocks the whole class of barrel-importing component suites that were hanging CI.

  • Organisations page tests no longer fail/hang on PageMeta and Breadcrumbs. OrganisationDetailPage.test and OrganisationsPage.test now stub the deep @/components/seo/PageMeta import (the global @/components/seo barrel mock in src/test/setup.ts does not intercept the deep path, so the real PageMeta/Breadcrumbs reached useTenant() from @/contexts/TenantContext and threw "must be used within a TenantProvider"), matching the pattern already used across 130+ other page tests.

  • Caring-community hour approvals now always mint time credits, matching the core volunteering invariant. Two caring-community services (CaringCommunityWorkflowService review decisions and CaringSupportRelationshipService auto-approved hour logging) still gated organisation payment on the org's auto_pay_enabled flag and on the org wallet having sufficient balance — so a carer's hours could be committed as approved while never being credited (the org wallet is a reconciliation figure, not a spending switch, and the verify paths only ever reprocess pending logs, so those credits were lost permanently). Both services now debit the org wallet unconditionally, allow it to go negative, and keep the fractional remainder in the org wallet — mirroring VolunteerService::applyVolunteerAutoPayment. Regional points remain an additive, opt-in reward and are unaffected. New regression tests cover both services at the helper level (mint into a negative balance) and through the full approval paths (auto_pay_enabled = 0 still mints).

  • Public route startup now defers tenant menu fetching until after first paint. The navigation components now depend on a lightweight menu-context core, while public tenant routes lazy-load the full menu provider after a short idle delay. This keeps custom public menus available without putting the menu API/provider code on the initial public page path.

  • Common public tenant pages now avoid the full protected/admin route registry. TenantShell loads a dedicated public route registry for public listings, events, groups, jobs, marketplace, volunteering, resources, organisations, ideation, blog, legal/static, and similar browse pages. Protected member, admin, panel, seller-tool, and editor routes still fall back to the full registry only when those routes are actually needed.

  • Tailwind no longer scans generated TypeScript declaration files for production CSS. The main stylesheet now excludes *.d.ts files, including the 2 MB generated translation resource declaration, from Tailwind's source scan so translation/type metadata cannot accidentally contribute utility candidates or build-time scan cost.

  • Public marketplace browse routes now stay on the public route registry while seller tools remain protected. The route classifier now treats seller profiles, public marketplace listing lists, and public offer lists as public browse routes, while coupon management, onboarding, order, pickup, shipping, and edit routes still load the full protected registry/provider stack.

  • Browse pages now defer map/filter-only UI until it is requested. Listings and members no longer include the generic map-view wrapper on the default grid/list path. Listings load the proximity filter only when the advanced filters panel is opened, while events and volunteering keep the proximity filter in a small lazy chunk outside their main route bundles. The volunteering landing page also lazy-loads signed-in-only tabs and guardian-consent UI, cutting its public opportunities route chunk by more than half.

  • Feed cards now split optional rich-content renderers out of the hot path. Plain feed items no longer carry the image carousel, media grid, video player, link-preview card, quote-embed renderer, or share modal workflows in the core feed-card path; those chunks load only when a visible feed item actually contains that content or the user opens the relevant action.

  • Admin shell hotfixes. The main admin header now uses an opaque theme surface so scrolled page content cannot show through behind the fixed top bar, and protected routes now mount the full app provider stack even while auth is settling so returning from admin to the main site hydrates header navigation/menu context correctly.

  • Documented the header/footer logo asset rule. Future frontend work must keep tenant brand logos in the header/footer on uploaded raster assets, preferably transparent PNGs, rather than converting them to inline SVGs; SVGs remain acceptable for icons and non-brand illustrations elsewhere.

  • Volunteering & organisations module audit — credit-flow integrity. Auto-approved volunteer hours now always mint time credits even when the organisation's auto_pay_enabled flag is off (the schema default), fixing a case where approved hours were committed but never credited and never recoverable (VolunteerService::logHours). The admin hours-verification endpoint now blocks admins from approving their own volunteer hours (separation of duties), matching the guard already present on the member/org path.

  • Volunteering module audit — service hardening. Opportunity suggestions now enforce the same public-visibility gates as the main listing (open/active opportunities, approved/active organisations) instead of surfacing closed or unvetted records; emergency alerts now honour their expires_at so lapsed alerts can no longer be accepted or listed; volunteering configuration defaults now match the values enforced at each call site (cancellation deadline, per-shift hours cap, certificate minimum), so the admin UI advertises what is actually applied; expense monthly caps use full-month datetime bounds (last-day submissions were excluded) and admin expense screens now load the volunteer's email; QR check-in token generation is serialised to prevent duplicate tokens.

  • Volunteering module audit — data & i18n. Removed a dead, schema-incompatible OrgWalletService; repaired corrupted (mojibake) copyright headers on five volunteering models; added the missing array cast on VolCustomField.field_options; removed tenant_id from volunteering models' mass-assignment allow-list as defence-in-depth; and localised the admin activity feed, which previously concatenated English fragments in SQL.

  • Volunteering module audit — frontend. User-facing organisations/volunteering pages now decode list responses through one shared helper with correct types (preventing silent empty lists on envelope changes), and the organisation detail page gained request-abort cleanup and accessible star-rating roles. Admin volunteering modules gained confirmation dialogs and in-flight guards on destructive actions (bulk approve/decline, org suspend, campaign deactivate, hours decline), replaced a native window.prompt() reject flow with a HeroUI modal, and closed several hardcoded-string / wrong-namespace i18n gaps (16 new keys across all 11 locales).

  • Volunteering deep audit (round 2) — safeguarding. The minor/guardian-consent gate lived only in the React API controller, so it was bypassable via the accessible (GOV.UK) frontend and by adding a minor to a group-shift reservation. The gate is now enforced in the service layer (VolunteerService::apply/signUpForShift, ShiftGroupReservationService::addMember), and re-checked at shift signup so lapsed/withdrawn consent is caught.

  • Volunteering deep audit (round 2) — data integrity. Fixed a dead delete branch that hard-deleted opportunities and orphaned their applications (now soft-deletes); recurring-shift deletion now cancels the emergency alerts that pointed at the removed shifts (previously orphaned and uncancellable); pre-shift reminders and "my shifts" now exclude cancelled opportunities; and admin application approval counts group-reservation slots against capacity so admins can no longer approve past a full shift.

  • Volunteering deep audit (round 2) — privacy. Approved volunteer hours no longer broadcast the volunteer's free-text description to the community feed and now honour the volunteer's show_on_leaderboard opt-out; public organisation endpoints no longer leak tenant_id, the owner's user id, or the Designated/Deputy Safeguarding Lead user ids.

  • Volunteering deep audit (round 2) — GDPR erasure. Admin "delete user" now routes through GDPR erasure (anonymise-in-place + full cross-module PII/file cleanup) instead of a raw DELETE that orphaned safeguarding/wellbeing/consent rows and left credential/receipt files on disk. Erasure now also deletes expense-receipt files, scopes the custom-field-value deletion by entity_type (was destroying colliding rows), and scrubs an organisation's contact email + the user's org memberships.

  • Donations — Stripe money-path hardening. Donations are restricted to the community's configured currency and converted with Stripe's zero-/three-decimal currency table (fixing a 100× overcharge on zero-decimal currencies and mixed-currency giving-day inflation); webhook handlers re-read under lock with conditional state transitions so out-of-order/replayed events can't resurrect refunded donations, double-count totals, or drive them negative; partial refunds are no longer treated as full refunds; and the gift-aid CSV export is sanitised against formula injection.

  • Volunteering deep audit (round 2) — performance. Added composite indexes for the unbounded-growth hot paths (vol_logs/vol_applications by tenant+status+created, vol_shifts.end_time, vol_donations by tenant+created), deduped the nightly lapsed-volunteer sweep in SQL, and cached the per-request is_federated schema probe.

  • Donations — Gift Aid claim lifecycle. The HMRC export now stamps exported declarations as claimed (with ?preview=1 for a dry run) and excludes them from subsequent exports, closing a double-claim risk; refunding an already-claimed donation flags it for adjustment on the next claim and the admin overview surfaces the count.

  • Wallet — credit-donation guardrails. The member time-credit donation endpoints gained rate limiting, a 1000-credit cap, and a double-submit lock (a double-clicked donate button no longer transfers twice).

  • GDPR — volunteering data lifecycle completed. The Article 15 export now includes the member's volunteer-payment ledger, safeguarding incidents where they are the subject/involved party (facts only — third-party narrative withheld), and the stored gift-aid declaration address. Four opt-in retention policies were added for volunteering special-category data (mood check-ins, wellbeing alerts, safeguarding incidents, guardian consents) with status guards so open cases are never purged, per-type recommended windows, and admin UI labels in all 11 locales.

  • GDPR — volunteering erasure & export gaps closed (round 3 audit). Article 17 erasure now scrubs the gift-aid declaration name, full home address, and donor message from vol_donations (previously only donor_name/donor_email were cleared, leaving directly-identifying data behind), with a documented HMRC record-keeping carve-out that retains the declaration fields on claimed rows while still scrubbing name/email/message. The Article 15 export and erasure now also cover community-project supporter messages and the reservation notes on group shifts a member leads, and swap-request erasure retains the counterparty's record (scrubbing only the erased member's message) instead of deleting both parties' rows. Guardian-consent retention now also purges withdrawn and abandoned no-expiry rows (guardian name/email/phone/IP) once past the window.

  • Volunteering audit (round 3) — organisation profile validation. The member organisation-update endpoint now validates input through a dedicated request (name length/uniqueness, valid contact email, and an http/https-only website rule), closing a hole where an organisation manager could persist a javascript: URL that rendered as a clickable link on the public organisations page, blank an organisation's name, or trigger a 500 with an over-length name.

  • Volunteering audit (round 3) — credit & wallet correctness. The three hour-approval payout paths now lock the member row before the organisation row, matching the wallet deposit path and removing a lock-order inversion that could deadlock an approval against a concurrent deposit. Organisation wallet deposits now reject fractional amounts with a clear error instead of silently flooring them (the deposit form no longer advertises quarter-credit precision the ledger cannot hold). Application approve/decline now guards on status = 'pending', so a concurrent or double-submitted decision is a no-op that fires no second notification instead of flipping an existing decision and sending a contradictory email.

  • Volunteering audit (round 3) — check-in, wellbeing & reminders. QR check-in tokens now expire (rejected once past the shift end plus a four-hour grace, or 24h after start when no end time), closing a replay window where a stale code could fabricate attendance weeks later. The wellbeing-alert lifecycle is now reachable: burnout-risk alerts that were written on every dashboard load but had no coordinator surface can be listed and acknowledged/resolved/dismissed through new admin endpoints and an admin panel. Volunteer expense totals are now computed across all of a member's records rather than only the current page, the nightly reminder sweep runs once per tenant instead of once-per-tenant-squared, certificate verification codes use 16 uppercase alphanumerics (the lookup column collates case-insensitively, so the previous uppercased mixed-case value added no entropy), emergency-alert expiry is clamped to a sane range, credential expiry dates are validated, and the federated-opportunity push no longer leaks internal ids.

  • Volunteering audit (round 3) — donations currency & Gift Aid eligibility. Offline/manual donations and the accessible-frontend donation path now record the community's configured currency and reject an explicit foreign-currency mismatch (previously client-controlled, and hard-coded to EUR on the accessible path — both routes to inflating a single-currency giving-day total). Gift Aid — a UK/HMRC scheme — is now gated to GBP donations across declaration eligibility, the claim export, and claim stamping, and the previously-ignored community-project donation filter is now applied.

  • Volunteering audit (round 3) — public data exposure. Public organisation endpoints no longer emit the owner's internal user id (the eager-loaded owner relation re-added the id that the field-stripping helper explicitly removes); the shift-listing endpoint now applies the same public-visibility gate as opportunity detail; admin joins carry tenant guards; and member-facing search escapes SQL LIKE wildcards.

  • Volunteering audit (round 3) — member & admin frontend. The hours-review and donations tabs now render an error-with-retry state on a non-thrown API failure instead of a misleading "all reviewed / nothing here" empty state. The organisation dashboard background-refreshes on balance changes instead of blanking the page and resetting the active tab, resolves the organisation from the member's managed-orgs list so owners of pending/declined organisations reach it (with the correct status chips) instead of an access-denied screen, and the organisations directory search now guards against out-of-order responses. "Hours by month" labels and the log-hours default date now use local time (they previously shifted a day/month in negative-UTC timezones); hours, expense amount, currency, and group-slot inputs gained client-side validation; admin CSV exports now fetch all pages before writing; and a batch of accessibility labels, SPDX header placements, and hardcoded-string/tenantPath gaps were closed across the member tabs and admin modules.


Back to all releases