---
title: Changelog - Project NEXUS | TimeBank Ireland
description: Every notable change shipped in Project NEXUS, in Keep-a-Changelog format, following Semantic Versioning. Sourced from CHANGELOG.md in the public source repository.
canonical: https://hour-timebank.ie/changelog
generated: 2026-09-14T04:26:02.912Z
---# Changelog

All notable changes to Project NEXUS will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) , and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) .

---

## Unreleased

### Removed

- BREAKING: Removed the abandoned mobile web wrapper, its native bridge, push/update hooks, update modal, dependencies and /api/app/version and /api/app/check-version endpoints. The Expo / React Native app in mobile/ is the sole native Android/iOS client; its version enforcement and native push remain supported. Project documentation and admin descriptions now reflect this scope.

### Added

- safeguarding on the federated member profile response — the API now says whether contact with this member would be refused, before the member tries. GET /v2/federation/members/{id} returns contact_allowed, the refusal code and status, a member-readable title/detail/message, can_request_coordinator and retryable, derived from the same SafeguardingInteractionPolicy::evaluateCrossTenantContact() decision that sendMessage(), sendTransaction() and FederatedConnectionService::sendRequest() enforce. Wording comes from MessageService::buildSafeguardingError(), so the sentence shown on the profile is the sentence that would have come back with the refusal, in the viewer's locale. It is advisory only — all three write paths re-evaluate the policy and remain the authoritative boundary, so a stale or absent value cannot let a refused interaction through. It fails closed: a policy lookup that throws reports not-allowed with the retryable SAFEGUARDING_POLICY_UNAVAILABLE code rather than advertising an action the server is about to refuse. Deliberately not added to the member list endpoint, which would cost a preference lookup per row on a paginated page.
- npm run check:spa-shell — the SPA-shell contract guard, now wired into something that runs. scripts/test/test-spa-shell-fallback.mjs had existed since the shell contract was written and was invoked by nothing: not package.json, not a workflow, not preflight.mjs. Run by hand it failed on five assertions, three of them stale. It also only grepped the nginx config as text, so it could not have detected the defect it was written to prevent (below). It now brace-matches the block that actually generates the response and asserts the marker there, rejects the marker in a block that hands off via return 421, reads the shell cache name from vite.config.ts rather than pinning a superseded literal, and skips try_files chains that delegate to @spa_shell. Wired into CI (react-build, BLOCKING), scripts/preflight.mjs, and .github/ci-paths.yml — the guard's own path is named in the frontend list so editing the guard re-runs it, since scripts/** wakes only the PHP cluster.
- npm run check:seo-delivery — an outside-in probe that asks the one question nothing else asked: would a crawler actually receive words? Every existing prerender check measures snapshot production (cache writable, queue moving, renders failing, coverage, scheduler liveness) and all of them were green from 2026-07-11 to 2026-09-13 while every crawler received a 1,950-byte empty shell — because the break sat between "snapshot exists" and "snapshot is served". scripts/check-prerender-delivery.mjs fetches public URLs over real HTTP as Googlebot, through whatever CDN and proxy stack is in front, and fails when the response is an empty SPA shell (under 10 KB, empty <div id="root">, no <h1>, no meta description). It is deliberately cause-agnostic: it would have caught the unterminated sidecar, the absent marker, the orphaned lock and the superseded rebuild equally, on day one. Exit 0 pass / 1 blank / 2 unavailable — never reported as a pass. Verified against production both ways: passes on hour-timebank.ie and timebank.global, and correctly fails on app.project-nexus.ie, which has no snapshots and genuinely does serve crawlers a blank shell. Runs non-blocking after a deploy when NEXUS_DELIVERY_ORIGINS is set in the per-installation env file (hostnames deliberately are not hardcoded in the repo, so a new installation configures its own).

### Fixed

- A member who had not opted into federation was told a federated member did not exist. GET /v2/federation/members/{id} gates the CALLER before it looks anything up, refusing a viewer who has not opted in with 403 FEDERATION_NOT_ENABLED. FederationMemberProfilePage mapped every unsuccessful response to member_profile.not_found_error, so that refusal rendered as "Member not found" — sending the member to look for a person who exists, is reachable, and is one switch in their own settings away. The page now keeps the error code and, for that one code, says federation is not switched on for their account, repeats the sentence the disabled buttons already use, and replaces Try Again (which cannot help) with a button to Federation settings. A genuine 404 is unchanged. Adds member_profile.optin_required_heading and member_profile.optin_required_action in all eleven locales.
- The federated member profile offered Connect, Message and Send Credits to members whose community safeguarding policy refuses contact, so the member only found out after composing a transfer. The profile payload carried only messaging_enabled and transactions_enabled, both read from federation_user_settings, and the page enabled every action from those two fields alone. A recipient can have both settings switched on and still be unreachable, because the recipient tenant's safeguarding policy is evaluated separately at send time — the live case is a Timebanking UK member with two safeguarding options carrying restricts_messaging, correctly refused with 403 SAFEGUARDING_CONTACT_RESTRICTED. All three buttons are now disabled up front, with the server's explanation shown as visible text beneath them. That text is deliberately not phone-only: walking the page in a browser showed a disabled HeroUI Button computes pointer-events: none, so it never receives hover or focus and its tooltip cannot open at all — the two older reasons on the same row (transactions_disabled_tooltip and optin_required_tooltip) had the same flaw and are fixed alongside it, so every reason an action is unavailable is now readable on any screen. Fixing that also exposed a duplicate: the transfer-reason line fell back to whichever broader reason applied, printing the opt-in sentence twice whenever the viewer had not opted into federation. It now states only the recipient's own transfer setting. Connect is included because FederatedConnectionService::sendRequest() applies the same gate and failed the same way. The server-side checks are unchanged and stay authoritative; this only tells the member sooner.
- A refused federated credit transfer said only "Unknown error", hiding the one sentence that explained it. FederationMemberProfilePage discarded the API's error body and always rendered member_profile.tx_unknown_error, so every distinct refusal looked identical: a safeguarding contact restriction on the recipient, a partnership with transfers switched off, a recipient who has disabled federated transactions, and a sender who has not opted in all produced the same dead-end toast. Observed on production sending from TimeBank Ireland to a Timebanking UK member who has two safeguarding options carrying restricts_messaging, where the API correctly returned 403 SAFEGUARDING_CONTACT_RESTRICTED with a member-readable message. The toast now shows the server's message and falls back to the generic string only when the response carries none. The refusal itself is correct and unchanged — this restores the explanation, it does not weaken the safeguarding boundary.
- The app failed to boot after every deploy for anyone whose browser had the site installed: the service worker was serving a previous build's index.html, whose asset filenames the deploy had already deleted. Workbox only stores a navigation response as the reusable HTML shell when it carries X-Nexus-Spa-Shell: 1 (cacheableResponse.headers on the NetworkFirst navigation route). nginx.bluegreen.conf declared that marker in location = / and location /, but both blocks end in an unconditional return 421 and the response is generated by the @spa_shell named location — and nginx does not inherit add_header into a block that declares any of its own. The marker was therefore discarded on every request and never reached a browser. Verified on production before the fix: X-Nexus-Spa-Shell absent from https://timebank.global/, the response header set matching @spa_shell's exactly, and the nexus-public-html-shell-v3 cache not present in a real browser after loading the site — only workbox-precache-v2 and nexus-immutable-assets-v1. With the runtime cache permanently empty, precacheFallback handed back the precached index.html of whichever build installed the worker whenever a navigation exceeded the 3-second network timeout, which is exactly what a blue/green switch produces. Old hashed assets hard-404 after a deploy (confirmed against production), so the app could not start and rendered the root error boundary in main.tsx. The marker now lives on @spa_shell, along with the Vary: User-Agent that block also lacked despite serving bots and users different bodies at one URL; the dead declarations are replaced by comments explaining why they were dead. Verified by running the real nginx: nginx -t clean, and X-Nexus-Spa-Shell: 1 present on / and on deep SPA routes, absent from every snapshot and maintenance block. Guarded by npm run check:spa-shell.
- Stale-chunk recovery gave up after one attempt, so the deploy crash was shown to the user rather than repaired. requestStaleChunkRecovery allowed a single reload per route per 30 seconds and returned false for anything inside that window. A deploy reliably produces two failures in a row — the reload re-requests the same URL and the service worker hands back the same previous-build shell — so the second failure always fell through to the error boundary. Recovery now allows three attempts, and every attempt after the first deletes the caches that can hold HTML (workbox-precache* and any *html-shell*) before reloading, forcing the navigation to the network. A hung or rejecting Cache API cannot block the reload: a 2-second timeout guard reloads regardless. The service-worker registration is deliberately left alone — it owns the push subscription. The attempt budget resets after 60 quiet seconds, and the legacy bare-timestamp sessionStorage record is upgraded rather than crashed on, so sessions open across this deploy still behave. Covered by nine tests in src/routes/lazyWithRetry.test.ts.
- The platform master tenant was excluded from prerendering entirely, so app.project-nexus.ie — the public front page that lists every community — had never had a single snapshot. PrerenderPlanRoutes carried ->where('id', '<>', 1), skipping master in every route plan ever produced. That hostname takes roughly 156 crawler visits a day and served every one of them the empty SPA shell. Master is now planned like any other tenant, with one deliberate difference: it renders at the app host (FRONTEND_URL), never at its own tenants.domain. Master's domain is project-nexus.ie, which Apache routes to the separate sales-site container — snapshots written under that hostname are read by nothing, and 48 such orphans had quietly accumulated. The reserved-slug guard is skipped for master because it is served at the host root with no slug prefix; without that, renaming master's slug to a reserved word would have aborted route planning for every tenant. Regression test in PrerenderPlanRoutesTest, verified to fail with the exclusion restored.
- ROOT CAUSE: the authoritative prerender publish could never complete, so crawler-facing prerendering was off platform-wide from 2026-07-11 to 2026-09-13. The publisher validates each staged page against its checksum sidecar with read -r recorded_hash recorded_bytes < "$checksum". POSIX read returns non-zero at EOF when the final line has no trailing newline — even though it assigned both variables correctly — and prerender-worker.mjs wrote those sidecars without one. Under set -eu the publish therefore aborted on the first page it validated, with no message, no partial state and exit code 1. Because .tenant-identity-v1 is written only at the end of a successful authoritative publish, and nginx fails closed without it, every crawler received the 1,950-byte empty SPA shell: across the whole retained bot-access log, 0 snapshots were served to any bot out of ~4,400 visits. Google indexed hour-timebank.ie with the app's own "Unable to connect" error as its description. The reader now tolerates the EOF status (the malformed/mismatch checks immediately after still reject empty or wrong values) and the worker now terminates the sidecar, matching sha256sum output.
- The regression test for that publish existed, passed, and proved nothing — and CI never ran it. scripts/test/test-prerender-authoritative-publish.sh wrote its checksum fixtures with a trailing newline while the real worker wrote them without, so the one byte that mattered was never exercised; and the script was not referenced by any workflow. The fixture now covers both shapes (verified: the test exits 1 with the reader fix reverted, 0 with it), and the test runs as a BLOCKING step in the existing Migration Safety Gate job — no new required job, so the deploy verifier's job list is unchanged.
- The prerender health report could not see that prerendering was switched off. nginx fails closed on .tenant-identity-v1: while that marker is absent it serves the empty SPA shell to every crawler regardless of how fresh or complete the snapshots are. Every existing check in PrerenderService::health() measures snapshot production — cache readable/writable, route planning, circuit breaker, queue age, recent failures, stuck jobs, coverage, scheduler liveness — and none measured delivery. The marker was absent in production from 2026-07-11 (commit f62b85fa4, which introduced the gate) to 2026-09-13, and across the whole retained bot-access log zero snapshots were served to any crawler while this endpoint reported green. A new serving_enabled check now reports red, with the exact remedy, whenever the marker is missing — and states that only a full authoritative rebuild writes it, so the condition cannot self-heal, and that the file must never be created by hand because its presence also activates strict missing-sidecar enforcement. Regression tests in tests/Laravel/Feature/PrerenderServiceTest.php, both verified to fail without the check.
- /terms, /privacy and /cookies had no meta description, canonical link or Open Graph tags on any tenant that uploaded its own legal document. All three pages branch three ways — loading, tenant custom document, default content — but only the default branch rendered <PageMeta>. A tenant with a custom policy took the middle branch, which returned <CustomLegalDocument> bare, so the page reached crawlers with a title from usePageTitle() and nothing else. AcceptableUsePage has always rendered PageMeta on both branches and was used as the reference. Confirmed against a freshly rendered production snapshot of hour-timebank.ie/terms: <title>Terms of Service</title>, zero description tags, zero canonical tags. Regression tests added to TermsPage.test.tsx, PrivacyPage.test.tsx and CookiesPage.test.tsx, each verified to fail without the fix.
- Every tenant domain advertised an empty sitemap to search engines. location = /robots.txt rewrites the platform hostname to the requesting host via sub_filter, but ran with sub_filter_once on. robots.txt opens with the comment # robots.txt for app.project-nexus.ie (React frontend), so the single permitted substitution was spent on line 1 and the Sitemap: directive on line 151 was never rewritten. Every community — hour-timebank.ie, pairc-goodman.com, uk.timebank.global and the rest — therefore told crawlers its sitemap was https://app.project-nexus.ie/sitemap.xml, which contains zero <url> entries, while its own correct sitemap (99 URLs for hour-timebank.ie) went unadvertised. Now sub_filter_once off, so the Sitemap: line and the llms.txt comment lines are rewritten to the serving host as intended. Both nginx.bluegreen.conf and nginx.conf carry a comment pinning the value. Verified by byte count against production: the served file was exactly 4 bytes shorter than the file on disk — one app.project-nexus.ie → hour-timebank.ie substitution, in the wrong place.
- Buying an item in the XP shop works again from the app already on members' phones. The purchase endpoint had begun requiring an operation key that only newer clients send, which would have refused every purchase made from a handset until a new store release. The key is now checked when it is sent and never demanded, so purchases that do send one keep their protection against a double tap buying the same item twice.
- Android release builds no longer fail on Windows when Node crashes while shutting down after the update-manifest step: the Gradle build now runs Node through a wrapper that exits that one script cleanly the moment its work is done and retries it, bounded, if Node still crashes at teardown (plugins/with-android-node-clean-exit, scripts/node-retrying.cjs). Seen in two of three builds of the first Expo SDK 55 bundle.
- A web sign-in page that is out of date no longer answers "Sign-in failed" when the server sends an answer it cannot read: the update reload is applied at once on sign-in, registration, password and two-factor pages instead of waiting for the cursor to leave a field, an unrecognised sign-in answer on a stale bundle says the page was out of date and refreshes it immediately, and a live session ended because the account now requires two-factor authentication is explained as such rather than as an expired session. Seen on the first web sign-in after mandatory administrator two-factor went live.
- The Play upload-key signing configuration and the -PplayVersionCode override are now generated into the Android project by a config plugin (plugins/with-android-play-signing) instead of living only in one machine's git-ignored Gradle file, so a bundle built from a clean checkout is signed with the upload key rather than the debug key.
- XP-shop purchases from the web app and the accessible site would have failed once the current code shipped. The purchase endpoint started requiring an 8–191 character operation key (c03aeefe6, so a retried request replays the original purchase instead of spending XP twice), but only the native app sent one; the React achievements page and the accessible shop form sent just item_id and would have received 422 on every purchase. Both now send a per-purchase key (the accessible form carries a per-render key per item, and the route mints one if a cached form omits it). Tests: AchievementsPage.test.tsx, web-uk api.test.js and shared-accessible-shell.test.js. Found in the 2026-09-12 review before deployment; production was unaffected. (E-005, O-019; commit 63b5f1134)
- Voting on a poll with an option from a different poll, or on a poll whose end date has passed, answered 500 instead of a proper status. PollService::vote() threw InvalidArgumentException('Invalid poll option') and RuntimeException('This poll has closed'); neither PollsController::vote() nor SocialController::votePollV2() caught them, so both cases surfaced as a server error (with the exception class and file path in the body under debug). Both controllers now answer 422 VALIDATION_INVALID_VALUE for a foreign option and 409 RESOURCE_CONFLICT for a closed poll. The closed case is a new typed App\Exceptions\PollClosedException (extends RuntimeException, so nothing that caught the old type breaks); a broad catch (\RuntimeException) was deliberately not used because QueryException is one too. No new translation key — both messages reuse api.invalid_input. Regression tests: four new cases in tests/Laravel/Feature/Controllers/PollsControllerTest.php. Found by the same-community access sweep. (E-003, F-001; commit a49671ea9)
- Native ideation submissions, votes, comments, edits and deletions now use synchronous pending guards, preventing rapid taps from dispatching conflicting or duplicate requests before React renders its loading state. Submitted idea, edit and comment fields lock for the duration of their write so a successful response cannot discard changes made after dispatch.
- Federation hub, partner-detail, member-card and marketplace-order actions in the native app now use enforced responsive columns instead of compressing controls into unreadable rows. Labels keep a readable size and may wrap to two lines, large-text and narrow-screen layouts become full-width, partner and order chips use the medium size, and federation directory filters no longer cap partner names and translations to one 136dp line.
- Android release builds no longer fail when Node crashes while shutting down after the JavaScript bundle step. The same Windows shutdown crash that the build already retried for the update-manifest step was seen in the Metro bundle step, after the bundle and source map had both been written in full. That step is now retried on the same bounded terms; an ordinary bundling failure is still reported on the first attempt rather than repeated, and other commands of the same tool are not retried.
- Native goal completion now uses the canonical atomic lifecycle transition, preserving completion history, milestones and XP while making retries side-effect safe. Goal forms and actions prevent rapid conflicting edits. Poll votes now commit with XP, return the committed choice after response loss, reject a changed second choice, and serialize rapid native taps; poll creation locks its submitted draft.
- Native paid-course, marketplace, daily-reward, challenge-reward and XP-shop actions now suppress rapid duplicate dispatches. Challenge awards commit with their claim marker, daily and challenge retries return committed results, and XP-shop retries use a durable content-bound identity so response loss cannot spend XP twice. Marketplace shipping no longer offers an ignored method override, and delivery copy now states the actual 14-day dispute period before escrow release.
- Native volunteering now preserves expense, donation-pledge and hour drafts across tab changes, warns before leaving with unsaved or pending input, serializes rapid submissions, and retries certificate, expense, pledge and hour writes with the same content-bound operation identity. Laravel durably returns the original record after response loss, preventing duplicate records and duplicate hour credits.
- Administrator shift-swap approve/reject decisions now have one atomic winner. Retrying the committed decision succeeds without repeating notifications or assignments, while the opposite decision returns a conflict.
- Native organisation settings preserve dirty edits across refresh and tab changes, guard navigation while dirty or saving, and retain rejected clears for retry. Optional description, contact email and website fields can now be cleared through the Laravel update contract.
- Native organisation dashboards now keep successful panel data visible when a later applications, hours, volunteers or wallet refresh fails, with a panel-specific Retry warning instead of a false empty state. Hour decisions, wallet inputs and settings fields also lock synchronously while their writes are pending, and the dashboard resets when the account, community or organisation changes.
- Volunteer-hours decisions now have one atomic winner across concurrent approve/decline requests. Retrying the winning decision after response loss succeeds without paying twice, while the conflicting decision returns HTTP 409 instead of reporting contradictory success.
- Native organisation directory/detail screens now reset on account/community changes and warn when a refresh fails instead of silently presenting stale records. Organisation details map the Laravel volunteer_count and opportunity_count contract, replacing misleading zero member/listing tiles and restoring the missing volunteer total.
- Native organisation registration now locks every field and its authority confirmation while saving, serializes rapid submissions, resets on account/community changes, and retries an unknown result with the same content-bound operation key. Laravel stores hashed operation identity so response loss and simultaneous retries return one pending organisation and one owner membership instead of a duplicate-name dead end or duplicate records.
- Native job analytics now distinguish a failed prediction request from “not enough data,” offer a direct retry, refresh figures and predictions together, and reset on account/community changes. Prediction totals and comparisons use real tenant-scoped application rows instead of stale stored counters, and leftover English analytics labels are translated in German, Spanish, French, Italian and Portuguese.
- Native candidate interview and offer decisions now serialize rapid taps, remain retryable after a failed request, and ignore completions after an account/community change or unmount. Laravel resolves conflicting decisions atomically, permits a response-loss retry of the decision that already won, and rejects the opposite decision.
- Job-alert emails now render role type, commitment and closing dates in the recipient's language and regional date format instead of leaking English enum labels and month names.
- Job-alert notifications now honour saved category filters and do not match location-specific alerts to vacancies with no location.
- Native job-alert creation now locks the draft while saving, resets on account/community replacement, ignores completions from the prior identity, and retries the same intended alert with a content-bound idempotency key; Laravel stores hashed operation identity so lost responses and simultaneous retries return one subscription instead of creating duplicates.
- Native job applications now read saved CV and cover-letter details from the Laravel response envelope, show an honest loading state, and expose failed lookups with Retry instead of claiming no saved CV exists.
- Job creation retries carry a content-bound idempotency key, and Laravel stores it with the vacancy, so lost responses and simultaneous retries return the original job instead of publishing duplicates.
- Job forms reset when the signed-in account or community changes, and completions from the previous identity cannot navigate or show success in the replacement session.
- Job forms lock every field, choice and generation action while a save is pending, preventing accepted submissions from clearing edits that were never included in the request.
- Job creation and editing prevent repeated save dispatches while a submission is pending.
- Delayed AI job descriptions no longer overwrite employer edits or apply after their generation inputs change.
- Switching job edit targets loads the correct vacancy and resets the form baseline instead of retaining the previous job's draft.
- Job forms track unsaved changes across all fields and treat an unchanged loaded vacancy as clean, including when edits are reverted.
- Hiring pipeline stage summaries use full-width rows so enlarged system text has room without relying on live font-scale notifications.
- Hiring pipeline stage summaries use explicit container widths to prevent narrow, stretched controls in the native layout.
- Job lists calculate application totals in a tenant-scoped batch query so stale stored counters no longer contradict job details and hiring pipelines.
- Job detail application counts now reflect actual tenant-scoped applications rather than a potentially stale stored counter.
- Mobile hiring pipeline actions prevent rapid conflicting moves for the same candidate while a request is pending.
- Mobile hiring pipelines keep withdrawn candidates in their own stage and suppress forbidden actions for accepted, rejected and withdrawn applications, using the server's authoritative stage.
- Mobile job candidate cards hide decision actions after acceptance, rejection or withdrawal, matching the server's terminal-status rules.
- Mobile job candidate decisions prevent rapid conflicting updates while a status change is pending.
- Mobile job applications respect server-derived vacancy availability, including expired deadlines and moderation restrictions, using the same end-of-day rule as submission.
- Mobile job application drafts are scoped to the job, account and community, preventing a previous job's covering message or attachment from appearing in another application.
- Mobile job application drafts retain their covering message when the sheet is closed and reopened after a failed submission; successful completion clears both message and attachment.
- Mobile job owners no longer see an application action for their own vacancy; owner management tools remain available.
- Mobile job applications wait for CV selection to finish before submission and recover when the document picker fails.
- Mobile job applications lock the submitted message and CV controls while sending and preserve the message after rejection; pending submissions cannot be dismissed or dispatched twice from the form.
- Mobile opportunity owners and organisation managers can include a decision note when reviewing volunteering applications. Tenant-required decline notes are explained and enforced before submission; failed decisions preserve the note for retry.
- Mobile volunteering prevents conflicting approve/decline requests while an organiser's application decision is pending.
- Mobile volunteering isolates application and form state by opportunity so navigating to another opportunity does not carry over a previous submitted status.
- Mobile volunteering prevents overlapping shift sign-ups and cancellations while a shift change is being submitted.
- Mobile volunteering pauses shift changes when existing registrations are loading or failed, offering retry instead of bypassing the confirmation required to move from another shift.
- Mobile volunteering now permits a new application after a previous application was declined or withdrawn, matching the server's eligibility rules.
- Mobile volunteering applications now show success only after server acceptance and lock the submitted note while sending, preserving it after failure for retry.
- Opening another volunteer QR token resets the check-in confirmation and prevents the previous volunteer's status or delayed response from appearing for the new token.
- Volunteer checkout retries return success for the original completed checkout without changing its timestamp or dispatching a duplicate shift-completed webhook.
- Native volunteer check-in and checkout offer a same-token retry after temporary network or server failures, retaining the volunteer name and retrying the correct action.
- Confirmation actions ignore duplicate events while an action is pending, preventing rapid presses from starting the same operation twice before the button disables.
- Android confirmation dialogs disable portal animations that could crash the app when a confirmed action navigated away, including discarding an unsaved course.
- Course completion checks the locked enrolment record before running completion integrations, preventing stale repeat requests from counting the same completion twice.
- Instructor grading preserves fractional percentage scores instead of truncating them to whole numbers; invalid or out-of-range scores are rejected without replacing an existing grade.
- Native course grading pauses score, pass/fail and feedback edits while saving, preserving the submitted draft when a grade is rejected.
- Refreshing the native course grading queue preserves unsent feedback and keeps accepted grades hidden; refresh failures appear beside the retained forms instead of discarding them.
- Native cross-community credit transfers save their retry key before sending, retain it after request failure, and prevent overlapping submissions or edits during a pending transfer.
- Internal cross-community transfers with an explicit retry key retain their committed receipt in the database, preventing cache loss from causing a second debit and rejecting reuse of a key with changed transfer details. The wallet recovery endpoint can confirm these receipts for the original sender and transfer details.
- Accepting a connection request no longer hides the member from the Connected tab if the user switches tabs before the request finishes.
- Closing or unmounting an open native bottom sheet dismisses its keyboard, preventing it from covering the screen beneath the closed form.
- Shared native top-bar labels remeasure after text-size changes, preventing the Back label and screen title from retaining clipped layout bounds.
- Bottom-sheet titles and the group discussion helper text remeasure when text size changes, preserving the discussion draft while preventing stale clipped text.
- Group discussion actions use full-width rows at larger text sizes to give translated and enlarged labels room to wrap.
- The group discussion composer opens at its expanded height so Android keyboard users can reach the message and publishing actions without first expanding the sheet.
- An accepted group answer is no longer reported as a failed post when its subsequent detail refresh fails; the question list refreshes and the UI reports the read failure separately.
- Group questions and answers pause text editing while submitting, preventing successful saves from clearing newer unsent edits.
- Group announcement title, body and pin controls pause during publishing so edits made during a pending save cannot be silently cleared; rejected saves retain the draft for retry.
- Group discussion fields pause editing while publishing and unlock after a failed request, preventing successful submission from clearing text entered during the save.
- Late native API authentication failures no longer refresh, retry under, or sign out a replacement session.
- Native API requests awaiting credential reads are cancelled before dispatch if the session is replaced or cleared, preventing queued actions from using another account's bearer or stale sign-out credentials.
- Native wallet reservations stop when the account or community changes during asynchronous recovery, preserving the original account's retry record.
- Native wallet retries older than the legacy cache window can now check for a matching durable server record and replay the original key when confirmed. Unknown or unavailable results retain the existing safety block and never create a replacement debit.
- Donation retry identities are persisted with the donation ledger, preventing a lost replay-cache result from debiting members twice. Member and community-fund paths retain atomic balance updates. Requires the credit donation retry-identity migration before rollout.
- Explicit wallet transfer retries now use a durable receipt committed with the debit and credit, preventing duplicate transfers after replay-cache failure or eviction. Requires the wallet transfer receipt migration before rollout; legacy unresolved transfers and other credit-changing paths retain their existing recovery limits.
- Organisation deposit retries now commit a durable receipt with both balances and ledger entries, preventing a replay-cache outage from charging the member twice. Requires the organisation deposit receipt migration before rollout.
- Late duplicate wallet success responses no longer erase the saved retry identity of a newer intentional transfer, donation or organisation deposit.
- Native text buttons grow to fit wrapped labels at larger accessibility text sizes. Android handles font-size changes without recreating the active screen and discarding its unsaved form state.
- Cold native deep links retain the main navigation behind the destination, so Back and Cancel can leave the screen and invoke unsaved-draft confirmation.
- Native image placeholders no longer persist when a failed image is replaced with a different URL. Saved event/group image recovery also uses the shared failure placeholder.
- Native podcast playback failures now show a retry action and retain the last playback position instead of leaving the player showing Pause after a decoder error. Playback also supplies episode and show titles to native media controls.
- Biometric unlock preserves its security boundary on failures. Native sessions stay locked when device authentication or the saved lock preference is unavailable; secure-storage failures no longer report successful enable/disable changes. Locked content is hidden from accessibility services, and hardware/enrolment errors use the existing translated messages.
- Native biometric settings explain that the lock protects a saved session, permits biometrics enrolled on the device and may accept the device PIN. Enrolment guidance correctly requires fingerprint or face unlock; all seven native locales include the clarification.
- Passkey cancellation and revocation remain under member control. Cancelling platform registration no longer opens a second prompt. Existing credentials remain manageable when tenant passkey sign-in is disabled.
- ASP.NET passkeys require local user verification. Registration and sign-in enforce verification before issuing verified-passkey claims, including ceremonies started under the older policy. New credentials must be discoverable.
- ASP.NET passkey removal preserves recovery and revokes sessions. Removing credentials cannot consume a passwordless member's final passkey, missing IDs cannot remove every key, and unknown IDs return a truthful failure. Successful removal invalidates access, refresh and security-confirmation tokens. Credential mutations, passkey sign-in and refresh rotation serialize against removal, and compatibility endpoints require security confirmation for enrolment and management.
- Updated the ASP.NET WebAuthn verifier to the stable Fido2.AspNet 4.0.1 patch.
- ASP.NET enforces the tenant biometric-login switch on registration and authentication, including assertions started before the switch was disabled, while retaining credential management.
- Voice-message retries retain their duration. Retrying a failed native voice upload now sends the original recording length instead of zero seconds.
- Marketplace media failures keep a recovery path. Saved listings retain failed photo/video operations for retry against the existing listing. Photos are tracked individually so a rejected file is not hidden by a successful batch response. Successful media steps are not repeated, and sellers can explicitly continue to the saved listing.
- Saved events and groups retain failed cover images. A failed upload now opens recovery with retry, replacement-image selection and an explicit continue action, without repeating the saved event/group creation. Recovery controls use all seven native language catalogues.
- Image recovery respects navigation and large-text attribution. Pending retries keep the recovery screen in place, completed retries do not redirect after unmount, and the native source-repository link wraps within the screen at large text sizes.
- Lost photo-upload responses no longer duplicate marketplace photos on retry. Native retries retain an operation key, and the API commits a durable upload receipt with the image rows. Matching replays return the original result, conflicting content is refused, and ownership remains enforced. Requires the marketplace image-upload receipt migration before rollout.
- Generated listing descriptions preserve newer edits. Delayed AI descriptions no longer overwrite text entered while generation is pending in native service and marketplace listing forms.
- Stale sign-in responses cannot replace the current account. Delayed native password, MFA profile and session-restore responses are ignored after logout or a newer session is installed, including old credential refusals.
- Delayed sign-out preserves a newer session. Native logout now stops obsolete cleanup after push unregister, server logout and local storage waits, avoiding deletion of credentials belonging to a subsequently signed-in account.
- Late authentication replies preserve the active API identity. Login/logout responses cannot replace a newer bearer token, and mutation requests correctly recognize an isolated refresh-suppression option.
- Credential cleanup follows pending writes. Native storage operations now preserve order per key, preventing a delayed write or cached read from restoring a token after logout removes it.
- Confirmed wallet payments survive local cleanup failures. A failed secure-storage deletion no longer reports an accepted transfer or donation as failed. Completion is retained so intentional repeats can receive a new durable operation ID while uncertain requests keep duplicate protection.

### Added

- Two-factor recovery codes can now be saved. The enrolment screen warns that the codes are shown once, and offers a copy button and a plain-text download built entirely in the page, so the codes are never requested, never logged and never left behind in an address that could be fetched again. The date inside the file follows the member's community region, and the file uses Windows line endings so it stays readable in Notepad. Translated into all eleven languages.
- Over-the-air mobile updates are now refused when the native surface (dependencies, Expo plugins, permissions, update channel) differs from the store build members have installed, recorded in mobile/live-store-build.json; verify:release also refuses an Android version code below the live one. Added after the two-factor lockout fix was nearly published as an update that the SDK 54 store build could not have run.
- docs/SECURITY-ASSURANCE.md — how security assessment works here, and the register behind it. Project NEXUS hosts communities for public-sector bodies with supplier-assurance obligations, so security work is now a maintained record rather than a series of one-off exercises: a private Security Assurance Register holds every engagement, every finding with a permanent identifier and a status vocabulary that distinguishes "a fix exists" from "the fix works in production", the cross-document reconciliation, and the evidence-freezing rules. The public document describes the process and deliberately carries no findings. The rule that security work must read and update that register is binding in AGENTS.md (MANDATORY RULES) and CLAUDE.md, and SECURITY.md now points a discloser or a buyer at the process. (E-003; commit aa19256b7)
- tests/Laravel/Feature/Security/SameCommunityAccessSweepTest.php — can member A reach member B's records inside the SAME community? Every earlier sweep kept actor and record in different communities, where TenantScope does the work; this one asks the question the per-endpoint ownership checks have to answer. Reads: every v2 GET with one path parameter, B's record vs A's own as control; a 200 carrying data must be registered as public by design (exact route, with a reason) or it fails, and B's e-mail address in any body always fails. Writes: every v2 non-GET with one path parameter, body grown from the API's own validation messages, B's row compared column by column after every request regardless of status; a changed or deleted row fails unless only counter columns moved, and a 2xx that leaves B's row alone must be a registered interaction. Consumed fixtures are re-seeded before the next endpoint (a control request is a real request). A third method covers multi-parameter routes (person routes with a third member seeded into both members' records; child routes with B's parent and child): first run 46 combinations probed, 25 refused with a working control, 7 refused at the permission gate, 0 served/accepted/changed. Shared machinery (fixture maps, seeding, id-type resolution, request helpers, multi-parameter enumerators) moved verbatim from CrossCommunityAccessSweepTest into tests/Laravel/Feature/Security/Support/AccessSweepTestCase.php; the cross-community sweep's behaviour and figures are unchanged. (E-003; commits 068dc7551, 95cfeaab0)
- Published docs/SECURITY-ASSURANCE.md, a public description of how security assessment works on the platform: what gets tested, how findings are recorded and tracked in the private register, how evidence is handled, and what a customer with a supplier-assurance obligation can ask for. It states process only and deliberately contains no findings. Linked from the documentation index and the published site navigation, with contributor and agent guidance pointing at it so security assessment is maintained as an ongoing record rather than a one-off audit.
- Repeatable real-backend MFA journeys for React, the accessible website (with JavaScript disabled), and the Android debug emulator, using dedicated synthetic accounts in the isolated end-to-end database. Browser tests cover enrollment, recovery acknowledgment, recovery sign-in and rejected code reuse; Android additionally checks session restoration after process restart.
- MFA recovery and operational tooling. Members can replace recovery codes with a fresh authenticator code and revoke remembered devices from React and accessible security settings. OAuth/SSO sign-in can continue into local MFA without prematurely linking identities or issuing credentials. Read-only security:mfa-readiness inventory and explicitly confirmed security:recover-admin-mfa host recovery commands support rollout and emergency recovery; security changes send recipient-localized notifications.
- Native Android/iOS MFA sign-in and required enrollment. Password challenges now open native authenticator or recovery-code verification. Mandatory setup presents the setup key and recovery codes, preserves issued credentials while profile loading is retried, and handles expired challenges without silently refreshing an unrelated session.
- Tenant member MFA enforcement with a mandatory platform administrator baseline. The authentication configuration panel now lets tenant administrators require MFA for every member, while the administrator requirement cannot be disabled. Restricted enrollment on React and the accessible website includes QR/manual setup, retries and recovery-code acknowledgment. Server checks cover existing sessions, role promotion and refresh; remembered devices cannot bypass required MFA, and required users cannot disable their factor. Existing passkey configuration permissions remain restricted to super administrators. Deployment activates the administrator baseline and requires the accompanying TOTP migration first.
- tests/Laravel/Feature/Security/ExternalSurfaceKillSwitchSweepTest.php — the inbound partner surfaces, enumerated from the route table instead of a list. External partner federation and the partner v1 API are switched OFF in production and have been since 2026-07-27 with no partner connected. Both already have dedicated kill-switch tests and both pass. This adds a sweep because those tests drive hand-written lists, and these are the one authentication path that never runs Authenticate — so they never see the cross-community checks proved in CrossCommunityTokenReplayTest. 69 inbound partner route-and-method combinations: all 69 gated, all 69 refuse with 503 while the switch is off, 0 reached. Surfaces covered: Credit Commons 17, Komunitin 17, Nexus ingest 7, legacy v1 15, partner v1 10, plus external webhooks, aggregates and hour-transfer inbound. Control-verified. "Everything refused" proves nothing if the routes are broken, so the sweep runs again with the switch ON and requires the answer to change; a route that refuses identically either way is reported INCONCLUSIVE, not as a pass. All 69 changed: 62 → 401, 4 → 400, 1 → 404, and 2 → 200. Those two are GET /v1/federation (a list of endpoint names) and /v1/federation/health (status and timestamp) — no member data, and both still behind the switch. 🔴 The population is defined by PATH, not by which middleware is attached — deliberately. Selecting "routes carrying the kill-switch middleware" is circular: a new external route added without the gate would simply not be in the population, and the sweep would report a clean pass over the routes that were already safe. The sweep therefore asserts separately that every route on an inbound partner path carries a gate, against a shrink-only KNOWN_UNGATED list that is currently empty. A new ungated external surface now fails this test on the day it lands. Honest scope note: the existing hand-written lists were not materially incomplete — they cover comparable ground today. What this adds is that the coverage is now derived from the live route table and cannot drift, that an ungated route is detected rather than merely absent, that the refusal is proved to come from the switch, and that federation and partner v1 are checked in one pass. The member-facing /v2/federation/* routes (connections, messages, opt-in, settings) are deliberately excluded: that is internal cross-community federation, live and ungated by design, and it runs Authenticate, so it is covered by the token-replay sweep.
- CrossCommunityAccessSweepTest::test_write_endpoints_refuse_a_foreign_id_even_with_a_valid_body — the write sweep's largest blind spot, closed by letting the API write its own request bodies. The write sweep sends an EMPTY body, so 108 of its 366 endpoints were rejected by validation before any community check could run — recorded as unproven, never as passes, and the single biggest gap in the assessment. Rather than hand-writing 108 bodies, the sweep now reads the API's own validation errors, which name the field that failed and often list the values it will accept ("Invalid reaction_type. Valid types: love, like, …"), supplies a plausible value, and asks again — up to eight rounds, stopping the moment validation objects twice to the same field, because that means the value was rejected rather than missing and guessing further would be dishonest. 366 endpoints, 0 MUTATED. Refusals rose 245 → 271, and the unproven bucket fell 108 → 77: 31 endpoints that had never been exercised now are. The 77 that remain need structured bodies (patch, permissions, tiers, a file upload) and are still recorded as unproven. 🔴 The first run of this test scored a flawless 366 of 366 refusals in under six seconds, and it was completely worthless. $e['uri'] already begins with api/, so prefixing another /api requested /api/api/v2/…, which matches no route; every response was a router 404, and a router 404 scored as REFUSED. A dedicated assertion now fails the test outright if any response carries NotFoundHttpException, so this cannot recur silently. Six seconds for what should have been thousands of requests was the only outward sign.
- tests/Laravel/Feature/Security/CrossCommunityTokenReplayTest.php — a valid account from one community, used against another, across the whole authenticated API. Every sweep before this one kept the actor and the community aligned (a tenant-2 member asking for a tenant-999 record). None asked the opposite and more dangerous question: what if the CALLER is from somewhere else? That matters more than it first looks, because every community-owned query is restricted by tenant_id = TenantContext::getId() and the community is resolved from the REQUEST, not from the account — so a member of community A whose request resolved as community B would be handed community B's data by queries all working exactly as designed. The protection cannot come from the queries; it has to come from refusing the request. 2,316 authenticated v2 routes swept, 2,303 refused, 0 reached, 13 inconclusive. Every single refusal carried the same code: tenant_mismatch (2,303 of 2,303). The 13 are router-level 404s where the placeholder fails a pattern constraint ({kind}.csv, {providerKey}, {provider}, {action}, {type}) — the same class as the 21 in RoleBoundarySweepTest. Authenticate carries two independent checks — the user's tenant_id, and the token's own tenant_id (which still binds a platform super-admin whose account may legitimately cross communities). Both were already unit-tested in AuthenticateTest; what had never been established is that they cover the whole API, since they live in one middleware and 129 of the 2,503 v2 routes do not run it. 🔴 This sweep proves check (1) only, and says so in the test. The actor is authenticated with Sanctum::actingAs(), so no bearer token is present and the token-tenant check cannot fire. A real issued token was tried first and returns 401 for its own community inside the test harness — an artefact of middleware ordering under the test kernel, not production behaviour. Testing what can be tested honestly beat chasing it. 🔴 The control caught the first version of this test proving nothing. Routes sort alphabetically, so the first 40 are all under /admin/, where an ordinary member is refused regardless of community — every control "failed" and the sweep would have been meaningless. The control now draws only member-facing, parameterless GET routes.
- CrossCommunityAccessSweepTest::test_no_endpoint_accepts_a_child_record_from_another_community — the rest of the multi-parameter routes, and eighteen new child fixtures to make them answerable. The companion to the foreign-person sweep: the 116 combinations whose deepest identifier is another RECORD (a lesson in a course, a version of a legal document, an answer to a question). Every identifier except the last is one of OUR OWN records owned by the acting user; only the deepest is another community's. 36 probed, 0 leaked, 0 mutated; 27 refused with a succeeding control (23 member, 4 community admin), 4 rejected by validation first, 5 inconclusive, 80 skipped. 🔴 This could not be bolted onto the existing resolution, and measuring first is what showed it. 84 of the 86 candidate routes resolved BOTH identifiers to the same fixture type, because the child parameter fell back to matching its parent's path prefix — the sweep would have requested courses/5/lessons/5, one row as both course and lesson, and the control would have rejected every one. It would have looked like coverage and proved nothing. CHILD_FIXTURES_BY_PREFIX resolves on the path segment before the parameter and is deliberately SEPARATE from the shared PREFIX_FIXTURES, whose figures are published; {questionId} shows why a shared map could not work anyway — under courses/…/quizzes/{quizId}/questions it is a quiz question, under groups/{id}/questions a group Q&A question. Eighteen child fixtures, each linked by a new needs clause to the parent seeded for the SAME community, so a control uses a child that genuinely belongs to the parent in the URL: course lessons/sections/cohorts/quizzes/questions, group discussions/chatrooms/chatroom-messages/announcements/questions/answers/files/media/invites/challenges/scheduled-posts, podcast episodes, legal documents and their versions. insertRow() gained needs, an optional owner column, a conditional tenant_id (legal_document_versions has none — it is scoped through its parent document by join, which is what LegalDocumentService does), a {today} placeholder, and a unique clause that reuses an existing row (legal_documents is unique on (tenant_id, document_type) and every seeded tenant already holds a terms row). 🔴 A CONTROL REQUEST IS A REAL REQUEST, and one destroyed the preconditions of the endpoints after it. Endpoints run in sorted order, so the control for DELETE .../versions/{versionId} genuinely deleted our own version and the controls for PUT, notify and pending-count on the same record then answered "Version not found" — three endpoints falsely INCONCLUSIVE. The identical trap hit the person sweep via group membership. Both sweeps now re-establish state per endpoint: this one re-seeds when recordsStillExist() says a needed row was consumed, which also makes it order-independent. Member refusals went 15 → 23 and the admin pass 1 → 4 from that fix alone. 🔴 A disabled feature module is not a pass. The courses module is off for the test community, so twelve course endpoints answered 403 FEATURE_DISABLED to probe AND control. A gate firing first says nothing about community scoping, so the sweep enables courses and podcasts for both communities via tenants.features. What it still does not reach: 80 combinations, 32 of them under events. Those are the registration-product, offline check-in and guardian-consent families, whose tables require twenty-odd NOT NULL columns of tokens, hashes and ciphertext each; a hand-built row would not represent real state. Recorded as untested rather than fixtured badly.
- CrossCommunityAccessSweepTest::test_no_endpoint_accepts_a_person_from_another_community — the multi-parameter routes, which no sweep reached before. The two sweeps above take routes with exactly one path parameter. 141 route-and-method combinations take two or more, and none of them had been exercised — including admin/groups/{groupId}/members/{userId}/promote, conversations/{id}/participants/{userId} and events/{id}/people/{userId}/history. That is where a scoping bug survives review, because the handler checks the FIRST id against the caller's community and forgets the second. The outer record is deliberately OURS and legitimate; only the person is foreign. 25 combinations name a person ({userId}, {childId}, {attendeeId}, {guestId}, {delivererId}, {partnerId}, …); every other parameter is filled with a tenant-2 record owned by the acting user. 17 probed, 0 leaked, 0 mutated; 6 refused with a succeeding control (3 as member, 3 as community admin), 7 rejected by validation first (not a pass), 4 inconclusive, 8 skipped. The detector is stronger than the single-parameter write sweep's, by design. That sweep compares the target row and states plainly that rows CREATED ELSEWHERE are invisible to it — which is precisely this sweep's subject, since "add this person to my group" writes a join row rather than altering the group. So this one counts, before and after every request, every row in the live schema referencing the foreign person across 302 person-shaped foreign keys. A count that moves in either direction is a confirmed cross-community write. The community-admin pass is fully conclusive: 3 probed, 3 refused, control-verified, 0 inconclusive — group member removal, promote and demote. GroupService::updateMemberRole() scopes its group_members lookup by tenant_id, so a person outside the community has no row and the operation refuses. 🔴 A test-ordering bug in the first version made seven results falsely inconclusive. Endpoints run in sorted order, so the control request for DELETE .../members/{userId} genuinely removed the control person from the group, and every later control then failed with NOT_MEMBER. The relationships are now re-established idempotently before every endpoint, not once per pass. Nothing about the platform was wrong; the sweep was. 🔴 group_members carries is_federated and source_tenant_id, so a member row pointing at a user in another tenant is a DESIGNED federation state. Do not extend this sweep by planting a foreign user in group_members and calling a successful promote a finding without reading the federation rules first. What is tested is narrower and sound: a person with no relationship to our record and no presence in our community must be refused. Verb-shaped parameters ({action}, {state}) are recognised as operations rather than records, locally to this sweep so the other two sweeps' published figures are unaffected.
- tests/Laravel/Feature/Security/PrivilegeFieldInjectionTest.php — a member sends role: admin, both super-admin flags, tenant_id: 999, a wallet balance, is_verified and id alongside a legitimate field on PUT /v2/users/me; every privileged column is read back with the query builder and must be unchanged. Passed first time: UserService::update() whitelists thirteen profile fields. The first thing a tester tries on a profile form, now pinned.
- CrossCommunityAccessSweepTest::test_no_write_endpoint_mutates_another_communitys_record — every non-GET single-parameter v2 route (796 route×methods, 691 routes) with a tenant-999 record id and an empty body, as a tenant-2 member and a tenant-2 community admin. 366 probed, 0 mutated the foreign record (snapshotted before/after so "accepted" and "changed" are not confused), 245 refused, 108 rejected by validation first (recorded as VALIDATION_FIRST — not a pass), 10 accepted no-ops pinned in KNOWN_ACCEPTED_NO_CHANGE. The first run listed 18 accepted writes; eight were fixed the same day (see Security). 🔴 {day} is a day of the week, not a record id. PUT users/me/availability/{day} resolved by path prefix to a member_availability row id, accepted it as a day number and answered 2xx — an eleventh accepted-no-change write. It failed only on a CI shard: the local run had failed to seed that fixture and skipped the endpoint, so the sweep was green locally and red in CI. day is now named in NOT_AN_ID, which is what moves the probed count from 367 to 366.
- Two route-table security sweeps, written for the Coventry City Council assurance request and kept as permanent regression tests. TenantIsolationTest covers three resources by hand; the v2 API registers 2,507 routes, 1,308 of which take a record id, so hand-written coverage was never going to keep pace. Both tests enumerate Route::getRoutes() at runtime so a new endpoint is covered the day it lands. Per-request evidence (statuses, control statuses, response-body excerpts) is written to .local-docs-archive/security-evidence/ (gitignored). tests/Laravel/Feature/Security/RoleBoundarySweepTest.php — every route reserved for a higher tier, requested by each of five actors with every declared method. anonymous 2,375 → 0 reached; member 1,121 → 0; broker 959 → 0; community admin 62 → 0; network admin 37 → 0. 4,554 requests, 0 reached a controller; 21 inconclusive (router-level 404s where the placeholder 1 fails a {provider} / {kind}.csv / {action} constraint — the gate was never exercised, so they are listed, not counted). 🔴 A 422 is a REACHED, not a refusal. Form validation runs after route middleware, so a member sending an empty body to an admin write endpoint and getting a validation error proves the gate did not fire. 🔴 Gates are read from Router::gatherRouteMiddleware(), not Route::gatherMiddleware(). The declared stack still lists a gate that ->withoutMiddleware(...) removed. The first version read the declared stack and reported the broker reaching /admin/listings (declared admin, effectively broker-or-admin — the broker's own panel) and the anonymous caller reaching /blog (declared auth, effectively public) as 48 breaches. None were. The first run did not disable rate limiting and the platform answered 429 to 2,097 of 2,397 anonymous requests after roughly 300 — recorded in the assessment as an observation that throttling engages; the test now removes ThrottleRequests because rate limiting has its own tests. tests/Laravel/Feature/Security/CrossCommunityAccessSweepTest.php — every single-id GET endpoint whose record type can be resolved, requested with a tenant-999 record, in two passes: as a tenant-2 member for member routes and as a tenant-2 community ADMIN for /admin/ routes. 371 endpoints in population, 220 probed, 124 refused, 0 returned another community's member data; 30 answered 200 with an empty body (correct); 62 inconclusive; 151 skipped (44 because the parameter is a slug/token/type, 102 because no fixture, 5 platform-tier routes covered by the role sweep). 🔴 Every refusal is control-verified. Each probe is paired with the same request for a same-type record in tenant 2 owned by the actor; a refusal counts only if the control returned 200/201. Without this, volunteering/shifts/{id} mapped to a VolOpportunity id "passes" with a 404 that means nothing. The control caught a wrong mapping during development — ideation-challenges is served by IdeationChallengesController, not the gamification Challenge model — which is exactly why it exists. 🔴 A 200 is classified by reading the body. The status-only first version flagged 19 endpoints; 16 were correctly scoped empty lists. Id type is resolved from the parameter name first ({userId}, {childId}, {caredForId} → user; {groupId}, {eventId}, {courseId}, {message}), then the longest matching path prefix (volunteering/organisations → VolOrganization, admin/newsletters/segments → NewsletterSegment). Slugs, tokens and type names are SKIPPED as "not an id", never probed with a number. Modules with no factory (courses, marketplace_listings, stories, podcast_shows) get a direct row insert filling only NOT NULL columns. KNOWN_SOFT_200 holds four reviewed endpoints that answer 200 for a foreign record while disclosing nothing — connections/status/{userId}, jobs/{id}/match, users/{id}/activity/dashboard, and (admin actor) admin/users/{id}/verification-badges. Each records the body read and the date. They should 404. Shrink-only in both directions. Found in passing, not fixed here: VolShiftFactory writes updated_at and vol_shifts has no such column; ResourceItemFactory likewise against resources. Both factories cannot create a row.
- Members are now asked whether they want notifications. registerForPushNotifications() took its prompting argument as opt-in and only the Settings switch passed it, so on iOS and Android 13+ the system permission dialog was never raised in the ordinary flow: a new member signed up, was never asked anything, and silently received no message, exchange-request or event notifications unless they found the switch. A card on the feed offers it once. Either answer is stored, because the OS cannot tell "tapped Not now" from "never asked" — both read as undetermined — so a permission-only rule would re-offer on every launch.
- A Vibration switch in Settings, and the app stops buzzing on everything. The shared button fired a haptic on every press of every variant, and toasts and confirmations added their own, so plain navigation buzzed as hard as sending credits. The button impact is now limited to the primary and destructive variants, and the new switch silences all three kinds of feedback for members who do not want them.
- Picked photos are shrunk before upload. Every picker passed quality only, which re-encodes without resizing, so a 12-to-48-megapixel camera photo left the device at full size — several megabytes against an 8 MB server limit and a 60-second timeout. On mobile data the member waited out the minute and was told the upload had failed. Capped at 1600px on the longest edge (2048 for marketplace listings). Adds expo-image-manipulator, so this needs a new store build. A PNG stays a PNG, so a transparent organisation logo does not gain a black background.
- Loading lists show the shape of what is coming. Notifications, marketplace, volunteering, jobs and the wallet transaction list showed a centred spinner on a blank screen while five other tabs already had card skeletons, so the same wait looked slower on half the app.
- mobile/docs/PLAY_RELEASE_PROCEDURE.md — how a build actually reaches Google Play, written from an end-to-end release rather than from memory. PLAY_SUBMISSION.md covered signing, listing copy and assets but not the act of shipping, so the order of operations lived only in the owner's head and an agent shipped straight to production without it. Every timing and fingerprint in the document is measured. The order is internal testing → phone → promote. Not production first. Play's live version code is the only source of truth. Measured 2026-09-09: Play was on 7 while EAS's remote counter said 4, so an EAS build with autoIncrement would have produced 5 and been rejected outright. The counter is stale because releases are built locally, which never touches it. Review took 33 minutes (submitted 7:32 PM, published 8:05 PM), against a dialog that warns of up to 7 days. With managed publishing off, submitting for review is the decision to go live. Internal testing and a production review can run at once — verified, not assumed: version 8 was submitted to production at 7:32 PM, published to internal testing at 7:47 PM, and the production submission published normally at 8:05 PM. Also records the two non-interchangeable certificates, the silent debug-signing fallback, the emulator-address landmine, the single-architecture trap, and the fact that navigating during a 90 MB upload aborts it with no error. Three documents still listed "correct the false no-money claim in the Play description" as the top release blocker. Reading the live listing shows it was fixed some time ago — neither description contains that wording. Corrected in all three, with a note to read the live fields before re-raising it.
- mobile/scripts/build-aab-play.sh — the signed Play bundle now has a checked-in recipe. There was none: build-apk-local.sh builds a deliberately debug-signed APK for sideloading, and the artefact actually uploaded to Google was assembled by hand each time. Three ways that goes wrong silently, all of which produce a file that looks completely normal, are now refusals rather than warnings. A debug-signed bundle. android/app/build.gradle:133 reads release { signingConfig playStoreFile ? playRelease : signingConfigs.debug } — with the signing values absent Gradle does not fail, it signs with the debug key and still writes app-release.aab. The script reads the key and its passwords from mobile/credentials.json (never echoing them) and verifies the finished bundle's certificate against the Play upload key before reporting success. 🔴 The wrong server baked in. .env.production.local was found holding EXPO_PUBLIC_API_URL=http://10.0.2.2:8090 — the address an Android emulator uses to reach the build machine, left behind by a test build on 2026-09-03. That value is compiled into the bundle permanently. A release built from it works flawlessly on an emulator and is dead in every real user's hand. The script regenerates the file and refuses any loopback, private-network or plain-http host. One CPU architecture. expo run:android writes reactNativeArchitectures=<one abi> into android/gradle.properties; a later release build inherits it. On 2026-08-20 that shipped an x86_64-only artefact — perfect on the emulator, "App not installed" on a real phone. All four are passed explicitly. It also requires --version-code rather than defaulting, because Play refuses a version code it has already seen.

### Changed

- Android version code 11: the store release carrying the minified, resource-shrunk build. The version name stays 1.5.0 because no native dependency changed between the two builds — only how the release binary is compiled — so the over-the-air runtime version stays compatible with the build already on phones. This is the release that answers Google Play's "DEX code optimization is below our threshold" warning against version code 10.
- Native app version 1.5.0, Android version code 10: the store release carrying the two-factor sign-in and the Expo SDK 55 upgrade. The bump also gives the build a new over-the-air runtime version, so an update built for SDK 55 can never be served to the SDK 54 build 9 still installed on phones.
- The member directory's explanation of why it lists fewer people than have joined the community is now a single collapsible line. It shows the count on one row with a "Why?" control, and only opens the visibility rules, the closing note and the privacy-settings link when a member asks for them. As an always-open alert it pushed the first row of member cards below the fold on desktop and took several lines on phones.
- The member directory now states its size once. The hero's member-count chip and the "Showing 24 of 25 members" line have gone: with the collapsible coverage note above them, the page carried three counts of almost the same thing, two of which used "of" to mean different things. The count while searching stays, because a match count is real information, and the Load more button already says how many members remain unloaded.
- Android release builds are now minified and resource-shrunk (R8). Google Play reported "DEX code optimization is below our threshold — Obfuscation (2%)" against version code 10, with a February 2027 deadline. Minification had never been switched on: the Expo build-properties block set only legacy packaging, so the generated Gradle properties carried no minify setting and the release build compiled unobfuscated — which is also why every upload reported "no deobfuscation file associated with this App Bundle", because R8 never ran and there was no mapping file to include. The keep rules the app actually needs are checked in at mobile/android-proguard-rules.pro and carried into the generated Android project by a config plugin, since that project is regenerated on every build. Each rule cites the dependency and call site that requires it, and the file records which dependencies already ship their own so nothing is duplicated; notably expo-notifications ships a rules file that its own Gradle configuration never applies, and react-native-gesture-handler ships none at all while two packages look its classes up by name. The build script now refuses a bundle that carries no R8 mapping file, and the release-config check refuses an app configuration that has lost either flag or the rules plugin.
- ASP.NET edition paused (owner decision, 11 September 2026) — not abandoned; it resumes when the rest of the platform is more mature. The four ASP.NET jobs in the platform-contracts workflow are pinned off so no runner minutes are spent on them, the ASP.NET markdown-link pass is removed from the shared inventory job, and AGENTS.md, react-frontend/CLAUDE.md, docs/REACT-DUAL-BACKEND.md and the ASP.NET README state the pause at the top. Code, ledger and earlier decision records are untouched; ADR-0005 records what was switched off and how to resume.
- Updated the native app to Expo SDK 55 and React Native 0.83, including the system-appearance API adjustment and explicit background audio configuration.
- Native voice messages, podcasts and lesson/marketplace video now use Expo's separate audio and video modules. Audio loading has a bounded timeout and cancellation on leaving the screen; podcasts seek to their saved position before playing and activate lock-screen controls. Voice recording prevents overlapping starts while permission is pending and late starts after leaving the screen.
- Updated the web passkey client to @simplewebauthn/browser 14.0.0, retaining the platform's existing Node 22 minimum and Laravel credential algorithms.
- 🔴 The mutation detector had three blind spots, all now closed and all now proved closed by a test that tries to fool it. An external review of the assessment identified them, and it was right on every one. Every sweep in this file reports "0 mutated", and that number is worth exactly as much as the detector behind it. A mutation was classified only when the response was 2xx, so a write that changed another community's record and then returned an error escaped the detector entirely. Now classified from the record comparison alone, with the status reported alongside rather than gating it. An exception skipped the after-snapshot altogether, so a request that changed something and then threw was filed as merely inconclusive. The record is now read again on the exception path too, in both the write and person sweeps. The person sweep compared row COUNTS, so changing somebody's role in a group — same row, same count — was invisible. It now fingerprints row contents (count plus a hash), and columns that cannot be read are counted and reported as blind spots rather than silently dropped (currently 0 of 302). The leak detector scanned the 300-character excerpt kept for the report rather than the full response body. It now scans the whole body. New: test_the_mutation_detector_sees_changes_it_is_supposed_to_see. The inverse of every other test here — it deliberately causes a field change, a deletion, a side row created elsewhere referencing the person, and a content change that leaves the row count identical, and fails if the detector misses any of them. A detector nobody has tried to fool is an assumption, not a control. After hardening, every sweep still reports 0 mutated. The stricter detector did not reveal anything the weaker one had missed — which is the reassuring outcome, and now it rests on something demonstrated rather than assumed.
- The pre-commit hook gained a third gate: the native push producer inventory. When app/**.php is staged it runs audit-native-push-producers.php --check in the app container — about a second — and blocks with the exact --write command if the inventory has gone stale. Added because that inventory pins NotificationDispatcher call sites by file and line, so any edit adding or removing lines above one makes it stale even when no producer changed, and that reds PHP Tests (shard 1), PHP Checks and Release Gate together. It happened twice on 2026-09-10, the second time to someone who had already written the trap down in a handoff. Skips with a notice when Docker or the container is unavailable, as gate B does for phpunit; CI remains the backstop. Verified by deliberately shifting a call site and confirming the commit was blocked.
- The mobile Create Course, Create Job, Create Opportunity and Podcast Studio forms are laid out like Create Listing. The owner named Create Listing as the form that looks right and the others as "really badly formatted": a single undivided column of up to twenty fields, a save button buried in the middle of a card, and no summary of what has been chosen. Each now opens with a hero card (module icon, eyebrow, title, one-line purpose, and summary tiles that echo the choices as they are made), groups its fields into titled sections with an icon (components/ui/FormSection), and keeps its primary action in the sticky footer that Create Listing and Create Event already had. Course titles that are missing are now also flagged under the field, not only in a toast that fades.

### Fixed

- Native wallet and message recovery. Transfers, donations and organisation deposits persist unresolved retry identities in encrypted storage across screen closure and app restart, scoped to account and community. Failed messages preserve the next draft and its attachments separately, with an unsent-draft switcher and navigation protection.
- Native message editing preserves unsent work. Opening an older message for editing retains the current draft and photos in the unsent-draft area. Edit switching and cancellation are unavailable while a send or edit save is pending.
- Native interaction and startup polish. Shared HeroUI buttons, tabs and interactive chips use 48dp targets; Messages has a compact header. Public configuration and preferences migrate from SecureStore to files, credential-write failures prevent false sign-in success, and browser-only Sentry replay is excluded from native bundles. iOS camera descriptions cover both QR journeys, and API/parity checks include MFA routes.
- Native audit follow-through. Authentication screens suppress internal server exceptions and display source attribution, achievement navigation wraps into readable rows, and decorative status badges disable their press responders. API and parity check mode no longer rewrites generated evidence files.
- Preserve pending two-factor enrollment when retrying setup, support the intended authenticator clock tolerance, and retain trusted-device choices through the accessible website. React now returns to sign-in after two-factor removal revokes the current session.
- 🔴 The mobile API-consumer ledger was stale, and a flaky test had been hiding it. mobile/ pins a fingerprint of routes/api.php and verifies that every endpoint the app calls still exists. Adding the partner-analytics rate limit changed that file, so npm run drift:check failed with "nothing was verified. This is NOT a pass." — the correct answer, and a good gate. It surfaced only after the jest flake below was fixed, because jest runs first in the same job and its failure stopped the job before drift:check ran. One red check can conceal another in the same job. Refreshed with npm run api:routes: 2,240 API paths, 514 of 514 consumed endpoints verified, and the only diff is two fingerprints and two review dates — no endpoint was added or removed, because the change added middleware rather than a route.
- A flaky mobile test that turned main red without any mobile code changing. PushPermissionCard.test.tsx — "does not send the member to system settings when registration merely failed" — failed once on a CI Android job (1 of 3,854 tests) on a commit that touched only routes/api.php, the changelog and a PHP test. It passes repeatedly on the developer machine. handleEnable() awaits two promises — the registration call, and the storage write recording that the member has now been asked — before it hides the card, so the default waitFor window is tight on a loaded runner. The component is deterministic (a failed result always hides the card), so the wait was widened to 5s rather than the behaviour changed, with the reasoning recorded at the assertion.
- The native push producer inventory is current again (239 calls, unchanged in content) — twice. It went stale a second time when the poll-vote fixes added lines to PollsController and SocialController, and was caught by CI rather than before the push, even though this exact trap is recorded in the working handoff. 🔴 Any edit above a NotificationDispatcher call in a tracked file reds PHP Tests (shard 1), and through it PHP Checks and Release Gate. Worth a pre-commit gate: the check is php mobile/scripts/audit-native-push-producers.php --check and takes about a second.
- First occurrence, same cause. The security fixes above added lines to ConnectionsController, StoryController and StoryService, which moved four NotificationDispatcher call sites down by a few lines. The committed inventory pins file-and-line ids, so NativePushProducerInventoryTest failed on CI shard 1 — no producer was added or removed, only the line references were stale. Regenerated with mobile/scripts/audit-native-push-producers.php --write. Worth knowing: any edit above a NotificationDispatcher call in a tracked file reds this gate.
- 🔴 The Android build on Google Play could never have received an over-the-air update. Found while trying to send one: the version code 8 bundle carried no update channel (Expo's cloud builds get it written in; our locally built Play bundle gets it only from app.json, which set none) and a runtime version of 1.2.0 inside an app labelled 1.4.0 (the build script only regenerated the native project when the folder was missing, so an August value was reused). Either fault alone means the update service serves that build nothing. app.json now bakes the production channel into every build, the build script regenerates the native project every time, and it opens the finished bundle and refuses it if the channel or the runtime version is wrong. Version code 9 is the first build that can take an update; the fix cannot reach phones still on 8 or earlier. Confirmed on a real phone the same day: build 9, installed from Play, showed the app's "Update ready" prompt for the first update ever published, which is the first over-the-air update this app has received.
- 🔴 Option pickers on eleven mobile screens were about 20dp tall and did not visibly show the chosen option. Create Job, Create Course, Podcast Studio, Create Event, Create Group, Create Marketplace Listing, Edit Listing, the support contact form and three marketplace filter bars all built their category / level / type / sort pickers on HeroUI Native's TagGroup size="sm", whose selected state is a pale tint — over which every one of them painted the label contrastText(primary), white on most communities, so the chosen option was white text on a pale wash. Measured on a device: below the WCAG 2.2 minimum target and less than half Android's guidance. Create Listing never had the fault because it used HeroUI Native Buttons. That idiom is now the shared components/ui/ChoiceChips (44dp minimum, full accent fill when chosen, label colour picked by the library for the community's accent, wraps to new rows, announces selected to screen readers), used everywhere, and components/choiceChipsMigration.test.ts keeps TagGroup out of the app so the small picker cannot come back one screen at a time.
- 🔴 The Goals "Add goal" drawer could not be scrolled and had no buttons. Reproduced on the emulator after the owner reported it: the drawer opened with the keyboard on the title field, the description and target fields sat underneath the keyboard, and Cancel / Create goal were not on screen at all — HeroCard.Footer has no row layout, so two flex-1 buttons in a column collapsed to zero height. There was no scroll container either. Three Podcast Studio drawers, the job application drawer and five marketplace drawers had a plain React Native ScrollView instead, which HeroUI Native's own documentation says the drawer intercepts, so dragging moved the drawer and never the content. The shared BottomSheet now takes scrollable (renders the gorhom scroll view the drawer does not swallow) and footer (actions pinned above the keyboard and home indicator; on Android the wrapper measures the keyboard itself, because neither the window nor the drawer moves for it under this app's root). Twenty-two drawers migrated. components/ui/sheetContentRules.test.ts fails on any React Native scroll view inside a drawer and on any drawer holding a text field that is not scrollable.
- 🔴 Form fields were hidden behind the keyboard on iPhone, on fifteen screens — including the wallet transfer amount and the marketplace offer amount, where the member could not see the figure they were entering before sending credits or money. Android is protected by the manifest's adjustResize; iOS has no equivalent and each scroll view must make room itself, which is why the gap survived every Android device test. app/keyboardAvoidance.test.ts now scans every screen with a text field.
- 🔴 Paged lists could show a row twice or lose one, and the warning that would have revealed it was switched off. usePaginatedApi appended pages without de-duplicating, so cursor pagination over a list the server re-orders handed React two children with the same key. Both of React's duplicate-key warnings were in LogBox.ignoreLogs, which is why it went unnoticed. The hook de-duplicates; the suppressions are gone, along with the nested-list one, which was checked rather than assumed to be needed.
- 🔴 Large system text clipped the tab bar and could squeeze a screen title's own Back button off the row. The app had no font-scale handling anywhere. Caps are applied only where the container cannot grow — 1.6 for single-line chrome, 1.3 for tab labels, whose bar height now grows with the same capped scale. Body text is deliberately left uncapped.
- 🔴 The iOS permission explanations described a narrower app than the one we ship, which Apple's guideline 5.1.1 checks against the running app: the camera text named only marketplace codes though it also scans event check-in codes; the photo-library text said "profile or post" though photos are picked for listings, events, groups and marketplace items, and videos are picked too; the location text named only the marketplace though the Listings tab uses it as well.
- A cold start showed three screens instead of one. expo-splash-screen was not installed, so the native splash hid as soon as React rendered — and the first thing React renders is a bare spinner. The splash is now held until the redirect has landed, with a five-second backstop and a release from the crash boundary so nothing can strand a member on it. The splash also gains a dark-mode background and stops letterboxing on other aspect ratios.
- "You're offline" now appears on every screen. The banner was mounted by hand on fourteen screens out of a hundred and seventy; on the rest a lost connection was indistinguishable from a server fault. It is mounted once in the shell and adds its own measured height to the safe-area inset, so it never covers a screen's own content.
- Every external link now opens through one checked helper. Linking.openURL was called from twenty-two files; six had no error handling at all, so a device with nothing able to open the link produced an unhandled rejection and, to the member, a button that did nothing. One opened an empty string when a parcel had no tracking link, and two opened a member-typed website with no scheme check.
- A picture that fails to load no longer leaves a blank hole. No image in the app had an onError, so a photo that 404s rendered as a blank rectangle the size of the picture. Seventeen server-image sites now use the component written for this.
- Harmless questions were being asked in red. The confirmation dialog defaulted to the danger variant, so enrolling on a course, confirming a purchase or a delivery, completing a group exchange, sending credits and even unblocking somebody all asked with a red button.
- Numbers now read the way the member's language writes them. Ratings and file sizes were built with toFixed, which always produces a full stop, so a French or German member saw "4.5" among numbers that otherwise read "4,5".
- A screen reader can jump by heading. The app had twenty heading roles in total and the screen title was not one of them anywhere; one line in the shared top bar covers 134 screens.
- The same screen no longer looks different depending on how you reached it. Groups and members are reachable at two routes that present them differently, and links were split between the two.
- A half-filled seller profile now asks before it is lost — four steps of business details with nothing between a stray Back gesture and losing all of it.
- 🔴 Android App Links have never verified, because assetlinks.json published the wrong certificate. The file listed F5:0D:87:55…, which is the upload key — the one used to sign what we send to Google. Play App Signing is enabled, so Google re-signs the app with its own key (79:38:E8:06…), and that is the fingerprint a phone checks. Every https://app.project-nexus.ie/… link therefore opened a browser or a chooser for everyone who installed from Play, while app.json's autoVerify: true half looked correct. Both fingerprints are now published — Google's app signing key first, the upload key retained so a locally signed release build verifies too. Confirmed against the Play Console's own ready-made snippet, which names the app signing key. verify-release-config.mjs already anticipated this in a comment and accepts multiple fingerprints; it passes.
- 🔴 Sign-up reported one mistake at a time and would not say which box it meant. RegistrationService::register ended its validation at $validator->errors()->first() — a single string with no field — and RegistrationController::register passed null as the field, so a member who got their email, phone and password wrong was told about one of the three, in a banner, across an eight-input form. Every failure is now returned, each naming the input it belongs to, through respondWithErrors(). The native app already places a message on whichever field the API names and simply had nothing to place. Backwards compatible by construction. The first entry keeps the code and message the single-error response carried, because both first-party frontends branch on errors[0].code to tell TERMS_REQUIRED and LOCATION_NOT_VERIFIED from a generic failure. Those two special cases no longer hide the other bad inputs, though — they lead, then the rest follow. The single-error refusals that belong to a visible input now name it too: duplicate account and the two email-domain checks → email, breached and mismatched passwords → password / password_confirmation, invite-code failures → invite_code. Raw latitude/longitude rows are deliberately dropped, being fields no member can see or correct. Prevention: two tests in RegistrationControllerTest assert every failed input is present and attributed, and that the special-cased code still leads. A control run confirms both fail against the old code — one because only a single error came back, the other because the terms branch hid the email failure. No new translation keys: the fallback reuses the existing api.validation_failed.
- Two volunteering lists could only ever serve their first page, so the app has no "Load more" on either. Certificates: VolunteerCertificateService::getUserCertificates() has always paginated and always returned a cursor, but VolunteerCertificateController::myCertificates() called it with no filters and so threw the caller's page request away. It now reads cursor and limit, accepting per_page as an alias because that is what the app already sends. The { items, cursor, has_more } shape is unchanged — both the app and CertificatesTab.tsx read data.items. Shift swaps: ShiftSwapService::getSwapRequests() returned everything it found behind a hard limit(50) with no cursor at all. It now takes limit (default 20, max 50) and a cursor. Ordering moved from created_at to id, both descending: a cursor on created_at cannot be stable, because two requests made in the same second tie and a tie makes rows repeat or vanish between pages. The endpoint answers through respondWithCollection(), so data stays a flat array — what VolunteerShiftSwapsResponse already expects — and the cursor rides in meta. 🔴 Found while doing it: filtering swaps by direction has never worked from the app. It sends direction=sent / received; the service only tested for incoming / outgoing, so both fell through to the catch-all branch and returned every swap in both directions. The service now accepts the app's vocabulary alongside the original, which fixes it without needing a new store build.
- 🔴 scripts/release.mjs corrupted react-frontend/package-lock.json when cutting v1.8.0, and turned main red. It bumped the version with a blind 1.7.0 → 1.8.0 string replace across every file carrying the version, and the lockfile carried that string in twenty-one places that had nothing to do with the platform version: esquery's own version (there is no esquery@1.8.0), the ^1.7.0 ranges for @emnapi/runtime and es-module-lexer, and seventeen Node engine ranges, where ^21.7.0 silently became ^21.8.0. Every job that runs npm ci then died with ETARGET No matching version found for esquery@1.8.0 — all eight React shards, React Build & Tests, Translation Drift Detection, the Release Gate, Security Scan, Lighthouse CI and the Platform-contracts React job. Four red workflows, one cause. Root cause: a blind string replace applied to dependency manifests. It had only ever worked by luck — no earlier platform version happened to collide with a dependency's, and 1.7.0 is a very common third-party version. The lockfile is repaired to the two root version keys only, so npm ci resolves again. Prevention: manifest versions are now set by key, never by text — scripts/lib/set-json-version.mjs writes .version and .packages[""].version and leaves every other byte alone, re-serialising with the file's own indentation (npm writes 2 spaces, composer 4) so a release diff stays reviewable. scripts/test/test-release-manifest-rewrite.mjs pins it in both directions, with decoys for each of the four collision shapes; it fails 4/4 against the old string replace. It is BLOCKING in CI (npm run test:release-manifest) and runs in local preflight.
- scripts/release.mjs could not finish a release, because it ran the semver gate one step too early. It wrote all thirty-six version files, then ran check-semver-policy.mjs before committing and tagging — and that gate asserts every release at or above its 1.7.0 enforcement floor has a vX.Y.Z tag. The tag cannot exist yet at that point, so the check failed by construction, the script exited without committing, and the release had to be completed by hand. It was latent until now: 1.7.0 was tagged before it became the floor, so 1.8.0 was the first cut to hit it. Root cause: a tag-dependent check placed in a pre-commit self-check block. check-version-consistency.mjs (tag-independent) still runs there and still blocks the commit; check-semver-policy.mjs now runs after the tag, and on failure prints the git tag -d / git reset --soft undo pair rather than leaving the state unexplained. Prevention: --no-tag and --no-commit now say in words that the semver gate did not run and what to run once the tag exists, instead of reporting checks as passed when one was skipped.

### Changed

- 🔴 CrossCommunityTokenReplayTest now exercises the REAL login credential path, because its previous claim about how authentication works was wrong. An external review of the assessment caught it and was right. Authenticate reads foreach ($request->bearerToken() === null ? $guards : [] as $guard), so the two tenant checks inside that loop run only when a request carries no bearer token — on the production login path, which always carries one, that branch never executes and the token-tenant check inside it is unreachable. The isolation on the real path is enforced by TenantContext::resolve(), which validates the JWT's signature and compares the community inside it with the community requested, exempting platform administrators. The test now mints a token exactly as login does (TokenService::generateToken($userId, $tenantId)), proves it works against its own community first, then replays it. 2,316 authenticated v2 routes, 2,316 refused, 0 reached, 0 inconclusive — every refusal attributed, not assumed: the mismatch handler is the only thing that sets the resolved community to 0, and the 13 routes whose URL pattern makes them raise rather than return carry the TENANT_MISMATCH code in the thrown body. Control: 6 of 25 sampled member endpoints served the token in its own community, reported as measured rather than rounded into an impression of universal success. 🔴 A second invented explanation is also corrected. The previous version said a genuinely issued token failed "because of middleware ordering under the test kernel". That was a guess written up as fact. The real reasons: user-login personal access tokens were deliberately retired (the middleware docblock says so, and refuses them so an old seven-day token cannot bypass JWT lifetime and revocation), and TenantContext::resolve() reads $_SERVER directly while the test harness clears it. Both verified in code. Platform-tier prefixes are excluded from the population and the document now says so: the total is of selected routes, not of every route.

### Security

- Two volunteering endpoints could echo raw database error text to the caller. Recording an offline donation (POST /v2/volunteering/donations) and submitting an expense (POST /v2/volunteering/expenses) mapped every RuntimeException to a 400 that repeated the exception message. Laravel's QueryException is a RuntimeException, and both services rethrow it when the request carries no idempotency key, so a database failure (reachable in practice only through a same-key race) would have answered with the full SQL statement, its bindings and the connection name. Both controllers now answer a generic 500 SERVER_ERROR and log the detail server-side. Regression test: tests/Laravel/Feature/Security/VolunteeringDatabaseErrorDisclosureTest.php. (E-005, F-016; commit a304ef718)
- A member could rebuild and download their personal-data export without limit by replaying an idempotency key. The 5-per-24-hours cap counted only new audit rows, and a replayed Idempotency-Key writes none, so every replay rebuilt the archive with no bound and the admin GDPR notice fired once. Every build now also counts against a per-member daily limit, replays included. Regression test in tests/Laravel/Feature/MemberDataExportTest.php. (E-005, F-017; commit 453aeaa27)
- The SVG logo sanitiser closes two bypass classes. A CSS escape (\75rl( reads as url( to the browser) slipped past the forbidden-token check in <style> bodies and style attributes, and a SMIL <set>/<animate> aimed at an event handler, href or style could re-create what the attribute scrub removed. Backslashes are now refused in CSS, and animation elements targeting those attributes are removed. Admin-only upload, served as an image, so defence in depth. Two new cases in tests/Laravel/Unit/Core/SvgUploaderTest.php. (E-005, F-018; commit 657810aa0)
- The last CSV writer goes through the formula-neutralising sanitiser. The administrator import-template download (AdminUsersController::importTemplate) was the one remaining bare fputcsv(), deferred on 2026-09-11 because the file was being edited; its content is constant, so this closes F-005 rather than fixing an exposure. The coverage test's pending allowlist entry is removed. (E-005; commit a3750df1f)
- A script file placed in the web-served uploads folder would have run as PHP; the web server now refuses it. Proven in the dev container: a .php written into httpdocs/uploads/ answered 200 and executed, while the same request under /storage/ answered 403, because the root .htaccess guarded ^storage/ only. Upload validation (which checks content) was the only layer. httpdocs/.htaccess now carries an <If> block denying .php/.php[0-9]/.phtml/.pht/.phar/.phps under /uploads/, written as <If> rather than a RewriteRule because uploads/.htaccess enables its own rewrite engine and per-directory rules are not inherited. It sits in the root file because production mounts a volume over httpdocs/uploads. Re-probed: every script variant 403, images and the API unaffected. Guard: tests/Laravel/Feature/Security/WebRootHardeningTest.php. Re-checked on the live API host on 2026-09-12 after release: GET /uploads/anything.php answers 403. (E-003, F-003; commit 61fa20fc3)
- Every CSV export now neutralises spreadsheet formulas. Nineteen exporters wrote member-supplied text with a bare fputcsv() and one only quoted cells, while the shared CsvExportSanitizer was used by twelve. New CsvExportSanitizer::put() is a drop-in for fputcsv(); all 32 exporting files use it. CsvExportSanitizerCoverageTest fails on any future bare fputcsv() in app/. The one file deferred at the time (AdminUsersController, off-limits during the audit) was converted on 2026-09-12 — see the E-005 entry above. (E-003, F-005; commit 259be739c)
- The accessible site resolved the community from a client-controllable X-Forwarded-Host. Apache merges a client-sent value in front of the real one; web-uk took the first. requestHost() and normalizeRequestHost() now take the last (proxy- written) value, falling back to Host. Test: web-uk/tests/tenant-routing-forwarded-host.test.js. Recommended at host level: RequestHeader unset X-Forwarded-Host in the Plesk vhosts. (E-003, F-006; commit 8733cb941)
- Turning two-factor authentication off now requires a current authenticator code as well as the account password (POST /v2/auth/2fa/disable takes code; 422 without it, 403 with field: code when refused). The code is checked with the same single-use 30-second step as login, so a code already spent on any other proof is refused, and it is only looked at after the password is accepted. The React settings dialog and the accessible site's two-factor page ask for both. Owner decision, 12 September 2026 (security register E-004 §7).
- Session validation no longer accepts the token from the URL query string; only the Authorization header or the request body is read, so tokens cannot end up in proxy, CDN or web-server logs (two-factor review F-010).
- Password-login answers that carry a two-factor challenge token, and the answers that complete login with credentials, are now sent Cache-Control: private, no-store (two-factor review F-014).
- Accessible site: the two-factor page (setup key, QR code, one-time recovery codes) is sent no-store; the remembered-device cookie is cleared with the same Secure attribute it was set with; a failed remembered-device revocation shows a translated message instead of an error page; the enrolment code is checked for six digits before it is forwarded; and /profile forms are rate-limited like every other form route (two-factor review F-015).
- Added 31 adversarial PHPUnit tests under tests/Laravel/Feature/Security/TwoFactor/ that pin the two-factor properties end to end (challenge boundaries, single-use authenticator steps, recovery-code lifecycle, remembered-device scope, disable and administrator-reset tiers, refresh and forged-claim and delegated-session enforcement, per-account limits, secret leakage), plus review tests for the React setup page, the native sign-in screen and the accessible two-factor page.
- Close MFA enforcement for operational-role accounts carrying platform-administrator authority, and rotate accessible-site sessions after password acceptance before storing MFA challenges. Recheck enrollment and administrator-reset authority under the user lock, using current revocation reads for security decisions.
- Scope restricted MFA setup and verification rate limits to the validated account, preventing unrelated members behind the accessible server or a shared network from exhausting each other's allowance. Replacement challenges share the same account bucket; invalid challenges remain IP-limited and the broad IP abuse ceiling remains enforced.
- BREAKING: Privileged SSO sign-in now requires a host-controlled SSO_PRIVILEGED_PROVIDERS JSON allowlist binding the exact tenant ID, provider key, issuer URL and client ID. Tenant-editable identity-provider settings cannot authorize administrator sign-in by themselves. Verified upstream authentication time is preserved rather than refreshed at callback, and provider trust is rechecked when pending MFA completes.
- Recheck passkey-removal authority and confirmation under the account lock; bind impersonation to its originating refresh session; prevent delegated message reads from changing read state and reject delegated data-export creation. Current database reads prevent stale transaction snapshots from accepting revoked authentication.
- BREAKING: Administrator impersonation now requires verified actor MFA and is read-only; revoking the actor's authentication invalidates delegated access. Administrator MFA resets require proof verified within five minutes, an identity-check reason and a durable audit record. Mandatory administrator MFA activates with this release; apply the accompanying TOTP migration before serving updated code.
- Harden two-factor enrollment and recovery with atomic session revocation, pending-setup invalidation and single-use authenticator timesteps and recovery codes. SSO requires verified upstream assurance or local MFA continuation. Upgrade React Router to 7.18.3 and validate login return paths to address unsafe navigation paths.
- Production served two of every security header, with two contradicting Permissions-Policy values. The production images now bake HSTS and nothing else. Dockerfile.prod and Dockerfile.bluegreen wrote an Apache conf with Header always set for Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, Referrer-Policy and Permissions-Policy; App\Http\Middleware\SecurityHeaders sets the last four as well, so every application response carried both. Measured live on 2026-09-10: camera=(), microphone=(), geolocation=(self), payment=(self) from the image alongside camera=(self), microphone=(self), geolocation=(self), fullscreen=(self), payment=(), usb=(), browsing-topics=() from the middleware. 🔴 The Header setifempty guard added to httpdocs/.htaccess on the same day could never have worked: always set writes to Apache's err_headers_out while setifempty reads headers_out, and the two tables cannot see each other. 🔴 A browser intersects duplicate Permissions-Policy headers, so the live pair denied camera, microphone, payment, usb and browsing-topics outright — payment=() breaks browser payment integrations. Each header now has exactly one source: the middleware for application responses, Header setifempty in httpdocs/.htaccess for files Apache serves itself, and the image for HSTS only (the container is reached over HTTP from the host proxy, so $request->secure() is false and the middleware does not emit it). Reproduced and fixed in the dev container across three states — no conf (one each), old conf (two each, matching production exactly), HSTS-only conf (one each). 🔴 The dev Dockerfile bakes no such conf, which is why the original fix was "verified on the local origin" and still shipped broken; header changes must be re-checked against the live service.
- scripts/check-duplicate-security-headers.mjs — a guard so that cannot come back. Fails if either production Dockerfile bakes an Apache directive for a header the middleware owns, if the middleware stops setting one of them (which would leave production with none), if httpdocs/.htaccess switches from setifempty to always set, or if the image stops setting HSTS. Runs in the always-on Dockerfile Drift Detection job so no path filter can let it sleep. Verified in both directions: passes on the fixed tree, fails when the old directive is reintroduced.
- Fifteen write endpoints that answered "success" for a record belonging to another community now answer 404. Each was an idempotent no-op — the scoped query touched nothing — so the sweep classed them as ACCEPTED_NO_CHANGE (Finding 6) or, when only a valid body reached them, as an accepted valid-body write (Finding 18), and pinned them in two shrink-only lists. Both lists are now empty. Every fix is the same shape: resolve the record (or the person) inside TenantContext::getId() before acting, and refuse with the endpoint's existing not-found key when it is absent. Endpoints: DELETE events/{id}/waitlist, POST events/{id}/attendance/bulk, DELETE feed/posts/{id}/share, DELETE goals/{id}/reminder, DELETE jobs/{id}/save, DELETE listings/{id}/save, PUT messages/{id}/read, DELETE stories/close-friends/{friendId}, POST stories/{id}/analytics, DELETE users/me/availability/{id}, DELETE users/me/sub-accounts/{id}, DELETE courses/{id}/enroll, DELETE members/{id}/endorse, PUT admin/volunteering/giving-days/{id}, DELETE admin/courses/instructors/{userId}. CrossCommunityAccessSweepTest re-run twice: write sweep 366 probed, 0 accepted-unchanged, 0 mutated; valid-body pass 0 accepted. No translation key was added — every message reuses a key already in lang/en/api.php or api_controllers_2.json.
- security-scan.yml: an unavailable Semgrep run no longer looks like a clean one. The step ended in || true and, when Semgrep failed to run (auth, network, install), wrote an EMPTY SARIF so the upload step never crashed — so a scan that never happened was indistinguishable from a scan that found nothing. The step now fails visibly (continue-on-error: true keeps the rest of the job running) and the upload runs only when Semgrep actually wrote a file (hashFiles('semgrep-results.sarif') != ''). Raised by external review of the Coventry assessment (audit item A8), which also noted the container scan runs on push only, not on the nightly schedule — now stated as such in the assessment.
- CrossCommunityAccessSweepTest: every accepted-but-unchanged write is now also sent with an identifier that exists nowhere (GHOST_ID = 2,000,000,000), and the two answers compared on status and on the body with digit runs normalised. Findings 6 and 18 in the assessment had called these fifteen endpoints "existence disclosures" without ever running that third case; measured, all 15 answer a nonexistent id identically — they are misleading success replies and disclose nothing about the record. Relabelled in the assessment (audit item A10). Recorded per endpoint as ghost in the evidence files and as a line in both write-pass summaries.
- Both poll-vote endpoints stopped answering 500 for a poll in another community (twenty-one endpoints fixed in total). POST /v2/polls/{id}/vote and POST /v2/feed/polls/{id}/vote. PollService::vote() is correctly scoped — it looks the poll up with WHERE id = ? AND tenant_id = ? and throws RuntimeException('Poll not found') — but both controllers catch only SafeguardingPolicyException, so the exception escaped as a server error instead of a not-found. Exactly the shape of the giving-days and event-RSVP fixes earlier the same day. Both controllers now resolve the poll through the tenant-scoped PollService::getById() first and answer 404. Found only by the valid-body pass: an empty body was rejected at validation before the vote was ever attempted. Five further endpoints answer success for another community's identifier and are visible only with a valid body — DELETE courses/{id}/enroll, POST events/{id}/attendance/bulk, DELETE members/{id}/endorse, POST stories/{id}/analytics, DELETE admin/courses/instructors/{userId}. Each was read before being pinned in KNOWN_VALID_BODY_ACCEPTED; all are inert. StoryService::trackAnalytics() is worth naming: it answers tracked: true but looks the story up scoped by tenant and returns before inserting, so no analytics row is created for another community's story. That check mattered — the one genuine cross-community side effect in this whole assessment was an endpoint of precisely this shape that did create a row (POST jobs/{id}/referral).
- The three paid regional-analytics partner endpoints are now rate-limited (throttle:nexus-route-30-per-1m); they had no limit at all. /api/partner-analytics/me/dashboard, /me/reports and /me/reports/{id}/download authenticate by resolving a per-subscription token inside the controller rather than through middleware, so no middleware ran on them: no authentication, no kill switch and no throttle. The rate limit was therefore the only thing between an unauthenticated caller and unlimited token guesses, and there wasn't one. Found by enumerating every route that does not run Authenticate — 129 of 2,503 — while checking which authentication paths bypass the cross-community token guard. Worth recording, because it is the reassuring half: the controller itself is soundly built. Tokens are stored hashed (SHA-256, with a plaintext fallback only for rows predating the hash column), read from the Authorization: Bearer header and nowhere else, and every query — including downloadReport — is scoped by report id and subscription_id and tenant_id, so there is no cross-subscription read. Every access is written to an access log. The route comment claimed the token could also arrive as ?token=. The controller has never accepted that, and must not: a credential in a query string is recorded in web-server access logs, browser history and Referer headers. Comment corrected to match the stricter code.
- Four more endpoints stopped acknowledging another community's CHILD RECORD (nineteen in total). All found by the new foreign-child sweep, all fixed the same day, and in every case the query was already community-scoped so no data crossed and none was disclosed — the responses were simply untrue, and a success for a foreign identifier confirms that identifier exists. DELETE courses/{courseId}/cohorts/{cohortId} — CourseCohortService::delete() already scopes the cohort to the guarded course and returns false when there is nothing of ours to delete; the controller discarded that and answered deleted: true. Now 404. DELETE courses/{courseId}/quizzes/{quizId}/questions/{questionId} — the delete is scoped to the quiz, which is scoped to the guarded course, but the row count was discarded and the answer was deleted: true regardless. Now 404 when nothing was removed. POST admin/legal-documents/{docId}/versions/{versionId}/notify — answered notified: true, count: 0 for another community's version. notifyUsersOfUpdate() is scoped and correctly notified nobody, but it returns 0 for several legitimate reasons too (not the current version, still a draft, acceptance not required), so the count cannot tell them apart. The controller now resolves the version in this community first, exactly as publishVersion() already did. GET admin/legal-documents/{docId}/versions/{versionId}/pending-count — same shape: count: 0 is indistinguishable from "nobody is pending". Same fix. All four reuse api.not_found / api.version_not_found, keys that already exist and are translated in all eleven locales, so no i18n work and no new gate debt.
- A fifteenth endpoint stopped acknowledging a PERSON from another community: DELETE jobs/{id}/team/{userId}. Found by the new multi-parameter foreign-person sweep. JobTeamService::removeMember() checks the vacancy is in this community and owned by the caller, and its delete is tenant-scoped — so nothing could ever cross — but it returned true regardless of how many rows it removed, so a member of another community was answered success: true for a delete that did nothing. A new tenant-scoped JobTeamService::isTeamMember() decides, and the controller answers 404 with api.not_found (an existing key, already translated in all eleven locales). The 422 for a non-owner is unchanged and is still returned first, so membership is not disclosed to anyone who cannot manage the team.
- Fourteen API endpoints stopped acknowledging records that belong to another community. All found by the two route-table sweeps on 2026-09-10 and fixed the same day; none disclosed member data, all confirmed a foreign id exists, and one created a side row against it. Read side (5). connections/status/{userId}, jobs/{id}/match, users/{id}/activity/dashboard and admin/users/{id}/verification-badges answered 200 with zeros/empty lists for a foreign id — indistinguishable from "no data", so a caller could confirm the id existed elsewhere. Each now checks the record is in the current tenant and answers 404. volunteering/giving-days/{id}/stats answered 500: the service throws for a foreign id and the controller expected null. Caught → 404. 🔴 POST jobs/{id}/referral minted a referral token for a vacancy in ANOTHER community — a real cross-community side effect, tenant_id 2 pointing at a tenant-999 vacancy. Now 404 unless getById() resolves the vacancy in this tenant. Write side, acknowledged but harmless (7 more, now 404). stories/{id}/view (service was scoped and returned void; controller said viewed: true regardless — service now returns bool), jobs/alerts/{id} delete/unsubscribe/resubscribe (scoped to the caller's own alerts; now report not-found when nothing was theirs), admin/jobs/{id}/unfeature (model-scoped zero-row update reported as success), admin/newsletters/{id} and admin/newsletters/templates/{id} (tenant-scoped DELETE reported 204 for zero rows). DELETE events/{id}/rsvp 500'd for a foreign event. EventRegistrationException extends RuntimeException and was caught by the generic clause first, which rethrew it. Catch order swapped. Ten further write endpoints still answer 2xx for a foreign id without touching anything (idempotent unsave / leave / mark-read / delete of a row the actor never had). Pinned in KNOWN_ACCEPTED_NO_CHANGE, shrink-only in both directions, and listed in the assessment as an open Low. StoryControllerTest::test_view_silently_handles_nonexistent_story asserted the old 200 and is now test_view_of_nonexistent_story_is_not_found.
- web-uk: a protocol-relative open redirect on custom accessible domains. On a community's own accessible hostname, /{slug}/accessible//evil.example redirected to Location: //evil.example, because the remainder after the prefix was used verbatim. Leading slashes are now collapsed (tenant-routing.js); tests/tenant-routing-protocol-relative-redirect.test.js pins it and confirms the /alpha/ redirect (which prepends the prefix and was never affected) still works. Found by CodeQL js/server-side-unvalidated-url-redirection; the other nine alerts under that rule are internally built urlFor() paths and were dismissed with that reason.
- web-uk: federation banner lookups now use Object.hasOwn. banners[status] resolved constructor / toString through Object.prototype to a function that is not a banner (CodeQL js/unvalidated-dynamic-method-call, two sites).
- Production API responses carried every security header twice, with two DIFFERENT Permissions-Policy values. httpdocs/.htaccess used Header always set, which adds to the error table alongside the middleware's headers on the success table. Now Header setifempty with values identical to SecurityHeaders middleware, so the middleware wins on PHP responses and Apache still covers static files; the obsolete X-XSS-Protection is no longer sent from .htaccess; X-Powered-By is unset. Verified on the local origin: one of each. 🔴 X-Powered-By: PleskLin on production comes from the HOST Apache and cannot be removed from this repository — recorded as a recommendation.
- Dependency advisories closed: morgan 1.11.0→1.12.0 (CVE-2026-15603), qs 6.15.3→6.16.0 via overrides (CVE-2026-82417/82562 — Express pins the range), sanitize-html 2.17.5→2.17.7 (CVE-2026-63670 textarea mXSS, CVE-2026-84371 SVG SMIL URI bypass), dompurify 3.4.11→3.4.15 (GHSA-55q2-fjhq-7xh7, GHSA-c2j3-45gr-mqc4), react-router / react-router-dom 6.30.4→6.30.6 (GHSA-jjmj-jmhj-qwj2, open redirect leading to XSS — the v6 line's own patch; the two advisories needing v7.18 stay open). The react-frontend lock was regenerated with npm 10.9.3 on the host and passes the node:20-alpine strict npm ci --dry-run; tsc --noEmit clean. Dependabot alerts and security updates were switched ON for the repository on 2026-09-10 (owner-authorised), and main gained branch protection — required check Release Gate, no force-push, no deletion, administrators not blocked so the commit-to-main workflow is unchanged; no required reviews. Dependabot immediately reported 52 alerts: 13 in shipped code, 39 in build/test tooling that is never deployed. Seven of the thirteen are closed by the upgrades above; image-size ×2 (mobile) dismissed as not_used — it is pulled in only by the Metro bundler at build time and no fixed version exists; react-router ×2 (v7-only fix) and decode-uri-component remain open and documented. The 39 tooling alerts are left for Dependabot's now-enabled security-update pull requests, each gated by Release Gate. 🔴 sanitize-html ≥2.17.6 requires htmlparser2 ^12, which is ES-module-only. Node 22 (the runtime) loads it; Jest's loader cannot, so 12 web-uk suites failed with "Cannot use import statement outside a module". web-uk/tests/shims/htmlparser2.cjs, wired via moduleNameMapper, hands that one package to the real Node loader through process.getBuiltinModule('node:module').createRequire() — NOT require('node:module'), which inside Jest is Jest's own module and maps htmlparser2 back to the shim (circular, {}). Verified: require('sanitize-html') succeeds in the node:22 web-uk container and the </textarea/> mXSS probe now sanitises to an empty string. Full web-uk suite: 148 suites, 3,049 tests. Left open, with reasons recorded on the alerts: react-router CVE-2026-53669 (fix is the v7 major); decode-uri-component CVE-2026-45822 under expo-router (fix is ESM-only and needs a store build); CVE-2026-53668 dismissed — its range is 7.9.6–7.12.0 and 6.30.4 is outside it; CVE-2026-53666 dismissed — needs SSR hydration the SPA does not do.
- GitHub code-scanning backlog triaged: 78 open alerts. 46 dismissed in-session with a written reason each (34 in web-uk/tests fixtures, 2 in a react-frontend test file, 4 in developer scripts, a vendor docs requirements.txt, a Stripe publishable key, 3 intentionally public nginx proxy locations). 19 more reviewed and prepared in .local-docs-archive/security-assessment/dismiss-reviewed-alerts.sh — the agent's permission classifier blocked the second batch, so a person runs it. 13 left open deliberately: the two genuine advisories above, and alerts on lines fixed today that CodeQL/Trivy will close on their next scan.

## 1.8.0 - 2026-09-09

### Added

- A job application sent from the phone can carry a CV. POST /v2/jobs/{id}/apply has always accepted one and the app never sent it, so every application made on a phone arrived with a covering message and nothing to read. The apply sheet now says plainly what will be sent, names a CV you have saved to your jobs profile — and says that it is not attached for you, because the server does not do that — and lets you attach a PDF or Word document from the phone. Files the API would refuse (wrong type, over 5 MB) are refused before you fill in the message rather than after.
- Fourteen more lists in the app now go past their first page. Each asked the server for twenty rows and stopped, with no button, no footer and nothing to say there was more — so the twenty-first member of a group, or a volunteer's twenty-first application, simply did not exist as far as the phone was concerned. Every one of these endpoints has always answered with a cursor saying where to continue; the screens read none of them. The podcast catalogue now continues as you scroll, like the course catalogue. Inside a group: members, discussions, announcements, files, Q&A, the media gallery and the task board. Only the marketplace tab paged before. Each of these ends in a "Load more" button rather than continuing on scroll, because the whole group screen is one scrolling page with a tab strip and there is no per-tab list to hang infinite scroll on. In volunteering: your applications, your shifts, the organisations you help run, expenses and donations. Opportunities already paged. 🔴 Two volunteering lists were deliberately left alone, and need a server change first. Certificates and shift swaps have no way to ask for a second page: the certificate service supports a cursor but its controller never passes one, and the swaps endpoint returns everything it finds. A "Load more" there would be a button that cannot work, so there isn't one. Prevention: each screen's tests assert the second page is requested, that its rows are added to the first rather than replacing them, and that the button disappears once the server says there is no more.
- Twenty more screens can be pulled down to reload, and a refresh that fails now says so instead of nothing. Seventy-five screens already had the gesture, which is why the gap was invisible from any one screen: nobody decided to leave it off course detail, the course player, all five ideation screens, job detail, the job pipeline, a help article, the marketplace seller tools, a donation receipt, a wallet transaction, a venue pass, the linked-accounts list or the community picker — the question was simply never asked when each was written. A member who came out of a tunnel and pulled down got no gesture, no spinner and no fresh data. A failed refresh was completely silent. When a screen already had rows, useApi kept the old ones and the failure went nowhere: the pull snapped back and stale figures sat there looking current. On a wallet, a job pipeline or an event roster that is misleading, not cosmetic. A small banner now sits above the content — "Couldn't refresh · You're still seeing what loaded earlier" — with Try again, in all seven languages. It never replaces content; a screen with nothing to show still gets the full error page it always had. Nine screens were doing the opposite and throwing content away. A help article, a donation receipt, four ideation screens, the job analytics page and a venue pass all rendered error || !data ? <error page>, so a refresh that failed deleted the thing the member was reading. They now keep it and show the banner. Seven screens blanked to a spinner mid-pull. refresh() sets loading back to true, so a guard written as isLoading ? rather than "loading and nothing yet" wiped the screen and scrolled the member back to the top every time they pulled. Fixed on course detail, the course player, a donation receipt, event management, a wallet transaction, gamification and a federation partner; the ideation, resources and reviews lists had the same fault below their tab bar. Prevention: mobile/app/pullToRefresh.test.ts fails when a screen that fetches data into a scrollable view has no pull gesture and no written reason, when an excuse names a screen that no longer needs one, and when a whole-screen loading guard would blank a page mid-pull. Six screens are listed as deliberate exclusions, all of them forms, where a pull would fetch the server's copy over half-typed input.
- A podcast episode keeps playing when the phone locks, and picks up where you left off. Locking the phone stopped a 45-minute episode dead: expo-av pauses every sound when the app goes to the background unless it is told otherwise, and it was never told. The app now asks for an audio session that survives backgrounding, and declares the iOS background audio mode in app.json — iOS silences background audio without both halves. The player remembers where you got to, on the device, and offers "Resume from 12:34" alongside "Start over". Nothing is sent to the server: the listen endpoint accepts a position for the community's own analytics but never hands one back, so a server-side resume would look like a feature and behave like nothing. A place is only kept once you are 15 seconds in, and is forgotten when the episode reaches its end, so finishing something does not leave it looking half-played. You can move around inside an episode: back 15 seconds, forward 30 seconds, and tapping a chapter jumps straight to it. Chapters used to be a plain list of times with no way to use them. A failed load now offers Try again instead of only saying it failed. 🔴 This needs a new store build; an over-the-air update cannot carry it. The iOS background mode lives in the app's native configuration, not in its JavaScript. 🔴 Android is improved but not finished, and the difference matters. The app no longer stops the episode itself, which is what was killing it before. There is still no media notification or foreground service, so Android may stop a long episode after the screen has been off for a while. Adding one means moving off expo-av (removed in Expo SDK 55), which is a separate piece of work. Four of the seven languages had a mistranslated skip label — Italian and Portuguese both said "forward 30 years" — which nobody had seen because the buttons did not exist yet. Corrected.
- Group admins can now work the join queue from the phone, and manage who is in the group. Until now an admin could see that people had asked to join their group — the waiting count is on the group overview — and could do absolutely nothing about it: requests sat there until somebody opened the website. The Members tab now lists everyone waiting, with a button to let them in and one to decline, and each existing member can be made an admin, have that taken away, or be removed from the group. All four of these have existed on the server the whole time with nothing in the app calling them. Declining and removing both ask first — the person declined is not told why and would have to ask again, and someone removed loses access to everything shared in the group. Neither action is offered against the group's owner or against yourself, because the server refuses both and an admin who demoted themselves would be locked out of the screen they were standing on. A group that is full answers with its own explanation rather than a generic failure, because "please try again" would be wrong for it.
- Members can endorse a skill and say thank you from the app — two features that existed on the server and could never be used from a phone. endorseSkill sat in mobile/lib/api/endorsements.ts with zero callers, the skill chips on a member’s profile were inert grey text, and there was no other route to it, so endorsements could only ever arrive from the website. It would not have worked if it had been called: it sent skill_id alone, and EndorsementService::endorse requires skill_name and refuses without it — the API test had been pinning that broken payload. Separately, the appreciations wall could be read and reacted to but never written to, though POST /v2/appreciations has been live all along. A "Say thanks" composer now sits on a member’s profile with a public or private choice, and both paths pass the server’s own refusal on (409 "you already endorsed this", and the appreciation rate limit). Found by the 2026-09-07 audit (F/F-3, F/F-4).
- Group discussions can be opened and answered from the app. The Discussion tab inside a group listed titles and reply counts and stopped there: the cards were not pressable, mobile/lib/api/groups.ts had no function for either endpoint that serves a thread (routes/api.php:665-666), and no screen consumed them. So a member could START a discussion and then never read a single answer to it, including their own. New screen mobile/app/(modals)/group-discussion.tsx shows the opening post, the replies and a composer; getGroupDiscussionThread and postGroupDiscussionMessage join the client. Stored HTML goes through toPlainText rather than being printed as markup, replies page backwards ("Show earlier replies", because the cursor walks from the newest), and 403/404 say what happened with a way back instead of a Retry that can never succeed. The seven-language strings for this screen already existed unused, so the screen appears to have been planned once and lost. Found by the 2026-09-07 audit.
- A volunteer can record the day they actually worked, and is told what happens next. The log-hours form hard-coded today with no field to change it, so somebody who volunteered yesterday evening and opened the app the next morning could not record it at all — the website has had an editable date all along. A successful log also said nothing: the fields cleared, a haptic fired, and nothing anywhere told the member that the organisation has to confirm the hours before they become time credits.
- The native app now refuses a screen whose module the community has switched off — every screen, not five. The React app wraps about 150 routes in a feature gate; until now the native app hid MENU entries and gated only the course builder, grading, analytics and the podcast studio, so a deep link, a push notification, a shared URL or a screen pushed from another screen opened Events, Groups, Marketplace, Wallet and the rest regardless of the community's configuration. mobile/lib/navigation/routeRequirements.ts is now the single table of screen → required feature/module; components/withRouteGate.tsx wraps every screen's default export and shows "Not available here" with a way back; app/(tabs)/_layout.tsx hides a tab whose module is off (Messages, Listings…) as the web does; and the deep-link/push store reads the same table first, so a tap can never be let through to a screen that then refuses. Unknown configuration (cold start, offline with no cached config, a test with no provider) allows — refusing then would turn a slow network into "every module is off". Home is deliberately ungated because it is the anchor every redirect lands on; it gates its own feed section instead, and Explore's listing and feed sections now respect their modules too. Prevention: app/routeGating.test.ts fails when a screen under app/(modals) or app/(tabs) has neither a requirement nor a written reason for having none, when a listed screen does not call withRouteGate with its own name, or when a requirement names a switch the platform does not have; lib/navigation/routeRequirements.test.ts proves the deep-link store and the screen gate agree for every gated route. Still a client-side courtesy, not authorisation: the API does not enforce courses or podcasts (recorded 2026-09-06, owner decision).
- Settings → App language. Seven locales ship and the device locale chose one at boot, but there was no way to pick a different one — the only call to changeLanguage in the app was a side effect of the translation-target setting, so choosing what other members' posts get translated INTO silently changed the interface language (audit 2026-09-07, B/F-16). The side effect is gone and a picker lists each language in its own name.
- The GDPR data export now actually reaches the member. The server answered the export request with the archive itself as a streamed attachment, and the app's JSON client turned a non-JSON body into null and reported success — so the member saw "Export requested", the history gained a row, and no file ever arrived on the phone (B/F-01). GET /api/v2/me/data-export now serves the same download (same handler, rate limit and audit row; expo-file-system can only stream a GET to disk) and the app hands the archive to the share sheet. Pinned by two new cases in MemberDataExportTest.
- Donations honour a client idempotency key. Transfers already replayed on a duplicate key; donations did not, so a donation whose request timed out and was tapped again donated twice (B/F-03). The app sends one key per intended donation and POST /api/v2/wallet/donate replays a duplicate for 24 hours; the key is bound to the donation's content so a reused key with a different amount is still a new donation. New WalletDonateIdempotencyTest (3 tests).
- The web header "+" now offers everything the native app's Create screen offers — it had four options against the app's fourteen. Owner's report, 2026-09-06. The root cause was two lists: Navbar.tsx declared its four options inline in JSX while QuickCreateMenu.tsx (the phone tab-bar sheet) held a separate seven-item array, and mobile/app/(modals)/quick-create.tsx a third of fourteen. Each was edited on its own for months, so a member who used both the site and the app saw a different platform depending on which one they opened. There is now one list — react-frontend/src/components/layout/createOptions.ts — and both web surfaces render it, so adding an option adds it to both. Ten options join the header: New Post, New Poll, New Message, New Marketplace Listing, New Group, New Challenge, New Goal, New Job Vacancy, New Volunteering Opportunity and Register an Organisation, alongside the listing, event, course and podcast it already had, plus Offer Time (Care in Community), which the native app deliberately does not carry. The dropdown is grouped under five headings — Share, Timebanking, Community, Opportunities, Learning — because fifteen flat rows is a wall; empty groups are dropped, and if a community has switched off everything a member could create, the "+" is not rendered at all rather than opening an empty menu. 🔴 Every gate matches the gate on the route it links to. Two were easy to get wrong and are pinned by tests: organisations/register requires both volunteering and organisations, while listings/create is a module gate rather than a feature gate. A looser gate would offer links that bounce to the dashboard or a "coming soon" page. New Message additionally requires direct_messaging on top of the messages module, because that page's own "New message" button is disabled without it. Three destinations had no builder route, so their index pages learned a flag rather than the menu dumping members on a list to hunt for a button: /feed?compose=post opens the compose hub on the post tab, /polls?create=1 expands the create-poll form (the shape the native app already used), and /messages?compose=1 opens the new-message dialog. Each flag is stripped from the URL once honoured, so closing the composer and reloading does not silently reopen it, and the messages one is ignored while messaging is unavailable rather than opening a dialog nobody can send from. Coverage: 8 new tests on the shared list (including one that fails if the web menu ever drops an option the native Create screen carries, and one asserting every href either is a builder or opens one), 2 more on Navbar (all fourteen present when a community has everything on; the "+" hidden when it has nothing), and 2 on PollsPage for the flag in both directions. tsc --noEmit and eslint src clean; the full check:i18n suite passes with the gap ratchet still at 0. 🔴 The new strings ship in all eleven locales written by hand, because Google Translate answered HTTP 429 to every request from this machine and is blocked for Irish regardless — the same condition recorded on the Native App entry below. 🔴 Not verified in a browser: the "+" is authenticated-only and no local sign-in was available, so the evidence here is component tests rendering the real HeroUI dropdown, not a screenshot.
- The native app no longer sends members to the website for anything it can do itself, and three tests now hold that line. Follow-up to the entry below, after the owner asked for the app to be finished and self-contained. The root cause of the first round was mobile/parity-map.json: course authoring, course grading, course analytics and the podcast studio were all recorded out-of-scope — "authoring workspace", which made a browser hand-off look like the correct answer. Sixteen React routes move from out-of-scope to native (153 → 169 native claims). Course grading and analytics are now native ((modals)/course-grading, (modals)/course-analytics), ported from CourseGradingPage/CourseAnalyticsPage and reached from the instructor dashboard. Field names were taken from CourseQuizService::pendingReviewForCourse and CourseController::analytics, not guessed; the grading queue deliberately never receives the answer key. The per-lesson chart is drawn with plain Views rather than adding a charting library. The support screen's nine items all open native screens fed by real server content. Linking, buildWebUrl and the "Opens on the website" pill are gone from it. 🔴 Its "Read in app" sheet had been showing a hand-written three-section summary of each policy — invented filler — with a link to the real document on the web; that is deleted, and the support.docs.* subtree with it. Help now reads GET /v2/help/faqs; about, contact and trust-and-safety read GET /v2/public-page-content/{pageKey} (the key is trust-safety, not the web path trust-and-safety); the four legal documents open the existing native legal-document screen. Community guidelines and acceptable use are added — both are valid legal_documents.document_type values that already rendered natively when a member was asked to accept them, so they were readable on demand and unreadable when merely wanted. The contact form posts /v2/contact and, because that endpoint is the one enforcing Cloudflare Turnstile, watches for that specific refusal and points the member at the community's own contact details rather than failing blankly. An event can be saved as a template from the app (POST /v2/events/{id}/template-preview then /templates), previewing before it commits and holding one idempotency key across a retry so a failure replays rather than duplicating. The empty state stopped telling members to do it "on the web". The button is gated on permissions.manage_agenda, which mirrors EventPolicy::manage() exactly — deliberately not permissions.edit, which also requires publication ≠ pending_review and would hide the button on an event where capture actually succeeds. Two screens had no door at all, found by auditing every modal for inbound navigation. exchange-requests — the member's own exchanges, and the only route to exchange-request-detail — could be reached by deep link alone, so a member could browse listings but never see the exchanges they were part of; it now has a button on the Exchanges tab. feed-hashtags, and through it the single-tag feed, was likewise unreachable, so a tag shown on a post led somewhere nobody could navigate to; it now has one in the feed header, matching TrendingHashtags on the web. Deep links to the new builders were landing on "not found". /courses/instructor/new was swallowed by the /courses/:id arm — instructor read as a course id — and /podcasts/studio by /podcasts/:slug. Both are matched first now, with tests asserting ordinary course and podcast links still resolve, because ordering is the whole fix. 🔴 components/FeatureGate.tsx — the native equivalent of React's <FeatureGate feature="…" redirect="/">, applied to the five new module screens. The app previously gated only its MENUS, and a hidden menu entry is not a gate: a deep link, a notification or a shared URL reaches the screen directly. It is a courtesy, not a boundary — see the Security note below. Removed two dead strings that could never be true in a native client: podcasts.player.unsupported ("Your browser does not support the audio player") and exchanges.groupExchanges.moreAvailable ("More group exchanges are available on the web view"), neither of which had a call site. The line is now held by tests, not by care. app/quickCreateRoutesAreNative.test.ts asserts every "+" Create option resolves to a screen file on disk and that the file contains no Linking or buildWebUrl at all — a render test cannot catch a typo'd route, because Expo Router renders an unmatched path as not-found rather than throwing. app/+native-intent.coverage.test.ts fails when a route has a native screen no deep link can reach; it caught three (faq, acceptable-use, community-guidelines) during this work that would otherwise have shipped unreachable. 🔴 Linking.openURL is still correct — and still present — for genuinely external destinations: a member's or organisation's own website, Stripe checkout/onboarding, Stripe Identity, an event's meeting link, attachments, the Play Store, the AGPL source repository, and +not-found.tsx's escape for a path this build has no screen for. Sharing a listing or event still shares a web URL on purpose, because the recipient may not have the app. Verified on the whole tree: tsc --noEmit clean, eslint . clean, 3,084 tests across 406 suites pass (up from 3,000), coverage ratchet OK at 77.86% lines, parity/API-ledger/theme-drift/store-audience/untranslated (ceiling 0, seven locales) all pass, release config verified, expo-doctor 18/18, startup bundle 15.21 MB against a 16.35 MB ceiling. npm run audit:touch-targets needs a running emulator and was not run.

### Security

- Updated two dependencies after twelve new high-severity advisories were published against them. Nothing in our own code changed and nothing was exploited — these are libraries we depend on, and their maintainers disclosed the problems and shipped fixes. @xmldom/xmldom (XML handling, reached through the app-store packaging tools) moves to 0.9.12, closing eleven advisories covering denial-of-service and several kinds of markup injection. js-yaml (configuration file reading) moves to 4.3.2, closing a denial-of-service flaw. Both are small patch updates with no behaviour change.
- The native app now refuses an absurdly long deep link before it reaches the router's query parser. From the 2026-09-06 audit's dependency triage. Of the fourteen advisories in the mobile production tree, thirteen are build-time tooling (Metro, the Expo CLI, image-size, @xmldom/xmldom under a config plugin) and are not in the shipped bundle. One is not: expo-router@6.0.24 → query-string@7.1.3 → decode-uri-component@0.2.2, which carries GHSA-vcc3-ghjq-m6fr — malformed percent-encoded input decodes in exponential time. It is genuinely attacker-reachable: app.json claims every https://app.project-nexus.ie/* URL with autoVerify and no pathPrefix, so any web page can hand a member an arbitrary link into redirectSystemPath. 🔴 It cannot be patched from here, and the obvious remedies are both wrong. npm's proposed fix is expo-router@5.1.11 — a downgrade from the installed 6.0.24. An overrides pin to the patched decode-uri-component@0.5.0 fails too: that release is ESM-only ("type": "module") and its consumer query-string@7 is CommonJS, so the override would break every deep link in the app rather than harden one. Verified against the registry rather than assumed. The durable fix is an Expo SDK / expo-router major, out of scope for a fix release and tracked separately. In the meantime redirectSystemPath caps an incoming path at 2,048 characters and returns the app root above it — far above the app's longest real link (a password-reset URL with a token, under 200 characters) and far below the length at which the decoder's cost becomes noticeable. Two tests cover both directions, because a bound that also rejects real links is a worse bug than the one it fixes.
- Courses and podcasts are not actually gated by their feature flags, despite both controllers saying they are. TenantFeatureConfig::FEATURE_DEFAULTS sets courses and podcasts to false, and CourseController and PodcastController each carry a doc comment stating the module is "gated by the per-tenant courses feature" — but no feature: middleware is applied to their route blocks (only the /v2/{...}/groups linking routes carry feature:groups) and neither controller checks TenantContext::hasFeature. The API therefore serves both modules to communities that never switched them on. Every query remains tenant-scoped, so this is a feature-gating bypass rather than a cross-tenant data leak, and both frontends hide the UI, so no ordinary member journey reaches it. Not fixed here, deliberately: the fix is ->middleware('feature:courses') and ->middleware('feature:podcasts') on those route blocks, which would immediately answer 403 for every community with the flag unset — a production authorisation change whose blast radius belongs to a decision, not to a mobile release. Recorded in mobile/docs/MOBILE_HANDOFF.md.
- Courses and podcasts can now be created inside the native app. The "+" menu opened the WEBSITE for both, which the owner reported on 2026-09-06 as confusing. The entry two below this one added them to mobile/app/(modals)/quick-create.tsx as Linking.openURL hand-offs, because mobile/parity-map.json recorded courses/instructor, courses/instructor/new, courses/instructor/:id/edit and podcasts/studio as out-of-scope — "authoring workspace". That declaration is the root cause: it made a browser hand-off look like the correct answer, and it removed the pressure to build the real thing. It is reversed here, and all four routes are now recorded native. New native screens, ported feature-for-feature from React: (modals)/course-instructor (authored-course list, status chip, enrolment/completion counts, publish/unpublish, pull-to-refresh) from InstructorDashboardPage; (modals)/new-course (create, then edit in place with the curriculum builder unlocked, publish control, cohort list when enrollment_type === 'cohort') from CreateCoursePage; components/courses/CourseBuilder (sections and lessons — add / rename / delete / reorder, per-lesson content type with the matching field, transcripts, drip settings, free-preview, quiz auto-creation and MCQ authoring, optimistic update with rollback) from the component of the same name; and (modals)/podcast-studio (create show, add episode, chapters, transcripts, readiness checklist, stats panel, per-show and per-episode publish / archive / delete, edit sheets, RSS feed check) from PodcastStudioPage. API clients extended, not invented. mobile/lib/api/courses.ts gains 16 functions and mobile/lib/api/podcasts.ts the studio half; every endpoint was taken from routes/api.php and every field name from the PHP services and controllers. npm run api:check verifies all 494 mobile endpoints against Laravel's route inventory — 0 missing. getAuthoredPodcasts() returns the meta alongside the shows, because show-creation gating, private shows, transcripts, chapters and the audio size ceiling arrive only there. 🔴 Audio upload needed a new NATIVE dependency, so this release cannot reach members over the air. expo-document-picker (~14.0.8) is added so an episode's audio can come off the phone as well as from a URL — the natural mobile action, and the API's audio multipart part was already waiting for it. Being native code, it needs a new Play build; owner-approved on 2026-09-06 in preference to shipping URL-only. lib/media/pickAudioFile.ts applies the tenant's own ceiling and MIME allow-list before the upload starts, and deliberately lets an unknown or application/octet-stream type through, because Android content providers routinely report one for an ordinary .m4a the server would accept. 🔴 New lib/api/uploadWithProgress.ts, because api.upload() physically cannot do this. The shared client is fetch-based, and React Native's fetch emits no upload-progress events and exposes no caller abort — tolerable for a 2 MB photo, not for an episode at the 250 MB default ceiling, where a member would face a spinner with no number and no way out but force-quitting. This uses XMLHttpRequest directly for real progress and a working cancel. Auth and tenant headers are resolved only from trusted storage and Content-Type is left unset so React Native writes its own multipart boundary — the same rules client.ts enforces, and a test asserts each. A cancelled upload rejects with code UPLOAD_ABORTED so the studio can keep the form and the chosen file for a retry instead of reporting a failure. createPodcastEpisodeWithAudio() deliberately omits audio_url when sending a file: the API prefers a present URL, so sending both would host the upload and then ignore it. Coverage. 3,000 mobile tests across 400 suites pass, up from 2,978; tsc --noEmit and eslint . clean; the coverage ratchet, parity, API-ledger, theme-drift, store-audience and untranslated (ceiling 0) gates all pass. Seven new translation keys across all seven mobile locales, hand-translated — three for the course form's unsaved-changes guard and four the web namespace never needed (a datetime format hint, and a discard prompt for a Back gesture a browser does not have). The quickCreate.opensOnWebsite string and the whole opensOnWebsite hand-off mechanism are removed rather than left unused, and a regression test presses every option on the menu and asserts none of them reaches Linking.openURL. Deliberately still web-only: the instructor grading and analytics pages (courses/instructor/:id/grading, :id/analytics), which are review tools rather than authoring, remain out-of-scope in parity-map.json. The native instructor dashboard therefore offers no buttons to them, rather than offering buttons that lead nowhere.
- The admin Native App page shows who has the mobile app again — the numbers half of the legacy panel was dropped in the React rewrite and never rebuilt. views/modern/admin/native-app.php (deleted with the rest of the legacy admin in 745673e0a) carried a device count, a distinct-member count, a web-push subscription count and a recent-registration list naming each member. Its React replacement, admin/modules/system/NativeApp.tsx, came across as a settings form only, so for months there was no screen anywhere reporting whether anybody had installed the app. New endpoint GET /v2/admin/config/native-app/install-stats (AdminConfigController::getNativeAppInstallStats, backed by App\Services\NativeAppInstallStatsService) and a new NativeAppInstallStats component restore all four, plus a per-platform Android/iOS split and a combined "reachable by push" figure computed as a UNION rather than the sum of the two channel counts, so a member with both a phone and a browser subscription is counted once. 🔴 Two authorisation tiers, and the boundary is the point. Any tenant admin gets their own tenant, scoped by tenant_id; the cross-tenant platform block — every community's totals, a per-community breakdown and a recent list naming members of communities the caller does not administer — is returned only when users.is_god is set, and is null otherwise. is_super_admin and is_tenant_super_admin alone deliberately do not unlock it: a hub-tenant super admin is confined to its own subtree by SuperPanelAccess and this block is not subtree-filtered, so the narrower flag is the correct gate. Email appears in the own-tenant list, where an admin already has their members' contact details, and is deliberately omitted from the cross-tenant one. 🔴 The response carries disclaimer_key: push_registrations_not_store_installs and the UI states it on its face, because these are not Google Play or App Store install counts: a row exists only once a member has installed the app, signed in and granted notification permission, so anyone who declines the prompt is invisible, and store install figures exist only in Play Console. Queries deliberately do not swallow exceptions — a missing column must surface rather than return a plausible zero. 13 PHPUnit tests cover the tiers (including a broker and a tenant-super-admin failing closed, and a second tenant's devices being invisible to a tenant admin) and 8 Vitest tests cover the component, including that a failed load reports an error rather than rendering zeroes. Strings ship in all eleven locales, written by hand because Google Translate returned HTTP 429 for this machine and is blocked for Irish regardless.
- Prerender health now names the platform-wide rebuild that is holding back drift detection. PrerenderService::health() gains an authoritative_block check, so the admin health banner and the nexus_prerender_health_status Prometheus gauge report why snapshot freshness has stopped rather than leaving an operator to infer it from queue age. Green when nothing is blocking, green with the job id and age while a rebuild is legitimately in flight, red once the block outlives prerender.authoritative_block_alert_seconds — with an action string pointing at the host processor log and the job id to cancel or finish. See the corresponding entry under Fixed for the 28-day production incident this exists to make visible.
- The native app's "+" create menu now offers every module a member can create in — jobs, volunteering opportunities, organisations, courses and podcasts were all missing. Owner's report on hour-timebank, where courses and podcasts were both on: the menu showed neither, so both features looked as if they did not exist. Auditing mobile/app/(modals)/quick-create.tsx against every module found three native builders that already existed but were never listed there (new-job, new-volunteering, new-organisation) and two modules the app can only view — courses and podcasts have no native builder at all. The three are added as ordinary options. The two hand off to the website's builders (/courses/instructor/new, /podcasts/studio) inside the member's own community, and say so on their face — an "Opens on the website" line, an external-link icon instead of a chevron, and a matching accessibility label — so a tap that leaves the app is never a surprise. All five are feature-gated (job_vacancies, volunteering, organisations, courses, podcasts). Two builders are deliberately still absent: group exchanges (the builder needs a group, so it lives inside one) and Care in Community (outside native scope by store-audience policy; check:store-audience still passes). Strings ship in all seven mobile locales, written by hand. Eleven regression tests: each option opens its target, each disappears when its feature is off, and the two hand-offs call Linking.openURL with the slug-prefixed URL and never router.push.
- The "+" create menu now offers Course and Podcast, which is why both modules looked unavailable on a community that already had them switched on. Reported by the owner on hour-timebank, where courses and podcasts were both true. The Courses and Podcasts pages have always carried working "+ Create course" / "+ Create show" buttons — verified in a browser — but the "+" in the header, which is where a member goes to start something, listed only New Listing and New Event. The only route to either builder was the index pages, and those sit last in the right-hand "EXPLORE" column of the "Community" dropdown, twelve items over two columns, under a heading that reads as members and events. So a member looking for "make a course" found a create menu without one and reasonably concluded the feature did not exist. Added to both menus — the desktop header dropdown in Navbar.tsx and the data-driven QuickCreateMenu behind the mobile tab bar — pointing at the builders (/courses/instructor/new, /podcasts/studio), not the index pages, since starting something is the whole point of that menu. 🔴 Both entries are feature-gated: courses and podcasts default to false platform-wide and are off or unset on 9 of 13 communities, and their routes carry FeatureGate … redirect="/", so an ungated entry would have offered most members a builder that bounces them to the home page. Four regression tests cover the gating in both directions. The Course entry first shipped with a raw purple-600 gradient stop, which the design-token gate (react-frontend/scripts/check-design-tokens.mjs, run in prebuild) forbids because brand hues must come from tokens; that failed Lighthouse CI and the React build, and it now uses fuchsia-600 like its neighbours. The strings ship in all eleven locales; the two short labels and the Irish were written by hand, because the machine translator skips short title-cased strings as possible product names and is blocked from Irish entirely. 🔴 Both modules are still labelled Beta, and this promotes them to every member of a community that has them on — a deliberate choice, not a side effect.
- "Get the app" now offers the Android app members can actually download, with a QR code, instead of telling them to wait. The page (/install-app) gained a section above the browser-install prompt carrying a Google Play link, a scannable QR code, an explicit early-release warning, an invitation to be among the first users and send feedback, and a line on where iPhone and iPad stand. It sits above the PWA prompt deliberately: for anyone on Android the store app is the better answer, and burying it inside the "kinds of app" grid further down would have left the weaker option on top. The QR code reuses the existing QrCodeImage component rather than a new dependency — it lazy-loads the generator into its own chunk (so the public route's bundle budget is untouched) and degrades to a plain link if that chunk fails, so the section cannot become a dead end on a stale deploy. It is wrapped in an explicit white panel so it stays scannable in dark mode, verified in both themes. The store URL and package id are now constants in react-frontend/src/config/externalLinks.ts; 🔴 there is one Android app for every community on the platform, not one per tenant, so the URL is deliberately not derived from tenant branding, and the copy tells a member to pick their community when the app first opens — which is what 1.3.0's new first-install picker does. The QR was verified to encode the right URL by comparing its decoded 33×33 module matrix against one generated independently (identical checksum), not by comparing PNG bytes: the browser builds the image through Canvas and Node through its own encoder, so a byte comparison across the two reports a mismatch for identical images and is worthless as a check. All 16 strings ship in all eleven locales; the Irish was written by hand because scripts/check-irish-translation-safety.mjs blocks the Google path for Irish and no OpenAI key is configured on this machine.
- The "Get the app" page said "The two kinds of app" above three cards. Browser, home-screen and the real store app are all described there, so the count was wrong in every one of the eleven locales — each had translated the number into its own words ("beiden", "dos", "2種類", "an dá chineál"), so a fix to the English alone would have left ten languages still saying two. All eleven were updated together and react-frontend/src/resources.d.ts regenerated.
- @xmldom/xmldom is patched, and the mobile production-audit gate is now tied to the mitigation it excuses. From the 2026-09-06 native audit, F04. Two copies of @xmldom/xmldom sat in the mobile production tree carrying GHSA-6gmq-8vp8-gcm6 — @expo/plist@0.4.9 → 0.8.13 and plist@3.1.1 → 0.9.10. Both parents already allowed the patched releases (^0.8.8 and ^0.9.10), so npm update @xmldom/xmldom moved them to 0.8.15 and 0.9.12 with no version-range change and no breaking upgrade; the mobile production advisory count falls from fourteen to thirteen and from six moderate to five. Both are build-time plist writers with no app-runtime reachability, but a patch that costs nothing is not worth an exception. 🔴 The remaining decode-uri-component advisory is now listed in scripts/check-production-audit.mjs rather than failing the gate — and, because it is the one advisory that genuinely ships and is deep-link reachable, the gate also asserts that its mitigation still exists: if MAX_DEEP_LINK_LENGTH and its guard leave mobile/app/+native-intent.ts, audit:production fails rather than continuing to wave the advisory through. Re-review both exceptions by 2026-09-30. Verified against the registry, not assumed: there is no CJS release of the fix, no query-string@7.1.4, and every @react-navigation/core 7.x up to 7.21.13 still depends on query-string ^7.1.3.

### Changed

- The community strip at the top of the feed is now the logo alone, at twice the size. Owner instruction, 2026-09-07. It showed the logo, then the community's name, then its tagline beside it — three ways of saying who you are, in a band across the top of the screen. Only the brand mark stays and it takes the room the text was using: 30dp tall becomes 60dp, and the width cap rises from 120 to 260 so a wide wordmark can use it. The name and tagline remain on the More screen, the community picker and the sign-in screen, and the logo carries the community's name as its accessibility label so a screen reader still identifies the community now that nothing on the strip says it in words.
- The native Android app moves to 1.4.0, version code 7, carrying the fifteen mobile changes committed since 1.3.0 went to Play. 1.3.0 / code 6 reached production on 2026-09-06 and is a full rollout in all 177 countries; the work since then is one new capability and the results of two device audits, so this is a MINOR bump. The capability is the "+" create menu offering every module a member can create in. The fixes include a marketplace price typed with a comma being sent as a hundred times its value, a paid course enrolling on a single tap with no confirmation, long conversations having no way back to older messages, bottom action bars sitting under Android's three-button navigation bar, text clipping at the larger Android font sizes, and several failure paths that told a member something did not exist when the request had merely failed. 🔴 The version lives in seven places, not the five previously recorded: mobile/package.json, mobile/app.json (both expo.version and expo.android.versionCode, which the earlier note omitted), mobile/store-listing/apple/en-GB.json, config/mobile.php, and the gitignored mobile/android/app/build.gradle (versionName plus the playVersionCode default). The seventh is mobile/package-lock.json, whose own root version had been left at 1.2.0 through the entire 1.3.0 release because no gate reads it; it is corrected here. config/mobile.php's minimum_version is deliberately left at 1.2.0 — it is the force-update lever and raising it would brick every install that has not yet taken an update. Gates run on the bumped tree: verify:release, expo doctor 18/18, type-check, eslint . clean, the network-security and certificate-pin checks, the 463-endpoint API ledger, the startup budget (15.04 MB against 16.35), untranslated zero in all six locales, the Play asset validator, and MobileVersionGateRegistrationTest — the PHP test that is the only guard on config/mobile.php and which CI skips on a mobile-only change. 🔴 audit:production still fails, unchanged from the 1.3.0 build and deliberately not re-baselined: fourteen advisories, all but one chain in build-time tooling (Metro, the Expo CLI, image-size, @xmldom/xmldom), the exception being expo-router's decode-uri-component and query-string, which do ship in the app and whose only fix is a semver-major expo-router upgrade — out of scope for this release and tracked separately.
- The native Android app moves to 1.3.0, version code 6 — the first new Play artefact since the app went public. The live production release is 1.2.0, version code 5, from 2026-08-26, and it predates the neutral first-install community picker: a clean install of the public build lands in a pre-selected community rather than asking. 1.3.0 carries that correction plus the genuine tablet captures, the Play asset validator and the 720dp tablet width caps. In the repository the version lives in two tracked files, mobile/package.json and mobile/app.json, and verify:release asserts they agree. 🔴 It also lives in mobile/android/app/build.gradle, which is gitignored Expo prebuild output and therefore machine-local: that file hardcodes versionName as a literal and derives its version code from a -PplayVersionCode Gradle property whose default was 2 — five below the live release — so a local bundle built on an existing android/ directory without that flag produces an artefact Play rejects as a downgrade, and no committed gate can see it. Both were corrected locally and the flag is now passed explicitly as well, but a fresh checkout regenerates that directory from app.json, so the durable fix is to keep the two tracked files right and pass the version code on the command line. The artefact was verified before upload rather than assumed: versionCode 6 / versionName 1.3.0 / package ie.project.nexus read back out of the merged release manifest, the signing certificate's SHA-256 confirmed as F5:0D:87:55… (the same EAS-managed upload key as version code 5, so Play accepts it as an update), all four ABIs present with 29 native libraries each, and SYSTEM_ALERT_WINDOW absent from the manifest that actually ships.
- The Courses and Podcasts modules are now labelled "Beta" to members instead of "Alpha" (Courses) or nothing at all (Podcasts). Both modules work end to end, but neither has been signed off as finished and tested, and the platform as a whole is generally available at 1.7.0 — so a member landing on either page needs to know it is held to a different standard from the rest of the site. "Beta" is the ordinary word for that and needs no explaining. The shared AlphaBadge component could only ever say "Alpha"; it is replaced by ModuleStageBadge, which takes the stage as a prop (amber for alpha, accent for beta, matching the admin module-config chip). The admin module registry is updated in the same commit so the three places that state a maturity level agree: Courses moves from alpha to beta, and Podcasts — promoted out of alpha on 2026-07-03 to no label at all, which reads as generally available — is now explicitly beta. The chip text reuses the Beta wording already translated for the admin stage chip in all eleven locales, so no new untranslated strings ship.

### Internal

- The emulator jobs now build the JavaScript bundle before the app asks for it, and give Maestro time to install its iOS driver. Two environment faults, both diagnosed from the failing run's own artefacts rather than guessed at. Maestro drives iOS through an XCUITest runner it installs onto the simulator on first use, which on a cold CI simulator exceeds its default startup timeout (IOSDriverTimeoutException); MAESTRO_DRIVER_STARTUP_TIMEOUT is now five minutes. Then, with the driver working, the app installed and launched and showed "Could not connect to development server" — which reads as a network fault and is nothing of the kind. 🔴 metro.log from the artefact showed Metro reachable and mid-build at 25.0% (281/664) when the flow's 90-second wait expired: Metro serves on demand, this app is ~660 modules, and the first request after a cold start takes minutes on a CI runner. Both jobs now warm the bundle with an explicit request before the device launches the app, using the exact URL the app itself issues — read off the redbox in the failure screenshot — so it lands on the same transform-cache entry instead of a neighbouring one. 🔴 This also removes a latent flake from the Android job, which passed only because its AVD boot happened to give Metro enough of a head start. 🔴 The previous commit in this series shipped without a changelog entry and turned main red — Documentation, Version, and Changelog Hygiene is blocking and CI counts as release-relevant, exactly as AGENTS.md says. Recorded rather than quietly fixed, because the local preflight.mjs changelog guard did not flag it and the miss was mine either way.
- Xcode 26 cannot compile this app, and the reason is Expo SDK 54's own Stripe pin — recorded because it will bite at the next SDK upgrade. Found while standing up the iOS launch smoke. @stripe/stripe-react-native@0.50.3 contradicts itself: its Swift-generated header forward-declares STPPaymentStatus as NSInteger while its hand-written StripeSwiftInterop.h declares the same type as NSUInteger. Earlier compilers tolerated the conflicting redeclaration; Xcode 26 treats it as an error and the build fails inside node_modules. GitHub's macos-latest image now carries twelve Xcodes, every one of them 26.x — measured, not assumed — so there is nothing to select within it and the iOS job is pinned at the runner label (macos-15) instead. 🔴 0.50.3 is NOT a stale dependency, and it must not be "fixed" on its own. npx expo install --check reports dependencies up to date: it is the version Expo SDK 54 specifies. Upgrading it alone (0.76.0 is upstream) would move the app off Expo's supported matrix — on the library that handles payments — for no benefit. The Xcode-26 pairing belongs to an Expo SDK upgrade, and when that happens Stripe must move with it. Dropping the iOS job back to macos-latest and deleting its Xcode-selector step is the signal that the two have realigned. 🔴 This is not a release blocker, and an earlier reading of it as one was wrong. EAS is what builds what gets submitted to Apple, and it built this app for iOS successfully on 2026-08-27 (profile ios-simulator, FINISHED, ten minutes). The mismatch is GitHub's runner image having moved ahead of Expo 54's toolchain, not a defect in the app. What blocks an iOS release remains what it was: the two owner-held Apple identifiers, and a signed production build — every iOS build so far has been an unsigned simulator one.
- A nightly emulator job now answers the one question the mobile Jest suite structurally cannot: does the app start? The 413-suite mobile run mocks the HTTP client, the native modules, and heroui-native — and that last mock drops testID/accessibilityLabel outright — so a broken native module, a construct Hermes will not run, a bad root-layout change or a missing polyfill passes every gate in CI and then crashes on launch. .github/workflows/mobile-emulator.yml builds a debug APK, boots an API-34 AVD, serves the bundle from Metro and runs the new mobile/.maestro/00-launch-smoke.yaml, which asserts the app reaches one of its two legitimate first screens. 🔴 Deliberately scoped to need no backend: both headings render before any network call resolves, so the flow stops short of tapping a tenant. That is what keeps it reliable enough to run unattended. 🔴 Schedule and manual dispatch only — never on push, and this is the design, not a limitation. An emulator is the slowest and by far the flakiest tier of testing available (AVD boot, Metro attach, Gradle, platform ANR sheets), and most of its failures are not the change under test. A main that goes red for reasons nobody caused is how a team learns to ignore CI, which this repository has already paid for once; nightly detection of a launch crash is worth more than a red tick on a push that a developer will simply re-run. It also lives in its own workflow because scripts/predeploy-ci-verify.mjs reads ci.yml and platform-contracts.yml only, so nothing here can trip the unknown-job rule or hold a deploy hostage while the job earns trust. Promoting it into ci.yml later means adding it to REQUIRED_JOBS in the same commit. 🔴 What a green tick here does not mean. It does not run the 01-13 journey flows — those need a live API and an account, and compose.ci.yml publishes no port for the app service, so the emulator cannot reach it without wiring that does not exist yet. It checks nothing visual: emulator screenshots are not stable enough across API levels and renderers to gate on, which is why mobile/scripts/screenshots.mjs stays a local tool. It does not run TalkBack and it never touches iOS. The 2026-09-06 audit's visual, accessibility and signed-binary gap (F10) is unchanged by this. 🔴 Unverified as shipped. A GitHub Actions emulator job cannot be executed locally, and the development server this repository needs to serve the bundle could not be started in the session that wrote it, so the YAML has been parsed but never run. Run it once via workflow_dispatch before trusting a green result from it; failure diagnostics (Metro log, logcat, the Maestro report and its screenshot) upload as an artefact.
- iOS is covered by the same nightly job, and it is the platform that needed it most. ios-launch-smoke runs on a macOS runner: expo prebuild, CocoaPods, an unsigned xcodebuild -sdk iphonesimulator build, a simulator chosen from whatever the runner image actually has, then the same launch-smoke flow. 🔴 Two of this release's fixes are specifically about iOS behaviour and had never been run on it. useUnsavedChangesGuard is built on usePreventRemove precisely because a beforeRemove listener does not work on a native stack, and the case that breaks is the iOS swipe-back gesture; the bottom-inset work is the same shape, since the home indicator is an iOS concern. Android passing said nothing about either. 🔴 The enabler is that a simulator build needs no signing key — CODE_SIGNING_ALLOWED=NO — which is why this can exist while verify:ios-release is still blocked on owner-held Apple identifiers, and why it costs queue time rather than money: macOS standard runners are free on public repositories. It is slower and more fragile than the Android job, because CocoaPods and xcodebuild are the least forgiving part of this toolchain. Both the scheme name and the simulator UDID are discovered at run time rather than hardcoded, so an app rename or a runner-image bump cannot break the job for a reason that has nothing to do with the app.
- A frontend coverage check that could never pass has been removed rather than repaired, because it was protecting nothing. Vitest Coverage Report (WARNING) re-ran the same 14 suites from scripts/ci-smoke-suites.txt that the blocking smoke step had just run, and measured them against the whole-codebase thresholds in react-frontend/vitest.config.ts (statements/lines 55, branches/functions 50). Fourteen suites cover 4.47% of src/, so the step failed on every run since it was written; continue-on-error: true swallowed the failure, and the Post Coverage Report as PR Comment step it fed said in its own text that it carried no coverage data. Verified rather than assumed: the step printed the identical Coverage for lines (4.47%) does not meet global threshold (55%) on run 34031712224 (2296b54ff), which concluded success. Both steps are deleted, saving a duplicate ~60s vitest run per pipeline. The thresholds are removed from vitest.config.ts too — they were only ever evaluated by the deleted step, and leaving them would move the same trap onto any developer running npm run test:coverage over a subset; provider, reporters and include/exclude stay, so local coverage reports still work. 55/50/50/55 is recorded in the config as a stated aim, alongside the note that nothing enforces a frontend coverage floor today and that the pattern to copy if that should change is the per-area, shrink-only ratchet already working in mobile/ (coverage-baseline.json + scripts/check-coverage-ratchet.mjs), placed on react-tests-full. Two stale comments corrected in the same pass: the smoke step's reference to "the coverage step below", and — more importantly — the claim at the quarantine-budget step that react-tests-full "carries continue-on-error at the JOB level, which swallows every step inside it, so a gate placed there would read as enforced while being unable to fail the build". That has been false since the shard job became blocking and joined the release gate's needs: list; left as a dated correction rather than deleted, because the old wording tells a reader no gate can ever work in the only job that runs the whole suite.
- A link-preview unit test depended on live DNS, so it passed in CI and failed on any machine without outbound resolution. test_fetchPreview_returns_cached_data_when_available asserted caching, but fetchPreview() runs OutboundUrlGuard::isSafeHttpUrl() first and that resolves the host to check it is not a private address — a failed lookup returns false, so the method returned null before the mocked cache was ever consulted, and the failure read as a caching bug. In the dev container gethostbyname('example.com') returns the input unchanged, which is how a green CI and a red local run coexisted. 🔴 The real cost was the pre-commit gate: it runs the PHP test files staged in the commit, so this file could not be edited offline at all without a blocked commit and a strong temptation to reach for --no-verify, which that gate explicitly forbids. The request URL is now a public IP literal, which the guard range-checks without resolving anything; domain is read off the cached row rather than derived from the URL, so the assertion is unchanged. Proven pre-existing before being fixed by running it against the committed file.
- A deploy could hang for an hour on an invisible password prompt; it now fails in seconds. When GitHub refuses the production server's anonymous git fetch it answers www-authenticate: Basic realm="GitHub", and git responds by asking for a username on a terminal nobody is attached to — so scripts/deploy.sh stopped dead at step 4/5 with no error, looking exactly like a long Docker build. It happened twice on 2026-09-02, once for about an hour. The server command now runs the fetch under sudo env GIT_TERMINAL_PROMPT=0, so the same refusal exits 128 immediately and the && chain stops before git reset --hard — and the script reports what happened, states plainly that production is unchanged, and exits 1. 🔴 The variable is passed through sudo env deliberately: sudo's default env_reset strips it, so exporting it on the server (/etc/environment, a shell profile) would never reach sudo git, which is why this is a change to the deploy command rather than to production configuration. Measured while diagnosing: git clone of the same public URL succeeded while git fetch failed 5/5 including inside a freshly cloned repo, and a control clone of the unrelated public git/git also failed — so the refusal is GitHub-side per-IP throttling of unauthenticated git traffic, not repository state, credentials, proxy, submodules or disk. Authenticated access is not subject to those limits, so a deploy key remains the durable fix; the failure message now says so. This fails safe either way, because the fetch precedes every mutating step.
- The AWID community is now reachable on its own two hostnames, and both are monitored. awid.timebank.global (React) and accessible-awid.timebank.global (web-uk) were created as Plesk subdomains of the timebank.global subscription and bound to the shared cloudflare-origin-tbg-20260809 origin certificate; before that both answered Cloudflare 526, because a proxied hostname with no origin certificate cannot be rescued by Let's Encrypt (validation runs over HTTPS, which is the thing that is broken). Their proxy configuration was copied verbatim from the working counterparts — the React vhost pair from uk.timebank.global, the accessible pair from accessible-minehead-and-coast.timebank.global — so both follow the blue/green ${NEXUS_FRONTEND_PORT} / ${NEXUS_WEBUK_PORT} Defines rather than hardcoding a colour's ports, which is the fault that pinned pairc-goodman.com to blue for months. Verified live: the tenant-aware PWA manifest returns "AWID Timebank" on the React host, /version on the accessible host returns nexus-webuk at the active release, the accessible host serves the community slug-less instead of the community chooser, both redirect HTTP to HTTPS, and api.project-nexus.ie echoes the React origin back in Access-Control-Allow-Origin. 🔴 The vhost files live only on the server and are not in this repository, so rebuilding the box loses them. Both new hostnames are added to scripts/uptime-targets.json at the same time as the vhost, which is the point of failure a platform-wide check cannot see, and the two deploy warnings that enumerate the accessible hostnames now name the third one.
- The database-column gate now watches upsert(), which is how a broken write got past it. scripts/check-db-column-references.mjs matched insert, update, updateOrInsert and insertGetId but not upsert, so AchievementUnlockablesService writing a non-existent tenant_id column sat in the tree unnoticed. Adding the one method brings 19 more column references under the gate, and it was confirmed to catch that exact write when run against the unfixed code. Everything else about the gate is unchanged, including its deliberate decision not to check where()/orderBy()/select(), which can name a joined table's column.
- Cutting a version is now one command, and the deploy says how much is waiting. node scripts/release.mjs --auto takes the bump the changelog justifies instead of requiring it to be stated, so a release needs no judgement call in the ordinary case. It deliberately takes the MINIMUM justified bump and never more — choosing to go higher is a claim about significance that only a person can make — and it refuses --auto alongside an explicit --major/--minor/--patch. Separately, bash scripts/deploy.sh now prints how many entries are sitting under Unreleased, what kind they are, and how long since the last release. Deploying and cutting a version stay separate, because most deploys contain nothing worth announcing and numbering them all would return the version to counting deploys; the cost of that separation was that nothing ever prompted a release, which is how the pile reached 47 entries with two member-facing features in it. The note is informational and cannot fail a deploy — it is called with || true and swallows its own errors, because a deploy that broke over a changelog message would be a self-inflicted outage.
- Semantic versioning is now a rule the build enforces, not a claim in a header. CHANGELOG.md has said "this project adheres to Semantic Versioning" since it was created and nothing checked it; in practice the version behaved like an odometer, incrementing the patch component by one per release and carrying at nine, so 1.5.9 was followed by 1.6.0 for no reason connected to the release. Both 1.6.1 and 1.6.2 shipped an ### Added subsection — new functionality — as patch releases, which means the number told a reader nothing. docs/VERSIONING.md now states what each component means for this platform, which consumers the promise is made to, and what counts as a breaking change for each of them. npm run check:semver enforces it: valid semver, strictly ordered releases with real non-future dates, a bump at least as large as the release content justifies, a recognised subsection vocabulary, no entries floating outside every subsection, a compare link per release, and a tag per release. Comparison and pre-release precedence use the semver package — npm's reference implementation, now a declared dependency — because hand-rolled string comparison gets 1.6.9 < 1.6.10 right and 1.8.0-rc.1 < 1.8.0 wrong. The policy is enforced from 1.7.0 forward; earlier releases are already published and are deliberately left alone, with the floor recorded as a constant rather than silently skipped.
- Releases are cut by a tool now, because the manual process had already lost entries twice. node scripts/release.mjs derives the minimum bump from the [Unreleased] section, refuses a smaller one, moves the section under a dated heading, updates the version in all thirty-six places, adds the compare link, regenerates the in-app changelog, re-runs both version gates against its own output, commits, and creates an annotated tag. It refuses to run on a dirty tree, off main, or when entries sit above the [Unreleased] heading — the exact fault that stranded nine entries outside every section, where two consecutive release cuts skipped them and their dates ended up eleven days wrong. It never pushes and never deploys.
- The fifteen compare links at the foot of the changelog pointed at tags that did not exist. Every one resolved to a 404, because no release had ever been tagged — none locally and none on the remote. scripts/backfill-release-tags.mjs locates each release from the repository rather than from commit subjects, which are inconsistent across the history: it walks every commit touching a version-carrying file and takes the first at which that file holds the released value. Fourteen of the fifteen were located this way, using react-frontend/package.json for the releases predating VERSION (introduced at 1.5.2). 1.5.0-rc.1 is recorded in the script as untaggable with its reason — it was documented retroactively and no commit ever set a version file to it.

### Fixed

- Signing in from a community's main site as a member of one of its sub-communities now just works. This is the cause behind the entry below, and the cure for it. A sub-community has no web address of its own — it is served underneath its parent — so its members reach the sign-in screen at the parent. The server lets them in on purpose, and has a passing test saying so. But the sign-in it hands back belongs to their community while the app carries on asking the server about the parent, and the server then refuses every single request. Two designs that are each right on their own, with nothing joining them up. The app now moves itself to the member's own community as they sign in, before the first screen appears. Silently, because the member did nothing wrong and there is nothing to ask them: an account belongs to exactly one community, so there is only one right answer and the app already knows it. It leaves well alone in every case where moving would be wrong. A platform super admin is not moved: they are allowed in other communities by design, and dragging them home on every sign-in would break the job they signed in to do. A hub's network admin is moved, because the server does not extend that permission to them and refuses them exactly like anyone else. A community that is not in the public list is not guessed at. The ordinary sign-in costs nothing extra. The community the app is showing and the community the sign-in belongs to are compared by number first, which needs no request; only when they differ does the app fetch the public community list to find the right address. A sign-in that has succeeded can never be undone by this. If the list cannot be fetched, or the community will not load, the member stays signed in and the rescue below still catches it. Prevention: the decision and every one of those exceptions are in mobile/lib/tenancy/signInTenant.ts, apart from the app, with 25 cases on them; four more on AuthContext prove sign-in actually asks, asks with the community it is really showing, moves before it navigates, and survives a failure. Control runs confirmed each of the three: removing the call, never moving, and wrongly exempting a network admin all turn tests red. 🔴 Not walked on a phone. The evidence is the code on both sides and the tests, not a device. Somebody already stuck in this state is put right on their next launch, without doing anything. Fixing it at sign-in does nothing for the people it has already happened to: their phone holds a sign-in for one community and the name of another, and every launch repeated the same silent refusal of everything. The app now checks once per launch, using the profile already saved on the device, and moves itself if the two disagree. A launch where nothing is wrong costs nothing. The check waits until the community's own details have loaded, so the comparison is two numbers already in hand; only when they genuinely differ does anything reach the network. It will not move somebody it cannot vouch for. A platform super admin is allowed in other communities, and a profile saved by an older version of the app may not record whether someone is one. Where the app cannot tell, it leaves them alone and the community picker rescues them instead — being wrong here would drag an administrator out of the community they meant to be in. It can still tell for the overwhelming majority: anyone the server says is not an administrator of anything cannot be a super admin, so their saved profile is enough however old it is. The apology and the repair no longer talk over each other. Both react to the same broken state, and at launch they raced — the member could be told "choose your community below" at the exact moment the app was choosing it for them. The picker now holds back while a repair is running, and speaks up again if one fails. Once per launch, not once per screen. It is a repair, not a rule: somebody who picks a different community later in the same session is not dragged back. 🔴 That guard was nearly untested — the first version of its test passed with the guard deleted, because it looked for the second attempt before it could have happened. A control run caught it and the test was rewritten.
- A member signed in to the wrong community is now told, and shown the way out, instead of everything silently failing. When the sign-in belongs to one community and the app is asking the server about another, the server refuses every single request. Seen for real on 9 September: one member's unread messages, notification count and phone registration for push notifications were all turned away inside one second. They were signed in, getting no notifications, and nothing on screen said why. Retrying could never help — an account belongs to one community — and the app could not quietly correct itself either, because the refusal does not say which community the sign-in came from. The app now says so plainly and opens the community picker. That picker genuinely works from this state, which is not luck: its list is fetched without a sign-in precisely so it still loads when everything else is refused, and it is deliberately exempt from the rule that sends a signed-in member back to home. This is the missing third piece — nothing was pointing anyone at it. It acts once, however many requests are refused. In this state everything in flight fails the same way; without that guard a single app launch would stack a dozen copies of the picker. Prevention: the decision lives in mobile/lib/navigation/tenantMismatch.ts rather than inside the root layout, which has no behavioural test of its own — a guard that fires "once" is exactly the kind of thing that quietly stops working. Six cases cover it, plus three on the API client proving the refusal is matched on its code and not on an English sentence the server could reword.
- A sign-up error the server blames on one field now appears on that field, not only in a banner at the top of a form taller than the screen. The app kept the input name the API sends and puts the message on it. 🔴 Only the app half is done, and it changes little on its own until the API changes too. Registration answers with one error at a time and usually names no field: RegistrationService::register ends its validation with $validator->errors()->first() and the controller passes null as the field. Somebody filling in three bad fields is told about one of them and not which. What the API needs to send is written out in mobile/docs/CURRENT_MOBILE_PRODUCTION_STATUS.md; the app will place each error without further change once it does.
- The course player no longer starts you at lesson one every time. A learner eleven lessons into a course reopened it and was put back at the beginning, every single time, with nothing to say the app knew any better — while the server had been reporting exactly which lessons were finished all along. It now opens at the first lesson you have not completed, says "Picked up where you left off", and offers to start from the beginning if that is what you wanted. It will not land you on a lesson the course's drip schedule has not unlocked yet, and a learner who has finished nothing still starts at lesson one with no message.
- Registering an organisation landed on a list that did not contain it. The app sent the member back to the volunteering hub with the new organisation's id in the address, but the hub was already open, so React reused it and the organisations list — which asks the server once and never again — showed exactly what it had shown a minute earlier. The member had no way to tell whether the registration had worked. Arriving with a registration id now re-reads the list.
- A job you cannot open for editing no longer leaves an empty form with a live Update button. A failed load in edit mode filled nothing in and left "Update job" ready to press, which would have wiped the vacancy's title, description, location and pay. It now says the job could not be loaded, with a Try again — or, when the vacancy is simply not yours, says that instead and offers no button that cannot work. The same fault was fixed on the volunteering form in September and never here.
- An organisation's website link no longer fails in silence. Neither half of opening it was wrapped: Android throws when checking a scheme it has not been told about, and opening rejects on a malformed address or a phone with no browser — and the address is typed in by the organisation itself, so a bad one is ordinary. The rejection went nowhere and nothing at all happened when the member pressed the button. It now says the website could not be opened and shows the address that was tried.
- The seller payments screen showed 0.00 when it had simply failed to ask. All three figures — pending, available, total earned — were fetched together, and one failure set every one of them to nothing. A seller with money waiting saw three zeroes and no hint that anything had gone wrong, on the one screen in the app that is entirely about money. It now says the balance could not be loaded, in words that make clear it is not a zero balance, and offers a retry. One request failing no longer takes the other two down with it, so a payout list that fails leaves a balance that loaded perfectly well on screen. "No payouts yet" is likewise no longer claimed on a failed request.
- Pickup-slot and coupon dates are checked before they are sent, and the common slot can be filled with one tap. Both forms passed whatever was typed straight to the server: "next Tuesday", "12/09/2026" and "2026-02-31 10:00" were all valid entries as far as the app was concerned, and a slot could be created that ended before it started. There is still no date picker — that is a native component and therefore a new store build, which these screens do not otherwise need — so the fields stay typed, the expected shape is in the message, and a bad value is refused with a reason. "Later today" and "Tomorrow" fill a one-hour slot on the next whole hour, which is the case people were typing thirty-two characters for. 🔴 A pickup slot is now sent as a moment in time, not as the text that was typed. The server reads a bare "2026-06-01 11:00" in ITS OWN time zone, so a seller an hour ahead of it saved slots an hour out — the same drift already recorded on challenge deadlines. Editing an existing slot now round-trips the exact instant.
- An accepted marketplace offer now tells the buyer that nothing has been paid yet, and takes them to the checkout that honours their price. Before, an accepted offer said "accepted" and stopped. The only button was View, which opened the listing; the buyer had to work out for themselves that the money had not moved and the purchase was still theirs to complete. A seller waiting to be paid and a buyer who thought they had bought the thing is the worst kind of quiet failure on a marketplace.
- Two things that were destroyed by a single tap now ask first. Deleting a job alert wiped the keywords, filters and location the member had typed in, with no undo and no trash to restore from. Removing a federation connection took an established link between two communities apart on one tap — and reconnecting is not a matter of tapping again, because the other community has to be asked and has to agree. Both now ask, in all seven languages. 🔴 Two, not thirty-three. A heuristic scan listed 33 candidates and could not see inside an onConfirm callback, so it counted every confirmed action as unconfirmed. Reading all 57 destructive call sites found that most already ask — several through a dialog that also collects a reason — and that the rest are explicit two-button choices (Accept/Decline), toggles, or actions undone by tapping again. Declining an incoming connection request and cancelling your own outgoing one stay one tap for exactly that reason. Prevention: both tests press the destructive button and assert nothing happened yet, then press confirm. The dialog stub in each file is presentational and never confirms by itself — an auto-confirming mock makes every confirmation on a screen invisible to every test, which is how a one-tap destructive action ships unnoticed.
- Nineteen screens stopped telling members "couldn't load — try again" when the server had actually said no. A 403 ("you are not the organiser") and a 500 ("something broke") looked identical: the same message, the same Try again. One of those buttons works. The other gives the same answer for ever, and the member is left believing the app is broken. It now says the thing is not available to them, and offers no button that cannot work. Changed: a course you are not enrolled on, its player and its grading queue; a podcast show and episode; an ideation campaign, challenge and the challenge list; a group, a group exchange, an event, an event's management screen; a job vacancy; an exchange request; an organisation; a volunteering opportunity and its edit form; editing a listing that is not yours; a member's appreciations; and a federation partner. A genuine server failure still gets a Try again, because retrying that can work. 🔴 The number is nineteen, not fifty-six. A heuristic scan counted 56 candidates. Reading them showed most were list screens where the community's own module switches already refuse the screen, or screens whose failure action was "go back" rather than a retry. Nine more screens already behaved correctly but each carried its own hand-written copy of the status list; those now ask one shared helper, so the answers cannot drift. Two tests were pinning the fault. One pressed Try again after a 403 on the course grading queue and asserted it worked — it only did because the mock was changed to succeed in between. The other did the same with a 404 on an ideation campaign. Both now assert the refusal is named as one, with a companion case proving a 500 still gets its retry. Prevention: mobile/app/refusalHandling.test.ts fails when a screen that loads one record by an id from the URL offers a retry without knowing what a refusal is, and when any screen writes its own 401 || 403 || 404 list instead of asking the shared helper.
- On the phone, searching the knowledge base only looked at the handful of articles already on screen, so it could tell a member their community had no answer when it did. The tab loaded one page of help articles and then matched what you typed against just those, in the app. An article that answered the question exactly — but happened to sit further down the list — came back as no results. The search now goes to the server, which looks at every article. There was already a working search endpoint for this; nothing in the app had ever called it.
- On the phone, the course catalogue and the ideas challenge list both stopped at the first page, with nothing to say more existed. A community with more than twenty courses had the rest simply invisible — and because the search box only filtered what had already been fetched, a course on the second page could not be found by searching for it either. The same was true of the ideas challenges. Both now load the next page: the course list continues as you scroll, and the challenge list has a "Load more" button (its cards sit inside a scrolling page alongside the filters, so a button is the honest fit there). Changing a search or filter starts the list again from the top, rather than stacking a filtered page underneath an unfiltered one. Both endpoints have always reported how many pages there are; the app was reading the first page and discarding that information.
- On the phone, voting on a poll threw away every page of polls you had scrolled through. Casting a vote reloaded the whole list from the beginning, so anyone who had scrolled down a few pages was thrown back to the top with everything below the first page gone, and their place lost. The reload was never needed — the vote already comes back with the poll's new totals, and the app was simply discarding them and asking again. The count now updates in place and the list stays exactly where it was. Pulling down to refresh still starts over, because that is the member asking for fresh numbers.
- On the phone, posting an idea to your community was permanent — you could not take it back. A typo published to everyone, a duplicate, or something you thought better of stayed there for good, and the same was true of your own comment on someone else's idea. Both can now be removed, and both ask first. Withdrawing an idea says plainly that its votes and comments go with it. The buttons only appear on your own idea and your own comment, because the server refuses anyone else and an offer that is always refused is worse than no offer at all. Both endpoints have allowed the author (or a community admin) all along; nothing in the app called them.
- On the phone, goal progress could only ever go up, so a mistake could not be undone. Someone who logged five hours instead of half an hour, or logged the same session twice, was stuck with the wrong figure — and if the mistake pushed them past their target, the goal was marked complete. Progress can now be corrected by entering a negative number, with a line on screen saying so. A correction can never take the recorded total below zero. The keyboard was also changed, because the numeric pad it used has no minus key on iPhone — a correction the keyboard cannot type is not a correction. The server has always accepted a negative adjustment; only the app refused to send one.
- Turning a module off now removes its remaining links and buttons from the React interface, including the alternate staff headers. The main navigation and direct routes were already gated, but the audit found cross-links that lived inside other enabled pages: Listings in the feed profile card, Timebanking guide and Future Care Fund; Organisations in Explore, Volunteering and Caring Community; Settings in Matches, Profiles, Notifications, member discovery, skills, newsletters and safeguarding prompts; Search on the 404 page; and Dashboard return buttons on recovery screens. Admin, broker, Caring, partner and super-admin headers also carried their own Profile and Notifications controls. Those surfaces now read the same tenant switches, disabled breadcrumb destinations are removed centrally, organisation management routes require both Volunteering and Organisations, Settings hides its Profile and Notifications tabs when those modules are off, and notification preferences omit controls for disabled capabilities.
- On the phone, sending photos in a message showed a spinner with no number, no estimate, and no way to stop. The only escape on a slow connection was to force-quit the app — and if the upload took longer than a minute it failed anyway, having given no sign that waiting would have helped. There is now a real progress bar with a percentage, and a Cancel button. Cancelling is treated as the member changing their mind, not as a failure: the photos and the typed message stay exactly where they were, ready to send again. Root cause: the send used the shared upload path, which is built on fetch — and React Native's fetch reports nothing at all while a request body is going out. A purpose-built uploader with byte-level progress and a real cancel already existed in the app, used by one screen. Messages now use it too.
- Disabled organisation APIs now refuse the request before validating its form fields. The Organisations switch already hid the React routes and controllers rejected valid organisation requests, but an empty or malformed create request reached CreateOrganisationRequest first and returned 422 while the feature was off. All organisation-specific volunteering routes now carry the Volunteering and Organisations middleware at the route boundary, so disabled reads and writes consistently return the feature-disabled response without executing validation or changing data. A two-session browser suite now toggles and restores all 41 top-level module controls, checking fresh member configuration plus representative routes and rejected writes.
- The leaderboard on the phone showed the top twenty and stopped, with nothing to press. In any community with more than twenty active members — which is most of them — nobody outside that top twenty could ever find themselves on it, which is the one thing a leaderboard is for. There is now a "Show more" button that goes down to a hundred, and when it reaches the end it says it is showing the top hundred instead of just stopping for no visible reason. The server has always been willing to send up to a hundred; the app simply never asked for more than the default twenty. Switching between weekly, monthly and all-time starts the list back at the top.
- Three seller tools on the phone deleted on a single tap, with no warning and no way back. A marketplace collection, a saved search and a pickup slot could each be erased by one press of a small button sitting next to Edit — no confirmation, no undo, nothing in a trash to restore from. The pickup slot was the worst of the three: buyers can already be booked into a slot, and deleting it took their collection time away without telling anyone. All three now ask first and say what will be lost. The coupon panel in the same screen has always asked; these three never did. They also swallowed the server's explanation on failure; all three now pass it through the same way the rest of the app does.
- On the phone, rewriting a published course on the phone and swiping back threw the rewrite away without a word. The unsaved-work warning covered creating a course but deliberately skipped editing one — because in edit mode every field is already filled in from the server, so the "has anything been typed?" test was true from the moment the page loaded and would have challenged every single Back press. The screen now compares what is on screen against what was loaded, so an untouched form leaves quietly and a changed one asks first. Saving makes the current version the new baseline, so leaving straight after saving is not challenged either.
- Tapping "Add cohort" twice created the cohort twice. The request is not repeat-safe and takes long enough on a phone connection that a second tap lands before the first has come back, so an instructor ended up with two identically named intakes and had to go and delete one. The button now locks while the first request is in flight and says so.
- On the phone, a member whose verification email never arrived had no way to ask for it again, and could not get into the app at all. After signing up they were shown "check your email" and a single Sign in button — and signing in is exactly what an unverified address blocks. If the email went to spam, bounced, or they had mistyped their address, that was the end of the road. Opening an expired verification link was no better: it advised them to "sign in and request another email from your account settings", advice that leads straight back to the same locked door. There is now a Send it again button on the sign-up confirmation, using the address they just typed, and an expired or already-used link offers to send a new one to an address they enter. The endpoint behind both has existed the whole time with nothing calling it. The confirmation deliberately says "if that address can be verified, a new email is on its way" rather than confirming the account exists. The endpoint answers identically either way so it cannot be used to find out who is a member, and the wording has to keep that promise.
- A community that has closed registration no longer invites people to create an account. The sign-in screen showed "Create account" regardless, and someone could fill in the whole eight-field form before the server turned them away. The app now asks whether the community is taking members, and says plainly that it is not — using the community's own wording when it has set one. If that question cannot be answered the button stays, because hiding the only route into a community that is actually open would be the worse mistake.
- Four more phone screens offered a Try again button for something no amount of trying could reach: a wallet transaction, a donation receipt, a marketplace order and a group invitation that are not the member's. Opening one of those — from a mistyped link, an old notification, a spent invite, or a federated row the community can no longer see — showed "could not load" and a retry button. They now say the item is not available, which is the truth, and only a genuine server failure still offers a retry. 🔴 Two of the tests covering this were asserting the fault. donation-receipt and group-invite each had a passing test that pressed the retry button after a 404 and checked that a second request went out. Both are rewritten to check the opposite. A green suite proves the behaviour it describes, not that the behaviour is right.
- On the phone, five organiser-only event screens told a member "could not load — try again" when the real answer was "this is not yours". The attendance list, tickets, communications, lifecycle history and recurrence blueprints all treated a refusal as a breakdown. A member who is not the event organiser, or who used to be one, was handed a Try again button that could never work no matter how many times they pressed it, and no explanation of what had actually happened. They now say plainly that it is not available to them, using wording that already existed and is already translated in all seven languages. A genuine server failure still offers Try again, because retrying that can work. Root cause: four of the five wrote their loader as catch { setLoadFailed(true) }, which throws the status code away before anything can look at it — so the screen could not have told the two apart even if it wanted to. A small shared helper now names the distinction once (401, 403 and 404 mean "no"; 5xx and a dropped connection mean "try again"), so the next screen does not have to rediscover it.
- On the phone, changing community signed the member out and only then found out whether the new community could be loaded — so a failure cost them their session and gave them nothing. An account belongs to one community, so switching genuinely has to sign the member out; that part is correct and stays. But the order was sign out, then load, and loading can fail on a flaky connection, a community taken offline, or a slug the API no longer recognises. The app then restored the old community and showed an error — leaving the member signed out, exactly where they started, with any work in progress gone. The app now checks the community it is being asked to switch to before anything is given up: one anonymous request, addressed explicitly to that community, that stores nothing. If it fails, nothing happens at all and the member is told in words that they are still signed in to their own community. The sign-out still runs before the community changes, which is separately load-bearing — a sign-out sent after the switch is refused by the server and leaves a live session behind. The API client gained a tenantSlug request option for this. A caller-supplied X-Tenant-Slug header is still ignored, deliberately, so this is the one explicit way to ask about a community other than the selected one.
- On the phone, the federated directory decided whether it had been refused by reading English words in the server's reply, so members in the other six languages got a Try again button that could never work. Federation refusals arrive translated: in English "Federation feature disabled for this tenant" matched the check and the member got an explanation, but in German the same refusal reads "Die Verbundfunktion ist für diesen Mandanten deaktiviert", matched nothing, and fell through to a generic error with a retry button — retrying a switched-off feature changes nothing, so the member was stuck. Irish, Spanish, French, Italian and Portuguese members were in the same position, and even English members who simply had not joined federation yet ("You must opt in to federation first") got the same dead end. The screen now reads the machine-readable code the API has always sent (FORBIDDEN, FEDERATION_NOT_ENABLED, MEMBERS_NOT_ALLOWED and the rest), which is identical in every language, and treats any unexplained 403 on a directory read as a refusal rather than something to retry. The English word-match survives only as a fallback for a server old enough to send no code. Every regression test for this is written in German for that reason. A member who has not joined federation is now told how to join, instead of being told the feature is off. Those are different situations with different remedies and they had been collapsed into one message. The new card names the actual next step and opens the federation setup wizard; strings added in all seven mobile languages. Root cause: usePaginatedApi exposed only error — the server's translated sentence — while its sibling useApi had exposed errorStatus and errorCode for some time. A screen given only a sentence can only match words in it. The hook now carries both, so no paginated screen has to guess again.
- On the phone, a stray tap on a course quiz could spend one of a learner's limited attempts on a blank answer sheet. Submit sent whatever was in the answer sheet, including nothing at all: the attempt scored 0%, counted against a limit that is often three, and could not be undone. Submitting with nothing answered is now refused outright with a line saying why, and a partly finished quiz asks first and states plainly that sending it will use one of the attempts (naming the limit when the course sets one). A completed quiz submits exactly as before — the point is to stop accidents, not to add a step. Ticking and then unticking a multi-choice question, or typing and deleting free text, no longer counts as an answer.
- The federated directory searched on every keystroke. Typing "gardening" fired nine cross-community searches, each fanning out to partner communities, and the reply to the eighth could arrive after the ninth. Those endpoints allow sixty requests a minute, so a member searching properly could exhaust their own allowance and start being refused. The two free-text filters now wait for typing to stop; the text box itself stays instant.
- The SessionTimeoutWarning countdown test could fail on a loaded CI runner even though the component was correct. It slept a fixed 1.1 seconds and then read the DOM synchronously, racing both the interval callback and React's flush of the resulting state update; on 2026-09-08 it took down React Full Suite (shard 4/8) on both attempts with the badge still reading 30. It now polls for the badge to fall below its starting value — which is the behaviour under test, since the countdown is derived from a wall-clock deadline rather than counted — instead of asserting one exact value at one exact moment.
- Three files the PHP suite reads woke nothing in CI, so a broken PHP test could pass unnoticed behind a green tick. .github/ci-paths.yml decides which changes re-run which jobs, and its php area did not list mobile/config/**, mobile/scripts/audit-native-push-producers.php or mobile/app.json — all three are read by NativePushProducerInventoryTest and MobileVersionGateRegistrationTest. Proven on 2026-09-08: the inventory test failed, the fix was committed to mobile/config/, and the next four CI runs reported "PHP Tests: skipped" while showing green; the repair had to be verified by hand in the container because CI never re-ran it. Note scripts/** does not cover mobile/scripts/ — these globs match from the repository root — which is why the audit script the test shells out to is named in its own right. Same blind-spot class as contracts/** (2026-08-03) and phpstan.neon (2026-08-19).
- A community that switches the feed off no longer keeps the feed's furniture, or a front-page button pointing at it. Members land on /feed after signing in — the route redirects them to the dashboard when the module is off, which was correct and is now pinned by tests — but two surfaces around that redirect still advertised a feed the community does not have. On the phone, switching the module off blanked only the LIST: the "Community Feed" heading, the "here's what's happening in your timebank" subtitle, the hashtag-discovery button, the For You / Recent tabs and the nine filter chips all stayed, sitting above a padlock saying the feature was off — a working-looking feed with a hole in the middle and controls that filtered nothing. The hashtag button was worse than cosmetic: its destination requires the same module (M('feed') in routeRequirements), so it bounced the member straight back. The greeting and the notification bell deliberately survive, the bell because it is the only door to notifications on that screen. On the web, HeroSection's signed-in call to action — the largest button on a community's own front page — linked to /feed unconditionally, so pressing it silently bounced the member to the dashboard; it now names the dashboard when there is no feed, using the home.cta_dashboard string that was already translated in all eleven locales and unused, and renders nothing when neither module is available rather than pointing at a third page that also redirects. Root cause: the module switch was applied to the data and to menu entries, never to the surrounding chrome or to inbound links from ungated pages. Same shape as the native gating work above — a hidden menu is not a gate, and neither is an empty list. Prevention: react-frontend/src/routes/feedDisabledLanding.test.tsx walks the landing journey against the real FeatureGate (feed off → dashboard; feed and dashboard both off → community home page, which is a soft landing rather than a loop; no redirect at all while the configuration is still loading). Login's /feed default was already pinned by LoginPage.test.tsx. Two cases added to HeroSection.test.tsx and two to mobile/app/(tabs)/home.test.tsx, each verified to fail with the fix reverted. The mobile suite's beforeEach now resets the tenant mock: jest.clearAllMocks() clears calls but not return values, so a mockReturnValue(false) leaked into every later case — harmless while only the composer was gated, and a test-order failure as soon as the header was. The phone screen's composer now gates on !feedUnavailable rather than hasModule('feed'): the latter is false while the tenant configuration is still unknown (cold start, offline first paint), so the composer blinked out for every member for as long as that lasted. Unknown configuration keeps the feed, as everywhere else in the app. Behaviour confirmed unchanged elsewhere: the accessible frontend lands on /dashboard and answers /feed with a translated 403 page; the API already refuses the feed routes behind module:feed; the web dashboard's Recent Activity card and its /v2/feed request were already switched off with the module. 🔴 Not walked against a live feed-disabled community — the evidence here is a code reading plus the tests named above.
- Explore hid its organisations section from communities that run volunteering. The section was briefly gated on a second feature flag named organisations as well as on volunteering. Organisations is a volunteering surface — the page table in the contributor guide gates it on volunteering, and records that the register route is gated the same way and explicitly NOT on the identically-named flag, because that confusion is a known trap. A community with volunteering switched on but no separate organisations flag lost the section with no way to tell why. Now gated on volunteering alone, as it was before and as the page’s own test had been pinning all along.
- Tenant module and feature switches now reject direct API access for Messages, Wallet, Feed, Notifications, Connections, Reviews, Search, Polls, Goals, Gamification, AI Chat, Blog, and Resources, including legacy and operational-admin routes. Route-inventory regression tests keep owned endpoints behind their switch, while public notification unsubscribe remains reachable when Notifications is off.
- Sending time credits to a member of another community happened on one tap. Up to 100 hours left the wallet immediately, cross-community and not reversible from the app. Every comparable movement of credits — the personal wallet transfer, the group exchange, the organisation wallet deposit, a paid course enrolment — was put behind a confirmation on 2026-09-06/07; this one was missed. 🔴 The reason no test caught it is worth recording: member-profile.test.tsx mocked useConfirm as confirm: (opts) => void opts.onConfirm(), running the action the instant it was asked to confirm, so every confirmation on that screen was unobservable and a new one-tap money path could be added with nothing to notice it. The stand-in now requires the second tap. Found by the 2026-09-07 audit (G/F-4).
- An instructor’s grade of "82,5" was recorded as 0%. Number(score) || 0 turns NaN into a real, wrong mark, so a grader on a German, Spanish, French, Italian or Portuguese device typed an ordinary grade, saw a success toast, and the learner was marked at zero. Any other typo did the same. The score is now parsed properly and refused if it is not between 0 and 100 (G/F-9).
- The federation hub drew a failed load as fact. All four of its requests threw their error away — grep -n "error" on the file matched nothing — so a 403 (federation switched off), a 500, or simply being offline still rendered "0 partners, 0 messages, 0 exchanges", an "Inactive" chip and two friendly empty states saying there were no partners and no activity. None of that was true, and the only way to retry was a pull the member had no reason to try. It now separates "not switched on here", with a button to the setup wizard, from a genuine failure, which keeps a retry (G/F-3).
- Re-opening the federation setup wizard switched every privacy control back on. The hub shows a "Setup" tile at all times, and the wizard always started from its own all-on defaults. A member who had already opted in and turned off cross-community search, location sharing and cross-community transactions could open it out of curiosity, press Next four times and Finish — and all three went back on, posted over their choices, with no warning. It now loads what they chose and starts from that (G/F-13).
- A video lesson nobody played was reported to the server as 100% watched, so it appeared in the instructor’s analytics as fully viewed. The watch figure reset to 100 for every lesson type, and the player only reports a real one once playback starts (G/F-5).
- Unpublishing a course took it away from everyone enrolled on one tap, while the course builder already confirmed every section and lesson deletion. The instructor dashboard also never refetched, so a course just created or edited was missing until the app restarted, and its load failure was titled "Could not create course" (G/F-11, G/F-14).
- Three more screens showed a refusal as a failure. An unpublished or deleted blog post, a removed knowledge-base article, and a withdrawn idea each answered 404 or 403 and were rendered as "could not load" with a Retry that could never succeed. The ideation test had been pinning that fault rather than catching it: it asserted the 404 offered a Retry and that pressing it fetched again (F/F-8).
- The resources and ideation searches fired a request per keystroke, so typing "gardening" sent nine and the list flickered through nine loading states on a poor connection. useDebounce already existed and neither used it (F/F-14).
- A deposit into an organisation wallet could be taken twice. VolunteerController::walletDeposit has read an Idempotency-Key since it was written and the app sent none, so a deposit whose request timed out left the admin unable to tell whether it had gone through — and tapping Deposit again took the credits a SECOND time out of their own personal wallet. One key per intended deposit now, reused across a retry, generated by the new shared mobile/lib/utils/idempotencyKey.ts. The same deposit also moved credits on ONE tap with no confirmation and said nothing afterwards; it now names the amount and the organisation before it goes, and reports success. Found by the 2026-09-07 audit (E/F-1, E/F-2).
- A comma decimal was silently multiplied by ten when posting a job. new-job.tsx stripped the comma before parsing, so an employer offering "1,5" time credits published a vacancy offering 15, and a salary of "50,5" was posted as 505, with no warning. Four more fields — a volunteer expense, a donation, logged hours and the organisation wallet deposit — rejected a comma outright, so on a German, Spanish, French, Italian or Portuguese keypad the journey simply could not be completed. All five now use the shared parseDecimalInput.
- The hiring pipeline could not make an offer, accept or reject. The card rendered the four lowest-indexed remaining stages, so everything past Shortlisted was unreachable and an employer who moved a candidate to Interview had no way to progress them; the fixed "Interview" button beside it silently did nothing once they were already there, and a pending applicant saw Interview twice. Every stage is reachable now, and rejecting confirms first because it is the step that sends bad news.
- The "Given" reviews tab was permanently empty. Both tabs were filtered out of one list, and ReviewService::getForUser() scopes that query to receiver_id, so no row could ever match. A member who had written twenty reviews was told they had written none — and because Delete only renders on that tab, they could never remove one. It now calls /v2/reviews/given, which exists and is registered ahead of /v2/reviews/{id} for exactly this purpose.
- A declined organisation registration looked approved. With only two buckets — pending, and everything else — a refused registration slid quietly into the ordinary managed list with a Manage button and no sign of the refusal, while "Create opportunity" stayed hidden with no explanation. There is now a third bucket that says what happened.
- Every appreciation card printed raw translation keys. The card asked for the profile namespace, which has no appreciations block; the keys live in members, and there is no fallbackNS. So under every thank-you note the three reaction buttons read literally "appreciations.react.heart", "appreciations.react.clap" and "appreciations.react.star", and a note from a deleted sender was attributed to "appreciations.someone". The test file’s own i18n stand-in was namespace-blind, which is why nothing caught it; it is namespace-aware now and the existing test fails without the fix.
- Two wrong numbers on the gamification profile. No badge ever showed the date it was earned, because the card read earned_at — the one column the server leaves null, while user_badges.awarded_at is NOT NULL DEFAULT current_timestamp(). And "Locked badges" read 0 for every member for ever: it was "all badges minus earned badges", but /gamification/badges returns rows from user_badges, which exist only once a badge is awarded, so every row was earned by construction. The tile now counts what is still to collect across the badge journeys.
- Spending XP in the reward shop happened on one tap. There was no confirmation and nothing in the API to reverse it, while rotating a venue QR code — which costs nothing — has been behind a confirmation for months. Deliberately no idempotency key: GamificationV2Controller::purchase() reads only item_id, so sending one would be theatre. The shop balance also froze after a purchase, because the optimistic local guesses were never cleared and were preferred over anything the server sent afterwards.
- Three owner-only screens showed a refusal as a failure. The organisation dashboard, the hiring pipeline and the job figures each answered a 403 with "could not load" and a Retry that could never succeed. The organisation dashboard was worse: under that card every tab claimed "No pending applications", "No hours to review" and "No transactions" — none of which was true, because access had been refused, not because the lists were empty. All three now say plainly that the screen is not theirs, with a way back, and keep the Retry for genuinely transient failures.
- Seven of the eight gamification loads threw their error away. Only the profile read error, so a failed badges call rendered "No badges yet" to a member who had earned ten, and the same went for challenges, journeys, the shop and the Nexus score: a failure was presented as an empty collection with no message and no retry. Each tab now shows its own failure and its own retry.
- Three irreversible taps now ask first, and a fourth reports what went wrong. Withdrawing a job application, cancelling a volunteering shift and withdrawing a volunteering application all happened on one tap; the volunteering DETAIL screen has confirmed the identical shift call since S4-16 and the hub was never brought along. Withdrawing a job application also swallowed the server’s reason, so "already withdrawn", "past the offer stage" and "vacancy closed" all read the same. Four further failure paths — applying for an opportunity, creating or editing an opportunity, registering an organisation, generating a role description — showed the raw error instead of going through describeApiError.
- A failed vote on an idea was invisible. The message went into a status line inside the "Submit an idea" card further up the page, usually off screen, so the member tapped Vote and nothing appeared to happen. It is a toast now, carrying the server’s own reason. The Vote button also stayed live during the round trip, and the server toggles a vote, so a second tap cast the vote and then took it away again.
- Real admins never saw "Edit challenge". The check matched user.role against four names, but super_admin, tenant_admin and coordinator are never written to that column — they are boolean flags — so only the literal admin could ever match. GET /v2/users/me already computes the answer and the app was discarding it; is_admin is now on the mobile User type.
- A newly created vacancy did not appear in My Postings until the member pulled to refresh, and the same went for an edited posting and an application just sent. The volunteering hub and the organisations list both gained this focus refetch in August; the jobs screen was not brought along.
- The job card’s salary period was hardcoded English — "yr", "mo", "hr" — on a card where every other label is translated, and the detail screen already had the words.
- React module switches now apply to header and drawer controls, custom navigation, footer links, Dashboard requests and cards, Explore and search results, profile subpanels, notification activity and deep links, cross-module messaging actions, and operational admin URLs. Protected content waits for tenant configuration; organisation API reads and writes now require both Organisations and Volunteering; three unimplemented granular controls are labelled Coming Soon instead of appearing functional; and the create menu places Marketplace under Community instead of Timebanking. The authenticated Playwright setup now uses the current tenant login route, the deterministic seeder credentials and storage state, and unambiguous form selectors; its navigation suite checks the Marketplace grouping in a real browser.
- Native marketplace — the money path, audited and repaired (2026-09-07, report D). This module moves real money through Stripe as well as time credits, and it was the worst part of the app. The first auditor was killed by the account's session usage limit with nothing written; a second, run alone, read all thirty screens. It found no crash or data-loss class and confirmed the order idempotency key and its server-side fingerprint replay are sound — and six member-facing faults on the money path, all fixed with tests. Checkout never showed a total. The item price sat at the top of the screen, each delivery option carried its own price, and an applied coupon showed no figure at all because the server's discount amount was read and thrown away. A card buyer first saw the real charge in the Stripe sheet; a member paying in time credits never saw a total at all. There is now a summary — item, delivery, discount, total — the button names the total, and the discount is cleared whenever the code, the delivery choice or the payment method changes, because it was validated against those. A time-credit or free purchase committed on ONE tap. The server debits the wallet as the order is created (MarketplaceOrderService::settleTimeCreditOrder), so the tap on "Buy now" was the purchase — the same class of fault as the one-tap paid course enrolment fixed on 2026-09-06. Both now ask first, naming the item and the credits. A card purchase keeps its single tap, because the Stripe sheet is itself the confirmation and now follows a visible total. A payment that had gone through could be reported as failed. If the app's confirm call failed after Stripe had charged the card — dropped connection, timeout, 5xx — the member was told "Payment failed" and could pay a second time. It now says the payment went through and is still being confirmed, and does not invite a retry. The order screen's "continue payment" had the same fault and the same fix. Cancelling the payment sheet gave the wrong instruction. It said "complete payment from the web checkout if the payment sheet does not open on this device" — it had opened, the member closed it — and left them on the listing with no route to the order now sitting unpaid. It now names the order and goes to Orders. Separately, the checkout idempotency key was never cleared after a completed order, so a second genuine purchase of the same item could be replayed as the first. "Free" was printed on any listing with an empty price, so "contact seller" listings and time-credit-only listings were labelled Free on every list and on the detail header — while the checkout underneath offered "Pay with 3 time credits". "Confirm delivery" released the seller's money on one untitled tap, next to "Dispute". It is the step that ends the buyer's protection, and a mis-tap while scrolling was enough. It asks now, and says what confirming does. Also fixed: offer accept / decline / withdraw / accept-counter had no busy state and no confirmation, so a second tap during the request produced "this offer is not pending" and read as if the first had failed — and accepting reserves the listing for that buyer; orders and offers had no pull-to-refresh, which matters because every payment hand-off returns to them; coupon minimum-order and max-uses were parsed with Number() on a decimal-pad field, so a comma became null and the coupon silently had no minimum; the public coupon list showed "No coupons" when the request failed; the pickup code was shown as text the seller had to type while their own tools tab offers a camera scanner, and is now a scannable QR code; delivery prices printed as "EUR 5.00" beside an item priced "€20.00"; the 3-D Secure return URL had no screen behind it, so a bank redirect pushed "This page doesn't exist" over the listing mid-payment; and order and offer failures showed a raw exception message instead of the server's own sentence.
- A broken picture now shows a placeholder rather than a blank hole, and ten more failure messages say why. There was no onError on any remote image anywhere in the app (audit A/F-13), so a photo that 404s rendered as an empty rectangle the size of the picture — which reads as a broken screen, not a missing photo. A new shared component shows a muted placeholder with an icon instead, hidden from screen readers because it carries no information; the listing card and the feed item use it. Ten more toasts that showed a fixed sentence and swallowed the server's reason now pass it on: member profile (connect, appreciate, report), podcast episode and show, venue pass rotation, the course builder, and the event registration, safety, credential and offline check-in components. Smaller items in the same pass: a donation receipt showed every status in green including "refunded"; the onboarding category border, the group cover placeholder and the groups filter chip used colour tokens that render nothing in this app; the meeting-link button on an event could reject unhandled on a device with no browser; podcast episodes and the trending-hashtag list gained pull-to-refresh; and the password toggle now says whether it will show or hide.
- Native app — second full audit, 2026-09-07 (source-only; 48 findings fixed, each with a Jest test observed failing first). Three areas were read in full by independent auditors — tabs and auth; messaging, wallet, exchanges and account; events and groups (about 60 screens and 40 supporting files). The marketplace auditor was cut off by the session usage limit before writing anything, and the volunteering/jobs and gamification clusters were not read; the open items and the unread modules are listed in mobile/docs/CURRENT_MOBILE_PRODUCTION_STATUS.md backlog item 8 and the pass is recorded in mobile/docs/HISTORY/AUDIT_2026-09-07.md. Nothing was walked on a device. Money and credits. A time-credit transfer committed on ONE tap, and a deep link (nexus://wallet?to=999&name=Alice) pre-filled whatever name the link's author typed — the recipient id is now resolved against the server, only the server's name is shown, and a confirmation names recipient and amount before anything moves. Confirming exchange hours pre-filled the proposed figure even when the other member had already confirmed a different one; the server marks a gap over a quarter of an hour DISPUTED, so two members who agreed on 2.5 h could end up with a broker case because the sheet said "2" — it now pre-fills the counterparty's confirmed hours, written the way the member's locale writes decimals, with a warning sentence. Completing a group exchange moved credits for every participant on one tap (and confirming committed hours the same way); both ask first. Unparseable per-participant hours or weights were silently sent as 0 / 1. Wallet action failures showed a raw message instead of the server's reason. Journeys that could not complete. "Sign out" on the "could not check your session" screen did nothing visible — the flag it depends on was never cleared, and router.replace ran with no navigator mounted. Registration on a community that signs members in at once was challenged by the unsaved-changes guard ("Discard your registration?" for an account that already existed). Requesting to join a private group showed "Joined" and then silently reverted to "Join" — the server's pending answer was typed away; it now shows "Requested" with the organisers' message. A 403 during offline check-in sync purged the encrypted queue including check-ins that had never been synced; the queue is kept read-only and only the confirmed "Remove offline data" discards it. The exchanges list stopped at 50 with no way to see more. "Exchange active" on a listing was a dead-end toast that never refreshed, so a cancelled exchange still blocked a new request — it now opens the exchange and re-checks on focus. Refusals reported as failures. A blocked / vetting / safeguarding refusal on sending a message read as "Message could not be sent. Please try again" and invited endless retries; a hidden or blocked profile read as "Could not load — Retry" (a retry can never succeed on a 4xx); a deleted conversation opened from a stale notification likewise; a refused exchange transition left the just-failed buttons on screen; an ordinary member deep-linking into someone else's event editor got "Could not load event" with a Retry that failed for ever; sixteen bare catch {} blocks across the event registration, safety, credential and offline check-in components discarded the server's sentence; any group-exchange load error read as "not found". useApi now exposes errorStatus/errorCode so screens can tell an answer from a fault. Wrong or missing information. Event detail never showed the end time or the end date of a timed event, nor the organiser's reason for a cancellation or postponement. The group Events tab formatted in the device zone and showed "00:00" for all-day events. Editing a joinable federated group saved it as listed. The group invite screen showed two raw translation keys. Six distinct offline-scan refusals ("already queued", "expired", "wrong event", "revoked", "issued after last sync", "queue full") all read as one generic "invalid" sentence. The inbox's "N unread" chip summed the loaded page while the tab badge showed the server total, and the inbox ignored a message arriving while it was open. The Events tab replaced the whole list with an error card when a LATER page failed, and Retry restarted from page one; switching a tab or filter kept the previous rows on screen until the response arrived. Marking notifications read did not update the tab badge; tapping a notification for a switched-off module pushed a second Notifications screen on top of the first. The thread jumped to the bottom on every incoming message and lost its place when older messages loaded, offered "Delete for everyone" on other people's messages, showed no avatar and a generic title when opened from a deep link, and carried a typing indicator that nothing could ever turn on. Smaller. The keyboard's Return could fire a second submit on all four auth forms; Home fetched and rendered the feed when the feed module was off; a password-reset or verification link opened while signed out was replayed after sign-in; icon-only buttons on login, register, listings and messages were 40 dp (Android guidance is 48); the password toggle did not say which way it would go; the offline banner was missing from Messages, Events and Members; two quick taps on a listing's bookmark raced save against unsave; swiping a conversation open raised the archive confirmation AND revealed a button that raised it again; the auth cards were not vertically centred; Explore statistics were not number-formatted for the locale; the reaction bar animated for members who had asked the OS for no motion; a saved resource opened the resources list instead of the resource.
- The queue watchdog had never run once. It was installed non-executable in May, so cron got "Permission denied" roughly 31,000 times and the safety net that restarts dead queue workers did nothing. Found by nightly Sentry triage on 2026-09-07 while diagnosing Sentry NEXUS-PHP-2F / NEXUS-PHP-3J / NEXUS-PHP-2B. /etc/cron.d/nexus-watchdog-queue runs /opt/nexus-php/scripts/watchdog-queue.sh directly, with no interpreter — the only entry in that crontab that does, and therefore the only one that depends on the file's executable bit. Git tracked the script as mode 100644 and /opt/nexus-php is a git checkout with core.filemode=true, so every deploy wrote it back non-executable; the script's own install notes told the operator to chmod +x by hand, which a checkout then reverted. The log on the server is 2.2 MB and its very first line is the permission error, so it had failed on every run since 2026-05-22. 🔴 The cost is measured, not hypothetical: on 2026-09-06 an apt upgrade of docker-ce/containerd.io (06:27:37–06:31:19 UTC) restarted the container engine, the queue container did not come back, and with no watchdog nothing restarted it — queue workers processed no jobs between 06:25:54 and 11:18 UTC, almost five hours, and queue:verify-liveness could only report the gap retroactively because the scheduler that runs it was down too. The script is now mode 100755 in git, so the next deploy installs it executable and no manual chmod is needed. Its install notes are corrected to say why, and to give the one-line check that distinguishes "running" from "logging the same error for four months". Prevention: new BLOCKING gate scripts/check-host-cron-scripts.mjs — any cron line in this repo that names a repo script without an interpreter in front of it must be mode 100755 in the git index. It fails if it finds no cron lines at all, so it cannot pass vacuously if the install notes ever move. It runs in the always-on Dockerfile Drift Detection job rather than behind a path filter, because a change that breaks a host cron script need not touch any path a filter watches. It found four more scripts documented for direct invocation (prerender-events-check, prerender-job-processor, refresh-bot-ip-ranges, refresh-bot-ua-list); none is installed that way on the server — the real prerender entry already uses /bin/bash — so their instructions are corrected to match the repo's bash scripts/... convention instead of flipping four more exec bits. Verified: the gate fails on the pre-fix tree naming scripts/watchdog-queue.sh:16 and passes after, with 7 cron invocations checked across 102 shell scripts; bash -n clean on all five edited scripts. 🔴 Not deployed — production still has the non-executable copy, so the watchdog is still dead until this ships. sudo chmod +x /opt/nexus-php/scripts/watchdog-queue.sh restores it immediately in the meantime, and this change makes that survive the next deploy.
- Editing a listing written in another language erased its accessibility note. From the 2026-09-06 native audit, F01, reproduced against the shipped parser. A listing's optional service details — experience, equipment, accessibility — are not columns: they are serialised into the tail of description as Label: value lines, and the label is the member's own translated word. A listing created in French stores Expérience: Professionnel / certifié; the edit screen looked for Experience: and Équipement: only in the current locale plus English, found nothing, and rebuilt the description without the block — so changing only the title deleted the step-free-entry note a disabled member had written. Labels and values are now matched against all seven bundled locales, and the whole parser moved out of the screen into mobile/lib/exchanges/serviceDetails.ts, where the invariant that matters can actually be asserted: parse a description, change nothing, rebuild it, and get the same bytes back. new-exchange.tsx and edit-exchange.tsx had duplicate copies of this logic; they now share one.
- An ordinary horizontal rule in a listing description truncated everything below it. Same audit, F02. The parser split on the first --- and kept only what preceded it, on the assumption that a separator could only mean the metadata block. First paragraph\n\n---\nSecond paragraph\n\n---\nThird paragraph came back as First paragraph, so a title-only edit silently deleted two thirds of the listing. Only the last section is now considered, only when every line in it reads as Label: value and at least one label is one we know — one line of ordinary prose disqualifies the whole section and it stays in the description. Detail lines that are metadata but map to no field we know (a field added by the web client, a locale we do not bundle) are carried through verbatim instead of being dropped.
- Pressing Save turned the unsaved-changes guard off, so a member could walk out mid-save. Same audit, F03. useUnsavedChangesGuard took one isBusy flag meaning both "a write is in flight" and "a write is confirmed and the screen is leaving on purpose", and all seventeen create/edit forms passed saving || hasSaved. From the instant the button was pressed the form was unprotected: leaving during those seconds lost the draft if the request then failed, and pulled the member forward into a screen they had already left if it succeeded. The two states are now separate props. isSaving keeps the screen protected and changes the question from "discard your changes?" to "your changes are still being saved — wait, or leave anyway?"; only hasSaved stands the guard down. navigation.dispatch is also now guarded by a mounted ref, so confirming the dialog after the screen has gone navigates nothing.
- A listing saved, but its skills or its photo did not, and the screen navigated away with them. Same audit, F06. Tags and images are separate requests from the listing itself. A failure was reported in a toast and the screen then ran router.replace to the listing anyway — taking the failed tags and the chosen image URI with it, so the member's only route back to their own skills was to fill the form in again, which on the create screen posts a second listing. Both listing forms now hold their ground on a partial save and show ListingPartialSaveNotice, which names exactly which write failed, keeps the failed input in the form behind it, and offers a retry that re-sends only what failed against the listing that already exists — never createExchange or updateExchange. mobile/lib/exchanges/listingExtras.ts attempts the two writes independently, so one failing no longer skips the other.
- A wallet transfer that timed out could debit twice on retry. Same audit, F07. The mobile client sent no idempotency key, so WalletService::transfer fell back to its 120-second content fingerprint — a window both too short and too blunt: a retry after a timeout that outlived it debited again, while two transfers a member genuinely meant to send twice inside it collapsed into one. The server has honoured a client idempotency_key for 24 hours all along, replaying the original transaction on a duplicate; the app now sends one. The id belongs to the transfer the member confirmed, not to the button press: it survives a retry of that same transfer, is discarded once the transfer is confirmed, and a changed recipient, amount or description claims a new one. A failed transfer also re-reads the balance now, since a request can fail after the server committed it and the error alone tells the member nothing. 🔴 Wallet donations are deliberately unchanged: WalletFeaturesController::donate has only a 10-second per-user lock and no client-key support, so a key sent there would be silently ignored — recorded rather than faked.
- A safe-area inset that genuinely shrank never shrank. Same audit, F08. setRootBottomInset was a ratchet that could only grow, to defend against Android modal routes reporting bottom: 0. But the root layout is its only writer and always passes the live value, so a real decrease — switching Android from three-button to gesture navigation, where the 48dp bar really does go away — left every modal reserving space for a bar that was no longer there, for the rest of the process. The latest root reading now wins; the modal-reports-zero case is handled where it belongs, in useBottomInset, which floors the screen's own value with the root one.
- An offline launch could throw away a valid session. Same audit, F09. On start-up the app validates a stored token against /users/me. The cached-user branch correctly told a revoked token (401) apart from an unreachable server; the no-cache branch wrapped everything in one catch and signed the member out for any failure — including a flat network on a launch where the cached profile happened to be missing, which is an ordinary thing for a phone to do to an app cache. The member was then asked to sign in again with no connection to sign in over, and the offline event check-in queue was purged with it. Only a 401 now ends the session; a timeout, a 5xx or a dead network keeps the credentials and raises sessionRestoreFailed, and the new SessionRestoreGate shows what happened with a retry and an explicit sign-out instead of a login form. Failing to write the profile cache is likewise no longer treated as the server rejecting the token.
- Android App Links could never verify, so every https://app.project-nexus.ie deep link opened the browser instead of the app. Found while closing out F05. app.json has declared autoVerify: true for the host all along, but the file Android actually fetches — react-frontend/public/.well-known/assetlinks.json — named the package com.nexus.timebank (not this app; the package is ie.project.nexus) and carried a literal REPLACE_WITH_RELEASE_KEYSTORE_SHA256_FINGERPRINT. The app.json half looked correct the whole time, which is why nothing caught it. The package is corrected and the release upload key's real SHA-256 is in place, and verify:release now fails on a placeholder, a package that disagrees with app.json, or a malformed fingerprint. 🔴 Not finished: if Play App Signing is enabled — it is mandatory for apps published since 2021 — Google re-signs with its own certificate, and that SHA-256 (Play Console → Test and release → App integrity) must be listed here too or links stay unverified for everyone who installed from Play. That value is owner-held.
- "Expand group" expanded nothing. Reported by the owner on 6 September 2026 and reproduced on the emulator the same day: a row reading "You earned the 'First 5-Star' badge!" with a chip saying "18 notifications" and a button that flipped to "Collapse group" while not one pixel else on the screen changed. Two causes behind one symptom, both fixed. NotificationService::getGroupedNotifications never sent the group's own notifications — only up to three actors — so both clients could render an expansion only when the grouped rows happened to carry an actor_id. An achievement, a wallet movement, a listing expiry and most system messages carry none, so those groups expanded into an empty space. Groups now travel with their member notifications (group_items, capped at 10; remaining_count reports the rest), the mobile app renders them with their own dates and links, and 🔴 the control is offered only when there is something behind it — a button that cannot do anything is the bug, not the absence of one. The web client had the identical defect and is fixed in the same commit.
- Eighteen unrelated notifications were collapsed into one row. The grouping key was type . ':' . (link ?? 'none'), so every notification of a given type that carried no link — and most types carry none — collapsed together under the newest one. The local fixture had 31 achievements sharing a single row. Grouping is for "Alice and 4 others liked your post": several notifications about one thing. Without a link there is no one thing, so those rows stay separate now, which is what they always were.
- Every notification in the native list showed the same grey bell. NotificationService has always had a type→category map — the unread counts and the category filter both use it — but it never attached the category to a notification in the list payload. The mobile app's Notification type declares category as required and about sixty lines switch on it for the icon, the tint and the label, so with the field absent every row fell to the default and the whole list rendered identically. The field is now sent on both list endpoints. 🔴 The mobile client was also keyed on singular guesses (message, listing, connection) the server has never produced; its maps and its eleven category.* translation keys are replaced with the real, plural vocabulary. 🔴 Three categories were missing entirely, and they covered the platform's most important notifications: counted on the local database, exchange_disputed (20 rows), vol_application_received (9), exchange_request_received (6), exchange_started (5), marketplace_order (4) and a dozen more all fell through to "Other" — so exchanges, the core of a timebank, were labelled "Other" and drawn with the generic bell. exchanges, volunteering and marketplace are added, along with the missing types in listings, safeguarding and system.
- The native notifications screen was rebuilt to be scannable. The owner's words were "it's a mess… it doesn't look professional at all", and the measurements agreed: three notifications filled a phone screen, so a member with 47 unread faced sixteen screens of scrolling. Each card carried a full-width action row — "Mark read" and a red "Delete" — duplicating swipe actions the same component already rendered, so a destructive red button repeated down the entire list. Above them sat a hero panel with an accent bar, a 48px icon, an "ACTIVITY INBOX" eyebrow, a 24pt "Notifications" heading, a sentence counting the unread and a chip also counting the unread — with the screen's own top bar already saying "Notifications", the word appeared three times above the fold. Now: one compact row per notification with its category icon, title, message, and a single quiet meta line (relative time · category · group count); the header is one line plus "Mark all read"; the unread marker is a dot rather than a full-width bar that every row had anyway. Six notifications fit where three did. 🔴 The inline buttons are replaced by a per-row overflow menu, not by swipe alone — a swipe is invisible to a screen reader, and deleting the only non-gesture route to Delete would have been an accessibility regression dressed up as a tidy-up.
- Taps on the feed's filter chips and notification bell sometimes did nothing. Reported by the owner; measured on the emulator rather than guessed at. Two independent causes. 🔴 The unread badge was eating the taps. It is absolutely positioned inside the bell button and drawn on top of it, and React Native gives a touch to the topmost view — so the badge, which has no press handler, swallowed everything landing on it. Measured at 420dpi: the button occupied [910,386]–[1015,491] and the badge [969,396]–[999,428], about 9% of the button and exactly the corner a right thumb reaches first. Tapping (984,412) stayed on the feed; tapping the centre opened Notifications. The badge is now pointerEvents="none" and hidden from the accessibility tree, with the count moved into the button's own accessible name so a screen reader still announces it. 🔴 And the targets were too small. scripts/audit-touch-targets.mjs — which existed and had never been run — measured the filter chips at 24dp tall, scraping the WCAG 2.2 AA floor and half Android's 48dp guidance, with the bell and hashtag buttons at 40dp and the For You / Recent tabs at 36dp. Fifteen targets on the feed were below guidance; after this, one is, and it is 2dp short. The notifications screen measures zero below either threshold.
- The marketplace map can be searched by place name — you no longer have to know your own coordinates. The second half of audit finding F14, missed on the first pass through it. The screen offered exactly two ways in: "use my current location", and typing a latitude and a longitude by hand. Almost nobody knows the coordinates of their own town, so a member who wanted to browse listings anywhere other than where they were standing had no way to say where. A place field now resolves a town, city or postcode through Location.geocodeAsync — the phone's own geocoder, already available through expo-location, so no new native module and no new API endpoint, and the fix reaches installs that already exist. The resolved coordinates are written back into the two fields rather than hidden, so a member can see what the name resolved to, correct it, or share it. A name that resolves to nothing says so instead of searching from nowhere, and a device with no geocoder at all (no Google Play services, or offline) is pointed at the coordinate fields rather than shown a bare failure.
- Accepting or declining a connection no longer throws you back to the first page. A second-order consequence of the paging fix above, and named in F14's own wording ("verify a second page with disjoint IDs, including action/refresh behavior"). usePaginatedApi.refresh() resets to page one — which is what a refresh means, and cost nothing while this screen fetched a single page. Once it paged, a member who had loaded four pages and accepted a request on the fourth was thrown back to the first and had to walk down again. Since every one of these actions removes the row from the tab it is in — accepting moves it to Accepted, declining and cancelling delete it — the row is now dropped locally, which is the known outcome of a request that has already succeeded rather than an optimistic guess. Pulling to refresh, or switching tabs, clears that local state so the list can never drift from the server for longer than one view.
- Pulling the wallet to refresh while a page was still loading could duplicate rows. Named in F12's fix ("test histories longer than one page and refresh during an in-flight next-page request") and missed on the first pass. A "Load more" request that started before a refresh and landed after it appended its rows on top of the newly reloaded first page, leaving duplicates and a cursor pointing into a list that had been replaced — two gestures a second apart, no error, and nothing to show for it afterwards but a wrong list. Page fetches now carry the request generation they started in and are discarded if it has moved on. The export walk carries the same guard: a refresh part-way through stops it and reports what it gathered as partial, rather than mixing rows from two different lists into one file.
- The native Connections screen stopped at the first twenty records, with no way to reach the rest. Audit finding F11. The API wrapper asks for twenty at a time and ConnectionsController returns a cursor and has_more; the screen called it once, with no cursor, and mapped that single page inside a ScrollView. There was no next-page action on any of the three tabs, so a member with more than twenty connections — or more than twenty pending requests — simply could not see the older ones from this screen. Moved onto usePaginatedApi, with a next-page control on every tab. has_more is trusted where the server sends it and otherwise inferred from a cursor being present, rather than assuming the list has ended.
- The wallet's Earned and Spent filters could show nothing while the credits were one page away. Audit finding F12. The wallet loads fifty transactions; Earned and Spent filter that in-memory subset, and the "Load more" control was rendered for the All filter alone. So a member whose recent fifty transactions happened to be all outgoing was shown a flat "No matching transactions" on Earned, with older credits one page back and nothing on screen to suggest their history went any further. Every filter can now extend the shared list, and the empty state distinguishes "nothing matches this filter" from "nothing matches in the part of your history loaded so far" — which are different statements, and only the second was ever true. 🔴 The Pending filter had the same shape and no paging at all: it asked for fifty rows and consumed neither the cursor nor has_more, so a member with more than fifty pending rows saw a truncated list presented as the whole of it. It now pages too.
- The wallet export produced a partial file that did not say it was partial, and shared it as a message rather than a spreadsheet. Audit finding F13. Export serialised whatever happened to be in memory, so its contents depended on how many times the member had pressed "Load more": open the wallet, tap Export, and you got your most recent fifty rows in a file that read as your complete record. It now collects the full completed history before writing — bounded at fifty extra pages so a years-deep wallet cannot hold the button indefinitely — de-duplicates by id, reports how many rows went in, and says plainly when the history was too long to fetch in full instead of quietly truncating. 🔴 The native branch also called Share.share({ message: csv }), which hands other apps a wall of comma-separated text: it lands in a chat or a mail body and cannot be saved as a spreadsheet, which is the only reason anyone exports a wallet. It now writes a real .csv to the cache directory and shares it with the CSV mime type, so the sheet offers Files, Drive and a spreadsheet app. A device with no share sheet at all falls back to the old text share rather than reporting a failure when the data is ready.
- The marketplace "map" drew three decorative dots and called them pins. Audit finding F14. The panel above the search fields rendered a fixed grid and three coloured circles at hardcoded positions, captioned {{count}} pins. It was passed no listing coordinates and never could be: changing the search location or the result set changed the number in the caption and left the dots exactly where they were. A member was shown three geographical markers that corresponded to nothing and told those were their results. The underlying nearby search is real; only the picture of it was invented. Replaced with an honest summary of the search area — the coordinates, the radius, the number of listings (not "pins"), and the distance to the closest actual result, all drawn from the data the search returned. The dead map.pinCount strings are deleted from all seven locales rather than left in the catalogue to be picked up again. 🔴 Deliberately not replaced with a working map: react-native-maps is a native module, so adding one means a new store build and no existing install could receive this fix. A real map is worth doing and is recorded as the next step; until then, showing nothing beats showing something untrue.
- The native app's automatic retry never covered a dropped connection — the one failure it exists for. Audit finding F07. useApi and usePaginatedApi both retry once when an error is "transient", defined as a retryable status, or not an ApiResponseError at all. But lib/api/client.ts wraps every network failure and every timeout as ApiResponseError(0, …), which satisfies neither arm: it is an ApiResponseError, and 0 was not in the retryable set. So on a train, in a lift, or on any weak mobile connection, every screen built on these two hooks — which is most of them — gave up on its first attempt while advertising a retry. 0 is now in the set, alongside 500/502/503/504, and a 4xx is still never retried because a refusal is a decision. Root cause: the predicate was written against a general idea of "a network error" rather than against the error object the client actually produces. Prevention: the two new tests use the client's own error shape; an earlier test built on a bare Error passed happily against the broken predicate.
- The unsaved-changes guard on every create and edit form was built on a mechanism React Navigation says does not work on the stack this app uses. Audit finding F08. useUnsavedChangesGuard used navigation.addListener('beforeRemove') plus preventDefault(). Expo Router's Stack resolves to native-stack, where React Navigation documents that approach as unreliable and recommends usePreventRemove instead — the installed native-stack even ships a warning for a screen the native side has removed while JS still holds it through such a listener. The consequence is not cosmetic: a native gesture (an iOS swipe-back, an Android predictive back) can tear the screen down natively while JS believes it prevented the removal, leaving the member with either their typed content gone after choosing "keep editing" or a screen still on the stack that no longer responds. This affected registration and every create/edit form, not one screen. The guard now uses usePreventRemove. 🔴 edit-profile.tsx was also carrying its own private copy of the same listener, written before the shared hook was extracted from it — so the app had two implementations of one protection and only ever corrected one. The copy is deleted; there is now a single implementation, and it is the supported one. Seven form-screen suites move onto a shared lib/test/unsavedGuardHarness so they ask the same two questions as before: is the screen protected, and what happens when a removal is prevented.
- A message thread kept marking messages read while another screen covered it, and suppressed the badge that would have said so. Audit finding F09. The thread subscribes to realtime messages in a plain effect rather than a focus-scoped one — deliberately, so a message arriving while the member reads the conversation's linked listing or event is already there when they come back. But the handler also marked every incoming message read, locally and on the server, and its mere existence told RealtimeContext that a viewer had taken the message, so the unread badge never rose. Following the "Regarding" card pushes a screen on top of a thread that stays mounted: a member who could not see a message was recorded as having read it, with nothing anywhere to tell them it had arrived. Receiving and acknowledging are now separate. The message is still received and cached whatever is on screen; it is marked read only when the thread is focused and the app is in the foreground, and the handler reports which of those happened by returning a boolean — which is now what decides the badge, rather than a listener merely being registered. Coming back to a thread that took messages while it was covered clears them once, on focus, so no stale badge survives. Root cause: "a listener exists" was used as a proxy for "the member can see this". Prevention: MessageHandler's return value makes the distinction explicit and impossible to omit by accident, and three tests cover receive-without-acknowledge, the badge still rising, and the clear-on-return.
- A screen the member could not see could swallow Android's Back button. Audit finding F10. AppTopBar registered a BackHandler listener returning true — which consumes the press outright — and kept it for as long as the bar was mounted. Tabs stay mounted when you switch between them, and so does a stack screen with another pushed on top of it, so a bar belonging to an unseen screen could take Back and run its own fallback instead of the focused screen's, sending the member somewhere they had not asked to go. The listener is now registered only while the screen is focused, so at most one bar is ever listening and it is the one the member is looking at.
- The native course player only ever showed text lessons — the other four types gave a learner a title, a completion button, and no content at all. Audit finding F03, traced from mobile/app/(modals)/course-player.tsx through lib/api/courses.ts to CourseProgressService::completeLesson. The schema declares five lesson types (text, video, pdf, embed, quiz) and the web player has rendered all five since it shipped; the native player rendered body and transcript and stopped. A video lesson — which normally has neither of those set — presented as its title and a "Mark as complete" button. Worse, that button called an API wrapper with watch_percent: 100 hardcoded, and the server persists whatever it is sent, so the app told an instructor's analytics that a video nobody had opened was fully watched, and a graded quiz could be passed without a single question being displayed. New mobile/components/courses/LessonContent.tsx renders each type properly: video plays through expo-av (already a dependency, so this reaches members over the air), pdf and embed hand off to the phone's own viewer/browser, quiz opens the new quiz journey, text keeps its body. Each type keeps the instructor's transcript available as the WCAG 1.2 text alternative, in a disclosure so it does not bury the content. 🔴 pdf and embed are deliberately NOT rendered inline, and the reason is release mechanics, not laziness. React Native has no built-in web view and react-native-webview is a native module: adding one means a new Play/App Store build, so no existing install could receive this fix. Handing a document to the system viewer is also simply better — real paging, search, zoom, save and print. An embed_url is third-party media (YouTube, Vimeo) by definition, so opening it externally is not the app pushing members back to our own website; it is the established Linking.openURL case alongside attachments and meeting links. If inline rendering is wanted later, WebView plus a store build is the change. New mobile/components/courses/LessonQuiz.tsx is the whole quiz journey: questions fetched from GET /v2/courses/quizzes/{quizId} (deliberately re-fetched rather than read off the lesson, because CourseQuizService::forLearner is the shape with the answer key removed and the endpoint re-checks enrolment and drip availability), single-choice, multiple-choice, true/false and free-text answering, submission to POST /v2/courses/quizzes/{quizId}/attempt, and an honest result. A score is withheld while needs_review is true — a percentage beside "submitted for review" reads as the final mark. A refusal shows the server's own words: MAX_ATTEMPTS_REACHED is final, and "please try again" would be a lie. watch_percent is now a measurement. A video lesson reports the high-water mark of what was played, not the current position, so a learner who watches to the end and drags back to re-check something does not lose their progress; playing to the end counts as 100 even if the last tick lands short. Types with no playback of their own still report 100, which is correct — a text lesson has no watch metric. New mobile/lib/utils/courseMediaUrl.ts applies the same http/https-only rule as the web client and the API before any instructor-typed URL reaches a player or Linking.openURL. On a phone file: and content: address the device's own storage, so this is a real boundary rather than tidiness. 16 new tests across four suites; the mobile suite goes from 3,084 to 3,123 across 409 suites. Twelve translation keys per locale in all seven, hand-written.
- A drip-locked course lesson offered a completion button the server was certain to refuse. Audit finding F04. CourseEnrollmentController::progress has always returned per-lesson availability and unlock_at; the native player consumed only the completion ids and the overall percentage and discarded the rest. So a lesson the server considered locked got ordinary navigation and a "Mark as complete" action, and the member learned it was locked only from the LESSON_LOCKED error that came back afterwards. A locked lesson now shows as locked — with its unlock date where there is one, formatted in the member's own locale — instead of the content and the button, matching the web player. Absent availability is deliberately treated as available, never as locked: a member must not be shut out by a field that failed to arrive.
- When course progress failed to load, the native player presented it as "nothing completed" rather than as a failure. Also audit finding F04, and the quieter half of it. The screen's error branch was reached only when there was no lesson to show, so a course that loaded successfully alongside a progress request that failed skipped the branch entirely: the member saw a 0% bar and unticked lessons, indistinguishable from a genuine fresh start. Progress now has its own retryable error panel, shown instead of the bar. Root cause: two independent requests shared one error path, keyed on the success of the other one. Prevention: a test asserts the progress bar is absent and the error panel present when only the progress request fails.
- A failed sign-out left the native app still making requests as the member who had just signed out. Found by the 6 September 2026 mobile audit (F01) and reproduced against the real API client, not inferred. lib/api/client.ts keeps an in-memory bearer that deliberately wins over the stored one, so that an iOS Keychain delay cannot make the request immediately after a login anonymous. It was cleared only on the /api/auth/logout success branch — but AuthContext.logout() catches a failed server logout and completes the sign-out locally anyway, so that path cleared storage and the UI and left the cached bearer untouched. The member saw the login screen; the app went on sending their token. Registration made it worse rather than fixing it: it writes the new account's token to storage and calls setSession, which set React state only, so the stored token lost to the cached one and requests went out under the OLD account while the NEW account's screens were displayed. Whether that token was still valid server-side depends on whether the logout request landed, which is precisely the case in question. Fixed by giving the API layer the two operations the app actually needs — installApiSession(token) and clearApiSession() — and calling them from every path that starts or ends a session: login, registration/setSession, logout (unconditionally, after the local cleanup), the genuine-expiry branch, and both session-clearing arms of session restore. 🔴 A session generation counter closes the second half, which nulling a variable could not: a token refresh already in flight when the session ends used to resolve afterwards and write both storage and the in-memory bearer, either resurrecting a dead identity or overwriting the credentials of whatever session had replaced it. A refresh that finishes across a generation bump now reports unreachable and applies nothing — deliberately unreachable rather than rejected, so nobody is signed out and no offline check-in queue is purged for it. Six new client tests assert on the Authorization header, which is the only thing that decides who the server thinks is asking, and two new AuthContext tests cover the failed-logout and registration paths. Root cause: session state was owned in two places that could disagree, and only one of them was updated on the failure path. Prevention: the API layer now owns the bearer and exposes install/clear as the only way to change it; storage clearing alone is no longer treated as a sign-out anywhere.
- Signing out from the fingerprint lock screen left an opaque cover over the login form, with no way back into the app. Audit finding F02, reproduced with a component test that fails against the previous code. BiometricLockGate makes its lock-or-open decision once per app start and guards the whole effect behind that one-time flag, and the "nothing to protect, stand aside" branch lived inside it. So the branch covered a member who arrives at the app signed out and missed a member who becomes signed out. Cancel the biometric prompt, tap Sign out — the escape hatch that exists for exactly the member whose sensor has stopped reading — and the session did end, but the gate stayed locked and its absolutely-positioned overlay went on covering the login screen underneath. Reinstalling was the only way back in. The signed-out case is now its own effect with no dependency on the one-time decision, so losing a session opens the gate at any point in the app's life rather than only at the start of it. The existing test asserted that the button called logout() and then retried biometrics; the new one asserts the overlay is gone and the login form is reachable. Root cause: a start-up optimisation was applied to a condition that is not a start-up condition. Prevention: the regression test drives the whole transition — cancel, sign out, re-render signed out — and asserts on what the member can see, not on the handler having been called.
- A stalled token refresh could hold every request in the native app indefinitely. Audit finding F05. Ordinary requests carry an AbortController and a per-method deadline; the silent refresh request carried neither. Because every other 401 collapses onto the same shared refresh promise by design, one refresh that opened a socket and then received nothing — the ordinary failure on a weak mobile connection — parked every screen and every mutation behind it, past the timeout each of them had advertised, with no error shown and no way to retry. The refresh now has its own TIMEOUTS.API_TOKEN_REFRESH (10s, deliberately shorter than a mutation because it is spent on top of the member's own request). 🔴 An expired deadline is classified unreachable, never rejected: nobody is signed out and no offline event check-in queue is purged because a connection went quiet. Root cause: one fetch call site was added without the timeout wrapper the others use. Prevention: a test asserts the refresh fetch receives an AbortSignal and that firing it yields unreachable with no storage removal.
- The forced-update and legal-acceptance screens stopped working for anyone whose token had just expired. Audit finding F06. After a successful token refresh, the retried request was parsed and thrown by a second copy of the response handling that knew nothing about the app's two central recovery levers — so a LEGAL_ACCEPTANCE_REQUIRED refusal never opened the acceptance screen and a 426 never raised the update screen when either arrived on the retry rather than on the first response. Both worked on a first response and silently stopped working across a renewal, which is both the harder case to notice and the likelier one to hit: a member whose session has just been renewed is exactly the member being asked to accept new terms. The retry now becomes the response and falls through to the single shared handler, so there is one response path and one set of levers. Two tests cover the refusal-after-refresh case for each lever. Root cause: duplicated response handling, where only one copy was kept up to date as central behaviour was added. Prevention: the duplicate is deleted rather than synchronised — there is now exactly one place a response becomes a value or an error.
- Scheduled-episode dates in the mobile podcast studio formatted as US English for everyone. formatScheduledDate() in app/(modals)/podcast-studio.tsx called toLocaleString() with no argument, so Intl fell back to the device's locale rather than the language the member chose in the app. Fixed to pass dateLocale() from @/lib/utils/dateLocale, the same helper the other 94 formatter calls in mobile/ already use. Caught by scripts/check-date-locale.mjs in the Android Native Release Gate, which is the compensating control for exactly this — a bare language code has no region, and no argument at all follows the handset instead of the app.
- The admin Native App page was not linked from anywhere, so the only way to reach it was to type the URL. Owner's report: "I don't see Admin System native app in the UI. When I go into Admin, I don't even see a System menu." Both halves were right. /admin/native-app has had a route and a breadcrumb since the legacy admin was retired, but no entry in AdminSidebar.tsx — so an admin browsing the menu had no way to know the page existed, and the install figures added in this release were invisible to the person who asked for them. Added to the Platform Operations section; system is the internal group key and is not a visible menu label, so describing the route as "Admin → System → Native App" was wrong twice over. Reuses the existing breadcrumbs.native_app label, already translated in all eleven locales, so the menu entry and the breadcrumb cannot drift apart; the new search_keywords.native_app synonyms are hand-written per locale so the admin search box finds the page under "mobile app", "installs", "android" and so on. A regression test asserts the link exists in that section and points at /admin/native-app, because a page with a route and no link fails silently — nothing errors, it simply cannot be found.
- The admin native-app install page showed an organisation account under its contact person's name, and formatted every number in the browser's language rather than the admin's. Two CI gates caught both faults on the first push of 601ed5ec9. (1) NativeAppInstallStatsService::displayName() concatenated first_name/last_name directly. An ORGANISATION account (profile_type = 'organisation') keeps its trading name in organization_name and a CONTACT PERSON in those two columns, so a community organisation appeared in the recent-installs list as whichever individual registered it. It now resolves through App\Support\UserDisplayName::resolve() — the single source of truth for a display name — and both selects feeding it now read profile_type and organization_name; the username fallback and the never-show-a-bare-row-id contract are unchanged. Caught by scripts/check-display-name.mjs (ceiling 0), and pinned by a new regression test asserting an organisation is listed by its trading name, not its contact. (2) NativeAppInstallStats.tsx called toLocaleString() with no locale in three places and Intl.DateTimeFormat(i18n.language) in a fourth, so counts and dates rendered in the browser's language and i18n.language omitted the region. All four now take getFormattingLocale() from @/lib/helpers, matching every other admin surface. Caught by check:locale-formatting, which runs in the React prebuild and had turned Lighthouse CI red. (3) The same commit added /v2/admin/settings/native-app-install-stats to routes/api.php without refreshing mobile/docs/generated/laravel-api-route-inventory.json, so the mobile API-consumer ledger reported UNVERIFIED - the route inventory is stale and verified nothing at all — correctly refusing to pass vacuously, and failing the Android Native Release Gate. Regenerated with npm run api:routes: 2,240 distinct API paths, 463 mobile endpoints verified, 0 missing.
- npm run check:i18n was red on a clean tree: 28 duplicate JSON keys across every mobile locale. scripts/check-i18n-json-integrity.mjs (part of the check:i18n aggregate) reported four keys defined twice in the same object, in all seven mobile/locales/ languages — detail.files.downloadFailed and detail.files.sharingUnavailable in groups.json, connections.remove in members.json, and certificates.sharingUnavailable in volunteering.json. All eight variants were introduced by the same commit (a4ba532f0, the 2026-09-05/06 mobile audit): two independent edits each appended the key to the same block, one near the top and one at the bottom. JSON.parse keeps the last occurrence, so the second definition had been the one rendering all along and the first was dead text — except to the integrity gate, which counted it. Fixed by deleting the earlier occurrence in each pair, which leaves every rendered string byte-identical: groups.json keeps the generic "Could not download the file." / "This device cannot open downloaded files." and volunteering.json keeps "This device cannot open downloaded files.", discarding the unreachable file- and certificate-specific wordings; connections.remove was identical in de/en/ga/it/pt, and in es/fr the surviving later value ("Eliminar", "Supprimer") is kept. The edit was applied by script from the checker's own line numbers, refusing any line that was not comma-terminated, and each file was re-parsed afterwards to assert the value at every affected key path was unchanged — 21 files, 28 deletions, 0 insertions, CRLF endings preserved. check:i18n:json-integrity and the full check:i18n aggregate now exit 0; mobile is green at 392 suites / 2,921 tests. No user-visible string changed. Note for later: certificates.downloadFailed in volunteering.json is referenced by no source file (volunteering.tsx uses certificates.openFailed), and the surviving generic wordings are less specific than the ones removed — both are wording decisions, deliberately left alone here so this fix could not alter what a member reads.
- SEO snapshot freshness: a platform-wide rebuild that could never finish silently switched off drift detection for 28 days, and every pass reported success. prerender:detect-drift stands aside whenever a global (tenant_id IS NULL) prerender job is still queued/claimed/running or fence-pending, because piling per-tenant recaches on top of an authoritative rebuild fights it. The guard is right; having no time bound was not. In production, jobs 4993, 4996 and 5001 sat queued back to back from roughly 2026-08-11 to 2026-09-06 — the host job processor could not claim anything, because php artisan failed to boot inside the container (bootstrap/cache/routes-v7.php was missing), which polluted the claim output and made scripts/prerender-job-processor.sh log FATAL: unsafe routes in claim output every minute. So every two minutes the sweep hit the guard, printed authoritative_global_job_exists, exited 0, and thereby refreshed prerender:sched:prerender-detect-drift:last_ok_at via the scheduler's onSuccess hook in bootstrap/app.php. The scheduler-liveness check in PrerenderService::health() reads that stamp, so the engine looked alive for 28 days while all 972 snapshots went stale and nothing alarmed. The guard now has an age bound: PrerenderService::blockingGlobalJob() is the single shared definition of "a global job is blocking", and marks the block stale once it outlives prerender.authoritative_block_alert_seconds (default 30 minutes, PRERENDER_AUTHORITATIVE_BLOCK_ALERT_SECONDS). A stale block makes the sweep print the block's full state, say in one line that nothing is being refreshed, and exit 1 — so the onSuccess stamp stops being refreshed and scheduler liveness goes red on its own within ~6 minutes. Deliberately carved out: a running job whose worker lease (COALESCE(heartbeat_at, started_at), the same reading the stuck-job check uses) was renewed inside the same window is genuinely rendering snapshots, so a long authoritative rebuild that is making progress stays routine and does not cry wolf. Three regression tests in PrerenderDetectDriftTest cover the stuck shape (never claimed, never started — exactly production's), a young block, and a long-but-progressing one; the pre-existing suppression test is unchanged.
- SEO snapshot freshness: one community missing one page stopped drift detection for every community after it. prerender:detect-drift — the two-minute layer that compares each tenant's sitemap against its snapshot mtimes — called PrerenderService::enqueueJob() inside the per-tenant loop with no guard. enqueueJob() re-validates every route token and throws InvalidArgumentException when the route is not in that tenant's prerender plan, so the exception escaped the loop and abandoned the sweep for every tenant that had not yet been reached; the cron restarted two minutes later and failed on the same route again. Root cause of the divergence: /install-app was added to SitemapService::getStaticPageUrls() on 2026-08-12 but not to PrerenderService::ALWAYS_PUBLIC_ROUTES, and nothing compared the two hand-maintained lists. Three changes: /install-app is added to the route plan (it is a public, unauthenticated React route in PublicAppRoutes.tsx, so it should be snapshotted); the sweep now asks tenantRouteCanBePrerendered() before enqueueing, passing the tenant target it already loaded so the check costs no extra query, and reports what it dropped in the per-tenant unavailable_routes / unavailable_sample fields and in skipped as routes_not_in_tenant_plan — counted in planning_errors, never silently discarded, and a malformed route still reaches enqueueJob() and still throws. This matches how prerender:auto-recache has always filtered. Regression tests: the sweep continues past a tenant whose enqueueJob throws exactly as production's does and still enqueues the following tenant, unavailable routes are reported alongside a tenant's renderable ones, and SitemapServiceTest now fails if any static sitemap route is absent from the prerender route plan. Bot-facing only — snapshots are served to crawlers, never to members.
- Native app: a full emulator and source audit of the Expo client, 2026-09-05/06 — 60-plus member-visible defects fixed across every module. The 5 September source audit had no device attached; this one walked the app on the nexus_test emulator in light and dark mode at 411dp and at a 360dp control, then six read-only sweeps covered the source by module. What a member would have hit, and what changed: Money. A comma-decimal price or offer in the marketplace (12,50) had its comma stripped before parsing and was sent as 1250; nine other decimal-pad fields (offers, shipping prices, coupon discounts, goal targets and progress, proposed exchange hours, wallet transfers) fell to Number() and rejected 1,5 as invalid or silently sent null. Every one now goes through the shared parseDecimalInput. All non-integer numbers interpolated into a translation are now locale-formatted centrally (alwaysFormat in mobile/lib/i18n.ts), so a German member no longer reads "1,5 Stunden" on one line and "+1.5h" on the next; the seventeen remaining .toFixed() displays (ratings, distances, file sizes, identity fees) use formatDecimal. Failures that looked like success. Declining or cancelling an exchange request popped the member back to the list even when the server refused; the job interview/offer accept and decline calls swallowed every error and the caller had no else branch; poll votes, match dismissals, review submissions and deletions, AI-chat feedback and saved-search deletions failed silently or with a success haptic fired before the request; a failed goal load read as "Goal not found"; a failed skills load read as "No skills yet" beside a live Add button; an image-step failure while editing a listing reported the whole save as failed although the fields had saved. Each now reports the server's own reason, confirms destructive actions through useConfirm, and buzzes only after the server agrees. ModalErrorBoundary — which wraps every modal route — never reported crashes to Sentry or the API log; it does now. Readability on a community's own colour. Around eighty icons and labels were hard-coded white on the tenant's primary colour (a pale brand colour made them invisible); the conditional forms color={selected ? '#fff' : primary} and backgroundColor: selected ? primary : … had escaped both existing guard tests, and FormActionFooter painted its disabled button theme.border while HeroUI kept the label colour for the accent, so "Save changes" was unreadable in both schemes. Selected pills now let HeroUI's primary variant paint and label itself, AccentIcon / contrastText() are used everywhere else, own message bubbles derive their text colour from the bubble fill, and components/accentIconColour.test.ts and accentOverride.test.ts now catch the conditional forms. Seen on the emulator. Hour Timebank's wide wordmark was squeezed into a 30×30 box (now sized from the image's aspect ratio); plain posts carried a stark near-white strip in dark mode and an invisible one in light; the author's name truncated to "E2E Us…" at 360dp because the type chip sat beside it; every message in a thread carried a permanent 👍 ❤️ 😂 ⋯ row as tall as the bubble (now shown on tap, options on long-press); voice notes read 0:00 because the server's duration was never passed to the bubble; the notifications hero said "You have no new notifications" while both requests were still loading and system notifications wore a "?" avatar and a chip reading "Notification"; Edit Profile's title read "Loading profile"; the Jobs tab strip truncated "My Appl…" at 411dp (two per row below 600dp now); the Groups and Jobs hero cards had no top padding; Members carried a developer-facing "5 loaded" chip and Messages a "1 shown" chip; the wallet's refresh indicator floated over the heading on first load; eleven screens said "Go back" where the rest say "Back". Everything else. Registration's Last-name field was the only one without a validation message (a blank surname was a silent dead end) and the eight-field form now guards against an accidental Back; the events tab wiped the whole list on a load-more failure; Explore showed raw ISO timestamps on event cards, duplicated a member listed as both suggested and new, and routed poll/resource taps to the list instead of the item; the search result row was a card inside a button (the measured zero-width fault); saved-search delete had no confirmation; the crash screen was light-only; the EVENTS_CONTRACT_DRIFT machine code was the message a member would see (now a translated sentence with the code in code, and the tests assert the code); five upload/AI errors were hard-coded English; the messages tab, exchanges tab, goals and group exchanges did not refetch on return; group exchanges could never show the 21st row; the appreciations list appended page 1 twice; the leaderboard collapsed the whole screen to a spinner on every period change; several pull-to-refresh indicators never showed or never stopped; the bg-content2 / bg-default-200 classes were HeroUI v2 web tokens that render nothing here; the carousel measured the window once at module load; the typing indicator, heart animation and poll bars now honour reduce-motion; NativePressable accepts hitSlop; the FormActionFooter on change-password, new-job and new-marketplace-listing says what is missing instead of "Ready". Dead ends. Notifications loaded exactly one page of 25 and offered no way to reach the rest, so a member with 47 unread could read the header saying 47 and open 25 of them. It paginates now. A failed check of whether you are connected to someone left the Connect button offering to send a request that may already exist; it offers a retry instead. A failed load of the blocked-members list, the data-export history or the translation preferences each fell through to a confident empty state — "No blocked users", "No exports yet" — and the translation form went further: with Save enabled over its own defaults, one tap wrote them over whatever the member had really chosen. Each now shows the failure with a retry, and the translation screen no longer changes the app's language when auto-translate is off. One tap, no way back. Removing a linked carer or dependent account, disconnecting from a member, and removing a saved item all acted on a single tap, while the same actions elsewhere in the app confirm first. All three confirm now. A post typed into the composer was lost to a stray Back with no prompt, as the registration form was. "This item may have been sold, removed, or moved." The marketplace listing, job, blog post and feed item detail screens turned any load failure into that sentence — a definite statement about someone else's content, produced by the member's own dropped connection, under a button that navigates away from the thing they were looking at. Each now separates a real 404 from a failed request and offers a retry. "Read in app" for Terms, Privacy and Cookies showed three fixed translated paragraphs rather than the community's own legal text, which is the text the acceptance gate enforces; those three now open the real document. The job and marketplace listing forms gained the unsaved-changes guard the exchange forms got on 5 September. A conversation stopped at 50 messages. The message thread fetched the server's first page and offered nothing to say more existed, so a long conversation was silently truncated. It now carries a "Load earlier messages" header, and older pages are held apart from the live page so an incoming message cannot be lost behind history. Sharing. A shared post carried the words and no link at all, so the recipient had no way to open it. Blog posts, jobs, groups and federation partners built their link from the platform host with no community, which lands anyone on a custom domain in the wrong community or on a 404. Every share, and the Help/Terms/Privacy links, now carry the member's own community. 🔴 The blog post share then captured the community before tenant configuration had loaded and never re-read it, so the first share after a cold start still went out without one — the same dead link, from the fix itself. Caught by the linter's exhaustive-dependency warning, which is why the audit's leftover warnings were cleared rather than left: three orphaned imports, four duplicated ones and three stale suppressions, taking the native client to zero lint warnings as well as zero errors. Guards and gates. Every fix that changed behaviour has a Jest test that was seen failing before it: 391 suites and 2,910 tests pass, with tsc, ESLint (0 errors), the drift, untranslated-phrase, theme, store-audience, release-config and startup-budget gates, and expo-doctor (18/18). Two new scan guards were added and proved red before green — one refuses any screen that deletes a decimal separator before parsing, one refuses a backgroundColor on the shared form footer's submit button — and the two existing colour guards were widened to see the conditional idiom (selected ? '#fff' : primary) that had hidden about eighty sites from them. New strings ship in all seven mobile locales, written by hand. Two design findings from the 5 September audit — the More screen's 21-item directory and the hero card repeating the top-bar title on roughly forty screens — are recorded, not restyled: both need an owner decision, not a mechanical fix. The marketplace, jobs, blog, podcast, course and federation screens were fixed where the cross-cutting sweeps reached them, but did not get their own module-by-module read; that pass is still outstanding. 🔴 The first push of this audit failed CI on the per-area coverage ratchet, not on any test: three areas had gained code faster than they gained tests. That is fixed by adding the missing tests, never by lowering a floor — the community banner's logo sizing, the trust-badge row's failed-check path, the listings category retry and focus refetch, and every one of Explore's fourteen sections now have their own regression cases. app/(tabs) went 70.45% to 76.06% and components/verification to 100%, and the baseline is re-cut so the new levels are the floor. Two unrelated areas that drifted down a tenth of a point keep their old, higher floors rather than being quietly re-baselined downwards. 🔴 The banner had no test at all because jest-setup.ts mocks that component away for every suite, so a file testing it must unmock it first.
- The Changelog page showed members the app's own HTML source instead of the release notes. The page asks the server for /changelog.md and renders whatever comes back. The blue/green nginx config's catch-all ends in an unconditional "serve the SPA shell" and never looks for the file on disk, so that request answered 200 with index.html — and because the page only checks that the response succeeded, it accepted that HTML as the changelog and rendered the markup. Anyone opening the release notes saw a page of <!DOCTYPE html><html lang="en">… under a "Changelog" heading. Fixed with an exact-match rule that reads the file and 404s when it is absent, so it can never fall through to the shell again; exact-match so nothing else is widened. 🔴 This was a regression introduced with blue/green: the single-colour nginx.conf still carries the try_files $uri the blue/green config dropped, which is why the page worked before and broke silently after. Every existing test mocks fetch, so the whole suite passed throughout — the defect lived entirely in the gap between the app and the server that serves it. The new guard asserts the serving rule exists, that it reads from disk, that it names the same path the page fetches, and that the two configs cannot drift apart; it was seen failing against the unfixed config. Found while verifying a deploy rather than by any check.
- Native app: text was sliced at Android's larger text sizes, on ten screens. The 2026-09-05 sweep photographed the profile screen at the 1.3x text setting and recorded a heading that "appeared sliced at the top" as an observation it could not confirm — possibly just a mid-layout capture. It was real, and it is arithmetic rather than a judgement call. React Native grows the font when a member enlarges text but leaves a line height that was written as a fixed number of pixels exactly where it was, and nothing in the app caps the scaling. So a heading set as 24px text inside a 28px line becomes 31.2px of text inside a 28px line at the 1.3x setting: the letters are taller than the space they are drawn in, and the tops and tails are cut off. Ten places were below the line — a day heading on Events, a blog post's title, three section headings, and five item titles across the marketplace, settings and profile. Each now leaves the line height to the platform, which scales it. A new guard refuses any future pairing that would clip at 1.3x, and reports the file, the line and the measurement rather than a rule number; it was proved to catch the worst of the ten before they were fixed. 🔴 Deliberately not swept further: the next tier down covers about 350 more places, all of which hold at 1.3x, and changing them would alter the app's spacing everywhere — that is a design decision, not a bug fix. Android also offers 1.5x and 2.0x, where that tier does give out; recorded rather than silently changed.
- Native app: an events screen formatted a date with no language at all, and the check that catches it had never run. new-event.tsx carried its own copy of a small "is this a real time zone?" probe, and that copy called the date formatter with no locale — which the mobile date-locale gate blocks, because a formatter with no locale follows the device rather than the language the member chose. The duplicate is removed and both callers now share one exported helper carrying one documented exemption, since the probe's output is discarded and never shown. 🔴 Worth recording is why this surfaced only now: a job's steps run in order and stop at the first failure, so while the coverage ratchet was red every later step in the mobile gate — this one, the translation ratchet, the drift check and the bundle budget — never ran at all. Fixing the earlier failure is what revealed it. All of the later steps were then run locally and pass.
- Native app: enrolling on a paid course spent time credits on one tap, and a failed member search said the person did not exist. The module-by-module read of podcasts, courses and federation that the 2026-09-05/06 audit left outstanding. Four member-visible faults, each with a regression test seen failing first. Courses: the Enrol button called the API immediately, so a course priced in time credits took them from a member's balance on a single tap, without a confirmation and without stating the price at the moment of spending — every other place the app moves credits asks first. A paid course now confirms and names the amount; a free one still enrols straight away, because there is nothing to weigh up. When enrolment was refused the reason was discarded and the member read "Could not enroll. Please try again." — and by far the likeliest refusal is not having enough credits, where trying again cannot work. The course price is also parsed and formatted like every other decimal in the app, so a comma-locale member no longer reads a full stop. Course player: the same discarded reason on "Mark as complete", a failed course load that offered no retry and so was a dead end, and a progress bar that rendered NaN% wide when the server sent a percentage it could not parse. Federation: a dropped recipient search left the results list empty, and an empty list is rendered as "No federated members found." — so a failed request told a member that the person they were looking for is not on the federated network at all. It now says the search failed and offers a retry. Feed: a scan for that same shape — a catch that sets a list to empty, where empty is rendered as "nothing matched" — found one more, in hashtag search: a dropped request read as "No hashtags match …". Fixed the same way. The other three matches (the marketplace map twice, the volunteering shift swap) already set an error alongside the empty list and were left alone. 🔴 Separately, the biometric-lock suite failed the release gate twice on things that pass here every time. Its async assertions relied on the testing library's default one-second wall-clock window, which the release gate's whole-suite, single-threaded, instrumented run on a four-processor runner does not reliably meet; they now carry an explicit five-second window, as one other suite already did. What was ruled out first, because a timeout is the wrong answer to a real fault: the suite logs no missing-act warnings, so updates are being flushed, and a component change to stop the lock's decision cancelling itself on every re-render was written and then reverted, because a test driving five re-renders during a pending check passed against the unchanged component. Nothing about the lock's behaviour changed. New strings ship in all seven mobile locales, written by hand. 🔴 Not everything found is fixed: the podcast and course screens still discard the server's reason in several other places, which is the known pattern recorded against describeApiError rather than a new finding.
- Native app: a rejected listing save no longer throws the member's input away, and eight more faults from the 5 September audit are fixed. An independent source audit of mobile/ (Codex, 2026-09-05) reproduced two P1 defects in listing creation and found seven P2s; all are addressed here, each with a regression test. F01 — new-exchange.tsx ran its navigation block after the catch as well as after success, so a failed or timed-out save flashed its error and then left the form (router.back() or replace to the listings tab), losing the description, tags and image; only a confirmed creation now navigates, and a failure keeps every field with the error visible. F02 — the form read only data from useApi, so a failed category request looked like "no categories": the picker and its required-field error vanished while the form still looked fillable. Loading, failed-with-retry (input preserved) and genuinely-empty states are now distinct and say what they mean, and the validation message distinguishes "not chosen" from "none could be loaded". F03 — the More screen showed "Trust status: Active" and "Account: Ready" from literal strings with no backend value behind them; replaced by a real profile-completion card (user.onboarding_completed) and an honestly labelled count of available areas. F04 — setExchangeTags(...).catch(() => null) in new- and edit-exchange hid tag failures; partial success is now reported like the existing image-upload warning, and the listing is still opened rather than re-created. F05 — new/edit listing had no unsaved-changes guard; the beforeRemove pattern from edit-profile is lifted into lib/hooks/useUnsavedChangesGuard.ts and applied to both (Back, Cancel, Android back and iOS swipe). F06 — Number('1,5') is NaN and parseFloat('1,5') is 1, so comma-locale members were refused or, worse, silently mis-booked; new lib/utils/decimal.ts (parseDecimalInput, formatDecimal) is used by the exchange forms, group exchanges, exchange confirmation, credit transfer, and the wallet/More balances. F07 — useApi and usePaginatedApi set the English literal 'An unexpected error occurred.' after a failed retry, which a dozen screens display verbatim; both now translate common:errors.generic at the moment they set it, as lib/api/client.ts already did. F08 — the shared Button replaced its label with a bare spinner while loading; the label now stays beside the spinner and accessibilityState.busy is exposed. F09 — the Messages tab badge sprang regardless of the OS reduce-motion setting and used hard-coded white text on the tenant colour; new useReducedMotion() skips the spring, the text uses contrastText(), and the badge carries a translated "{{count}} unread messages" label. F12 (partial) — store-listing/apple/en-GB.json said 1.2.0 while the app is 1.3.0; corrected. 🔴 The other two iOS blockers need the owner's Apple identifiers (Team ID for the association file, the numeric App Store Connect app ID) and cannot be fixed from the repository. Not done, by decision: F10/F11 (More-screen hierarchy and repeated headers) are design changes the audit itself ranks after reliability and asks to validate on devices first. Tests: 6 new regression cases in new-exchange.test.tsx (rejected save stays; failed categories + retry + blocked submit; loading vs empty; tag failure toast; dirty-exit guard; comma decimal), plus unit suites for the decimal helpers, the exit guard and the reduced-motion hook. Full mobile suite green.
- Ten native-app screens drew their bottom controls under Android's 3-button navigation bar. Owner's screenshot, 2026-09-05, of the member profile: Connect / Send credits / Send Message sat beneath back / home / recents. Two causes stacked. An absolutely positioned bar (bottom: 0) never receives a SafeAreaView's padding, so the wrapper that looked like protection was not; and inside presentation: 'modal' routes useSafeAreaInsets().bottom is 0 on Android, so the four screens that did read the hook padded 16px and were under the bar too, while non-absolute footers got no padding from their SafeAreaView at all. The eleven form screens on FormActionFooter were already right because it floored the hook with the inset the root layout records (mobile/lib/ui/rootInsets.ts). That floor is now useBottomInset() in the same module — the one value any pinned control must clear — and FormActionFooter, BottomSheet and CommentSheet call it rather than inlining the expression. Fixed: member profile, job detail, event detail, exchange detail and marketplace detail (action bars; the ScrollView reserves the same extra room so nothing hides behind them), the marketplace map's floating preview card, the new- and edit-exchange form footers, and the chat and thread composers. Audit method, so it is repeatable: every absolute … bottom-* element, every footer rendered after a ScrollView, every KeyboardAvoidingView screen without FormActionFooter, every raw insets.bottom read, and every bottom sheet; the bulk of the raw matches were decorative side stripes (absolute bottom-0 left-0 top-0 w-1), which are unaffected. Auth screens are not modal routes and the tab bar already sizes itself from the inset, so both were left alone. Tests: four for the hook, and regression assertions on the member-profile and job-detail footers with a 48dp root inset. Verified on the nexus_test emulator switched to 3-button navigation: the member-profile bar now sits fully above the system bar.
- Every link the native app handed to the website omitted the community slug, so on the shared host (app.project-nexus.ie) all five landed on the platform landing page instead of the page the member was sent to. Found while auditing the create menu. Since 2026-05-08 the web app resolves a URL exactly as typed, and a slug-less path on the shared host is the platform page — the destination is simply lost. Affected: the identity-verification web fallback (mobile/lib/payments/identityPayment.ts and .web.ts → /settings/verify-identity), the marketplace order-payment web fallback (marketplacePayment.ts → /marketplace/orders), and the three share sheets — exchange (/listings/:id), marketplace listing (/marketplace/:id) and member profile (/members/:id). They only ever worked for a community on its own domain, where the domain names the community. New mobile/lib/utils/webUrl.ts — buildWebUrl(slug, path) — is now the one place these URLs are built; it falls back to a slug-less path only when tenant config has not loaded, and never emits /undefined/. The two payment presenters take an optional tenantSlug, passed by the three calling screens. Tests: four for the helper, slug-and-fallback cases for both presenters, and the orders screen asserts the slug is actually passed.
- The server still told locked-out members to install 1.2.0 after the app moved to 1.3.0. config/mobile.php → expo.current_version is the version the mobile version gate advertises to a member it has blocked, and the 1.3.0 bump missed it — so the app would have pointed people at a build that is no longer current. 🔴 It is a fourth place the version lives, and the only one written in PHP, which is why a hunt through mobile/ did not surface it; npm run check:version does not cover it either, and its own comment says so. The single guard is MobileVersionGateRegistrationTest::test_the_advertised_current_version_matches_the_manifest, which compares it against mobile/app.json. 🔴 That guard did not fire for four commits: ci.yml skips the PHP suite when a change touches no PHP paths, and the bump plus the three commits after it were all frontend, mobile and docs. It surfaced only once an unrelated PHP edit made the suite run — a worked example of the repo's own warning that a green tick is not proof the code was checked. minimum_version is deliberately left at 1.2.0: raising it force-updates every existing install.
- A YouTube video in the feed would not play in the native Android app, though the same post played on the website. The card rendered the preview's image_url — which for a YouTube link is the video's own thumbnail — inside a plain View with no press handler at all, and ignored content_type entirely. So it looked exactly like a player and tapping it did nothing. 🔴 This was never YouTube-specific: no link preview of any kind was tappable, so every shared article was a dead end too. The web build embeds an iframe; the Expo app ships no WebView and adding one would mean a new native dependency against 1.47 MB of startup headroom, so the fix hands the URL to the OS instead — on Android that opens the YouTube app, which plays it better than an in-app frame would. Video previews now carry a play badge and a "plays outside the app" line so a thumbnail is not mistaken for an inline player. The scheme is checked before opening, because url is member-supplied content echoed back by the API and Linking.openURL will hand any scheme to whatever app claims it. content_type was also missing from mobile's link-preview type, which is why the card could not tell a video from an article; embed_html is deliberately still absent from that type, since nothing in the app could render it. Five regression tests in mobile/components/FeedItem.test.tsx, and the three new strings ship in all seven mobile locales.
- A link whose address existed only in an anchor's href never produced a preview. LinkPreviewService::extractUrls() called strip_tags() first, which discards attributes — so a link inserted with the composer's link button, where the visible text is the member's own wording, lost its address entirely and no preview was ever built. Hrefs are now read out of the raw HTML before stripping, deduplicated against the visible text, and filtered to http(s) so a javascript: or mailto: href can never reach fetchPreview. Entities are decoded after stripping, never before: decoding first would turn an escaped &lt;a href=… into a real tag and hand an attacker an extra candidate. 🔴 The failure was invisible — createPostV2 catches preview errors and logs them at debug level, so nothing reached Sentry or the normal log. Reproduced locally across six shapes of composer HTML before any fix; the href-only case returned an empty array.
- An HTML-encoded ampersand corrupted every extracted URL. &amp; survived extraction, so the stored address held a literal &amp; and every query parameter after the first was wrong. YouTube escaped it only by luck — its video id sits before the first & — so the embed still worked while the saved link did not.
- Both tablet screenshot slots on the Google Play listing held phone screenshots, and had since the app went public. The listing filled 24 slots from only 8 distinct files — the same eight portrait 1080×1920 captures used for phone, 7-inch tablet and 10-inch tablet. Proven rather than inferred: every img in all three sections resolved to the identical eight lh3.googleusercontent.com URLs, and downloading all eight at full resolution returned 1080×1920 portrait for each, so the genuine 10-inch captures — which are 2560×1440 landscape — could not have been among them. One slot's detail panel named its file 01-feed.png, a phone screen, dated the original upload. 🔴 Dimensions alone cannot detect this for the 7-inch slot, because a genuine 7-inch capture is also 1080×1920; only the shared-URL evidence separates them. store:assets:check passed throughout, because it validates the files on disk and cannot see the listing — which is exactly how "the screenshots are correct" and "the listing is wrong" were both true at once. Replaced with fresh captures taken from 1.3.0 itself on the nexus_tablet_7 and nexus_tablet_10 emulators (1200×1920 and 2560×1600 raw, converted by store:tablets:prepare, which pads rather than crops or stretches), because the previously prepared captures came from the older submitted APK and 1.3.0 changes tablet layout. The listing now holds 16 distinct files across 16 slots, verified by re-downloading each: 4 × 2560×1440 landscape in the 10-inch slot, 4 × 1080×1920 in the 7-inch, and the phone slot's 8 untouched. 🔴 The change is saved but deliberately not submitted for review, because submitting restarts any review already in flight and the 1.3.0 production release is in one.
- The live Google Play listing told members two things about the app that were not true. Its "Your time, tracked properly" paragraph said "No money changes hands and nothing is ever put behind a payment", which contradicts the optional second-hand marketplace where members arrange purchases of physical goods with payments handled by Stripe; and its "Open and independent" paragraph claimed "no tracking", which contradicts the app's own Data Safety declaration, since Sentry collects crash diagnostics and Firebase delivers push notifications. Both were live on the public listing from the first production release on 2026-08-26. The money sentence is replaced by the truthful distinction already prepared in mobile/docs/PLAY_SUBMISSION.md — time-credit exchanges do not involve money, physical-goods payments may use Stripe — and the independence sentence now claims only what is true (no advertising, no sale of personal data) while pointing at the Privacy Policy for the security, push-notification and crash-diagnostic services that are disclosed there. 🔴 The second claim had not been recorded anywhere as a defect; only the money sentence was on the release backlog. A store listing that contradicts the Data Safety form is a policy exposure in its own right, independent of the payments question, because the two are shown side by side on the same public page. The listing is English (United Kingdom) only, with no translations, so this was a single edit; it was submitted for Google review on 2026-09-03 and the heading above it changed from "A community, not a marketplace" to "A community, not just transactions", which had become self-contradictory once the paragraph above it named the marketplace.
- The super-admin panel could not tell an operator why a community create or edit was refused, and one of its failure paths put raw exception text in the API response. TenantHierarchyService returns a specific reason for every refusal — the hierarchy depth cap, a slug or domain already taken, a parent that does not allow sub-tenants, a name left blank — but 57 of its 74 error returns were hardcoded English, so the panel could not show them to an operator who may be working in any of the eleven locales and fell back to a fixed "Failed to create tenant" instead. All of them now go through __('api.*'): 23 new keys in lang/en/api.php, translated into the other ten locales (Irish written by hand, because the translation script only implements a provider whose Irish output is not approved for release), plus three existing keys reused where the English already matched. The interpolated ones take named placeholders, so "Slug 'x' is already in use" is now api.super_slug_in_use with a :slug value rather than a sentence assembled in PHP. Separately, seven catch blocks returned 'Failed to create tenant: ' . $e->getMessage() and its siblings — raw exception text, which is unlocalised and can carry SQL, table names and connection detail into a response the browser displays. Those now log the exception (with the throwable attached, so the trace is kept) and return a translated generic failure. react-frontend's TenantForm shows the server's reason again as a result, guarded at both ends: ApiErrorLocalisationTest fails if a literal 'error' => string returns to that service, and the TenantForm regression test — which had been deliberately inverted while the reasons were untranslated — asserts the specific reason is shown and was confirmed to fail against the unfixed component. 🔴 Note that tenantToggleHub still sets max_depth to max(current, 2), which permanently caps any hub already at depth 2 or deeper; four production hubs remain capped. That is a separate open fault and is not fixed here.
- The native app's message badge came back at every login, however many messages the member had read. Unread message state lives in two separate stores and only one of them was ever cleared. messages.is_read is the real per-message flag, and reading a conversation clears it — MessagesController::show calls MessageService::markAsRead on every thread fetch. The bell rows in notifications that NotifyMessageReceived writes for the same message (type new_message, link /messages/{senderId}) were never touched, so they accumulated permanently. /v2/notifications/counts reports its messages category by counting exactly those rows, and the mobile badge was reading that number — so a member who had read everything still met a non-zero count at the next launch. Measured against the development database: one member held 0 genuinely unread messages and 2 unread new_message rows, which is the phantom badge exactly. Reading a conversation now also clears that conversation's message-type bell rows, scoped to the member's own user_id and the conversation link; federation_message is deliberately excluded because the message write above skips federated rows. That also deflates the notification bell and the OS launcher badge, which counted the same stale rows platform-wide, on web as well as native. Separately, the mobile badge now reads /v2/messages/unread-count — the count of real unread messages — which is the endpoint the React frontend has always used for this purpose and pins with its own test; a failed request keeps the previous value rather than claiming everything is read. Opening the Messages tab used to zero the badge in local state and tell the server nothing, which is why the fault looked like it kept returning rather than never having been fixed; arriving at the tab now re-reads the true count, so the badge agrees with the "N unread" chip on the conversation list, and opening a thread refreshes it once the server has marked it read. Regression tests were confirmed to fail against the unfixed code on both sides.
- Creating a community in the super-admin panel failed with an unexplained "Failed to create tenant", and five of the seven hubs it offered as a parent could never have accepted one. Three separate faults stacked into one dead end. The create endpoint rejects a bad request with a 422 and a specific reason — "Maximum hierarchy depth exceeded", "Slug 'x' is already in use" — and TenantForm discarded it and showed a fixed string instead, so the operator had a refusal with nothing to act on and no trace anywhere: a deliberate validation refusal is not an error, so it reaches neither Sentry nor the daily log. Showing that reason was reverted once before release and has now been restored, because the thing it was waiting on has been done. The refusals were hardcoded English in TenantHierarchyService (57 of its 74 error returns), so surfacing them would have put untranslated English in front of an operator working in any of the eleven locales — scripts/check-admin-ui-literals.mjs blocks that pattern as server-message-bypass, correctly, and it failed CI on 0d23d3ad7. Those refusals now go through __('api.*') with keys translated into all eleven locales (see the entry below), ApiErrorLocalisationTest lists the service so a literal cannot come back, and the reason is shown again with the generic string kept as the fallback for a failure that carries none. Second, the parent picker listed every hub returned by GET /v2/admin/super/tenants?hub=1 without checking whether that hub can still take a child. max_depth is an absolute level cap, not a relative allowance — TenantHierarchyService::createTenant refuses a child whose own depth would exceed the parent's max_depth — so a hub sitting at its own cap can never hold anything, and picking it produced the unexplained 422 above. Measured against live data at the time: Timebanking UK, Agoris, timebanks.us, Bancos de Tiempo and Pairc Goodman were all at depth 2 with max_depth 2, leaving only Project NEXUS, Timebank Global and Partner Demo selectable out of eight. Such hubs are now disabled in the picker and say why. Third, Timebanking UK's cap was corrected from 2 to 3 in production, because it already held Crewkerne, Stratford and Minehead at depth 3 — the stored cap contradicted the communities already under it. Note that tenantToggleHub sets max_depth to max(current, 2) when hub capability is enabled, which reproduces exactly this state for any tenant at depth 2 or deeper; that is an open fault, not fixed here, and the four remaining hubs above are still capped.
- When the phone's secure store refused a write, the mobile app lost the value outright instead of keeping it for the session. storage.set() caches in memory only after a successful encrypted write — deliberately, because a failed credential write must not manufacture a session that vanishes on the next launch. That rule is right for tokens and wrong for everything else: when the store refuses, a non-credential value is simply gone and the app spends the rest of the session without it. Measured: expo-secure-store fails outright on an iOS build with no keychain-sharing entitlement ("A required entitlement isn't present") while writing nexus_tenant_slug — the community the member is browsing — which is the symptom the set() reporting was added to chase in the first place. A small allowlist (nexus_tenant_slug, nexus_language, nexus_theme_mode) is now kept in memory when the encrypted write fails; credentials are still dropped exactly as before, and the failure is still reported, now with a recovered_in_memory flag so triage can tell a survived value from a lost one. It is an allowlist rather than a denylist so a key added later keeps the strict behaviour until someone decides otherwise. This is a partial mitigation and not the whole answer: memory does not survive an app restart, and making non-secret values persist properly means storing them outside the Keychain, which needs a persistence dependency the app does not currently have — an open decision, not an oversight. Two of the new tests were confirmed to fail against the unfixed code.
- Every scheduled ops alarm reported itself to Sentry twice, so each one counted double in the queue and against the post-deploy safety budget. All five alarm commands log an ERROR line and separately call Sentry\captureMessage() with a deliberate setFingerprint(), which is what keeps one unresolved condition as one Sentry issue instead of a fresh one nightly. Those two legs were not independent: production's LOG_STACK contains the sentry channel, so the log line ALSO became a Sentry event — and a log line cannot carry a fingerprint, so it grouped by message text as its own separate issue. Measured, not theorised: the safeguarding contact-gate alarm fired once on 2026-08-30 and opened both NEXUS-PHP-65 (the log leg, tagged logger=production with a log_context extra) and NEXUS-PHP-66 (the capture leg) in the same second. The same doubling applied to BackupVerify, OverdueGdprRequestCheck, SloCheck and StuckStripeWebhookCheck. The sharper cost is scripts/postdeploy-watch.mjs, whose alarm budget is ten events: a single doubled alarm spends two of them inside the window whose only job is to prove a deploy safe. The alarm line now goes through App\Support\Sentry\OperatorLog::withoutSentry(), which logs to the configured stack minus that one channel, so local visibility in docker logs and the daily file is unchanged and the fingerprinted capture is the only thing that reaches Sentry. SafeguardingPolicyHealthCheck was also missing from the explicit list in AlarmSentryFingerprintTest, so it was covered only by the looser sweep; it is named now. Regression tests were confirmed to fail against the unfixed code, and assert both directions — that no alarm command calls Log::error() alongside a capture, and that each still logs locally rather than silently losing its log line.
- An address the map provider could not resolve was retried for ever, and the batch jobs called the provider ten times faster than its rules allow. GeocodingService cached successful lookups only. A failure cached nothing, and the batch jobs select rows that have no coordinates — so an unresolvable address never gained any, was picked again on the next run, and paid a fresh network round trip every time, up to the full ten-second timeout. That ran every thirty minutes, for every tenant, permanently; it is the source of the recurring Geocoding exception in Sentry (a Nominatim timeout on the address "Partner Demo"). The same stall sat inside the marketplace listing save path, so a member entering an address the provider dislikes waited ten seconds for their listing to save. Failures are now remembered: "no such place" for a day, a timeout or provider error for fifteen minutes so an outage still self-heals. Separately, the batch loops slept 100 ms between addresses while their own comment claimed one request per second — ten times Nominatim's stated absolute limit, which is grounds for blocking the platform's address. Rate limiting now lives at the one place that actually calls out, so it covers every caller and costs a cached address nothing. Regression tests cover both, and were confirmed to fail against the old code.
- Equipping a profile theme, avatar frame or name colour has never worked. All three methods in AchievementUnlockablesService filtered user_active_unlockables on a tenant_id column that table has never had, so reading, setting and removing an equipped item each threw "Unknown column". The upsert also named that column as its conflict target, which does not match the real unique key (user_id, unlockable_type). A member belongs to exactly one tenant, so user_id already scopes the row; the queries now use it, and the conflict target matches the index, so equipping a second theme replaces the first instead of failing. Two tests that had been skipped on the belief that this was "test-database schema drift" are now real — the schema dump, the development database and the test database all agreed the column was absent, so the code was wrong, not the database.
- A member with a stored season ranking could not load their leaderboard position. LeaderboardSeasonService::getUserSeasonRank() read season_rankings.season_xp; the points are stored in score and no season_xp column exists. It threw "Undefined array key" and then ordered by the missing column, uncaught, so the gamification endpoints that call it returned a server error for exactly those members who had a ranking row. The value is now read from score and still published as season_xp, which is the key this method promises — previously the stored-ranking branch and the fallback branch returned different shapes, so a caller reading season_xp got nothing even when the query worked.
- The regional-analytics partner dashboard returned a server error whenever demographics were enabled. computeDemographicReport() selected a gender column from users. No table in the schema has ever had one — the platform does not collect gender — so the query failed and took the whole subscriber dashboard with it. The column is gone from the query; gender_buckets stays in the response so the published payload shape is unchanged for subscribers, and every active member is now reported as Unspecified, which is the truth.
- Deleting a comment logged an error every time. CommentObserver maintained a comments_count column on feed_posts that does not exist and no migration ever added, so each of its three handlers threw and was swallowed into an error log. It could not have worked in any case: it had no handler for comment creation, so the counter it decremented could never have been correct. Comment counts are computed live by subquery in SocialController, AdminFeedController and PostAnalyticsController and were always right; the observer, its registration and its test are removed. Its test had documented the missing column and then asserted the broken behaviour, which is why it passed.
- Feed and matching each carried a broken duplicate of a working query. FeedRankingService::calculateNegativeSignalsScore() looked for a hidden post by feed_hidden.post_id; that table identifies an item by target_type and target_id, as the live batch path alongside it already does correctly. MatchingService::getStats() read score, is_mutual and distance from match_cache, which stores match_score, distance_km and match_type = 'mutual'; the failure was swallowed and the method reported zeroes. Both now use the real columns. HashtagService's six query methods were beyond repair in the same way — they selected post_hashtags.tag, which lives in hashtags.tag, and joined feed_activity when the foreign key points at feed_posts — and nothing called them, because the working implementation is FeedSocialService. They are deleted rather than repaired so there is one hashtag query path instead of two that can drift apart again; the text-parsing helpers on that class are correct and are kept.
- One mobile fault was opening a new Sentry issue for every device it happened on, and it swamped the error queue. POST /api/app/log built its log message by embedding the client's whole payload, the JavaScript stack included. Those stack frames are absolute paths carrying a per-install simulator and bundle UUID, and Sentry groups a plain Log::error by message text — so two reports of the identical fault never matched and each one opened its own issue. Measured on 2026-09-01: a single expo-secure-store failure on iOS ("A required entitlement isn't present", writing key nexus_tenant_slug) had produced roughly 25 separate Sentry issues in four days, which was 25 of the 37 items waiting in the nightly triage queue. That also spends the post-deploy error watch's alarm budget, the same way the hand-typed php -r events did. The message now carries only the fault's identity — the sanitised event, version and platform plus the payload's scalar fields, each bounded and sorted by key — and the untouched payload is passed as log context, so it still reaches both the log file and Sentry as the log_context extra. Nothing is lost and genuinely different faults still group apart, because the error's name and message stay in the identity. Same defect class as the GDPR alarm in NEXUS-PHP-51, where request ages embedded in the message re-grouped it nightly. Regression tests hold three properties: one fault reported from two devices logs a byte-identical message, two different faults do not, and key order in the payload does not change the message.
- The deeper native-push audit now checks the final wire payload and hardens delivery, registration and exact destinations. The earlier producer audit proved the route before the privacy boundary but missed that the boundary stripped every benign fragment, so a job application still lost #applications before it reached a phone; supported application, comment and discussion fragments now survive validation, and comment notifications open the relevant native comment sheet, scroll to the named comment and visibly identify it. Care in Community—including its emergency alerts—now remains fully suppressed from both adults-only native apps instead of making an out-of-scope route actionable. Expo fan-outs are split at the documented 100-message limit, retry transient provider failures with bounded backoff, optionally authenticate both send and receipt requests, retain missing receipts for queue retry, expire ordinary pushes after one day and other supported emergencies after fifteen minutes, and request time-sensitive interruption only for supported emergencies. Firebase credential/project errors no longer delete valid member tokens. Emergency/safeguarding categorisation now outranks generic module words, so volunteer_emergency appears as Safeguarding rather than Opportunity. Registration validates token shape/platform, removes registrations after permission revocation, handles Expo-token rotation and iOS provisional/ephemeral permission, while the authoritative unread count now clears/synchronises the launcher badge. Paid-campaign selection accepts the integer boolean representation written by the settings endpoint, and bulk preference filtering no longer performs one query per recipient. The admin health view now calls a successful hand-off “Accepted,” removes the misleading “Success rate,” and says explicitly that mobile acceptance remains unconfirmed until device evidence exists.
- The maintained Android Events journey now verifies the list after scrolling instead of requiring an off-screen header to remain visible. The app correctly returned from Past to Upcoming and displayed the deterministic future event, but the harness then scrolled the Upcoming tab label off the viewport and asserted that it was still visible. It now checks the API-backed event that the scroll is meant to exercise, so a valid screen does not make exact-source device CI red.
- Mobile push notifications now say what kind of update arrived and open its exact relevant native screen instead of dumping the member at the app/notification centre. The backend had replaced every ordinary title, body and destination with “New Notification”, generic text and /notifications, discarding the only route a tap could follow. It now keeps the body private, renders a translated category or explicitly curated title in the recipient's locale, and carries only a versioned string-only validated destination; arbitrary fields, credentials, sensitive links, staff/browser-only targets and unavailable native journeys fail closed. A generated inventory and exact native_href review cover all 239 direct and indirect Laravel producer calls and fail when a new or mismapped path appears. The audit corrected comment, group-discussion, mention, local/federated connection, federated review, moderation, job, achievement, match, poll, resource, volunteer-organisation and marketplace listing/order/payout routes; shared job-application links retain the valid React #applications destination while the native mapper converts that fragment to the employer pipeline, and exact order taps resolve the signed-in member's buyer/seller role and highlight the referenced order even beyond the first list page. It removed thirteen duplicate job alerts, routed legacy realtime sends through the canonical privacy/dedup layer, suppresses stories, group chat and every Caring alert until exact in-scope viewers exist, and makes disabled modules or deleted entities recoverable. Foreground, background and terminated-app responses share the App/Universal Link mapper and wait behind authentication. Ordinary Android alerts use the translated default-importance channel; supported non-Caring emergencies select the translated maximum-importance safeguarding channel and request high-priority FCM/APNs delivery. Separately opted-in paid campaigns retain reviewed copy and may open only a credential-free public HTTPS CTA; their numeric tap id is deferred until sign-in and recorded once through a tenant-scoped endpoint without blocking navigation. Regression coverage holds the complete producer population, exact producer/native-route equality, payload/privacy/localisation contract, module and stale-entity fallbacks, paid analytics, response lifecycle and member destinations. Stored notification links are verified as real destinations in both the React bell and the native mapper.
- The unsigned iOS screenshot workflow now retries transient CocoaPods CDN failures. A public CDN returned HTTP 400 for Stripe's podspec before compilation began; three bounded full-resolution attempts now distinguish that external download failure from an application build failure without hiding a persistent dependency problem. Exact-current rerun 33376091426 then compiled, installed, toured and content-verified all four App Store PNGs successfully.
- The Android persisted-message journey now targets Expo Router's exact composer modal. A first hardening pass replaced an ambiguous New message text tap with a public messages URL, but exact-source run 33329914433 and a local emulator reproduction proved that Android's custom-scheme launcher interpreted both public URL forms as a conversation identifier and rendered Invalid conversation. The guarded harness route is now nexus:///(modals)/new-message; the complete four-effect flow passed locally through real message persistence, volunteering, event RSVP and marketplace save, then exact-source run 33332261410 completed all thirteen journeys and all eight independent database assertions.
- Personal saved collections now have a real Android creation journey with independent persistence proof. The thirteenth Maestro flow creates a private collection through the native form, waits for the refreshed list, reopens the result and verifies its empty detail state; the Laravel-side checker separately proves the exact tenant, owner, name, description, privacy and item count. The first device run exposed a harness-visible reachability fault: tapping the visible Name and Description labels did not focus their fields, so Maestro reported text entry as complete while both inputs stayed empty and no request was sent. Stable field identifiers now drive the real inputs, and the corrected local emulator run completed with the database assertion green. Exact-source cloud Android run 33324976225 then built the APK, completed all thirteen journeys and all three effect suites, and uploaded twelve visually inspected opaque 1080 x 2400 screenshots. That evidence promotes the journey from a pre-counted reserve row and raises the formula-backed mobile score from 712 to 714/1000. A separate deterministic populated collection and saved listing make all three collection getters live-checkable. A deterministic published article then exposed four invented native blog requirements that Laravel's anonymous public response does not send; blog screens now consume the real featured_image, reading_time and category-object fields and no longer show a fabricated unknown author. The populated screen also exposed its date and reading-time labels as 20dp no-op buttons in the live accessibility tree; both blog screens now use the accessibility-aware informational chip, and the shrink-only direct-import budget drops by two. The full 37-route rerun verifies 281 targets with zero below AA. The full API audit moves to 104 checked / 20 empty / 17 blocked / 16 unresolved / 44 mapped with zero required fields missing, while the current 15,590,798-byte Hermes export remains green with 1.49 MB below its internal ceiling. All 24 maintained Play images were format-validated and visually reviewed; the populated eight-screen core set remains stronger than replacing a frame with an empty new-module screen. Exact-current unsigned iOS run 33328237409 clean-built the app, passed its four-page tour and OCR, and produced four independently inspected opaque native screenshots. Android rerun 33328312721 passed 12/13 flows but exposed an ambiguous text tap where two New message controls could leave the effect journey on the Messages page; that journey now opens the already-tested native composer deep link deterministically, with a shrink-only workflow guard. No store or production state is touched.
- The native mobile response-contract audit now measures conditional direct requests and honours targeted module runs. Two marketplace detail getters choose the same GET endpoint with or without an accepted-offer query, but the audit classified their ternary as client response reshaping and withheld both from live shape checks. They are now recognised as passthroughs, moving the full 201-getter evidence from 101 checked / 19 empty / 46 mapped to 102 checked / 20 empty / 44 mapped with zero required fields missing. The documented module-filter command also dropped its first filter whenever --json was absent and silently ran the entire audit; regression tests now hold both parser boundaries.
- Native mobile release evidence now verifies seven persisted effects and fifteen role-aware live contracts. Two resettable Android journeys independently prove offer creation, saved listings, connection requests, exact-recipient messaging, volunteering applications, confirmed event registration and saved marketplace listings against Laravel instead of treating successful taps as completion. Repeating the RSVP journey exposed a missing caller idempotency key; the app now reuses a stable key on retry and clears it only after success. The field audit has bounded fetches, machine-readable checked/empty/blocked/unresolved/mapped classifications, and primary/organisation-owner/admin validation of protected high-risk shapes. The authenticated touch crawler now covers 37 routes, settles on actionable structure rather than changing decorative geometry and found the Federation status exposed as a 20dp no-op button; migrating that screen to the accessibility-aware status component removed the false action. The disposable Android tenant now enables Marketplace before its role-aware gate and twelve-flow tour, and screenshot absence only adds a failure after otherwise successful prerequisites instead of masking an earlier cause. Exact-source Android run 33320137825 passed all twelve journeys, seven effect assertions and its inspected screenshot artifact; unsigned iOS run 33319960815 rebuilt, installed, toured and OCR-verified the four accepted App Store frames. The formula-backed M1 score is therefore banked at 712/1000. Fresh light/dark captures retain the stronger populated Play artwork, and the current Hermes export has 1.48 MB of internal budget headroom. Apple Team-ID/AASA, the numeric App Store ID, signing, APNs, TestFlight and physical-device proof remain approval-gated; no store, deployment or OTA state was changed.
- Native mobile pre-approval checks now prove effects and current screen contracts instead of stopping at successful taps. The disposable Android fixture includes two addressable listings, an eleventh Maestro journey creates a listing, saves another member's listing and requests a connection, and an independent Laravel verifier resets and checks all three persisted effects. A three-role live contract gate validates populated listing, member-directory and bilateral connection responses. The authenticated touch-target crawl covers 34 routes and removes actionless sub-24dp metadata controls found in four newer modules; Courses, Clubs and Partner Venues join reviewed light/dark pixel baselines; and the Google Play capture recipe prepares a stronger populated-module candidate for the currently empty Messages marketing slot without changing any store state.
- A mistyped one-liner run by hand in production counted as a production error, and spent part of the safety budget that guards a deploy. A fatal is reported to Sentry however the process started, including a php -r command typed into the production container by someone inspecting the database. Those are not defects, but once they arrive they are indistinguishable from real errors: issue NEXUS-PHP-41 holds nine such events in three bursts, has been triaged twice for the same conclusion, and does not even describe one fault — Sentry groups every PDOException by its shared stack shape, so an unknown-column typo and a stray %s placeholder landed in the same pile. The sharper cost is that three of them arrived during a post-deploy error watch on 2026-08-30 and spent 3 of its alarm budget of 10, inside the thirty-minute window whose only job is to prove a deploy safe; a second typo would have alarmed a healthy release. Such events are now tagged invocation:cli-eval and the deploy watch excludes them from every count, baseline included, so the comparison stays like-for-like. They are tagged, not dropped — that someone ran a query by hand against production is itself worth knowing. The match is deliberately narrow: only code supplied inline to php -r, php -a or stdin, where PHP reports its own placeholder instead of a script path. Artisan commands, queue workers and the scheduler are production and still report, which is what the eighteen unit tests mostly exist to hold — a false positive here would hide a real failure from the one check standing between a bad deploy and a silent outage.
- Native mobile pre-release checks now fail closed and run without two misleading test-environment faults. The live Laravel response-shape audit checked 108 non-empty typed getters with zero missing required fields and now returns a failing process status if a future checked response omits one; previously it only printed the finding and still exited successfully. Jest now intercepts the app's documented direct Ionicons import synchronously, while retaining the real glyph map used to validate server-provided icon names, and seeds HeroUI Native's base muted/default colours; this removes asynchronous font work, invalid-colour warnings and their false test-worker noise without changing the production bundle. The maintained Apple evidence now points to green unsigned iOS Simulator run 33286909272 at exact mobile source 58654079a, while preserving signing, TestFlight, APNs, universal links and physical-iPhone testing as separate approval-dependent gates. No Google Play release or listing state was changed.
- Publishing a GitHub release failed outright for six of the fourteen versions, because their notes were too long. GitHub rejects a release body over 125,000 characters with HTTP 422: body is too long, which fails the job and publishes nothing at all. This changelog carries a full paragraph per entry, so the larger sections are far past that — 1.6.2 alone is roughly 368,000 characters, and 1.5.5, 1.5.6, 1.5.9, 1.6.0 and 1.6.1 are over it too. release.yml now truncates the notes at a line boundary and appends a pointer to the complete section in CHANGELOG.md, because a release carrying most of its notes plus a link is useful and a release that does not exist is not. The cap is applied to bytes against a character limit, which is deliberately conservative: UTF-8 is never fewer bytes than characters, so a body inside the byte cap is always inside the character limit. Verified against the real 1.6.2 section, which reduces from 369,221 bytes to 117,654 characters.
- The mobile API drift gate is current again after six Laravel routes were added. Its checked-in inventory now records 2,238 distinct API paths and verifies all 462 endpoints consumed by the native app with zero missing or method-mismatched routes. The generated ledger explanation no longer freezes historical Laravel/OpenAPI counts in prose, preventing the documentation itself from becoming stale on the next route addition.
- Cloud store-image workflows now distinguish operating-system automation faults from app failures and verify the completed navigation outcome. Android run 33268853140 rendered the tenant picker in all ten independent jobs while the API 36 emulator's own “System UI isn't responding” dialog intercepted every journey, so the disposable headless device now suppresses platform crash/ANR dialogs while preserving Maestro and log-based app-crash failures. iOS run 33270768040 proved Apple's native screenshot path works, but Maestro treated a momentary hierarchy change as a successful Partner Demo selection even though the app remained on the picker; the login flow now retries until the destination login screen is actually visible. Run 33271244590 then captured a correct current Feed but exposed an iOS confirmation sheet over three byte-identical deep-link images, with two false OCR passes caused by their bottom-tab labels. Run 33271690460 showed that opening a fresh XCTest session for every page left the Messages tap on Listings; the tour now keeps one continuous session, prepares exactly four screenshots and applies page-content-only Vision OCR to every resulting PNG. Run 33273775980 then proved that the HeroUI feedback wrapper exposed the Partner Demo row to XCTest but did not deliver its accessibility-driven tap; feedback-free NativePressable controls now use React Native's plain press responder, matching the tenant picker's documented intent and preventing a fresh iOS installation from being stranded on the required community screen. Run 33275173679 proved that fix reached login, then exposed Maestro ignoring its zero-settle instruction and spending 76 seconds querying the login route transition until XCTest died; the tour now submits through the password field's native Enter/Done action without invoking that unstable hierarchy lookup. Run 33276612778 then exposed an intermittent dropped tenant press; the complete Partner Demo tap plus its destination assertion now sits inside the retry, so a reported tap cannot count unless the app actually reaches login. Run 33277812745 reached and captured Feed and Listings before iOS acknowledged but did not deliver the foreground Messages URL. Run 33278248859 exposed a stale-hierarchy race after accepting the Messages sheet, and run 33278642889 proved that locating Apple's SpringBoard confirmation button by text can itself spend 69 seconds serializing the system hierarchy and kill XCTest. Run 33279028208 finally proved the boundary: even a geometrically correct app-driver tap cannot dismiss a SpringBoard-owned sheet. The public tour therefore no longer uses custom URLs at all; it remains inside the app through the stable Messages and More tabs and the visible Events row. That run also exposed a 28-minute cold rebuild caused only by changing the workflow's Jest test, so non-bundled mobile/scripts/ are now excluded from the native app fingerprint and the just-built exact-source cache is migrated once to the stable key. Run 33280416707 then caught Maestro/XCTest probing the absent Safari view service during an ordinary in-app Listings coordinate tap and killing its own runner while the app remained healthy; the workflow now retries the complete tour up to three times with freshly installed app data and a new driver session, while accepting only a full four-page run that passes the existing image and OCR gates. Run 33280917178 completed the first three captures twice and proved the final Events row initially sat beneath the fixed tab bar at y=877, where its otherwise valid accessibility tap was intercepted by the centre Create tab; the tour now moves the More list once and targets the exact Events label above that overlay.
- The current iOS Simulator screenshot candidate is green and independently verified. Run 33281688048 authenticated the protected Partner Demo account and captured Feed, Listings, Messages and Events from commit fc496aedddd6793835f49d80a6c7abc4ffbf7771 on an iPhone 16 Pro Max Simulator running iOS 26.2. All four opaque 1320 × 2868 PNGs passed checksum, dimension and page-content OCR gates; their downloaded originals were then visually checked for the intended page and for system sheets, credentials, keyboards and debug overlays. The Apple handoff, screenshot guide, readiness matrix, freeze checklist, build evidence and journey ledger now point to that exact run while retaining the unsigned-Simulator evidence boundary.
- Android device CI can no longer hide missing screenshot evidence. Run 33282022295 passed all ten independent emulator journeys but its later listing tour checked for the tenant picker while the fresh app was still on its native splash, skipped the selection branch, produced no images and suppressed the failure. The tour now waits for either legitimate initial destination, targets the Hour Timebank row by stable native ID and proves arrival at login; the workflow fails if the tour or artifact is absent and uses the maintained v5 checkout, Node and Java setup actions. Run 33283006194 then showed Android's own “System UI isn't responding” sheet covering a correctly rendered tenant picker in every independent flow despite the emulator's hide_error_dialogs setting; both the shared login setup and screenshot tour now choose the safe Wait action on that platform-owned sheet and still require a real app destination before proceeding. Run 33284698299 passed all ten independent journeys and produced eight current images before exposing a real foreground-routing defect: nexus://listings/create was treated as listing ID create and rendered “Invalid exchange ID.” The authenticated deep-link replayer now matches cold-start handling for listing, exchange, event, group, job, volunteering and poll create aliases, with direct regression coverage.
- The 1.7.0 release notes said the platform had shipped twenty-two security fixes when it had shipped three. Fourteen entries about date formatting, Podcasts, Courses, mobile parity and an admin audit-log crash had accumulated under ### Security, which a reader scanning for security work has to be able to trust. All forty-seven entries in that release are now filed under the subsection that describes them — fifteen Added, six Changed, one Removed, twenty-two Fixed, three Security. Entry text is untouched; the change was verified as pure movement by comparing every content line before and after. This also made the version honest: forty-four of the forty-seven entries were floating above every heading, so the release derived as a patch bump despite containing fifteen new features, and the gate flagged it on its first run.
- The cloud device tours no longer depend on unstable menu geometry or hierarchy settling, and foreground nexus:// links now navigate instead of being silently discarded. iOS run 33267129079 proved the Release app authenticated and loaded its feed APIs, but Maestro's XCUITest host lost the main accessibility window during the login route swap. Retry 33268251338 captured Feed and Listings before the same host killed itself while scanning the long, module-dependent profile hub. Run 33268900439 then proved a separate app defect: the foreground link parser ignored a custom URL's host, so canonical links such as nexus://messages worked at cold start but did nothing while the app was open; it now parses the host as the section and routes Settings as well. Finally, run 33269363809 reached the corrected feed before Maestro's server died while taking its first PNG, confirming that screenshot transport—not the app—was the remaining fault. Maestro now performs secure login only; Apple's own simctl navigates and captures exactly the four visually accepted public screens, and macOS Vision OCR must find each expected screen title before the opaque/native-size preparation gate will accept them. Rejected modal captures remain excluded instead of spending runner time recreating known-bad artwork. Android run 33267127839 passed nine of ten independent flows; its sole failure stopped one viewport above the sign-out button after recent modules lengthened the profile hub, so the logout journey now scrolls through the full current menu before tapping the stable control.

### Security

- fast-uri picked up four new high-severity advisories during the day and is upgraded rather than suppressed. The mobile tree's existing overrides pin was ^3.1.4, which had resolved to 3.1.5 — inside the vulnerable 3.0.0–3.1.5 range — so the pin closed the previous advisory and then aged into this one. The floor moves to ^3.1.6 and the lockfile resolves 3.1.7, closing GHSA-5jgf-p345-68v8 and GHSA-jqff-g426-hqxp (host confusion via skipped IDN canonicalization and via percent-encoded scheme normalization) and GHSA-f65p-4m7j-42xc and GHSA-fph4-wmhf-6fwf (server-side request forgery via malformed IPv6 normalization and via repeated hostname percent-decoding). It arrives transitively through expo-build-properties → ajv, a build-time config plugin that never ships in the compiled APK/IPA, but no exception was needed because a fixed version exists within the same major line — an entry in .npm-audit-exceptions.json is only correct when there is nothing to upgrade to. 🔴 The failing CI step audits five trees in one bash -e block, so aborting at mobile meant web-uk was never audited on that run; all five were checked by hand here and the other four were already clean. The pin is a floor, not a ceiling, and this is the second time it has aged into an advisory: ^3.1.4 would have accepted 3.1.7 on a fresh resolve, and only the committed lockfile held it at 3.1.5. Found by the security scan on commit 0d23d3ad7, which was the first run after the advisories were published — the same check passed on 5837ea12e four hours earlier. The two image-size exceptions it also reports are the pre-existing, documented ones and are unaffected.
- Two dependencies picked up new high-severity advisories overnight; both are upgraded rather than suppressed. league/commonmark moves 2.9.0 → 2.10.0, which closes four advisories — three denial-of-service paths and, more importantly, GHSA-f8fg-pg57-v4j8, an XSS filter bypass where a U+000C form feed defeats the on* event-handler filter in the Attributes extension. It reaches us transitively through laravel/framework (^2.8.1), so a single-package update was enough. browserslist moves 4.28.1 → 4.28.8 in the mobile tree via a new overrides entry, closing CVE-2026-73088 (prototype pollution) and CVE-2026-73089 (unbounded memory growth); it arrives transitively through Babel, Expo's Metro config and core-js-compat, all of which accept ^4. Neither needed an entry in .npm-audit-exceptions.json or .trivyignore, because in both cases a fixed version exists — an exception is only correct when there is nothing to upgrade to. Found by the scheduled security scan on 2026-09-02, which was the first run after the advisories were published; the two image-size exceptions it also reports are the pre-existing, documented ones and are unaffected.

## 1.7.0 - 2026-08-29

### Added

- The Caring caregiver-consent journey now has a committed paired instrument that runs Laravel and ASP.NET in the same execution. e2e/journeys/caring/caregiver-parity.spec.ts drives the unchanged React frontend against each backend — switched by VITE_BACKEND_TARGET only, with no page or component branching on the backend — and asserts effects by reading each engine's own database rather than trusting the API response that wrote them. It compares outcomes, not identifiers: the two fixtures hold different accounts and tenants by design, which is what makes a difference attributable to the backend rather than the seed. Fields are compared against a manifest derived from the React components themselves, per ADR-0004's consumed-boundary rule, so a superset is not treated as a gap. Both backends now reach identical outcomes at every gate. The journey ledger row (3.33) stays OPEN: it also covers /my-relationships and /my-trust-tier, which this journey does not exercise, and the staff caregiver-review queue is named by no row at all.
- The accessible frontend can now arrange and answer a caring relationship — previously it had no Caring screens at all. web-uk had caring_community switched off and not one caregiver page, so the members these relationships are most likely to be about could not propose one, agree to one, refuse one, or review one. It now carries the equivalent slice: a Caring entry on Explore, the caregiver-link request, the care recipient's own agree/refuse decision, the staff review queue with recorded consent evidence and an explicit attestation, and the on-behalf request an approved relationship unlocks. It consumes the same Laravel records and lifecycle endpoints as the React frontend rather than reimplementing the workflow in Express — proven by walking the journey in a browser and reading the resulting rows back out of the database. The whole journey runs with JavaScript disabled, which is the point of this frontend: every action is a plain form POST, and the member search is a GET form rather than a live dropdown. Strings come through the Laravel govuk_alpha_caring catalogue in all eleven languages; the Irish was authored directly rather than machine-translated, because Google's Irish output is not approved for release, and it is a reviewed-pending draft awaiting a native speaker. caring_community stays false by default, matching Laravel — it is an opt-in per community, not an unfinished flag.
- The Caring caregiver-consent journey now has real browser evidence, walked by three separate people against a disposable database. A new opt-in Playwright journey (npm run test:e2e:caring) drives the full lifecycle — propose, recipient confirm and refuse, staff approve and reject, then the authority an active link unlocks — and verifies each outcome by reading the persisted rows, notifications and audit records back out of the database in a separate process rather than trusting the API response that wrote them. It runs against the disposable nexus_webuk_e2e stack with synthetic accounts only, never the production-derived local database, and is deliberately held outside the default suite's test directory so it cannot be swept into the five broad projects that point at real member data. Missing prerequisites fail loudly with setup instructions instead of skipping, because a skipped safeguarding check reads as a pass. bash scripts/caring-e2e-provision.sh provisions the fixture idempotently.
- Caring Community caregiver authority now requires an explicit, auditable consent journey instead of leaving links permanently pending. A member can propose a same-community care relationship, the care recipient can confirm or reject it, and authorised staff can approve it only after recording consent evidence and re-running bilateral safeguarding checks. Pending and rejected requests remain visible, both participants are notified of decisions in their own language, and only active links unlock on-behalf help requests, schedules and cover care. The separate volunteering label now reads “Carer of the young volunteer” and does not enrol anyone or grant Caring permissions. React carries the member and staff journey; ASP.NET mirrors the touched API contract but remains uncertified, and the maintained web-uk accessible frontend still lacks this journey and is therefore a release blocker for accessible parity.
- Added a manually dispatched, free GitHub-hosted iOS Simulator release-mode screenshot pipeline with an eight-screen Maestro App Store tour, opaque native-resolution asset validation and checksum evidence, while preserving TestFlight and real-device testing as separate release gates.
- The native mobile member journey is now complete against the maintained React route inventory without broadening its store audience. First installs open the community picker while returning and signed-in members keep their remembered destination; Courses, Podcasts, Clubs, partner venues and passes, donation receipts, event management, full Ideation campaigns and outcomes, group invitations, and coordinator check-in journeys now have native routes, deep-link handling, Laravel response contracts, translated seven-language catalogues, resilient loading/error states, and TalkBack-aware controls. A fail-closed policy checker and submission guidance keep the app strictly 18+, exclude guardian/child consent and linked-child messaging, exclude Care in Community (including aliases), and keep browser-based payment and fundraising side effects outside the store apps. The parity ledger now classifies every consumed React route with no unresolved native gap, while API, safe-area, route-wiring, touch-target, theme and untranslated-content gates guard the result until the next approved physical-device build.
- The live PHP JSON translation catalogue is now measured and ratcheted. __() reads lang/<locale>/<ns>.json before the .php loader, yet no gate ever counted those files — 49,223 values (63.6% of the non-English JSON catalogue, four fifths of them in admin.json) sat byte-identical to English while every i18n gate stayed green. A new blocking CI check and preflight step (scripts/check-php-lang-json-untranslated.mjs) now hold that number as a shrink-only ceiling, and scripts/translate-php-lang-json-gaps.mjs exists to work it down.
- Accessibility is now machine-enforced in the React frontend instead of resting on reviewer discipline. eslint-plugin-jsx-a11y is installed and its recommended set runs at the repository's zero-warning cap, so the WCAG 2.1 AA target has a gate behind it rather than only check-nested-interactive.mjs. Triage of the 78 initial findings fixed real defects — reaction menu items advertised aria-pressed on role="menuitem", which is invalid ARIA and told assistive tech nothing about the active reaction (now role="menuitemradio" with aria-checked); combobox options in the skills, skill-tag and mention pickers were not focusable; and a full-screen story scrim announced itself as an empty element. The remaining sites are suppressed individually at the element with the reason written there. Two rules are off platform-wide and documented in eslint.config.js: no-autofocus (44 sites, all dialog/overlay focus management that WAI-ARIA APG requires) and media-has-caption (9 sites, an honest product gap — member-uploaded media has no caption-upload feature to supply a <track>).
- Two more quality checks that existed but nothing ever ran are now blocking, and a new one stops test files duplicating. The bundle-size budget — which also forbids pulling the whole HeroUI barrel into a route chunk — was written but never invoked, so it was advisory only; it now runs against the production build in CI, verified against a fresh build first. A new shrink-only ceiling catches a component being tested from both Foo.test.tsx and __tests__/Foo.test.tsx in the same folder: there are 103 such pairs, every one a separately written suite rather than a copy, and SavedSearches had rotted in both copies at once because a fix to one never reached the other. Same-named tests in genuinely different folders are deliberately not flagged.
- Podcasts now has a complete member-facing native path. Members can search the catalogue, open shows and episodes from web links, follow shows, play hosted or external HTTPS audio in-app, see real playback progress, read transcripts and chapters, react, and submit a safety, spam, rights or other report. Playback failures are visible, listen/completion analytics use the existing Laravel contract, and all text comes from the maintained seven-language catalogue.
- Each community can now choose its own date and number format. A new "Date and number format" setting in Admin → Settings takes a country (Ireland, the UK, Switzerland and others) and applies that country's conventions — field order, month names, 24-hour clock, number grouping — in whichever language each member is reading. It does not change the language. The control shows a live sample of what members will actually see, because a country name alone does not tell an admin whether they are about to get 17/08/2026 or 8/17/2026. Communities that set nothing inherit their registered country, then the platform default of Ireland, so nothing needs configuring for the setting to be correct today. Saving now also clears the settings cache, which previously held old values for up to five minutes and made a save look like it had not worked. This replaces general.date_format / general.time_format, which were writable and seeded at provisioning but read by nothing, in any language, ever.
- A new blocking CI check keeps American date formats out of PHP. It rejects month-first patterns and bare-locale Carbon calls across app/, with a date-format-exempt: escape for genuine machine formats. Verified against a probe of eight known-bad and two correct forms, because a guard that cannot catch the bug is worse than none.
- Courses now has complete member-facing native navigation without producing a new binary. Android and iOS can browse and search courses, open My Learning, inspect a syllabus, enrol with visible success/failure feedback, follow web course links into the correct screen, read lesson content and save completion against the real Laravel contract. The player only marks a lesson complete after the request succeeds, and all course text is supplied by the maintained seven-language catalogue.
- The adults-only native-store boundary is now an enforced release invariant. Current Google Play and Apple primary rules are recorded alongside exact 18+ declarations; guardian consent is an exceptional staffed web workflow rather than a native child-access route, and all Care in Community routes are deliberately outside Android and iOS. A new fail-closed checker protects the parity map, deep links, store worksheets and official source references from drifting, while reviewer copy distinguishes service/skill matches from dating or romantic matchmaking.
- The native client closes five member-facing parity gaps and strengthens local release evidence without producing a build. Match preferences can now be loaded, edited and saved natively; review-request, job-application, volunteering-application, volunteering-organisation and optional identity-status links reach their intended screens. Signed-out recovery/community actions now look actionable. Touch-target sweeps fail on locked devices and incomplete coverage instead of printing a misleading clean result, and the Windows certificate-pin check now discovers Git's bundled OpenSSL and verifies both configured pins against the live API chain. Direct tests now guard the biometric cold-start lock, the reactors sheet's TalkBack labels and navigation, voice-message download/playback failure states, and offline event check-in loading, conflicts and retry.

### Changed

- Mobile store preparation now has a reproducible, isolated device gate and current Android artwork. Every Maestro journey establishes its own clean tenant and login state instead of depending on discovery order, CI pins the intended emulator and keeps its lock screen awake, and the Windows runner can select an explicit Android serial when several emulators are attached. A tenth device journey proves the newly completed Courses, Podcasts, Clubs and Partner venues catalogues are reachable through ordinary in-app navigation; the feature-gated entries now appear in More instead of existing only as deep-linkable screens. The production-configured release APK was rebuilt with debug-only E2E mode forced off, installed on a separate phone emulator and verified against the live API; the device journeys ran against the isolated local fixture. Google Play artwork was recaptured from the fictional Partner Demo tenant in controlled light and dark phone modes and both tablet sizes; all 24 current PNGs pass the opaque 9:16/16:9 asset gate. The Android startup bundle remains within its measured ceiling with 1.49 MB spare. Apple signing, APNs, Universal Links and App Store Connect submission remain deliberately blocked until enrollment supplies the Team ID and numeric app ID.
- The frozen iOS candidate now has current Simulator runtime and screenshot evidence. GitHub Actions compiled commit ad2029ba7fc1473cc8c7816a3c9344650a851597 as an unsigned Release build, installed it on an iPhone 16 Pro Max Simulator running iOS 26.2, authenticated to the protected Partner Demo tenant and completed the eight-screen tour. All eight opaque 1320 x 2868 PNGs matched their manifest checksums after download. Visual inspection accepts four clean primary-tab images as the valid draft App Store set and quarantines four modal captures with a black top backdrop; Volunteering also clips a tab label. Matching hashes from the prior run prove those visual defects predate the bundle optimisation. This remains Simulator evidence, not TestFlight or real-iPhone certification.
- The native bundle budget now describes the completed app instead of an obsolete smaller feature set. Expo Atlas traced the growth to the seven-language catalogue and the expanded native journeys; no language or member feature was removed. Switching the accessibility-safe Ionicons wrapper from the package barrel to Expo's documented direct import removed 373,195 bytes from the Android Hermes bundle (15,963,007 → 15,589,812 bytes). The blocking budget is deliberately re-baselined from that measured candidate with 10% headroom, and its ledger now states explicitly that this is an internal JavaScript regression budget rather than an Apple App Store download limit.
- That JSON catalogue is now fully translated — the untranslated count is zero. The shrink-only ceiling introduced above started at 49,223 English values and stood at 29,812 when this run began. The remaining eight locales have now been worked through by hand, namespace by namespace: the Irish, Japanese, Polish, Dutch, Portuguese, Italian, Spanish and French admin catalogues, plus the Irish navigation, API, community, safeguarding, volunteer, federation and service-notification catalogues. Every administrator-facing screen — billing, broker, CRM, content, deliverability, enterprise and GDPR, events, federation and its webhooks, gamification, goals, groups, ideation, impact, jobs, listings, matching, moderation, newsletters, polls, reports, safeguarding, super-admin, system operations, onboarding, registration and timebanking settings — now reads in the administrator's own language rather than falling through to English. .github/php-lang-json-untranslated-baseline.json is re-baselined to 0, so any future English value added to a locale JSON file fails the gate immediately instead of being absorbed into a large ceiling.
- The deploy’s migration safety check can now tell a free column change from a locking one, instead of blocking both. Blue/green shares one database, so a migration runs while the other copy is still serving live traffic — which is why any raw ALTER TABLE … MODIFY stops the deploy. But the check only matched the word MODIFY; it never looked at what the statement did. An enum gaining a single value was indistinguishable from a table rewrite, so the only way past it was the emergency override. That is how a genuinely dangerous migration eventually gets waved through: a gate with one exit trains everyone to use it. Hit for real on 2026-08-29, where the blocked table turned out to hold zero rows. A statement that spells out ALGORITHM=INSTANT is now allowed through, because that clause is not a promise in a comment — MariaDB refuses the statement outright if it cannot be done as a metadata-only edit (measured on 10.11.18: appending an enum value is accepted and instant; inserting one mid-list fails with ERROR 1846 … Try ALGORITHM=COPY). Everything else still blocks, including the same statement with the clause removed, a file that mixes a proven statement with an unproven one, a migration whose emitted SQL drops the clause, ->change() (Laravel cannot demand INSTANT, so it proves nothing), and DROP TABLE. Seven contracts pin both directions in scripts/test/test-migration-safety-gate.sh, and AGENTS.md now carries the append-and-prove convention — along with two things learned the hard way: check the table’s row count before ever reaching for the override, and note that deploy.sh cannot pass the override through, so using it also skips the 30-minute post-deploy error watch that must then be run by hand.
- Dates in the web app are day-first everywhere instead of American. Every user-facing date and number funnels through getFormattingLocale(), which returned a bare language code (en). A bare tag carries no region, so Intl falls back to the language's default one — the United States for English — and 17 August 2026 rendered as 8/17/2026 across a platform whose communities are in Ireland and the UK. The language still comes from the member's own choice; the region now comes from the community (general.region setting, else the tenant's contact country, else the platform default IE) via a new provider-free regionStore, never from the browser or OS. A tag that already carries its own region (pt-BR) is left alone. Regression tests assert the rendered outcome (17/8/2026, 17 August 2026), not the locale tag, so they still fail if the resolution strategy is rewritten. Note one deliberate consequence: relative timestamps now read 30 sec ago / 3 days ago rather than 30s ago / 3d ago, because those single-letter abbreviations exist only in US English locale data — Irish and British English have no compact form at any Intl style.

### Removed

- Fifteen dead code paths that queried tables and columns which do not exist have been deleted. Every one came from a single 2026-03-20 commit that wrote services against an imagined schema; each had no route and no caller, and each duplicated a feature that already works elsewhere. Deleted: HashtagService::syncTags (its unscoped ->delete() would have wiped a post's hashtag links before the insert threw), the unwired DeliverableService::create/::addComment and DeliverableController::addComment (the service and the controller wrote different phantom column names for the same field — proof neither ever ran), AdminListingsService::approve/::reject (superseded by ListingModerationService), ExchangeService::decline, GroupSSOService::findOrCreateSSOUser, LeaderboardSeasonService::endSeason, SkillTaxonomyService::addSkill (the routed path is addUserSkill), AdminContentController's three content_reports methods, AdminSettingsController::features/::toggleFeature (real toggling is TenantFeatureConfig + tenants.features), CronJobService::run/::getHistory (real monitoring is CronJobRunner), and AuthService::refreshToken (real tokens are TokenService JWTs). Together with the comment-moderation fix below, the shrink-only schema gate baseline drops from 17 tracked problems to 1. Thirteen stale routes/*.txt copy-paste snippets went too: all 103 routes they declared are already registered, to the same controller and method.

### Fixed

- Fixed (ASP.NET): a caregiver could not end a caring relationship they had ended once before — the request failed and the relationship stayed active. DELETE /v2/caring-community/caregiver/links/{id} answered 500 (Npgsql 23505 against ccl_tenant_caregiver_recipient_status_unique) whenever an inactive row already existed for the same caregiver/recipient pair, because that index covers the status column. Laravel carries the same index and answers 204: CaregiverService::removeLink() deletes the superseded inactive row inside a transaction first, and RemoveLinkAsync did not. This was not cosmetic — the caregiver believed they had ended the relationship while the authority it confers remained in force. Found by running the journey against both backends in one execution; neither engine's unit tests covered a second removal, so both suites were green while the two behaved differently. Regression-guarded in CaringCommunityCaregiverControllerUnitTests.
- Fixed: the "Link a care receiver" form could not be completed at all, because its member search never displayed a single result. /v2/users/search answers { data: { items: [...] } } and api.get() unwraps exactly one level, so the page received an object where it expected an array; reading .length on it produced undefined, and the dropdown fell through to "No matching members found" for every query — including ones the API had just answered with a match. No care recipient could be selected, so no caregiver relationship could be proposed through the UI. The page now accepts both the paginated and bare-array shapes. This was invisible to the existing component tests, which mock data: [ ... ], a shape the endpoint never returns; a regression test now pins the real one. It became blocking rather than latent because "Become a caregiver" was rerouted from volunteering into this form.
- The two translation gates stopped contradicting each other, and seven real wording inconsistencies they had found were fixed. check-php-lang-untranslated.mjs audits its allowlist by looking for a counter-example: if a locale renders the same English value differently somewhere else, the entry is suppressing real work. That rule had deadlocked the build on 31 entries, and its own advice — remove the entry — could not be followed, because the allowlist is shared with the newer check-php-lang-json-untranslated.mjs and every one of the 31 was needed there, where the per-file ceilings are 0. Removing them was tried in both directions and simply moved the failure. Two precision faults were corrected instead: the .php gate no longer audits entries with no byte-identical occurrence in any lang/**/*.php file (15 of the 31 existed solely for the JSON catalogue, so the error was unactionable), and a counter-example that contains the term is now read as a context qualifier rather than a rival translation — German writing "Ihr Name" on a contact form or French writing "Date de la visite" says nothing about how a bare column heading should read (9 more). Neither change hides a value: the untranslated count and the 196 ceiling are identical before and after, at 112. The 7 genuine finds that remained were fixed in the catalogues rather than exempted, each resolved to the form that locale already predominantly uses: German "Stand"/"Bearbeitungsstand" → "Status" (8), French "Descriptif" → "Description" (9), "Structure" → "Organisation" (4), "Insigne" → "Badge", Dutch "Webadres" → "Website" (2), and — going the other way, where the translated form was the majority and the better word — German template "Version" → "Fassung" and a stray French "Messages" → "Messagerie". web-uk's generated German, French and Dutch catalogues were re-synced from the same source.
- Admins who do not read English were being refused in English by every /v2/admin/* route. EnsureIsAdmin returned the bare strings "Authentication required" and "Admin access required", while its sibling EnsureIsBrokerOrAdmin had always sent both through __(). The translation keys already existed and were already translated — only the two call sites were missing. Found while verifying, endpoint by endpoint, that the nine admin screens which deliberately show the server's own refusal really are showing a localised one. Those nine now carry the project's scoped admin-i18n-ignore marker naming the controller checked and the evidence, so the blocking check-admin-ui-literals gate is back to zero violations. That gate had been failing on main unnoticed since it went blocking: an earlier step in the same job failed first, and a failing step hides every step after it.
- The platform version is now verifiable from the frontend suite rather than only from a root script. releaseStatus.test.ts asserted a hardcoded v1.6.2, so releaseStatus.ts and its test could be bumped together while the root VERSION file was left behind, with the frontend suite none the wiser. The test now reads VERSION as the single source of truth and also rejects a stale second version in the label, and check-version-consistency.mjs enforces that it keeps deriving the version instead of restating it. A change-detector assertion that failed on any additive key was replaced with a required-keys check.
- Achievement campaigns now actually reach members. Until now the whole feature awarded nothing, for every campaign type: an admin could build a campaign, choose who it was for, set a schedule, activate it, and it would sit there indefinitely while no member ever received the badge or the points, with nothing on screen saying so. Two separate faults caused it — there was no delivery step at all, and activating a campaign stored a status the database did not recognise, so the scheduler could never see it. Both are fixed, reusing the existing badge and points machinery so recipients still get a notification in their own language. Two deliberate rules: if the scheduler misses runs, the next run pays once, never a backlog — handing out a lump of points for something nobody did cannot be undone; and a member can be paid at most once per period, enforced by the database rather than by the code, so an overlapping or retried run cannot double-pay. Suspended and unapproved accounts are excluded, a campaign with no reward set pays nobody, a one-off campaign closes itself when finished, and a filter left unconfigured now selects nobody instead of everybody.
- Every disabled test suite is back in the build — the skip list is now empty. 46 of 1,283 suites were being skipped by the full test run, so a green pipeline was proving 1,237 of them and quietly passing over the rest. 45 were repaired and now run for real; the 46th was deleted because it tested a screen that has never existed. Two of the repairs were not test-only: creating a badge showed a blanket "Failed to create badge" instead of the server's actual reason (such as a name already being taken), leaving an admin editing blind; and the broker's member-approval test could never have failed — the menu it needed never opened, so the whole approve-and-confirm path had never actually been tested despite reporting green.
- Rejecting a reported comment now actually removes it. ContentModerationService::applyDecision wrote comments.is_hidden, a column the comments table does not have, so approving or rejecting a queued comment threw, the failure was swallowed by a catch-all, and the queue row was still marked reviewed: a moderator was told the report was actioned while the comment stayed visible to every member. Rejecting now withdraws the comment and approving restores it, using the same deleted_at test every read path already applies. It had been triaged as unreachable because the search looked for ->applyDecision(, which a private static method called as self::applyDecision( can never match — the regression test now reads the comment row back rather than asserting a mock was called.
- Outbound Credit Commons transactions now name accounts instead of sending bare member ids. A Credit Commons account is addressed as node/account, but both the transaction and its double-entry ledger payload passed member ids through unqualified, so a partner node received "123" as payer and payee and would have been entitled to reject the transfer. Local members are now resolved to the same node/username identity the platform answers inbound Credit Commons requests with, so a member has one identity in both directions. Naming a member who lives on the partner's node is deliberately still unresolved and documented as such: guessing it would assert we hold someone else's account, which is a routing error in a money path. External partner federation remains switched off in production with no partner connected, so nothing live was affected.
- A member's matches page is not emptied when their community has not configured safeguarding rules. This was an open question against a live error report, and the answer is now pinned by a test: the page fills normally and only members who require vetted interaction are withheld, which is the intended protective outcome. Community admins are told separately by the scheduled safeguarding policy health check.
- The admin "request plan upgrade" endpoint no longer crashes on every call. POST /v2/admin/billing/upgrade-request invoked two helper methods that have never existed on the controller base class, so every request threw a fatal error — invisible to static analysis because Laravel's controller magic-call absorbs it. The endpoint now uses the real helpers, a test exercises the formerly fatal path, and the regression guard was inverted from a known-bad-names list into a sweep that resolves every controller helper call against the methods that actually exist, so the next such mistake fails CI without anyone having to predict its name. The legacy POST /listings/delete route, whose handler also never existed, was removed outright.
- Every page was re-reading the community's date-format region from the database, repeatedly. The region behind every date and number was cached for communities that had set it explicitly, but any community still relying on its country code paid an uncached lookup on every request — and again for each date on the page. It is now read once per process, and clearing a community's settings clears it too, so an admin's change still takes effect immediately. Measured on the group wiki page: one fewer database query per request. This also restores the build to green: eight test failures were all fallout from the day-first date work, three of them tests still asserting the American formats that were deliberately replaced.
- Thirteen production errors that were being thrown away now reach Sentry. Error reporting only leaves the browser through logError(); thirteen catch blocks across the admin, GDPR, legal-document, editor and passkey screens called the browser console directly instead, which does nothing in production. All three WebAuthn passkey registration failures were among them. A new lint rule blocks the mistake in future, with documented exemptions for the handful of places console use is deliberate.
- The frontend lint ratchet actually ratchets now. The real warning count was five while the cap was ten locally and thirty in CI, leaving room for twenty-five silent regressions; the five are fixed and both caps are zero. Two checks that existed but were never run by any CI step — the dead barrel-mock ratchet and translation-placeholder parity across all 1,595 locale files — are now wired in, and the schema-driven test-skip ceiling drops from 140 to the actual 128.
- A blocked ASP.NET test run can no longer read as a pass. When Windows App Control blocked locally built assemblies, dotnet test executed zero tests and still exited 0; the runner now refuses success without a real test-count summary, reports a partially blocked run as PARTIAL with genuine counts, and diagnoses the App Control block instead of hiding it.
- The phone app now follows the community's date format, not the handset's. The previous fix took the region from the device, so a member whose phone was set to the United States still read American dates — the same bug for a smaller group, and invisible to anyone testing on an Irish handset. The region now comes from the community, the same source the website uses, so both agree. A further 32 places were still bypassing the shared helper — event screens, goals, blocked users, data export, marketplace prices and pickup times, chat and thread timestamps, job salaries, the comment character counter and the federation member count — some following the phone, some passing a bare language code. All now go through it, and a new blocking check keeps them there.
- Date entry fields now match the dates shown around them. Date pickers took their field order and first day of the week from the browser rather than the app, so a member could see a British date in a listing and an American date-entry field on the same screen. The app now supplies the same locale to both. Verified by a test that reads the rendered field order, with a control proving the test can actually fail.
- Guarded against a trap in the date library's own data. Carbon's en_DE locale renders "August 17, 2026" — several of its English regional locales inherit American patterns — so an English-speaking community choosing Germany would still have received American dates from a correctly configured setting. English locales are now checked to confirm they really put the day first, falling back to a known-good one if not. A community that explicitly chooses the United States still gets American dates: the rule is "never American by accident", not "never American". Pinned by a test that also fails if the library's data is fixed upstream, so the guard can be removed when it is.
- Emails and notifications no longer send American dates. Two separate causes, both live for months. Carbon's locale was never set, and every notification service passed app()->getLocale() — a bare language code — explicitly at the call site, so Carbon resolved it to US English and overrode any global setting; a 2026 audit that converted these to isoFormat fixed month-name translation while silently locking English readers into month-first order. Separately, sixteen services and endpoints held hardcoded 'M j, Y'-style patterns, which date() renders identically in every language. Dates now resolve through the new App\I18n\FormattingLocale, which combines the recipient's language with the community's region (general.region setting, else the tenant's country_code, else the platform default IE) — so an English recipient gets "17 August 2026" and an Irish-language recipient gets "17 Lúnasa 2026". LocaleContext::withLocale() now switches and restores Carbon's locale alongside the translation locale, including after an exception. The premium grace-period date, which was computed outside its recipient-language block, now takes the recipient's language explicitly. Three API endpoints that returned pre-formatted American dates were fixed at source.
- The web app's Intl formatters no longer follow the visitor's browser. The build-time locale gate only understood method calls, so ~40 new Intl.DateTimeFormat / Intl.NumberFormat constructions escaped it: some passed no locale at all (following the browser or OS rather than the language chosen in the app), others passed a bare i18n.language. All now use the app's formatting locale, and the gate covers Intl constructors too, rejects a bare i18n.language as a locale, and accepts a local const bound to the helper. Formatters built once at module load were converted to per-call factories — at import time neither the member's language nor the community's region is known yet. Deliberate machine formats declare themselves with a locale-exempt: comment giving the reason. Marketplace, jobs, pricing and dues pages therefore now show prices in the community's regional convention rather than the visitor's.
- A TimeHelper test asserted the wrong weekday. It expected 15 January 2026 to be a Wednesday; it is a Thursday. The test was already failing before this work and is unrelated to date order.
- The admin identity audit log now opens instead of failing. Every attempt to view the identity verification audit log returned a server error, on every tenant, for the whole life of the endpoint — the page could never have worked. The three read methods in IdentityVerificationEventService called DB::statement(...)->fetchAll() / ->fetchColumn(), but DB::statement() returns a bool rather than a statement object, so each read crashed with "Call to a member function fetchColumn() on bool". Reads now use DB::select() / DB::selectOne(). The write path (log()) was always correct and is unchanged, which is why events were still being recorded while nobody could read them back. Reported by Sentry as NEXUS-PHP-54 on GET /api/v2/admin/identity/audit-log.

### Security

- The overdue security-suppression review has been done properly, and a third of the list turned out to be dead. The list of known vulnerabilities the scanner is told to ignore asked for a quarterly review and was three months past it. All 67 entries were checked one by one against what the project actually installs; 22 were suppressing problems that have since been fixed by upgrades and have been removed, leaving 45. One further exception elsewhere was removed on the same evidence, verified by re-running the check without it. The review date has only been moved because the review genuinely happened, and the file now records exactly how it was done so the next one is a repeat rather than a fresh investigation. Twenty-six entries about the Linux kernel are kept and openly marked unresolved: everything indicates they are obsolete, but confirming it needs a source that was not reachable, and they have not been dropped on a guess.
- A security suppression can no longer quietly become permanent. The npm-audit exception list asked for a quarterly review in prose while nothing enforced it, and entries recorded when they were added but never when they expire. Exceptions now carry an expiry date that the blocking security check enforces: past it, the advisory blocks again with a message naming the date, so the choice is to re-justify or remove rather than to drift. The three existing entries are dated one quarter out. .trivyignore's own quarterly review remains outstanding and its stamp has deliberately not been moved.
- A newly signed-in iPhone session no longer reaches the home screen without its bearer token. The API client retains the access token returned by a successful login immediately while encrypted iOS Keychain/Android Keystore persistence completes, so the first feed, notification and realtime requests authenticate even if Keychain is briefly unreadable; logout and unrecoverable session expiry clear both copies.

## 1.6.2 - 2026-08-28

### Added

- Registering a volunteering organisation now tells somebody — at both ends. Registering one used to notify nobody at all: no email and no in-app notice to the admins who alone can approve it, and nothing to the person who registered when a decision was finally made. Two organisations sat waiting unseen, one for seven weeks. Admins are now emailed and notified when an organisation is registered, and the registrant is emailed and notified when it is approved, declined, suspended or reinstated. Admins can now actually decline one — the endpoint previously accepted only active and suspended, so there was no way to say no — and a decline or suspension can carry a reason, which is passed on in the message the member receives. The admin dashboard gained a "pending organisations" alert so a queue cannot build up unnoticed again; that gap was a known parity gap recorded in the dashboard's own notes. The admin audience deliberately excludes brokers and coordinators, who are refused the approval endpoint, and is selected by admin flag as well as role so that flag-only admins are not skipped. All new member-facing copy is translated into all eleven languages, and all five new email types are registered in the email audit matrix so their delivery is tracked like every other transactional email.
- The native iOS release path now has an evidence-backed App Store submission pack. Apple store copy, privacy and age-rating worksheets, reviewer notes, screenshot rules, owner/legal decisions and a release-candidate freeze record now sit alongside automated iOS release checks and EAS build/submit wrappers. Paid promotional pushes require a separate default-off preference, while ordinary native pushes now use generic lock-screen wording and replace sensitive payload detail with one generic authenticated notification- centre link; the in-app notification retains the useful detail. The protected Partner Demo review account has also been re-verified against production without placing its credentials in Git. A signed device build, App Store Connect record and real-iPhone/TestFlight proof remain gated on active Apple enrolment and explicit owner approval. Production EAS builds now refuse a dirty mobile tree and embed the exact Git source commit in their temporary build config.
- The GDPR audit trail now names what actually happened, in all eleven languages. Every action the platform records — consent given, data exported, the six member rights requests, processing started, request assigned, note added, export generated, breach updated and data-protection-authority notification — plus the data-export record type had no wording at all, so the admin audit list read "Unknown action" for all 280 recorded entries, 256 of them consent records. All seventeen labels are added and translated, with Irish hand-written rather than machine-filled. Six neighbouring Irish labels are corrected at the same time, including one that rendered withdrawn consent as "baptised permission" and two that called a data breach a physical break.
- Mobile quality gates now cover more real failure modes without creating a build. The Irish catalogue's remaining 107 English-identical phrases are translated or narrowly allowlisted as product/unit invariants; the API response-contract audit supplies a discovered real listing target to both comment endpoint families; the accessibility-tree crawler force-stops between routes and adds Connections, Activity, Endorsements, Reviews and Skills; and the screenshot tour accepts its asynchronous tenant logo only after two consecutive login frames meet the existing pixel budget. Irish catalogue integrity and the contract changes are locally verified. Phone, 7-inch portrait and 10-inch landscape emulator captures now reproduce with zero changed pixels when repeated, and the capture tool requires an explicit device when more than one emulator is attached. A live public-entry accessibility-tree pass found and removed one more informational chip exposed as a tiny button; the login and community-picker layouts are also capped to a readable width on landscape tablets. Coherent authentication, endorsements, Explore, notification-group, saved-search, messaging attachment/voice, home activity/poll, gamification, goals, group-exchange, ideation, nearby-marketplace, federation, event, quick-create, residual terminology, group collaboration, group-wiki, group-task, group-analytics, group-detail/marketplace and group-create/edit translation batches reduce the guarded English-identical baseline by 2,387 entries, from 4,531 to 2,144; the large groups namespace is now clear in all seven shipped locales. Profile matches, reviews and marketplace navigation reduce the baseline by a further 158 entries, for a total reduction of 2,545 entries and a current baseline of 1,986. Profile destination descriptions reduce it by another 161 entries, for a total reduction of 2,706 entries. Profile help, resource, policy and safety navigation reduces it by another 85 entries, for a total reduction of 2,791 entries and a current baseline of 1,740. Reviewed About, Contact and Terms summaries reduce it by another 110 entries, for a total reduction of 2,901 entries and a current baseline of 1,630; the summaries continue to identify the published web documents as canonical. Privacy, cookies/storage, accessibility and trust/safety summaries complete the profile namespace in all seven shipped locales. Account hints, blocked-member management and data-export journeys bring the total reduction to 3,196 entries. Language, translation and feed-order preferences bring the total reduction to 3,281 entries. Linked/delegated account access brings the total reduction to 3,391 entries. Identity verification status, fee, secure-payment and failure states complete the settings namespace in all seven shipped locales, bringing the total reduction to 3,502 entries. Volunteer shift, certificate, expense and campaign- donation journeys bring the total reduction to 3,682 entries. Volunteer organisation dashboards, approval, application and hours-review journeys bring the total reduction to 3,805 entries. Organisation wallet/settings and shift-swap journeys complete the volunteering namespace in all seven shipped locales, bringing the total reduction to 3,939 entries. Job-owner analytics bring the total reduction to 4,089 entries and the baseline to 442. Hiring-pipeline, application-history and job-alert journeys complete the jobs namespace in all seven shipped locales, bringing the total reduction to 4,247 entries and the baseline to 284; every remaining entry is in the separately modified members catalogues. Member profile, trust, listing, achievement, collection and public- appreciation journeys complete those catalogues, reducing all 4,531 guarded entries to zero across the six non-English shipped locales. This is catalogue integrity and a reviewed AI translation pass, not native-speaker certification. The emulator touch-target auditor now reports partially visible controls clipped by a scroll viewport separately, so a full-size card at the bottom edge is not misreported as an 8dp target while genuine undersized controls still fail the audit. The screenshot gate also treats its unpinned launch-state capture as inspection-only: first-install, remembered signed-out and authenticated launches legitimately open different screens. Protected TalkBack routes and the source fixes remain explicitly pending the next permitted build and authenticated device session.
- Apple App Store preparation now has a separate, fail-closed release path. A full Apple-source readiness audit now orders everything that can be completed before enrollment, the immediate post-enrollment credential work, signed-iPhone certification, TestFlight and App Review. Device notification permission is no longer requested merely by signing in: members explicitly enable it in Settings, while paid promotional notifications have a separate default-off opt-in and in-app opt-out. Campaign recipient selection now fails closed unless that consent is exactly true. Expo ticket IDs are followed by delayed receipt checks so invalid device tokens are removed, notification taps now survive cold launch and accept every link key emitted by backend producers, logout can unregister a stored token even after OS permission is revoked, and group-chat lock screens no longer contain private message previews. The audit conservatively declares developer marketing purposes and Advertising = Yes while paid radius-targeted campaigns exist, and records the unresolved conflict with the published promise not to monetise user data through advertising instead of silently rewriting legal copy. Camera rationale is narrowed to the marketplace QR scanner actually used by the native app, and release checks protect the consent-sensitive privacy declarations and maintained readiness matrix. The Expo app declares localized Face ID use in all seven native locales and records its standard exempt-encryption status; its 1024-pixel store icon is now encoded without an unused alpha channel; dedicated EAS preview, production and TestFlight commands are documented alongside an iOS configuration gate that remains red until the real App Store Connect numeric app ID is available. The maintained Apple handoff keeps enrollment, signing, APNs, universal links, privacy inspection, cloud builds and real-iPhone testing explicitly open rather than treating shared Android source as proof of an iOS app. The handoff also records the initial Apple policy assessment for first-party login, physical-goods Stripe payments, user-generated content controls, account deletion, privacy declarations and iPhone-only artwork. A machine-checked English (UK) store record, official EAS Metadata mapping, conservative App Privacy worksheet, current Apple age-rating worksheet, real-iPhone screenshot storyboard and credential-free reviewer notes now prepare the App Store Connect fields while keeping manual release mandatory. The generated app privacy manifest now conservatively declares Project NEXUS data collection and linkage with tracking disabled, keeps Stripe-held card details distinct from purchase history, and explicitly aggregates third-party SDK manifests. The native member profile now also exposes the existing tenant-scoped Block member control—with confirmation, translated copy, connection removal and a reversible Settings entry—so Apple Guideline 1.2's abusive user blocking requirement is a real member journey rather than an inaccurate review-note claim. The production and preview EAS environments now also carry the public Stripe publishable key, and native PaymentSheet initialization passes the registered nexus scheme separately from its complete return URL so iOS 3-D Secure and bank redirects can return to the app correctly. A fail-closed AASA generator is ready to publish Universal Links from the genuine Apple Team ID while excluding staff consoles and browser-owned authentication callbacks; the release gate refuses a placeholder or missing association file. Expo's generated iOS metadata is also narrowed to the permissions the app really exercises: foreground location only, with no Always/background-location declaration and no unused photo-library write permission. The EAS upload wrapper now strips the Android native tree from iOS archives and excludes stale .cxx products and local screenshot evidence on every platform, preventing hundreds of megabytes of irrelevant machine output from being sent to the cloud builder. The iOS deployment target is pinned to Expo SDK 54's supported minimum of 15.1, and an unnecessary static-framework override is removed to avoid the documented React Native 0.81 / Stripe 0.50.3 header conflict. A production-like unsigned iOS Simulator profile now provides real macOS compile evidence before Apple enrollment, without pretending that a simulator .app is a signed device or TestFlight build. Its first EAS run completed successfully; inspection of the final app verified the bundle/version, iOS 15.1 target, URL schemes, narrowed permissions, seven native locales and 21 aggregated app/Stripe/Sentry privacy manifests, with the artifact identity, hash and remaining signed-device limits preserved in the Apple build record. The authoritative journey ledger now records iOS as PARTIAL rather than OPEN: a real Apple-toolchain compile was attempted and succeeded, but the app has not yet run on an iPhone, so the signed-device journey remains deliberately uncredited beyond that step. TestFlight submission is now fail-closed around an explicitly named EAS build instead of “latest”: it verifies the finished production profile, store distribution, non-simulator target, bundle identifier and app version, then still requires an owner-approval flag before starting the upload.
- Google Play upload-key recovery is now independently backed up and tested. The current production JKS and credential metadata have a byte-verified unencrypted offline copy plus matching password-protected, header-encrypted offline and cloud archives, each accompanied by password-free recovery instructions. No secret or recovery artefact is committed. Mobile lint now runs ESLint directly without invoking Expo, scopes Jest/CommonJS exceptions to test and configuration files, and passes with zero warnings instead of 529.
- Google Play tablet artwork is now captured from genuine Android tablet emulators and checked automatically. The mobile release tooling prepares a curated four-screen set for both 7-inch (1080x1920) and 10-inch (2560x1440) listings without cropping or stretching the app, and the Play asset validator now enforces Google's current four-image tablet minimum and checks phone and tablet counts, dimensions, colour mode and the exact 9:16 or 16:9 listing ratio.
- Google Play compliance pages have been added to the maintained React frontend. Public, tenant-aware /account-deletion and /child-safety routes explain the app's deletion process, retention boundaries, child-safety standards, reporting routes and operator identity. The legal hub and footer link to both, and their contact actions open a correctly pre-filled support form. Both pages are published in all eleven languages: the 66 new keys were translated into the other ten locales by hand, because the public translation endpoint refused the batch and no translation API key is configured. The operator's registered name, charity number, address, package id, the literal DELETE confirmation word, the CSAE/CSAM/NCMEC terms and the published contact address are held identical in every locale, and the two addresses are registered as reviewed invariants so the Irish audit and the gap ratchet do not read a deliberately unchanged mailbox as missing work.
- The Play submission handoff now uses the official Timebank Global domain. The prepared privacy, account-deletion, child-safety and support URLs point to timebank.global, the Advertising ID answer is grounded in the built Android manifest, and misleading no-payment/no-tracking store claims are replaced with accurate Stripe marketplace and Sentry disclosure wording.
- Store screenshots — two full sets of eight, taken from the real app. Not mockups: the release build, running against the live Partner Demo community, captured at full phone resolution with a tidied status bar. Light and dark, so you can pick whichever suits the listing. Getting there meant tidying the demo community itself, and it is worth knowing what changed in case a partner walkthrough relied on the old wording. Every listing title had begun "Timebank request:" — which the badge beside it already says — and several described testing the software rather than offering anything ("sanity-check the listings demo flow"). All twenty are rewritten as ordinary offers and requests set across West Cork; six requests became offers, because a board of nineteen requests and one offer reads as a queue of people wanting things rather than a timebank; thirty-one members who lived at "Data room" or "Volunteer desk" now live in real towns; and one listing whose owner was not a member of the community at all has been removed. The originals are archived and every field can be put back.
- Everything that could be prepared for the Google Play Store, ahead of your account being verified. A new page, mobile/docs/PLAY_SUBMISSION.md, holds the drafted store description, the Data Safety answers worked out from the code rather than guessed, the content-rating answers, the exact commands for each step, and the two decisions only you can make — the app's signing key, and the Google service account. It also names the order to do things in once clearance arrives.
- The store artwork Play insists on. The app's own icon is 1024 pixels square, which Play rejects — it wants exactly 512 — and the banner across the top of a listing did not exist at all. Both now do, and both are generated from source (npm run store:assets in mobile/), so anyone can change them rather than being stuck with an image nobody can edit. The banner is a developer's draft: it says the right things in the right colour, and a designer would still do better.
- Google Play assets and production dependencies now have automated release gates. npm run store:assets:check validates the exact icon and feature graphic formats plus every phone screenshot's dimensions, aspect ratio and alpha channel. npm run audit:production fails for any new dependency advisory while explicitly tracking the two unpatched, build-time image-size findings inherited through Metro. The Play handoff now also contains ready-to-enter support, reviewer-access, Data Safety and content-rating answers.
- You can now delete your account from inside the phone app. The website's settings page has had this since before the app existed; the app had nothing — no button, no screen, no request. It is also a firm Google Play rule that an app which lets people create an account must let them delete it in the app, so this was a blocker for publishing as well as a gap a member would notice. Settings → Account → Delete account. It uses the same server request the website uses, so what actually happens to the data is unchanged: type DELETE, enter your password, and your profile, listings and sent messages go, while time-credit records stay in the community's accounts with your name removed. The screen says all of that in plain words before the button will work, in all seven of the app's languages. Fixing it uncovered a quieter fault: the app's own code for "delete" requests threw away any information attached to them. Nothing had needed it before, so nothing had noticed. Without the fix, the password could only have been sent in the web address itself, where it would be written into server logs.

### Fixed

- Posts written on the website no longer show their formatting code, and short posts no longer get a "Read more" they do not need. A member reported her own post opening in the phone app as <p class="mb-1 leading-relaxed text-[var(--text-primary)]"><span>So I had…. The feed card was fixed for the phone on 2026-08-24; this completes the same fault everywhere else it reached. On the website, the small card shown when someone quotes another post rendered the stored HTML as literal text — the one place on the web that shows post content without going through FeedContentRenderer. It now shows the words, using a new shared htmlToPlainText helper that keeps the paragraph and line breaks stripHtmlToText collapses, and decodes the entities it leaves encoded. On the server, the feed preview counted raw characters towards its 500-character budget, markup included: six short paragraphs from the web composer carry about 450 characters of tag before a single word is counted, so a three-sentence post was flagged as truncated and given a "Read more". The preview is now measured on the visible text, cut on a word boundary, never cut inside a tag, and closes whatever tags the cut left open — a half-written <p class="mb-1 lead has no closing bracket, so no tag-stripper can match it and the fragment reached the screen. The appended ellipsis now sits inside the final paragraph instead of after it. Separately, the phone's feed card spent one of its four preview lines on the blank line between two paragraphs, and React Native drew its own "there is more" ellipsis onto that empty line — the stray "..." floating under a card, which looked like left-over markup and was not. The preview closes those gaps; the post's own page keeps its paragraph breaks. Every new test was checked against the unfixed code first: four earlier drafts passed with the fix reverted, because a substring match still matches when the tag soup is on screen.
- The three feed pages that had no automated safety net now have one. The tests for the hashtag feed, the hashtag discovery page and the single-post page had been switched off because they were failing, so nothing was checking those three screens. All three failed for the same reason, and it was the tests that were wrong, not the pages: each was looking for the wrong kind of control. The "back to feed" control is a link, and the tests demanded a button; the hashtag search box is a search box, and the tests demanded a plain text box. Neither page was changed. Two checks that could never have failed were also replaced with real ones, so the search test now proves a search actually happens. All 31 tests pass, and the count of switched-off test files drops from 49 to 46.
- A failed role deletion now explains itself. The admin role list discarded the reason the API gave and always showed the same generic failure, so an admin was never told why a delete was refused. It now shows the server's own translated reason and falls back to the generic message only when none is sent, matching the twenty other admin screens that already did this.
- The platform's own release checks disagreed with the iOS build they were checking. Preparing the App Store submission correctly dropped the permission that asks to save photos to your library — the app never saves photos, and Apple rejects an app that asks for something it does not use — but one check still insisted the permission be present while two others insisted it be gone. The same submission added an iPhone-simulator build, which a second check mistook for a build that could never be sent a fix, because it recognised the exempt kind of build by its name rather than by what it is. The privacy policy also gained new App Store disclosures and a new date without its check being told. All three now agree with what actually ships. Separately, the Enterprise role screen's delete message is now marked as already-translated text, so the translation check stops reporting it as untranslated English.
- A connection request from a member with no first name arrived blank. The notification read " sent you a connection request" instead of naming anyone. The line meant to fall back to "Someone" chained through the account's display name, and that display name is now always a string — empty rather than absent for a nameless account — so the fallback could never be reached. Empty now counts as missing. The same line also preferred the first name unconditionally, which for an organisation account is the contact person's name, so an organisation now sends its own name and a person still gets the informal first name. Alongside it, twelve tests were repaired that had quietly stopped testing what they claimed: they set a member's display name directly, but the platform recalculates that column from the first and last name on every save, so the name each test chose was replaced by a randomly generated one. One of the twelve was the spreadsheet-export safety check for group analytics, whose hostile filename payload never reached the exported file — the guard itself was working and is now genuinely exercised. Two others had been passing only because they reproduced a known misspelling ("organization" for "organisation") that stopped the organisation branch ever running.
- An organisation account now shows its organisation name everywhere, instead of sometimes showing the contact person's own name. A member who signs up (or later switches) to an organisation in profile settings stores the trading name separately from the person's first and last name. The person's name was leaking through into the members directory, group creators, the explore lists, the feed sidebar, shared and quoted feed posts, the gamification profile card, the leaderboard and the AI assistant's memory. There were three underlying causes, all fixed: the stored display-name column was written from the person's name when an account was created, was never written at all by self-registration, and was never recalculated when a member switched their profile to an organisation. A migration repairs existing accounts. The decision about what an account is called now lives in exactly one place on each side (App\Support\UserDisplayName and resolveUserDisplayName()), roughly 700 hand-rolled name assemblies across the API and the React app were routed through it, and a new blocking check (npm run check:display-name, ceiling zero) stops them coming back. Two services also compared the profile type against the American spelling, which never matched, so their organisation branch had never once run.
- The admin Enterprise pages no longer go blank after an account is deleted. A GDPR audit entry recorded for a deleted account carries no entity type — by design, because the record it would point at is gone. The Enterprise dashboard and the GDPR audit log both assumed that value was always present and crashed the entire page to the error boundary when it was not, even though the API had answered normally. Both now fall back to their existing "Unknown entity" label via a single shared helper, the affected columns are typed as nullable so the assumption cannot be reintroduced silently, and regression tests cover the null value at both the helper and the rendered-page level. Reported from production as support report NXR-260827-ND1UJA; three communities were affected.
- Native refresh controls and comment reactions now report real outcomes. Explore and Exchange Detail pull-to-refresh indicators follow the underlying request instead of fixed 650 ms and 1.2 second timers; Exchange Detail keeps its existing content visible during a refresh. A failed comment reaction now restores authoritative state and shows the translated server or fallback explanation instead of relying on error haptics alone.
- Native wallet and listing feedback now reflects the real request outcome. Wallet pull-to-refresh remains active until balance, transaction, community-fund and pending-credit requests have all settled instead of stopping after a fixed delay. A failed listing save or unsave still rolls back its optimistic state and now also shows the translated server or fallback explanation instead of relying on error haptics alone.
- Native mobile refreshes and first-install community selection now fail safely. Older paginated responses can no longer overwrite a newer refresh or block a newer filter request across feed, listings and messages; a community that cannot load is no longer remembered; signed-in and signed-out picker failures now remain visible with an explanation; and wallet pending transactions plus the authoritative notification unread total refresh with their respective screens. Each regression was reproduced by a failing test before repair.
- Mobile organisation deposits now reconcile both sides of the movement. Funding a volunteering organisation writes the organisation ledger and the member-facing wallet transaction in the same database transaction, with idempotency coverage preventing a replay from duplicating either entry.
- Mobile startup and accessibility guards cover more real failure modes. A removed remembered community now clears the stale selection and returns the installation to the neutral picker, while goals, organisations and settings use the non-interactive status-chip wrapper so informational labels are not exposed to assistive technology as undersized buttons. Response-contract tests now pin the nested settings, linked-account, export and activity payloads, and the two actionable production hook-lint warnings have been removed.
- The maintained mobile release status now describes the app that actually ships. Rubric M1 is reconciled to a machine-checked 708/1000 local candidate (with 629 retained as the committed/CI-backed floor) from the current 140-row journey ledger, live Google Play distribution, Sentry, policy pages, tablet evidence and release gates. The roadmap and Play handoff no longer claim that identity verification, signing, listing assets, Sentry or distribution are missing, and one risk-ordered pre-build backlog replaces their contradictory historical task lists.
- Fresh mobile installations now start with an unselected community picker. A community is remembered only after the member chooses it, then signed-out returning members go directly to that community's login while authenticated members continue directly to the app. Existing stored community selections are preserved. Android shipping-manifest validation also ignores generated debug manifests while continuing to inspect generated release manifests.
- Accessible tenant routing now reserves the two Google Play compliance paths. /account-deletion and /child-safety can no longer be mistaken for child tenant slugs on parent domains, keeping Web UK aligned with Laravel routing.
- The public Google Play child-safety standard now names only reporting controls that the native app actually provides. The eleven published locales direct members to report a post, listing, exchange or marketplace item, removing unsupported claims about profile, message and event report buttons while preserving the separate public child-safety form.
- Legal-document administration now honours its selected notification audience. Choosing “all active members” previously sent only to members who had not accepted the selected version, while reporting success as though the wider audience had been used. The API now validates and applies the chosen audience, and every newly created legal version is forced to begin as an editable draft so a crafted request cannot create an unpublished version that cannot be edited through the administration screen. Notifications and compliance figures now exclude administrators, matching their deliberate acceptance-gate exemption; the pending-member total is exact across multiple documents rather than an average; only the current published version can be notified; notification timestamps are recorded; historical versions cannot be republished as an accidental rollback; and registration records only documents whose configured acceptance point is registration. “All members” may still receive the neutral in-app update, but members who already accepted that exact version are no longer sent a misleading action-required email.
- Self-service account deletion no longer leaves an untracked personal-data export on disk. A direct deletion now erases the account without first generating a ZIP that had no GDPR-request record and therefore no expiry path. DPO-managed deletions may still create a request-linked seven-day export, and cleanup now removes historical orphan export files after the same period.
- The mobile-only EAS wrapper can now resolve Expo configuration plugins before creating a Play build. Its isolated upload context correctly omitted node_modules, but EAS evaluates expo-router and the other plugins locally before creating the archive, so the production AAB command stopped immediately. The context now uses an upload-excluded local dependency junction, guarded by a regression test, while the remote build remains mobile-only. The wrapper also materializes the app-version runtime policy for the native EAS context, and EAS now owns version-code auto-incrementing remotely so failed isolated builds cannot repeatedly reuse the same Play version code. Finally, the Android post-install hook now copies and validates the existing EAS Firebase file secret into the native location before Gradle runs, instead of failing at processReleaseGoogleServices. The repaired path produced and locally verified a signed production AAB on 2026-08-26. After rotating the upload credential, a quota-free local Gradle path produced and verified 1.2.0 / version-code 5 against the new EAS-default certificate; generated credentials, AAB and APK artifacts are ignored so they cannot be committed accidentally.
- The Google Play phone screenshots now meet the upload rules without losing any app content. The original 1080×2400 captures exceeded Play's 2:1 aspect limit and contained an alpha channel. All sixteen are now opaque 24-bit 1080×1920 PNGs, proportionally fitted on colour-matched side gutters with no cropping or stretching.
- Reaction details now open from the feed-item detail screen. The card was updating a reactor-sheet state that the screen never rendered, so tapping the reaction summary did nothing. The sheet is now present and covered by a focused open-and-close regression test; related production hook dependency warnings in feed, events, marketplace and volunteering flows are also resolved.
- ASP.NET message media now stays playable after a conversation is reloaded. Voice-message thread reads now retain the voice marker and duration, and both voice and ordinary attachment URLs use the participant-authorized private-media routes instead of owner-only generic file links.
- The Google Play feature graphic now includes the complete app icon. Its renderer previously replaced a placeholder mentioned in an HTML comment instead of the image source, leaving a blue tile with a broken-image fragment. The renderer now targets the image source directly and refuses to capture the banner until the embedded icon has decoded successfully.
- A goal deadline that isn't a real date is no longer thrown away silently. Setting a goal due on 31 February said "Your goal has been created" and created a goal with no deadline at all — the date the member typed simply vanished, with nothing said. The check that catches impossible dates was already there and already working; the goals page was ignoring its answer. It now says "Enter a real date", the same way every other part of the site already did.
- The accessible site blamed members for things their community had simply switched off. Opening a page for a module the community does not use — courses, the marketplace, podcasts and others — said "You do not have permission to view this page" and "this page is not available to your account… contact your community organisers if you think this is wrong". Both are untrue: nothing is wrong with the member's account, and there is nothing for organisers to fix. It now says "Not available — this module is not enabled for this community", in the reader's own language. Genuine permission refusals, such as an ordinary member opening an organiser-only page, are unchanged.
- New members on the accessible site could not finish setting up their account, and were not told why. On the last step of the six-step setup, pressing "Finish and go to my dashboard" said only "Something went wrong. Please try again." Trying again could never work. What was actually missing was a profile photo, which is required — the server said so clearly, and the page was looking for that answer in the wrong place. The member is now taken back to the photo step and told plainly that a photo is needed. This mattered more than it looks: a member whose setup is unfinished does not appear in the member directory at all, so they could not be found by anyone.
- The setup wizard's messages were in English for everyone. All six of its error and confirmation messages had been translated into all eleven languages already; the page was showing hardcoded English instead. It now uses the translations — which matters most here, since this is the very first thing a new member does.
- The accessible site told families a carer could read a supported member's messages. They could not, and the person was never asked. On the Linked accounts page, "View their messages" sat in a list of tick boxes under a Save button. Ticking it did nothing at all — that switch has granted nothing for years — but the page made it look like a carer's message access was something the carer could just turn on. The real arrangement was built some time ago and is the opposite: the carer has to ask, the supported member has to agree in their own "Waiting for your approval" page, every time the carer looks it is recorded with a reason they had to state, and the supported member can stop it in one press. All of that existed, in all eleven languages, and simply was never put on the page. It is there now, and the misleading tick box is gone.
- And the carer's message view could never open, even with permission properly given. Three separate faults, each of which alone would have stopped it. The reason a carer has to state before looking was sent in a way that cannot carry Japanese, Arabic or Polish at all, and cannot carry anybody's typed note in any language — so the request was never made, and the carer was told the permission "may have been withdrawn" when nothing of the sort had happened. Nothing reached the record either, on a feature whose whole point is that every look is recorded. Separately, the page could not work out the supported member's name and treated that as "this isn't your person", and once open it labelled every conversation and every message "Unknown member" and showed a raw :name placeholder where a name should be. The same sending fault was present in the main app and is fixed there too.
- "You have 1 connections" — the accessible site's My network page no longer gets its grammar wrong. With exactly one connection, or one request waiting, the summary line read "You have 1 connections, 1 requests waiting for your reply…". It could not be fixed by making the words plural-aware, because that one sentence carries three separate counts and the translation system can only adjust for one. The three counts are now shown as plain labels with their numbers beside them — right in all eleven languages, and no new wording for anyone to translate.
- The accessible site's Notifications page now tells you what happened. Two silences, both found by using the page. Marking a single notification as read said nothing at all — every other button there (mark all as read, delete, delete all, mark a group read) confirms itself, and the confirmation wording for this one had already been written and translated into all eleven languages. It had simply never been switched on. Worse, when any of these actions failed, the page reloaded looking exactly the same and said nothing — so a failed "mark all as read" was indistinguishable from a successful one. Failures now say so, in the reader's own language rather than in English. This matters most for people using a screen reader, who get no visual cue that a page changed.
- Everyone reading the app in English was seeing American dates. A listing posted on 17 August showed as "8/17/2026" — month first — on a platform built for Ireland and the UK. The app deliberately follows the language you pick in settings rather than your phone's, so that switching to Spanish gives you Spanish dates; the flaw was that a language on its own carries no country, and English on its own means America. Dates now take the language you chose and the country your phone is in, so an Irish phone shows 17/8/2026 and Spanish still reads in Spanish.
- A listing whose author could not be found showed a bare question mark. Where the member's name and photo belong, the app printed "?" twice — which reads as the app being broken rather than the person being gone. The server had been sending a proper answer all along and the app was discarding it. It now shows that, or "Unknown member" if there is nothing at all. Both of these were found by using the real app on a phone to take store screenshots — neither would have been caught by a test, because both look entirely normal until you read them.
- Members' surnames were being handed out by the wallet's recipient search, and are not any more. Everywhere else on the platform a surname is private to everyone except a community's admins — the member directory and profile pages both hide it. The box you type into when sending someone time credits did not, so anyone could have collected every surname in their community two letters at a time. It now returns the first name only, exactly like the rest of the platform. So that you can still tell two members called Mary apart before sending credits, each result now shows the member's username beside their first name, and the send button names them both — sending credits is not something you can undo, so the list must never be ambiguous. Searching by surname still finds the right person; the surname is simply not read back to you. Admins see surnames as before.
- Crash reporting for the phone app is now set up, and proven to work. There was nowhere for the app's crashes to go: the website and the backend each have their own place in the error-reporting service, and the app had none. One was created, a test report was sent to it and read back to confirm it arrived, and the nightly error summary now covers it. Release builds also upload the file that turns compressed code back into readable line numbers — without it, crash reports arrive as gibberish, and every build profile had that switched off, including the one for the Play Store. Locally built releases carry crash reporting automatically now, and say so plainly when they cannot. Cloud builds are wired up too, now that you're signed in: the app built for the Play Store and the one on the download page both report crashes, and both send the file that makes those reports readable. The download-page build matters most there — it reaches people before anything on the store does.
- A password-reset link from one community could be used while the browser was pointed at a different one. The link itself was always tied to the community that sent it, and the new password was only ever written to the right account — but the server did not check the two agreed before doing the work. It now refuses that combination outright, before touching the password or signing anyone out, and the unused link stays valid so the member can still finish the reset in the right place. A test covers it.
- The app was asking Android for permission to draw over other apps. Nothing in it draws over anything — the permission comes from the development tooling and should never have been in a release build. Google Play lists every permission an app asks for, and that one invites questions. It is now blocked, and the proof is in the built file rather than in the setting: the app was rebuilt and the permission is gone from the manifest that actually ships.
- A release build could not be produced at all without silently switching off crash-report uploading. Building one the ordinary way fails outright, because the crash-reporting tool has no project configured to upload to. That is why every build profile switches it off — including the one for the Play Store, which means a released app would have sent crash reports nobody could read. What's needed to fix it properly is written down: one Sentry project and two settings, all on your side.

### Changed

- Gamification badges and level-ups no longer appear as posts in the feed. Earning a badge or reaching a new level used to publish a full-width celebratory card into the community feed — an oversized icon, a coloured gradient panel and, on the web, a confetti burst. On an active community that was a large share of the screen, and it pushed real member content down. The feed now carries member content only, on all three clients: the React web app, the accessible (GOV.UK-based) frontend, and the mobile app. Everything else about gamification is unchanged. Badges and levels are still awarded, XP is still counted, level bonuses still apply, and members are still told about a new badge or level by in-app notification, push and email. Badges remain visible on the achievements page and on member profiles, and the admin gamification tools are untouched. Removal happens in four places so no client can bring the cards back: the service stops recording the activity row, the feed query excludes the two types (which also hides the rows already in the database), the feed filter cannot be asked for them by name, and each client both filters its list and refuses to draw a milestone card handed to it by a stale or cached response.
- The .NET edition now handles forgotten passwords and staying signed in the same way the live site does. This is the second edition of the server, the one some public-sector buyers require; it is not in use anywhere. Four things were wrong. The reset email was sent and forgotten about, so a member could be told "check your inbox" when nothing had been sent — the link is now only created once the mail server has accepted the message, which also means a failed send no longer cancels a link the member already has. The link pointed at a web address that does not exist on the real site. Two reset attempts, or two "stay signed in" attempts, arriving at the same moment could both succeed; only one can now, and the loser gets the same harmless retry the site already handles. And the reset form's own fields were named differently from the ones the website sends. Reset and refresh credentials are also refused when they are presented through a different community, before any valid session or link is consumed. Fifteen tests cover the two journeys end to end. No production code or configuration is affected.
- Identity verification is hidden in the phone app for now. Google requires apps to use Google's own payment system for anything bought inside them, and the app was charging a card through Stripe for ID verification. Getting that wrong gets an app removed after it launches, not rejected before, so the option is switched off for the first release rather than argued about. Anyone already verified still sees their verified status; the screen now says the check can't be started in the app, and the button that sent people to the website to pay is gone — sending people elsewhere to buy is a separate rule, broken on its own. It is one setting to switch back on, and the code underneath is untouched and still tested. Two things deliberately left alone: buying second-hand items from other members still works, because Google's rule doesn't apply to physical things people post or hand over; and donating time credits was never money in the first place.
- Every sign-up form now says the same thing about age: Project NEXUS is for adults, 18 and over. The web app's form had always said "and I am 18 years of age or older". The phone app's said nothing about age in any of its seven languages, and the accessible frontend's said nothing in any of its eleven. So the platform was describing itself two ways at once, and the phone app is the one going to Google Play, where the audience has to be declared and has to match what the app tells people. All three forms now carry the declaration, hand-translated in every language each one offers, and a new check (node scripts/check-age-declaration.mjs, blocking in CI) fails if any of them ever stops saying it. Nothing could see the disagreement before: every key was present in every language, so both translation gates were green while the three sentences said different things.
- The public features page no longer advertises something the platform does not offer. It described "consent flows for members under 18", which cannot be true of a platform whose sign-up requires being 18 or older. The guardian-consent capability is real and is unchanged; it is now described for what it is — an optional, staff-mediated consent record for communities running supervised activity with young people, switched off unless a community turns it on — with the adults-only position stated alongside it, in all eleven languages. docs/PRODUCT-AUDIENCE.md records the position, what the code enforces versus what a member declares, and the gaps still open (social sign-up makes no age statement; two communities' terms documents contain no age clause).
- Two translation checks that existed but were not being run are now blocking in CI. The phone app's untranslated-value ratchet was written yesterday and wired to nothing, so nothing would have noticed English creeping back into the other six languages. It now runs in the mobile job, alongside the new sign-up age check.
- The app icon was being cropped on real Android phones. Android cuts the launcher icon into whatever shape the phone uses — a circle on most — and only guarantees the middle two thirds of the artwork survives. Ours filled the whole square, with the four dots right at the edges, so those dots and the outer ring were sliced off. It looked correct on the emulator because that launcher trims less, which is why it took a real phone to notice. The same artwork now sits inside the safe area with the blue supplied behind it, so the whole design shows on any phone shape. It appears slightly smaller — that is the space Android reserves, not a change to the design. A test now measures the icon and fails if artwork strays outside the safe area again.
- The listings page now spends its screen on listings. The block at the top — title, description, result count, search, type tabs, Near me, Filters and sort — was not part of the list: it sat above it as a fixed panel that could never scroll away, leaving the list less than half the screen and about one and a half listings visible at a time. Only the search box is pinned now; everything else scrolls with the list, which is what the app's five other list screens already did. Measured on a phone: the fixed area dropped from 331 to 48 points, the space for listings went from 48% to 79% of the screen, and you now see two and a half listings instead of one and a half. Nothing was removed — every control is still there, one scroll up. The line "Search by skill, category, place, or member" was dropped, because the search box directly below it says the same thing.

### Fixed

- Three more Tier 1 React journeys now have reproducible dual-backend certification coverage. The paired runner starts a genuinely empty direct-message relationship, proves the exact first message from both members' fresh thread views, drives a connection request through recipient acceptance and both members' fresh accepted lists, and changes then restores a settings tagline across fresh sign-ins. The work closed ASP.NET's v2 member-search and connection-list contract gaps, added explicit tenant boundaries to connection acceptance, and made the disposable Laravel relationship actors approval-complete.
- The ASP.NET do-nothing endpoint baseline now records the gain the sign-out and onboarding work actually made. Implementing the tenant-aware maps configuration and onboarding endpoints removed two more placeholder routes, taking the inventory from 550 routes/317 methods to 548/315. The shrink-only ratchet is enforced in both directions, so the improvement itself failed the Platform contracts build until the baseline was lowered to match; aspnet-backend/scripts/noop-stubs-baseline.json now carries the new figures and the checker matches on all ten metrics.
- Sign-out and first-time onboarding now have complete dual-backend journey evidence. The unchanged React client registers a disposable member, completes the mandatory photo and bio profile steps plus each tenant's configured optional steps, reaches the dashboard, and proves onboarding_completed=true after a fresh reload on both ASP.NET and Laravel. The same paired browser run signs out through the real user menu, verifies the client clears access, refresh and tenant state, replays both copied credentials, and proves protected navigation cannot resurrect the session. The journey exposed a shared security gap: logout revoked refresh credentials but a current access JWT remained usable until expiry. Both backends now issue uniquely identifiable access tokens and denylist the presented token on logout; focused ASP.NET and Laravel regressions cover the server-side guarantee.
- Seventeen core ASP.NET member journeys now have full same-run certification instead of ASP.NET-only proof. The paired React smoke now creates and reloads its own listing, event, feed post, comment, transfer, RSVP, message and signup/legal-acceptance evidence; drives real listing/event/feed filters; verifies dashboard destinations, wallet history, exact member/profile identities and persisted theme state; and advances only generated disposable registrations through verification, approval and onboarding before proving the first-sign-in legal gate in a fresh browser context. One comprehensive run completed 22/22 steps as MATCH against ASP.NET and the Laravel control, with no failures or skips. The same certification gate exposed and closed the tenant-aware maps configuration stub and documented the already-tested recurrence capability projection, shrinking the no-op inventory from 553 routes/319 methods to 550/317. This promotes sign-up, verification, legal acceptance, sign-in, dashboard, feed browse/filter/create/comment, listings browse, event discovery/RSVP, existing-thread messaging, wallet history, directory browse, profiles and theme persistence from PROVEN to CERTIFIED; the banked score floor remains unchanged until the batched push is green.
- No git safety check had been running in this repository, including the scan that stops passwords and keys being committed to a public repo. Found while explaining why a broken test reached main. The mobile app's tooling sets core.hooksPath for the whole repository whenever packages are installed under mobile/, and git then ignores the project's own checks completely. The file it pointed at instead said mobile pre-commit hook disabled and did nothing. Both of the project's gates were therefore silently off: the credential scan, on a public repository, and the check that runs a commit's own PHP tests. Nothing reported this, because doing nothing and passing cleanly look identical. That mobile hook now hands control to the project's real checks rather than overriding them, so the gates survive the next package install instead of being switched off by it. The installer no longer claims both gates are live without looking: it detects the override, confirms the checks are genuinely reachable, and refuses with instructions when they are not. Line-ending rules now cover both hook locations, which the previous pattern missed because it only matched the repository root. Verified by attempting real commits: one carrying a fake access key and one carrying a failing test were both blocked, and neither commit was created. No evidence any secret was actually committed while the scan was off; that has not been audited and is tracked separately.
- A post longer than a few lines could not be read in full in the mobile app, and posts written on the website appeared as raw code. Reported by a member with a screenshot: her post opened showing <p class="mb-1 leading-relaxed… and tapping "Read more" did nothing. She was right on both counts, and the first one was a server fault, not an app one. A post's own page was being built by the same code that builds the feed list, so it inherited the list's 500-character preview: an 872-character post came back as 503 characters. There was no request any part of the platform could make that returned the rest of it. The post page now returns the whole post; the feed list still sends a preview, deliberately, because it carries twenty posts at a time. In the app, the post page was also still clipping the text to four lines and offering a "Read more" button that pointed at the page you were already on — so it was greyed out and led nowhere. It now shows the whole post and drops the button. And posts written on the website are stored with formatting markup, which the feed was printing literally; it is now shown as words, with the paragraph breaks kept. Six other screens in the app already did this — the feed, the first screen anyone sees, was the one that did not.
- ASP.NET listing search and filters now narrow the catalogue instead of silently returning every listing. The endpoint now honours the React app's search text, offer/request type, category, estimated-hours range, delivery mode, posting age, coordinate requirement and distance radius. Nearby results include their computed distance only when requested. A paired browser journey proves exact search and type changes through the unchanged React UI against both ASP.NET and Laravel, with database-backed coverage for the remaining filter combinations.
- The mobile app's screenshot check now watches twice as many screens — and one screen it was already watching turned out to be unreliable. The check photographs screens and compares them with approved copies, so an accidental layout change is caught. It covered three screens; it now covers six in the light theme and four in dark, adding settings, the support page and an empty create-listing form — all chosen because nothing on them moves or changes with the date. Each new screen was photographed twice and the two photographs compared before being trusted. That same check showed the sign-in screen differs by about 1% between two identical runs, because the community logo is fetched and sometimes arrives after the photograph. It has been taken out of the comparison and the reason written down, so the check can't fail on a run where nothing changed.
- 33 things across the mobile app were too small to be offered as tappable, and none of them should have been tappable at all. A new tool measures every tappable thing on 24 screens against the accessibility minimum, using the screen's real density. Everything it flagged turned out to be the same thing: a small label — "5 members", "Not ID verified", "Closes Sep 22, 2026", "2 votes" — that the design library quietly renders as a button. Fourteen screens and cards now use the wrapper that leaves labels as labels, so they are no longer offered as targets at all. Re-measured from a clean start: every flagged screen is now clear. The tool's own first results were wrong, which is worth saying: it measured whichever screen happened to be showing, so on a slow screen it reported the previous one's contents. It now proves which screen it is looking at before measuring, and says "unverified" instead of guessing.
- ASP.NET listing-creation certification now proves the persisted result. The paired React smoke reloads each newly created listing and requires the submitted title on both ASP.NET and the Laravel control, so a redirect or invented success response can no longer pass journey 1.19.
- Five things the app was saying to blind members that it shouldn't have been. Found by turning on Android's screen reader and reading back everything the app announces, then comparing it with what a person would expect to hear. None of it was visible in a screenshot. Every post in the feed was read out twice — once as a summary that stopped mid-sentence after 100 characters, then again in full — and the author's name was said twice inside the second reading. Every profile picture in the app announced itself as "Avatar", and in the member directory each row also read out a lone letter (the initial shown when someone has no photo). The round button on the home screen announced itself as "Action button", which says nothing about what it does; it now says "New exchange". And every search box announced its magnifying glass as "Search icon". All fixed and checked on a device. The home screen went from 25 spoken stops to 22 clear ones; a member row now reads "View E2E's profile, Not ID verified, 5 given, 8 total" instead of six stops including a stray letter. Nothing looks any different.
- ASP.NET listing edits and deletes now follow the production Laravel journey. The React edit form's listing type, category, coordinates, estimated hours, available-hours cap and delivery mode are persisted and returned after a reload; skill-tag replacement is owner-authorized; bodyless deletes return the expected success status; and an unrelated same-community member can no longer edit, retag or delete somebody else's listing. A database migration adds durable storage for the available-hours cap and delivery mode.
- The wallet no longer offers a "Pending" filter that can never show anything. In a community that isn't connected to another platform, nothing ever puts credits into a pending state — so tapping Pending always answered "No matching transactions". It now appears only when there really is something pending. The summary still says "No pending credits", so nothing is hidden; the difference is that a member is no longer invited to tap a control that leads nowhere.
- Volunteers can now ask to swap a shift — which was impossible anywhere on the platform until today. Answering a swap request worked; asking for one did not exist, on the app or the website, and the website's own help text pointed at a page that had never been built. The obstacle was that the request had to name the volunteer you wanted to swap with, and nothing tells you who is on which shift — showing that is a privacy decision nobody had taken. It turns out the decision wasn't needed. On the app you now pick the shift you'd rather do, and the platform asks whoever is on it without telling you who they are: "We will ask whoever is on it — you will not see their name unless they agree." Nothing new is revealed, because the number of people signed up per shift has always been shown. If several people are on the shift you want, it asks the one who hasn't already been asked, so two people aren't left queuing behind the same volunteer. Walked end to end on a device: asked for a swap, the other volunteer was notified, they accepted, and both shifts genuinely changed hands. The website still has no equivalent screen.
- On the mobile app, "No matches yet" was hiding the real reason, which was usually fixable in ten seconds. The server already explains an empty match list — no area on your profile, matching switched off, or nothing posted yet — and the app was throwing that explanation away. So a member with no location was told "no matches yet", which reads as "nobody suits you", when the truth was "we can't look until you tell us roughly where you are". Matching is built on what is near you, so without an area the app genuinely cannot suggest anything local. The screen now says which of the three reasons applies, in all seven languages, and offers the button that fixes it. Two mistakes it deliberately avoids: it never guesses a reason the server didn't give, and it doesn't blame your location when you have matches but happen to be looking at an empty tab. Measured on a device while fixing it: with an area on the profile the member gets a real listing match at 51%; with the area removed, that match disappears completely.
- Volunteers were told to check their details when they were actually waiting for the organisation. Logging volunteering hours needs an approved application first. When it wasn't approved, the page said "Your hours could not be logged. Check the details and try again" — so a volunteer would keep re-reading a date and an hours figure that were both perfectly correct, with no way to discover the real reason. It now says they can log hours once the organisation has approved them, with separate wording for an unapproved application and an unapproved organisation. New messages in all eleven languages.

### Notes

- The volunteering journey now works end to end, and has been walked. Previously it could not be checked at all because the test community had no volunteering data. Seeded through the pages themselves and walked: registering an organisation (which correctly waits for approval, and explains that clearly); an administrator approving it; posting an opportunity (an empty form refused with a proper error summary); another member finding it by browsing and by search; applying once, after which the apply form is correctly withdrawn so nobody applies twice; the organisation seeing and approving the application; logging hours, which are held as pending; and the organisation approving those hours, at which point the time credits arrive — two hours became two credits, and nothing arrived before approval. The volunteers roster then shows the member as approved with their total hours.
- Saving a listing showed up on one page and nowhere else. Pressing Save changed the button to "Unsave", but the listing list you came from still showed no "Saved" label. Two different features were sharing the word "Saved": the button records a favourite, while the list was checking a separate "saved items" store that the button never writes to. The list now reads the same thing the button writes, so the two agree. Checked by saving and un-saving and watching both pages follow along.
- People who had never asked to join a group were told an admin was reviewing their request. Opening a group's discussions as a non-member showed "Your request to join is waiting for an admin to approve it" — which could easily stop someone from joining at all, since they would assume they already had. It now says "Join this group to see its discussions", and a genuinely pending request still gets the pending message. New wording in all eleven languages.
- Asking to join a group that needs approval said "You have joined the group". The same page then said the request was waiting for an admin, so one screen said both. The server is explicit that the request is pending; that answer was being thrown away. It now says the request has been sent, and open groups still say you have joined.

### Notes

- Volunteering could not be walked. The test community has no volunteering opportunities, so there was nothing to open or apply for. The listing page, its search and its empty states are correct, but applying, shifts and hours remain unchecked. Recorded so this is not mistaken for a clean result.
- Member profiles were walked and are correct. Search with a working empty state; connect (which correctly becomes "Request sent" and offers to cancel); reviews (empty refused, real review appears); sending credits from the profile (a bad amount refused, one credit moved exactly one); and blocking, which hides the profile and is reversible from the blocked-members list.
- 872 phrases in the mobile app now really are in the reader's language, and the rest can no longer be missed. The app has always had every phrase present in every language, which is why every existing check said the translations were complete — but 5,671 of those phrases still held the English sentence, about one string in nine, in German, Spanish, French, Italian and Portuguese. (Irish was nearly done.) Nothing could see it: the checks compared the list of phrases, not the phrases themselves — the same blind spot that once hid 99,139 untranslated values on the website. 872 have been filled from the website's own reviewed translations, matched phrase by phrase, which is better than machine translation and cost nothing. The remaining 4,648 need a paid translation service: the free one now refuses this machine outright. The gap is now counted and held: a new check reports how much English is left in each language and fails if it grows — and also fails if it shrinks without the new, lower number being written down, so an improvement can't be quietly spent later.
- On the mobile app, a link that pointed at a particular part of a page landed on the wrong part — and the reason turned out to be different from what had been written down twice before. Found by printing what the screen was actually given, after two earlier fixes had failed to change anything. Opening a link like "volunteering, donations" from a closed app produced two copies of the page: the right one, opened on the right section, and a second, plain copy on top of it. The app deliberately re-follows a link once it knows who is signed in — that matters for someone who starts signed out and has to sign in first — and the code that re-followed it was dropping everything after the "?" in the address. The right page was underneath the whole time. So this was never only about sections of a page: anything a link carried was being lost — a filter, a category, "start a new one". That is fixed at the source, for all forty places that navigate this way, and a link can no longer smuggle in a different record id than the one in the address itself. Confirmed on a device from a closed app. Two other things were tidied in the same pass: two screens now notice a link that arrives while they are already open, and the guard that was supposed to protect this behaviour was rewritten — it had been checking for the names of the old code rather than for the behaviour, so it stayed green through the whole fault.
- On the mobile app, recording a donation to a fundraising campaign appeared to do nothing. Walked on a device. The donation was accepted, but the form simply emptied: the campaign still said "€0.00 raised", the donor count stayed at zero, and the only trace was a line far below the form. None of that was wrong — a donation recorded this way is a pledge, and it only counts once someone confirms the money arrived — but from the member's side it read as a failure. It now says what happened, in all seven languages: "Pledge recorded — your donation has been recorded and will count towards the campaign once it is confirmed."
- The message inbox contradicted itself about unread messages. The summary at the top of the page and the badge on each conversation row show the same words, "1 unread message", but came from different places — one cached for 15 seconds, one live. They disagreed in both directions: after reading a message the summary kept claiming it was unread, and a message that had just arrived showed on its row while the summary said nothing at all. Reading a conversation is what marks it read, so the page now clears the cached count at that moment, and the inbox reads its own figure fresh. Verified against the server at each step.
- Pressing "Translate" told members to try again when translation was not available at all. If no translation provider is configured, the service reported the same "translation failed, please try again" as a genuine provider error — advice that could never work, a permanent server error in monitoring, and, in the React app, an auto-translate loop that retried every message on every cycle. The API now answers with a distinct "not available" result, and members are told plainly that message translation is not available on this community. New wording added in all eleven languages.
- Starting a group conversation offered you yourself as a member to add, then refused to create the group. Taking that offer listed you twice — once as the administrator, once as a nameless "Community member" — and counted towards the page's own "at least two other members" rule, so the page said the group could be created and the attempt then failed with an unexplained "We could not create the group". You are the administrator by definition, so you no longer appear in the member search, and a hand-edited web address naming you is ignored rather than producing the same dead end.
- Nothing was found wrong in the wallet. Walked end to end for completeness: eight different bad transfers were each refused with their own specific reason, a real transfer of 3 credits moved exactly 3 (102 → 99 and 23 → 26, nothing created or lost), both members' histories and balances updated, the CSV export contained the transaction, and re-submitting the same page did not send the credits twice. Recorded here so it is not re-audited.
- On the mobile app, group exchanges never appeared in the list, and the split of hours was shown as raw database field names. Found by creating a real group exchange on a device — a screen that had only ever been looked at before. The exchange was created successfully and the app opened it, but the list it came from still said "No group exchanges found", and it stayed empty on every visit. The app was looking for the list in the wrong place in the server's answer, so an organiser could never see anything they had set up; the wording of the empty screen made that read as "you have none". On the exchange's own page, the section that shows how the hours are divided printed lines like "To member #role: provider hours" — the names of the fields in the server's answer, not the answer itself. The app expected a different shape entirely: a grid of transfers between members, which the platform has never had. It now shows one line per person, with their name and their share: "E2E UserB — 4 hours / Provider". Both were invisible to the tests because both test fixtures had been written from the app's own mistaken idea of the server's answer rather than from the answer. Both are rewritten from real responses, and each fix was checked by breaking it again and watching the tests fail. Verified on a device.
- On the mobile app, changing community while signed in locked you out of your own community. Found by walking it on a device. An account belongs to one community, and its sign-in is not valid anywhere else — so the moment a member picked a different community from the picker, every request was refused: their profile, the community's own branding, their notification count and the feed all came back with "Token tenant does not match requested tenant". The home screen kept the previous content on display, so it still looked signed in and working, and the only control offered was a Retry button that could never succeed. Closing the app and reopening it did not clear it. The way back was blocked too. The community picker is built from the public list of communities, but the app was sending the sign-in token with that request, so it was refused as well and the screen said "Could not load communities" — the one screen that could have put the member back where they belonged was the one screen that would not load. Both are fixed. The picker now explains what a switch means before anything changes — "Your account is with Hour Timebank. To use Agoris Caring Community you need to sign in there, so you will be signed out of Hour Timebank now" — and signs the member out for them, in the order that lets the sign-out reach the server. Choosing the community you are already in does nothing, as before. The list of communities is now fetched without the token, so the picker always loads. Proved on a device from the broken state, and covered by tests that were each checked by breaking the fix and watching them fail.
- Four languages titled the cookie settings page with the word for the biscuit. German said "Kekse", Portuguese "Biscoitos", Spanish "Galletas" and Dutch "Koekjes" — the food, not the browser kind. Spotted while checking a German page during other work. Every other string in the same section already said "cookies", so the title was the odd one out in each language, and the same slip appeared on the React privacy page's navigation link. All eight now say "Cookies", which is the standard term in all four languages. Irish ("Fianáin"), Japanese, Polish and Arabic were already correct and are untouched.
- On the accessible frontend, creating an event was a dead end, and an organiser could not reach their own event's tools. Found by walking the events journey in a browser; no static sweep across seven audits could see it. An event is created as a draft, the page said "Success — your event has been created", and there was no way to publish it — so it stayed invisible to members for ever. The same root cause hid the check-in page, attendee management, broadcasts and lifecycle history: all of those controls are gated on the event's permission set, which exists only on the canonical (v2) events contract, and this app never negotiated it. Measured before and after: an organiser went from 3 links and no publish control to 7 links and a working publish control, and create → publish → visible to another member now completes entirely within the accessible frontend. The permission set is read in a separate call on purpose. Opting the event detail read itself into v2 was measured and rejected: on the same event it drops 43 fields, 21 of which this app uses, including all ten venue accessibility fields (step-free access, hearing loop, quiet space, accessible toilet, parking, seating, transit details, assistance contact, notes), and changes location from a string to an object. Losing accessibility information on the accessible frontend to gain a publish button is not a trade worth making; a proper v2 migration is separate work.
- Event door staff were told the wrong thing when check-in was refused. The attendance endpoints answer HTTP 409 for five different situations, and the accessible frontend showed one message for all of them: "This attendance record changed elsewhere. The roster has been refreshed; review it before trying again." Confirmed live — checking someone in before the window opened produced exactly that, sending staff to re-read a roster that was perfectly fine. Four of the five now say what actually happened and what to do: check-in has not opened yet; check-in has closed; this person has no confirmed place; this event is not published. The original wording is kept for the one case it describes, a genuine concurrent edit. Applies to both the signed-code and roster-pick paths, with copy hand-translated into all eleven languages.
- The ASP.NET edition now supports the complete event create, edit, and owner-management journey used by the React app. Event writes persist the consumed schedule, timezone, location, capacity, remote-attendance, video, and venue-accessibility fields; return the canonical event contract instead of the former flat compatibility DTO; enforce tenant and organizer/admin/group-manager authorization; and expose the owner capabilities that make the management workspace reachable. A repeatable browser control now creates, reloads, edits, and manages an event through the unchanged React UI against both ASP.NET and Laravel, backed by focused persistence and authorization tests.
- A silent mail server could hold a member's request open for a minute, and make a completed action look like it failed. Found by walking the create-a- listing journey in a browser rather than reading code — none of the seven static sweeps could see it. Some emails are sent synchronously inside member-facing writes: creating a listing sends the confirmation before replying. App\Core\Mailer is a hand-rolled SMTP client, so it connected with a 30-second timeout and then read every reply with no timeout at all, inheriting php.ini's default_socket_timeout — measured at 60.1 seconds per read, and a send performs several. Reproduced end to end: with the mail host unreachable, creating a listing took 9.6s, the accessible frontend gave up at its 15s budget, the member saw a failure — and the listing had been created. Anyone retrying gets a duplicate. Both waits are now bounded by the existing SMTP_TIMEOUT setting, which this mailer had simply never read, and a timed-out reply now aborts the send instead of being mistaken for a valid response — so one stall ends the attempt rather than costing a timeout per read. The default drops from 30s to 5s (a healthy relay connects in well under 100ms). Pinned by tests that bind a real socket which accepts and then stays silent; with the fix removed they measure 60.1s and fail. 🔴 Worth knowing: MAIL_MAILER=array does not disable this mailer — it opens its own socket and ignores Laravel's mail layer. Using it as a control to rule mail out is misleading, and did mislead during this investigation. 🔴 Honest scope: this is a latent fragility with a demonstrated failure mode, not a proven live production fault — production's relay presumably answers quickly, and that was not measured. The remaining ~4s of a listing create is still the synchronous email and is unaddressed; queueing it is the real fix and carries its own risks (the queue crash-loops without Redis, and a slow queued listener sends duplicates).
- Thirteen screens that had only ever been glanced at are now actually used. Events, event details, groups, group tabs, the members directory, member profiles, polls, jobs, job details, marketplace browsing, marketplace listings, listings, listing details and your balance — each opened on a real phone, with real data, and something done on it: searching the directory narrowed five members to one, tapping a job showed its two applications, the group discussion tab listed a real topic.
- And one of them was quietly broken: the resources screen never refreshed itself. A file added while the app was open left the screen saying "Nothing found" — and it wasn't even asking the server. It had looked once, when the community had no files, and never looked again; you had to close and reopen the app to see anything. The pull-down-to-refresh handle is what makes that nasty: the screen looks like it's checking, so "nothing here" reads as the truth. It now re-checks whenever you come back to it, proved by adding a file with the app running and seeing it appear. Three example files were added along the way, since the section was completely empty.
- The notification log could not answer "was a push actually sent?" — now it can. When a push found nobody to send to, the system wrote nothing at all, so "we never tried" and "we tried and nobody had a device registered" looked identical in the records. The code's own notes said that was exactly what production showed. It now records that case explicitly, which is the difference between "nobody has installed the app yet" and "notifications are broken".
- A correction to something I told you was broken. The note on push notifications said a real message produced no notification at all. Re-testing today: the notification is fine — what was missing was the background worker that processes queued jobs, which does not run in the local test setup. Thirty-three jobs were waiting. Run the worker and the notification appears immediately, correctly worded and pointing at the right conversation. The earlier finding is withdrawn, and the test setup now warns when jobs are piling up so nobody mistakes that silence for a fault again.
- What is still genuinely unproven: a push arriving on a real phone. No device can register for notifications in the local setup at all, and the two ways to fix that both need something only you can provide — either the app's identifier on your Expo account, or Firebase credentials. It is now written down as a decision to make rather than an open mystery.
- When something goes wrong, the app now tells you what the server actually said. In 165 places across 53 screens it threw the explanation away and showed a generic line instead. The example that started this: logging volunteer hours failed and the app said only "Could not log these hours", while the server had answered "You have already logged hours for this organization and date". The member learns nothing, tries again, and fails again — with the answer sitting there unused.
- All 165 now pass the reason on, with limits. The server's wording is only shown when it is fit for a member: never for an internal server fault, never for anything long or that looks like a web page, and never for the refusals the app answers by taking you to the right screen instead. Checked on a real phone against a real refusal: the message read "A problem cannot be reported for this exchange right now" where it used to say "Please try again". The helper doing that filtering had no test of its own despite standing in front of every one of these messages; it now has eight.
- And a check that stops it coming back, which names the file and line of any screen that reports a failure without passing the reason on. It is deliberately narrow: a quiet failure that tells the member nothing is left alone, because a background refresh that fails should not raise an alarm at anybody.
- Six places where the app and the server disagreed about what a response contains — found by measuring, not guessing. The app asks the server 494 questions and only 78 of them were checked in any way. The other 416 fail quietly: a field the app expects and the server never sends simply arrives empty, so a screen shows blanks or falls over. That is what took the Matches screen down yesterday. The checking tool now finds ids for itself instead of using a fixed list, which nearly doubled what it can actually inspect — 58 questions checked before, 115 now — and the marketplace went from 11 of 32 to 19 of 32 once I also created the missing test data (an offer, a saved search, a collection, a pickup slot).
- The one a member would have noticed: a group page never named the person who runs it. The app was looking for an "admin" field the server does not send, so the whole "Group admin" card silently disappeared from every group. It now uses the group's creator, which the server does send, and there is a test that fails if the card vanishes again.
- The other five were the app promising itself data that never arrives. Achievement badges, the gamification profile, member surnames, organisation logos and volunteering opportunities all declared fields the server does not send. The screens survived on defensive coding, but the app's own description of the data was wrong — which is how the next change breaks something. All five now describe what actually comes back, with the reason written beside each one. Two more "mismatches" turned out to be the tool's fault, not the app's, and it now recognises that case instead of crying wolf.
- The nightly phone tests were failing for a reason that had nothing to do with the app, and all nine now pass. Six of the nine were red — two for days, four as of this morning — and every one of them failed for the same silly reason: a yellow developer warning bar sits along the bottom of the screen, right on top of the tab bar, so a tap meant for "More" hit the warning bar instead. Every test that taps a tab then failed the next check. Meanwhile the ordinary test suite was green and so was the whole build pipeline, which is exactly why nobody caught it.
- The warning bar was there because of a real gap in the community colour themes. One colour variable was missing from the generated per-community themes, and the app complains about that on every launch. The generator now carries every variable across on its own, so the next one added cannot go missing. The developer warning bar is also switched off for the test build — a test suite that any unrelated warning can break cannot tell you anything about the app.

### Added

- A course video lesson can now carry a transcript, so someone who cannot hear or watch it is not simply shut out. Found by the accessible-frontend audit (2026-08-23): a video lesson offered no text alternative at all, which is a WCAG 1.2 failure, and there was nowhere in the schema to put one. Instructors now get a Transcript box on video and embedded lessons in both the React builder and the accessible frontend, and learners get it under the player — in a disclosure, so a long transcript does not bury the rest of the lesson. A new course_lessons.transcript column carries it, following the existing podcast_episodes.transcript and messages.transcript precedents. Pinned by a round-trip test through create, update and read (a field that is accepted and silently dropped is worse than no field, because the instructor believes they provided one), and a render test proving markup inside a transcript is shown as text rather than executed. 🔴 Deliberately NOT included: transcript_language, which both precedents have. Theirs supports automatic transcription, which detects a language; this one is typed by the instructor in the lesson's own language, so the column would have no consumer. 🔴 This is a transcript, not captions. Real captions need a subtitle file displayed over the video as it plays, which needs an upload and storage path the schema still has no room for. The React player also stopped emitting an empty <track kind="captions">, which declared a caption file that does not exist — worse than declaring none.
- The ASP.NET edition's wallet-transfer journey is now certified and banked. The unchanged React application selected a real recipient and transferred one credit against both backends in the same controlled run. ASP.NET moved the sender and recipient by exactly -1/+1, persisted the transaction in wallet history, traversed no known do-nothing endpoint, and the same checks passed against Laravel. Green CI at the exact evidence SHA raises the fixed R5 floor from 309 to 310 and the certified count from 11 to 12 of 250.
- You can now write a post to your community from your phone. Until today the app could read every post a community wrote and never add one — there was no composer, no way in, and nothing in the app that called the server, even though the server has accepted posts all along and the website has had a composer for a long time. There is now a "What's on your mind?" row at the top of your feed, and a matching entry in the Create menu. Write, press Post, and the post opens so you can see it; go back and it is at the top of your feed. Two deliberate limits: no photo and no poll yet — those are separate jobs on the server side and each deserves its own proper test rather than being bolted on. Communities that have switched their feed off do not see any of it.
- You can now report a problem with an exchange — for the first time anywhere on the platform. Coordinators have always had a tool for settling a disputed exchange, but nothing could raise one: the only way an exchange became disputed was an automatic check when the two people entered different hours. So if someone never turned up, or the help was not what was agreed, there was no way to say so about that exchange. Now either person can, while the work is under way or waiting to be confirmed: pick what went wrong, add a note, and a coordinator is told. On the phone today; the website still has no way in, and that is written down.
- Found and fixed while testing it: the other person would have been told the wrong thing. Because the new report and the old automatic hours check shared the same notification, someone reporting "nobody turned up" made the other member read "conflicting hour confirmations". Telling someone their hours are in dispute when that is not what was said is a false statement about their own exchange. The wording now depends on what actually happened, and the automatic case keeps its precise message. There is a test that fails if that ever merges back together.
- What the reporting screen deliberately does not do. It does not open a safeguarding case, and it says so plainly rather than leaving someone in danger thinking it has. Only the two people in an exchange can report on it. It is not available before the work starts (either side can simply cancel) or after it is finished (the credits have moved, and only a coordinator can undo that).
- You can now open the app with your fingerprint. Sign in with your password once, turn on "Unlock with fingerprint or face" in Settings, and after that the phone's own fingerprint, face or PIN is what lets you back in. Walked on a test phone with a real enrolled fingerprint: the prompt appears before anything else on screen, the fingerprint opens the app, and cancelling leaves a clear "Locked" screen with an Unlock button.
- Two things about it worth knowing. First, it protects the session already on your phone — it is not the same as the passkeys the website offers, which the server checks. Bringing those to the phone needs decisions only you can make (the app's signing certificate, and a file published on the API's domain), so it is written down and not started. Second, there is always a "Sign out" button on the lock screen: if a phone's fingerprint reader stops working, nobody is shut out of their own account. Turning the feature on requires passing the fingerprint check first, for the same reason.
- The phone app now has a size limit it cannot quietly exceed, and we know for the first time how long it takes to start. Nothing in this project had ever measured either. The app's code now weighs 14.1 MB, and the build fails if that grows past 15.5 MB — so it can still grow with new features, but only on purpose. I proved the check can actually fail, and that it says "could not measure" rather than "fine" when something stops it running.
- Start-up: about one second is spent in the app's own code. Measured three times on the test phone. Two things worth knowing. The number everyone quotes — the one the standard Android tool gives, about 1.3 seconds — is the splash screen appearing, not the app being ready, so it flatters us. And the honest figure for what a member actually waits on a real installed app still isn't measured: that needs the crash-and-performance service switched on, which is one of the things only you can do. I've written down what is and isn't measured rather than rounding it up.
- Loading more of your feed as you scroll now has a test behind it, and was checked properly for the first time. It had never been exercised on a phone because the local test community only had twenty things in its feed — not enough to have a second page. Now there are forty-four, and scrolling to the bottom fetched the second page, then the third, and finished with "You've reached the end" without repeating anything. One thing worth knowing came out of it: the server can answer a request for twenty items with eighteen and still say there are more. An app that took a short page as the end would strand people part-way down their own feed; ours does not, and there is now a test that keeps it that way.
- Your feed now notices when you have written something. The feed deliberately does not reload every time you open it, because that would cost a request on the app's busiest screen. But it did mean a post you had just written was missing when you came back to the list — which reads as a post that was never saved. It now reloads on return, and only when something was actually written.
- You can now open a time credit to see what it was. Tapping a line in your wallet history opens it: who it was with, what it was for, when, what kind of exchange, and your balance afterwards where the platform has it. Before this the rows did nothing at all — and the server has been able to answer this question all along, with nothing anywhere asking it, in the app or on the website.
- Every achievement you had earned was shown as locked. Ten of them, behind padlocks, on the Achievements screen. The app was looking for three different "has this been earned" markers and the server sends none of them — it records the date the badge was awarded instead. Found by the new check that compares what the app expects with what the server sends, which is exactly the kind of silent mismatch it was built for. Worth noting the achievements screen had no entry at all on the 140-item work list, though it is a whole part of the app; it has one now.
- A check that the phone app and the server still agree with each other. Three of today's faults came from the same place: the app assumed the server would answer in a particular shape, nobody had ever checked, and the test alongside it was written from the app's assumption rather than the server's answer. There is now a check that takes nineteen real answers from the server — fifteen questions and four actions — and runs each one through the app's own code. Proof it works: delete the field that caused this morning's check-in fault and the check goes red. Worth knowing that the first version of it only covered questions, not actions, and would not have caught that fault — actions are the dangerous half, because by the time the app complains the server has already done the thing.
- And it says what it does not cover. Five lists were empty when the answers were captured, so the check proves nothing about the items inside them, and it names all five rather than quietly implying it covers everything.
- The wider picture from that audit, which is the part worth your attention. The app asks the server 494 different questions. Only 78 of them are checked in any way; 416 are not checked at all. The unchecked ones fail silently — fields come back empty and the screen either shows blanks or falls over, which is exactly what happened to Matches today. The biggest unchecked areas are the marketplace (89), groups (40), volunteering (34), jobs (31), federation (28) and exchanges (20). That is the largest pool of unexamined risk in the app and the obvious next piece of work.
- web-uk: the last native date input is converted to the GOV.UK three-field pattern, and its browser-only min guard is replaced by a server-side check that a poll's closing date is in the future. min is advisory: anything posting the form directly could always create a poll that was already closed. A new poll_create.expires_past_error is hand-written in all eleven languages and both PHP lang gates pass with zero values added to the untranslated count.
- web-uk: the 120 recorded GET API contracts are now replayed against a live Laravel on every push. npm run api:verify runs as a step of the Web UK authenticated accessibility job, which boots a real Laravel against a synthetic-only database. Its synthetic-accounts guard reached the database through docker exec, which cannot work on a CI runner; it now accepts the client command via WEBUK_CONTRACT_MYSQL_CMD instead. There is deliberately no flag that disables the guard, and it was verified to still refuse when fed a client reporting non-synthetic accounts.
- web-uk: a missing bearer token on a helper is now a failing check. The consumer ledger classifies each helper guest/optional/required; the contract sweep learns from the live API whether an anonymous call is refused. Crossing the two detects the /organisations defect class. It has zero subjects today, so scripts/ledger-token-crosscheck.js is pinned by tests rather than trusted to be alive.
- web-uk: every literal translation key in a template is checked to resolve. translate() returns the key itself when nothing matches, so a mistyped key renders as raw text to the member without any error. 7,839 complete keys scanned, none unresolved; concatenated keys are explicitly out of scope.
- web-uk: the event moderation queue is pinned as a faithful window onto Laravel — it must not add its own filter, must send the moderator's token, and must not re-sort, de-duplicate or drop rows. The order assertion was mutation-checked against a deliberately sorting route.
- web-uk: scripts/locale-invariants.js classifies English-identical locale values. npm run locales:audit now reports the raw count and the count actually needing a translator side by side.

### Fixed

- Depositing credits into an organisation wallet can no longer move them twice. The deposit locked the rows it touched, which prevents over-spend but not duplicate intent: a double-click or a network retry of an affordable amount created two real movements, both looking entirely legitimate in the audit trail. The personal wallet transfer and donate have carried an idempotency key for months — the organisation deposit was the outlier, and the accessible frontend's data-prevent-double-click was the only thing standing in the way, which does nothing without JavaScript. VolOrgWalletService::depositFromUser() now takes an optional idempotency key and uses the same guard as WalletService::transfer: an explicit client key over a 24-hour window, a 120-second content fingerprint as the fallback so an accidental double-click is caught even from a client that sends no key, the fingerprint bound to the content in both branches so a client that wrongly reuses one key for a different amount still gets two deposits, a replay of the original outcome rather than a second debit, the claim released when the deposit was refused so a corrected retry is not blocked by our own guard, and fail-open on any cache trouble. VolunteerController::orgWalletDeposit accepts the key from an Idempotency-Key header or the body; web-uk renders a fresh one per page and refuses to forward a stubby one. Proven by disabling the guard and watching the duplicate tests go red. 🔴 One existing test was silently testing the guard rather than pagination: test_get_transactions_returns_paginated_items made three identical keyless deposits, which now correctly collapse to one, so its fixture uses distinct amounts. 🔴 Recorded, not fixed: lang/*/svc_notifications_2.json is untranslated English in all eleven locales. The one key added here is properly translated rather than adding to that debt.
- The accessible site no longer says "Page not found" about members who are really there. A member profile that the server declines to show has four quite different reasons, and every one of them landed on the same blank "Page not found" page: the person genuinely is not there; they have not finished setting up their profile; they show their profile only to their connections; or one of you has blocked the other. That last case was worse still — it produced a server-error page. Each now gets its own page, in all eleven languages, and the two that are not errors keep the site's normal header, navigation and footer instead of stranding you on a bare error document. This mattered more than it sounds: on one live community, 235 of its 260 active members were being reported as "Page not found" purely because they had never finished the sign-up wizard.
- The API now says when a profile is withheld rather than missing. GET /api/v2/users/{id} returns the new PROFILE_PRIVATE code for a profile restricted by its owner's privacy setting, mirroring the existing PROFILE_INCOMPLETE. Both privacy branches previously returned no code at all, so every client had to guess, and all of them guessed "not found". The change is additive — still HTTP 404, so a restricted profile is still not confirmed to exist by status code alone, and a client that does not know the code behaves exactly as before.
- A refused profile lookup no longer contaminates the next one. UserService::getPublicProfile() never reset its static error bag, unlike every sibling operation in that service. Because the controller reads the first recorded error, any process serving more than one lookup — a queue worker, a test run, Octane — reported every later refusal with the first one's reason, so a member who did not exist could be reported as having an incomplete profile.
- ASP.NET RSVP certification now uses a real Laravel attendee control. The controlled React smoke signs the disposable fixture's second member into the event owned by its first member, asserts the saved relationship after reload, understands the confirmed-status chip, and restores the starting RSVP state.
- ASP.NET event RSVPs now survive the page reload. The self-service action returns the canonical relationship, metrics and RSVP counts that the unchanged React client validates before refreshing; going and interested states are persisted for the signed-in member and reappear on event detail.
- web-uk: around two dozen forms stopped discarding what the member typed. A field marked in error was re-rendered EMPTY after the failure redirect, so the member lost their work — worst case a group announcement, where someone could write a long notice, be told "Enter content", and find the box wiped. Group announcements (create and edit), the blog/review/resource/listing/feed/ ideation/goal comment forms, the account-deletion reason, insurance details, volunteering wellbeing/donations/expenses/safeguarding, group-exchange creation, saved collections and appreciations now stash the submission and replay it, via shared src/lib/form-replay.js. A password is deliberately never stashed. Two further defects surfaced on the way: volunteering training expiry read a field the GOV.UK date input never posts, so every training record saved as never expiring; and the appreciation form hardcoded checked on the public box, so a deliberate "keep this private" flipped back to public on every failure.
- web-uk: eight create/edit forms sent every error to one hardcoded field. A member who left the price blank was told "Enter a price", the summary link jumped to Title, and nothing on the page marked Price. Marketplace listings and coupons, seller onboarding, podcasts, federation transfers, organisations, polls and the event registration form now link each error to its own field and mark that field; page-level API failures are no longer pushed into the error-summary list as link-less items. Nine more templates signalled validation through a status string and so never got the browser tab's "Error: " prefix. The resource library also never showed whether you had already liked something — the list rows were requesting reaction fields the API does not return, so the count was always zero.
- web-uk: 61 back links moved out of the main landmark. The skip link targets #main-content, so those pages handed a keyboard user "Back" as the first thing after skipping. 154 templates were already correct, so this was drift; tests/back-link-placement-contract.test.js now pins it. "Load more" controls also never underlined on hover — 17 block-mode pagination links were missing the modifier govuk-frontend adds itself, while the 14 labelled prev/next links are deliberately left alone.
- web-uk audit #7 — parity drift left by a week of backend fixes. The wallet manage page summed pending-in and pending-out into a single "Pending" figure that corresponds to nothing (the same fault was fixed in react-frontend and mobile; web-uk was missed); marketplace orders paid in time credits printed the cash total, so a 2-credit order read "€0.00"; voice messages sent from web-uk carried no duration, so Laravel stored every clip at its 1-second floor and other clients rendered "0:00" (now measured client-side by public/js/voice-duration.js, with no-JS behaviour unchanged); a moderated community's seller was told their listing was "published" while only they could see it, and meta.notice from the create endpoint was discarded; the listing detail page never rendered the ?status=… its own handlers redirect with, so no create/save/report confirmation was ever shown; a failed event cover-image upload was swallowed and reported as a clean save; creating a recurring series confirmed nothing; and the community fund rendered a live donate form over a permanent zero when the tenant's wallet module was off (enabled: false was dropped). Laravel side: the page-1 federation overlay merges status = completed rows and was still running for type=pending, interleaving settled credits into a pending-only list.
- web-uk: silent successes, unreachable pages and lost input across eleven routes. Event invitation campaigns rendered their success sentence inside a red error summary (the template's success whitelist had outgrown the route file); /jobs sent an offset that JobVacanciesController::index ignores, so "Next" re-served page 1 for ever and every vacancy past the first page was unreachable (now cursor-paginated like its sibling pages); deleting an ideation challenge fed the confirmation token into the API's status filter and emptied the list; four optional date fields discarded readDate().error, creating never-expiring polls, invites, coupons and vacancies from a mistyped date; and message attachments collapsed every problem into one vague failure while translated "too many"/"wrong type" messages sat unreachable (limits mirrored from MessageAttachmentUploader, not invented).
- web-uk: error summaries no longer race the screen reader. 49 hand-rolled summaries carried role="alert" on the element initAll() focuses — reproducing the race govuk-frontend fixed upstream by nesting the alert in a child container — while 180 others already used the corrected form. Summaries reporting a transient load failure additionally no longer steal focus on page load (data-disable-auto-focus). Pinned repo-wide by tests/error-summary-alert-contract.test.js.
- web-uk: route-built English stopped leaking into all eleven languages. Course levels/costs/lesson types, ideation attachment types and badge rarity/tier/type rendered raw English enum words while their translated twins sat unused; job alerts built their entire description by English concatenation; percent suffixes, decimal points, am/pm with a fixed day-month-year order, a currency-symbol prefix map that puts € before the amount in German, byte units and ", " list glue were all locale-blind. Now Intl-formatted throughout, guarded by tests/route-label-localization-contract.test.js. Two corrupted values fixed at source: the Japanese feedback mailto: had raw multi-byte characters in its percent-encoded query, and five locales had localised example.com into real registrable domains.
- web-uk: GOV.UK component conformance. The event create/edit forms showed the same error under every field — the templates used a three-argument selectattr, which nunjucks silently treats as a truthiness filter — and date-field error links targeted a wrapper <div> that cannot take focus instead of the day input. 27 warning texts announced "There is a problem" or "Important" where the icon means "Warning"; the marketplace and event registration status banners were valid as neither a notification banner nor an error summary (no coloured band, no body); a federation error box had a title and no message; marketplace coupon and slot tables had no row headers and an action column headed "View" over "Edit" links; three navigation lists used a class that does not exist in the design system and rendered as stacked links; 19 decorative live-region attributes sat on static text; and five dead utility classes dropped their styling silently — one of them losing line breaks in members' own registration answers.
- web-uk: keyboard and screen-reader fixes. The submit-button loading state set disabled, which drops focus to <body> and suppresses the new label's announcement (GDS says not to disable submit buttons; double submission is now blocked by a form guard instead). The review star rating reversed its row in CSS so arrow keys moved focus visually backwards. A duplicated aria-describedby silently detached the password-strength live region on register and reset. Language-switcher options and machine-translated event text now carry lang (and dir for Arabic). Repeated card-list actions, onboarding's five identical "Change" links and the announcement actions now name the item they act on; decorative images no longer repeat the adjacent heading; the voice-message player has an accessible name; and tightly packed link rows meet the 24px target-size minimum rather than relying on the spacing exception.
- The two web-uk accessibility gates now pass in CI, where they had never actually run. The Web UK authenticated accessibility job was only added in this cycle, so its first real execution surfaced two faults that a local run could not: the keyboard/focus gate still asserted role="alert" on the error-summary root that the same cycle deliberately moved to a nested child, and the Arabic/Irish poll gates walk from the polls index to a poll detail page without any poll existing — E2ETestDataSeeder seeds none, and the gate had only ever passed against a developer database that happened to hold polls. The stale assertion is replaced by one pinning the corrected placement (verified to go red when the old form is reintroduced), and the job now seeds the polls the gates need as an explicit precondition.
- Changing how the Web UK jobs are set up now re-runs them. The webuk filter in .github/ci-paths.yml watched web-uk/** but not platform-contracts.yml — the file that defines the Web UK jobs — so the commit that added the poll fixture woke no Web UK job at all and Platform contracts reported success with all three SKIPPED. The omission was deliberate and documented, on the stated grounds that these filters "appear in no REQUIRED_JOBS entry"; that ceased to be true on 2026-08-17, when the three Web UK … jobs were added to REQUIRED_JOBS because web-uk is the production accessible frontend serving three live hostnames. webuk now watches the workflow and ci-paths.yml itself, both workflow triggers accept ci-paths.yml, and the stale reasoning is corrected in place rather than silently contradicted. aspnet is deliberately unchanged — it is genuinely outside the deploy path, so the original cost argument still holds there, and a CI-config edit wakes the Web UK jobs, not the ~92-runner-minute .NET suite.
- That poll gate then proved nothing, and now runs. Seeding one standard poll cleared the failure but the gate walks on to a RANKED ballot, and its test.skip for "no ranked-choice poll" marks the whole test skipped retroactively — discarding the assertions that had already passed. The job went green while the Arabic poll family was never checked. It now seeds an open standard poll AND an open ranked poll, each with two options, idempotent per poll_type. The remaining skips in that job are the declared ones (ACCESSIBILITY_ORG_ID=none, ACCESSIBILITY_GOAL_ID=none), which state their absence rather than inferring it from whatever the database happens to hold.
- ASP.NET global React bootstrap calls now return tenant/member state instead of plausible empty payloads. Public menus include persisted published pages, OAuth discovery requires both the global switch and the tenant's provider allowlist, algorithm labels expose the four areas the React client consumes, and identity status reflects the member's latest verification session and badge. The no-op ratchet falls from 561 routes / 325 methods to 553 / 319; cryptographic CSRF generation and live realtime configuration are now explicit, tested defensible cases rather than silently broadening the scanner heuristic.
- The ASP.NET React journey smoke no longer mistakes its own selector and confirmation failures for backend results. Credit transfer now selects the intended member inside the accessible search-results group, asserts both wallet legs and records every method/path touched by each journey for the no-op gate. RSVP accepts the product's real confirmation dialog, records its mutation, and client-reported response-contract drift now fails the owning step. Registration tokens are redacted from console evidence.
- web-uk tests: three poll fixtures posted a hardcoded expires_at: 2026-08-01, a date that had been in the past since 2026-08-02. They encoded a stale literal as "a future closing date" and would have reddened the build for whoever added a past-date guard. They now compute a date relative to today.

### Changed

- The accessible site's footer now says the software is open source, rather than "free". The bottom of every accessible page read "Project NEXUS is free software licensed under AGPL-3.0-or-later." It now reads "Project NEXUS is built in the open. The software is open source under AGPL-3.0-or-later." The licence is still named, which is what the licence itself asks for. Six of the ten translations had rendered "free" as free of charge (gratis, gratuito, saor in aisce, フリー, مجاني) rather than free as in freedom, so all ten are rewritten too, with the Irish hand-written.
- The Matches screen crashed for everybody. Matches is where the platform suggests people, listings and opportunities to you — and on the phone it did not open at all. It fell over immediately, because the server describes a match using one set of names and the app was reading a completely different set: the app asked for "source type" and the server calls it "module", and so on for the id, the reasons and the person. Nothing in the app checked, so nothing reported it. Fixed by translating the server's answer into what the screen expects, in one place, and the app now also copes with a kind of match it has never seen rather than falling over. Both suggestions now show properly, with their score and the reason for them.
- Your listed skills were barely affecting your suggestions at all. This is close to the heart of what a timebank does. Skills are stored as you typed them — "gardening", "cleaning", "plumbing" — while listing text is chopped into word stems: "garden", "clean", "plumb". The two were then compared for exact equality, so they almost never agreed. Measured with the real code: gardening never matched a gardening listing, cleaning never matched cleaning, plumbing never matched plumbing, and any two-word skill such as "dog walking" could never match anything at all. Both sides now go through the same word processing, and a skill you rated yourself expert at still counts for more than a beginner one.
- Honest about the limits of that second fix. It is proved at the level of the calculation — three tests, two of which fail without it — but I could not show it changing a suggestion end to end on real data, because your own listing text usually already contains the same words as your skills, which hides the fault in the easiest cases to set up. So the mechanism is fixed and demonstrated; the visible improvement is not yet measured. Recorded that way rather than claimed.
- And a third test written from the wrong source. The test for the matches screen used the app's own invented field names as its sample data, so it passed happily while the screen crashed on the real thing. That is now the third such case today. It uses a real server response.
- Offline event check-in has never worked, on any phone — and now it does. This is the feature that lets an organiser keep checking people in when the venue has no signal. Walked it properly for the first time: authorised the phone as a staff device, put it into aeroplane mode, entered someone's code, watched it queue, killed the app and reopened it to prove the queue survives, then reconnected and synced. The attendance was recorded correctly at the end. Before today it fell over at the very first step: authorising the phone worked on the server but the phone told the organiser "That offline check-in action could not be completed" and carried on saying no devices were authorised. The cause was two characters. The app stores the key that encrypts the offline queue under a name containing colons, and the phone's secure storage flatly refuses any name with a colon in it — so the write failed silently, the read came back empty, and the whole thing gave up. Every other stored name in the app uses underscores, which is why nothing else was affected. There was a second name with the same fault, found immediately by the new check that now refuses any name the secure storage would reject.
- And the tests were pinning the bug rather than catching it. They asserted the exact broken names, copied from the code — so they passed while the feature could not work at all. Same lesson as the check-in fault earlier today: a test that agrees with the code cannot tell you the code disagrees with reality. Fixed, and the guard now checks the shape of every stored name instead of repeating it.
- One honest limitation: I could not test restarting the app while still offline, because the development build fetches its own code over the network and cannot start without it. The restart was done after reconnecting but before syncing, which is what proves the queued action had really been written to the phone's storage.
- The first screen-reader test the phone app has ever had — and it found three things. Someone using the app by ear, with the screen reader turned on, was being read the raw symbol code of every little picture before the actual words: every tab, every filter button. It sounds like gibberish and it happened before every single label. Second, every small informational badge — "1 conversation", "0 unread", "3 results", "No pending credits" — was announced as a button they could press, which then does nothing; the message list alone went from claiming 17 buttons to 12 once that was corrected. Third, the "save this post" button on the feed had no name at all, which only became visible once the symbol noise stopped hiding it. All three fixed, and re-measured on the phone: every control now has a proper name and no symbols are read out.
- And the first check that buttons are big enough to hit. Measured properly this time, using the phone's real screen density — an earlier guess would have made everything look better than it was. Nine controls were 20 points tall where the accessibility standard asks for at least 24: the five filter buttons on the very first screen, and four on the exchanges screen. Fixed, and re-measured to none. Being honest about what this does not cover: five screens out of roughly 137, and a large group of buttons sit at 40 points — above the minimum but below what Android itself recommends. Both recorded.
- Also worth writing down: the tool for reading what a screen reader sees was believed not to work on this app, and that belief had blocked this work. It does work — it just needs the screen reader switched on first. That is now written into the testing notes.
- The wallet added credits coming in to credits going out and showed you the total. With 7 hours due to arrive and 4 hours due to leave, the wallet said "11 pending" — a number you have nowhere, and with no direction, sitting right next to "Earned +3h" and "Spent −5h" which do say which way the credits are going. It now reads "7 coming in, 4 going out", and the card beside it reads "+7h −4h". The website said the same wrong thing and is fixed too.
- And when you tapped "Pending" to see what those hours were, the wallet said there were none. The list of transactions was fetched in a way that could only ever return finished ones, so the Pending tab was empty for everybody, in the app and on the website, while the card above it claimed hours were pending. The tab now asks the server for them properly. The rest of the history deliberately still shows only finished transactions, because the earned and spent figures are worked out from that list and a pending amount must never be counted as if it had happened.
- Every row in your transaction history was cut off. The description and the amount were not drawn at all — a row showed a circle with initials, a name and a date. The cause was the same one behind the notification cards last week: the whole row sat inside a button, and a button limits its own height. Fixed, and there is now a check that stops this spreading — it counts the eight remaining places built the same way and refuses to let that number grow.
- Worth knowing about pending credits generally: the only thing on the platform that creates one is a transfer from another installation of the platform, which is switched off and has never been connected to anybody. So in every real community these figures are always zero, while the wallet gives them a badge, a card and a tab. Not a fault, but it is three pieces of furniture for something nobody can see.
- Every job advert said "Posted by" and then showed nobody. Walked applying for a job on the phone. The card naming who posted the advert was blank — a heading with an empty space under it — because the code that fetches a single job never looked up the person who posted it. It was a fault in the shared part of the platform, so the website's job page had it too, and both are now fixed. The job lists were always fine, which is why nobody noticed: only the detail page was blank. Worth recording how nearly this went wrong: there are two near-identical copies of that fetch, and the fix landed on the copy the website and app do not use. It looked correct and changed nothing. The test now checks both.
- Applying for a job takes nine and a half seconds, and could tell you it failed when it had not. Measured. The reason is that the request sends two emails — one to you, one to the employer — before it answers, and it only had fifteen seconds before the app gives up. Your application is saved in the first second, so if the app does give up, the employer already has your application while you have been told it failed — and trying again is refused as a duplicate. This is the same trap as signing up, fixed the same way for now: this one request is given more time, and if there is still no answer it says something true — your application may already have been sent, check before trying again. The proper fix is to stop sending those emails inside the request, which has not been done and is worth doing.
- Checking someone in at an event worked, and the app said it had failed. Walked on a phone as the organiser of a live event. Tapping "Check in" recorded the attendance properly — the database has the row, and the person's RSVP moved to "attended" — but the screen showed a red "Attendance not updated" and the list still said "Not checked in". The obvious thing to do next is tap again, and that genuinely fails, because the person is already marked as attended. So the organiser is left believing the app is broken while it has actually done the job. The cause was one field: the server has always sent back a note about whether attendance earns time credits, and the app was set up to reject any reply containing anything it did not already expect. Fixed, and checked in and out again on the phone from start to finish. Two things worth recording. The existing tests stayed green through all of this, because their sample reply was written from what the app expected rather than from what the server really sends — the new tests use the real thing. And the app now always re-reads the list after a failure, so an organiser can never be left looking at something that is not true. There are 141 places in the events part of the app set up to reject unexpected replies the same way, and nothing checks them against the server; that is recorded as a risk rather than fixed today.
- Volunteer shifts: signing up for a second one quietly cancelled your first. Walked on a phone. A volunteer can only hold one shift per opportunity — that is how the server is built — but nothing on screen said so. Every shift looked the same, including the one you had just joined, so tapping "Sign up for shift" on another date silently dropped you from the first while the app said "Shift joined. You have signed up for this shift." Checked against the database: the volunteer was moved off Monday onto Wednesday with no warning. The shift you are on now shows a green "Confirmed" mark and a "Cancel shift" button, and joining a different one asks first, naming the date you would lose. Also worth recording: nothing anywhere on the platform can create a shift — there is no screen and no route in either the phone app or the website. Shifts only appear when the nightly job turns a repeating pattern into dates, and creating that pattern produces nothing until the job next runs, so an organiser sees an empty list and reasonably thinks it failed.
- Both the phone app and the website described a shift-swap request backwards. When somebody asks to swap shifts with you, the card showing the two dates had them the wrong way round: it labelled their shift as "your shift" and your own as the one being proposed. That is the one card carrying Accept and Reject, so a volunteer checking their diary could easily turn down a swap that suited them perfectly. Walked with two accounts on two phones: the request, the accept, and both volunteers genuinely changing places were all checked in the database. Fixed in both the app and the website, each with its own test. One more thing found and recorded rather than fixed: there is no way to ask for a swap anywhere on the platform. Both can only answer requests, and the website's own empty state points members at a page that does not exist. Building it needs a decision from you first, because it means showing volunteers who else is on which shift.
- Correction: the phone app has no fingerprint or face sign-in at all. The work list said this had "never been attempted on a device", which reads as though the feature exists and simply had not been tested. It does not exist — there is no code for it anywhere in the app, and no library. The website has it and the server is ready for it, so this is a gap between the two, and it is now recorded honestly as missing rather than untested. Whether to build it is your call. Worth knowing it can be tested once built: the test phone here supports it.
- The "please accept the updated terms" gate now works cleanly on the phone. Walked it by removing a member's acceptance and then trying to do something: the app showed the terms, the version, a link to read them in full, and a choice between accepting and signing out. Accepting was recorded properly and the half-typed message survived. One thing fixed: alongside that screen the app also flashed a red "Message failed to send. Tap to retry." Nothing had failed — the send was held back until the terms were accepted — and retrying could never have worked. Two contradictory explanations of the same moment is worse than one, so the misleading one is gone. Worth knowing how this gate is built: it guards doing things, not reading them, so somebody who has not accepted can still read the app normally and is stopped at the point of acting.
- The "you must update" lever has been fired for the first time, and it works. This is the one safety mechanism that cannot be added later — once a copy of the app is on someone's phone, it either already knows how to lock itself out or it never will. It had been built but never actually triggered. Triggered now: the server refused the app's version, and the app replaced itself with an undismissable screen offering the download, with no way past it. One real fault found by firing it: the small print read "Latest version 1.2.0 · you have 1.2.0" — on a screen refusing to let the member continue. That sentence tells someone the block is a mistake and leaves them nothing to do about it. It now shows the version genuinely required, and says nothing at all rather than something contradictory. What still remains before a release is the other half: the update it demands has to be genuinely downloadable.
- Resetting a forgotten password works on the phone, both halves of it. Walked end to end: asking for a reset link, then following that link into the app and setting a new password. Checked afterwards that the new password signs in, the old one is refused, and the link cannot be used twice. Nothing needed fixing. Two things worth recording for whoever works on this next: the confirmation screen deliberately says "if an account exists with that email address", so it cannot be used to find out who is a member; and the reset link is only created after the email is accepted for sending, on purpose, so an email outage can never quietly cancel a link somebody is already holding.
- Signing up on the phone created the account and then told you it had failed. This is the worst thing found today, because it is the very first thing anyone does. Registration checks that your email domain can actually receive mail and that your password has not appeared in a known breach — both of which reach out to the internet — so it often takes longer to answer than the app was willing to wait. The account was created; the app said "Request timed out. Please check your connection." Anyone who believed that and tried again was told their address was already taken, and would reasonably conclude the platform was broken. Registration now waits three times as long, and if it still gets no answer it says something honest: your account may already have been created, try signing in first.
- And the error you did get was off the top of the screen. The sign-up form is longer than a phone screen, and its failure message appears at the very top — so someone who has just pressed "Create account" at the bottom sees nothing happen at all. The message now scrolls into view. Walked on a phone with a deliberately undeliverable email address, which is how both faults were found.
- The "app looks dead on launch" problem was already fixed — nobody had checked. It was recorded in August as the worst open fault on the phone: a member whose sign-in had gone stale saw a bare spinner and the app hammering the server in a loop, which looks exactly like a broken app. It was reproduced deliberately today by revoking that member's sign-in on the server and launching: the app goes straight to the sign-in screen and says "Your session has expired. Please log in again." — one request, no loop. The repair had landed in the meantime and the record still said broken. Also confirmed by test that a flaky connection does not sign anyone out or wipe their queued event check-ins, which is the half that matters most.
- Every voice message ever sent on the platform was recorded as one second long. Send a voice note from the phone or the website and the server stored its length as 1 second, so the recipient saw "0:00" whatever they had actually been sent — a 38-second message included. The cause was a single argument: the code that saves a voice message passed a hard-coded zero instead of the length, and the save then applied a "minimum one second" rule to it. Fixed on both sides — the app now sends the length it already measured and shows you, and the server now reads it. Checked on a phone: a two-second recording is now stored as two seconds. The website has the same missing half and is recorded for a separate fix.
- New: a hand-off document for the phone app, at mobile/docs/MOBILE_HANDOFF.md. It states the goal, what has actually been proved on a device, the 30 journeys still to walk, the traps that have cost real time, and — separately marked as my own view rather than agreed plan — eight things I think the plan is missing. The most important is that there is no agreed definition of "ready", so the document proposes one and asks you to accept or change it. Two others worth your attention: a change to shared server code can break the phone app without any phone-app check running on it — which is exactly what happened with the voice-message bug above — and the test data on this machine has now drifted enough that repeatable automated runs need a reset script before that work can start.
- You can now message the other person about an exchange from the phone. There was no way to: the exchange screen showed the status, the hours, who confirmed what and the full history, and offered no route to the person on the other side of it. Someone needing to say "I'm running twenty minutes late" had to leave the exchange, find the member and start a conversation from scratch. There is now a "Message {name}" button, shown while the exchange is live and dropped once it is finished — the same rule the website uses. Walked on a phone: it opened the existing conversation with the right member and the message was sent.
- Searching listings, filtering them by offer or request, and taking a listing down were all walked on a phone and all work. Searching narrowed three listings to one; the Offer tab narrowed to the two offers; deleting a listing behind its confirmation removed it from the directory and the count fell. No changes were needed to any of them — the value here is that they are now proven rather than assumed, and each is pinned by a test so they cannot quietly break.
- Notifications on the phone were being cut in half, and the unread number was wrong. Every notification card was cropped: the heading ("Marketplace order", "Ideation idea submitted"), the little category label and the "1h ago" timestamp were not shown at all, and the message itself was sliced through the middle of a word with no "…". The cause was the whole card sitting inside a button, and a button limits its own height. The header also said "10 unread" when 26 were genuinely unread, because it counted only the notifications it had loaded rather than asking the server — the correct number had been available the whole time and nothing was using it. Both fixed and checked against the database on a phone.
- Job alerts can now be reached and created. Tapping a job-alerts link always landed on the Browse tab, because the screen never read the part of the link that names the tab. Worse, once you were on the Alerts tab, the alert you created was drawn below the bottom of the screen with nothing to scroll — you could not see it, pause it or delete it. Both fixed, and an alert was created and read back from the database.
- A whole family of "content you cannot reach" bugs is now closed. The cause of the job-alerts one is a quirk we already knew about: the styling shorthand used for "fill the screen" silently does nothing on one particular container, so those screens size themselves to their content and anything past the bottom edge is unreachable. 86 places across 56 screens were given the real instruction, matching the 97 screens that already had it, and the check that watches for this is now set to zero tolerance instead of the old allowance of 115. A side effect worth noting: the open-source licence line at the bottom of Settings was previously cut off and now shows in full. Three screens were re-checked on a phone and the whole test suite stayed green.
- Sell an item, then buy it — both now work on the phone, and a seller is no longer told their brand-new listing does not exist. The worst thing found in this sweep: communities have marketplace moderation switched on by default, so a listing you publish waits for a moderator before anyone else can see it. That part is correct. What was wrong is that both the phone app and the website then took the seller straight to a page that said "Listing not found. This item may have been sold, removed, or moved." — about the item they had just created, seconds earlier. The seller had no way to tell whether their listing existed. A seller can now always open their own listing whatever its moderation state; nobody else can, which was already the intended rule and is still enforced. The phone app also now passes on the message the server was already sending: that the listing is waiting for a moderator. This fix repairs the website as well, since the fault was in the shared API.
- A marketplace purchase paid with time credits no longer says it cost €0.00. Buying with time credits leaves the cash total at zero, and the orders list printed that cash total — so a member who had just spent two credits saw "€0.00" against the order. It now says "2 time credits". Walked with two accounts on two phones: one member listed an item, the other bought it, the credits moved (25 to 23, and 86 to 88 the other way), and the order appeared as paid. One thing recorded and not fixed: the Checkout panel shows a heading with nothing under it when there are fewer than two ways to pay.
- Community idea challenges now work end to end on the phone, and a single tap no longer wipes the page. Walked with two accounts: one member created a challenge ("How should we spend the tool library budget"), both members submitted an idea to it, and the second member voted on the first member's idea — all four actions checked in the database afterwards. One real fault fixed: tapping Vote, or submitting an idea, replaced the entire challenge with a loading spinner for several seconds before rebuilding it, because the screen could not tell a refresh from a first load. The page now stays put while it updates, and the button says "Submitting..." instead.
- Members can now create a poll and vote in one on the phone, and the vote counter has stopped saying "1 votes". Both were walked on two phones with two different accounts: one member created a poll with two options, both voted, and both votes were checked in the database afterwards. Three real faults turned up on the way. The poll card showed a result that was not a result. The platform deliberately keeps the running tally private while a poll is open, so nobody's vote is swayed by what others chose — only the person who created the poll sees the split. The phone app did not know that, so it printed a small chip with no number in it (just the word "votes") and, once you had voted, drew two bars both reading 0%. It now says "Vote to see results" before you vote and "Results revealed when poll closes" after — the same words the website has always used — and it shows how many people took part when the server does tell it that. The question appeared twice, once as the card's heading and again immediately underneath. And the count was ungrammatical in every language: with one vote it read "1 votes".
- Every "one of something" in the phone app now reads correctly, in all seven languages. 129 labels that count things — votes, posts, members, applications, minutes, results, reviews, spots left — had no singular wording at all, so a single item was always described in the plural: "1 votes", "1 members", "1 spots left". Someone had started this work and added the plural halves only. 903 singular phrases were written across English, Irish, German, French, Italian, Portuguese and Spanish, and a new check now fails the build if a new counting label ships without one, so it cannot drift back. Two smaller repairs came with it: three labels had lost their accents entirely ("postail" for "postáil", "Beitrage" for "Beiträge", "publicacoes" for "publicações"), and the Irish word for coupon uses was blank. 43 labels are deliberately exempt and listed by name — those are ones where the number is not counting a thing, like "3 left" or "All (3)".
- Experimental ASP.NET backend: the first ten member journeys on the accessible site are now certified, and two of the three faults that were blocking them could not have been caught by any comparison we run. Development-only; the live platform is unaffected. "Certified" means the journey was driven through the site's own forms against .NET and the identical run passed against the PHP platform side by side, so a difference in test data can never be mistaken for a broken backend. Ten now clear that bar — signing in, posting to the feed, creating a listing, replying to an event invitation, sending a message, transferring credits, applying to volunteer, joining a group, leaving a review, and changing a setting that sticks — run twice, both engines, nothing excused. Joining a group was broken for every group. The server sent "are you in this group?" beside the group instead of inside it, so the page never saw it and offered "Join" even to a group's own owner, whose join was then refused. Every piece of that answer was individually correct and both engines replied "fine", so nothing that compares answers could have found it — only opening the page did. Joining a private group was also refused outright, where the PHP platform creates a request awaiting approval; a "you can join" signal the server will not honour is worse than none, so both were fixed together. Leaving a review could not work at all. The address that saves a review did nothing while replying "saved", so members were told their review had been left over a page that stayed empty. And the form was built without the recipient, because that field was simply absent from the reply — an absence, which a comparison of what two replies have in common cannot see. Both fixed, with the review now genuinely stored and the same anti-abuse rules the PHP platform applies. Two more were never faults. Applying to volunteer and joining a group could not be checked against the PHP platform because its test data gave the test account the only opportunity and the only group; recorded as test-data gaps rather than blamed on a backend, and now fixed — along with the discovery that the test-data file could not be run twice at all, dying on a database constraint and silently seeding nothing after that point.
- Experimental ASP.NET backend: a review can now be attached to the exchange it is about, and a stricter-than-intended rule that silently blocked honest reviews is gone. Development-only. The reviews table had no link to the transaction being reviewed, so the rule "one review per exchange" could not be expressed and the rule actually enforced was "one review per person, ever" — meaning two members who completed a second exchange together were refused a second review by the database, with no message any screen could show. The link now exists, the correct rule is enforced, and the old one is removed. Also added: a review attached to an exchange must be between the two people who took part in it, which is what stops someone fabricating reviews for exchanges they were never in. Proved by replaying the change on a throwaway database both from empty and from one already holding reviews, checking existing rows survived, and confirming the new rule accepts a second exchange with the same person while still refusing a duplicate for the same one.
- The ASP.NET plan was audited by three independent passes and it found errors in the documents written the day before, including in the score itself. All corrected. The score published yesterday as 355 was arithmetically wrong — the journey list's summary claimed 20 proved items where its own rows held 19, and one row carried a status that was not in the list's own vocabulary at all, so the published formula matched no reading of the table. Three documents said "eight categories" above a nine-row table, including the instruction telling every future report to list eight. And yesterday's headline promise was false: it said the new score "cannot go down because we looked harder", when under its own rules adding a newly discovered journey diluted its section and did exactly that. The audit also confirmed the opposite of what was feared about the code: the ASP.NET backend is real, roughly nine in ten of its endpoints do genuine work, and its money-handling code is production-grade with proper database locking and tests that fire five simultaneous transfers to prove no overdraft.
- The ASP.NET scope is now everything, including the mobile app, and the measuring frame has been re-cut one final time to match: 270/1000. Owner decision. The mobile app (331 server addresses, about 138,000 lines) was in no plan at all and is now a tracked section of the work; the admin surface grew from 25 tracked journeys to 72, because 514 admin addresses were being represented by 25 items and public-sector buyers evaluate the admin panel. The work list grew from 130 journeys to 250. That is why the number moved, and it is the only one of the four re-cuts caused by a deliberate decision rather than a measurement correction. Three mechanisms now make another re-cut impossible, and all three are enforced by the build rather than promised in prose: every section of the list carries spare pre-counted slots, so a journey discovered later fills a slot instead of lengthening the list; a recorded floor means a published total can never fall, with any demotion recorded honestly in the list while the headline waits for the next net gain; and the score is recomputed from the list on every run, with the build failing if the two disagree. That last check was proved to fail correctly against twelve deliberate errors — including the exact arithmetic fault that produced yesterday's 355 — before being trusted.
- The claim that the ASP.NET database had no backup was overstated, and the correction had been sitting unread in the repository for five days. A document written on 16 August — whose own index says "read before repeating the no-backup line" — records a restore-tested copy taken off the server on 10 August: 265 of 265 tables, 49,958 rows, verified by actually restoring it into a throwaway database. The container has been switched off since that date, so the copy is current. The older claim ("no successful backup since 8 March, nothing to restore from") was repeated in six places yesterday without that document being read. What genuinely remains: the scheduled backup job is still broken, the final two and a half hours before shutdown exist in only one copy, and the container must not be restarted. Corrected in all six places, and in the source document too, which still asked an owner question that has since been answered.
- Two ASP.NET measurements were also overstated in the platform's favour and are corrected downward. Background tasks were reported as 26 of 69; Laravel's true scheduled surface is about 117, because one of those 69 fans out into 49 more, so the honest figure is 26 of 117. And push notifications are worse than recorded: the code uses both a Google delivery address and a login method that were switched off in July 2024, so native push cannot function at all rather than merely being outdated. Two others were overstated in the opposite direction and are corrected upward: search indexing has a working administrator rebuild (only automatic updating is missing), and Stripe payment webhooks are properly handled on two paths with correct signature verification — only one unused alias is not, and it refuses honestly.
- The ASP.NET edition's goal has been rewritten, because how it was written down was making the job far bigger than it needed to be. Three faults, all in documentation rather than code, all now corrected in the documents themselves. First, every agent guide called the work optional — a decision record from 15 August described ASP.NET as "an optional future alternative" and said "do not promise that ASP.NET will be deployed", and that wording had spread into the main agent guide, the frontend portability guide, the documentation policy, the public README and the ASP.NET workstream's own guide. An optional project gets no scoped delivery plan, so nobody was ever authorised to shrink the goal. Second, nothing recorded why the work exists: a segment of public-sector buyers require a .NET application stack as a condition of procurement, so without this edition those contracts cannot be bid. That is now a formal decision record. Third, the goal was measured by comparing whole API responses, and because Laravel often returns raw database rows, copying internal columns no screen reads — down to a category's password-reset token — counted as required work. The target is now journey equivalence at the boundaries clients actually consume, with fields no client reads explicitly out of scope. A question put to the owner on 19 August about exactly this had gone unanswered, and the work continued under the strict reading by default.
- The ASP.NET readiness score is now 355/1000, replacing 653/1000, and nothing regressed. The rubric changed and the two totals are not comparable — a rule now enforced in the documentation policy. The old rubric asked how much of Laravel's API surface had a .NET counterpart that looked right, and deducted points simply for surface that had not been measured yet, so auditing more carefully lowered the score while the software improved. That is why it had already fallen from 712 to 598 in August. The new rubric asks how much of the product has been proved to work on .NET, and is computed mechanically from a new finite list of 130 enumerated user journeys rather than from roughly 2,650 API endpoints. A journey list can be scheduled, split between agents and finished; an endpoint count cannot. The new score can only rise by making the product work, and it cannot fall because someone looked harder.
- A finite work list now exists for the ASP.NET edition, with a status for every one of the 130 journeys and an explicit distinction between "runs against .NET" and "proved to match Laravel". That distinction exposed a real gap: the main app's automated browser test drives 37 steps against .NET but never runs the same steps against Laravel in the same pass, so nothing is fully certified yet — 21 journeys are proved to work but not proved to match. Adding that comparison arm is a half-day change to the test with no product risk, unlocks up to 21 journeys at once, and is now first in the queue. Also recorded: the rules that stop parallel agents colliding (migrations serialise, the shared test scripts conflict, one controller owns one verb), and six decisions that only the owner can make.
- Stale instructions removed from the ASP.NET workstream's agent guide. It still told agents to treat a July pause handoff as the resume point, five weeks after the pause was lifted, and still described the deleted Blade accessible frontend as the source of truth for the accessible site's routes, layout, forms and workflows — telling agents to port patterns from code that was deleted on 14 August. The public README's ASP.NET figures were five weeks out of date (254 controllers, 165 migrations, 3,386 tests, a score from a paused workstream); they now read 279, 184, ~3,774 and the current rubric.

### Added

- The tool that measures how close the .NET backend is can now tell the difference between a real fault and a database column nothing ever reads. Development-only; the live platform is unaffected. Until now it compared entire server answers, and because the Laravel platform often hands back whole database rows, a single listing carried about 76 fields — including internal columns no screen has ever displayed, and one that should never have left the server at all. So "80 of 195 answers differ" was published as a ceiling rather than a fault count, which is honest but not usable as a work list. The tool now has an opt-in mode that first asks whether any of the four apps — the main app, the admin panel, the accessible site and the phone app — actually reads a field, by searching all four for where it is read and recording the file and line. Nothing is thrown away: every difference lands in one of three labelled groups, and the count of "no app reads this" is printed rather than quietly dropped. Result: 80 differing answers, 64 of them touching a field an app really reads, 16 cleared. That is a smaller reduction than hoped, and the reason is worth recording rather than hiding: the list of addresses being tested was itself generated from the main app's own code, so almost everything on it has a reader by definition. The field-noise problem is mostly in the answers to saving data and in the admin screens, which is where this mode should pay off next. The default behaviour of the tool is deliberately unchanged, so every previously recorded number stays comparable. One measurement fault was found and fixed along the way: when one backend returned an empty list, every field of the rows it did not contain was being counted as missing — 30 of one screen's 64 "missing" fields were that, and they are now labelled as untested rather than as faults.
- Accessible frontend: three things the system could always do, but no one could reach. Members can now create an event for a group (the button never existed, so an event could never be attached to a group). Anyone reading a notification can now "Mark as read and view" in one step instead of two. Sellers renewing a marketplace listing can now choose 30, 60 or 90 days, where before every renewal silently took a default. All three come with hand-written translations in all 11 languages.

### Fixed

- Changing the picture on an event did not work. Not "sometimes" — it failed for every event that had a publication status set, on the live site, for everyone. A member hit it on 18 August and simply got an error back. The cause was one line inside the check that decides whether somebody is allowed to change an event's image. It compared the event's publication status by converting it to text, but that value stopped being plain text at some point and became a structured value, and converting one of those crashes outright. So the check died before it ever got as far as saying yes or no. Fixed, and two tests now cover both answers: a published event must be editable, and an event still awaiting review must still be refused — so the fix cannot quietly turn into "always allow". Worth knowing this is the whole of it, not the first one found: the same pattern appears in twenty other places in the events code, and every one was checked. All twenty are safe, because they read the value by a different route that does not do the conversion. This was the only broken one.
- Mobile app: the phone was throwing away its saved copy of the community's settings every single time, so it had to ask the server on every launch. The app keeps a local copy of your community's name, colours and which parts of the platform are switched on, so it can show you something immediately while it checks for changes in the background. That copy was being filed under a name the phone's secure storage refuses to accept — one wrong punctuation mark — so both saving it and reading it back failed silently. Nothing showed an error, because a failed read looks exactly like "nothing saved yet". Two consequences: every launch waited on the network before it could show you your own community, and a launch with no signal fell back to showing no community at all — no branding, and every section of the app switched off. Fixed, and there is nothing to migrate because the old name never successfully saved anything. A test now drives the app with a deliberately awkward community name and refuses any storage name the phone would reject.
- Our own test runs were writing fake crashes into the live error log. The phone app reports crashes to the server as well as to the crash-reporting service, which is deliberate — it is the only route that works today. But there was nothing stopping that from happening during a test run, and one test crashes the app on purpose to prove the crash screen works. So every time that test ran — on this machine and on the build server — it filed real crash reports against the live site. 77 of them in three days, and the error log had started flagging one group as getting worse. The practical harm is that they bury the real reports this nightly check exists to find. Test runs can no longer reach the server; the one test that is genuinely about the reporting itself still checks it, against a stand-in. Verified by running the whole phone test suite: 309 files, 2,116 tests, all passing.
- Both of the safety checks that run when you make a commit were switched off on the development machine, and one of them had been off for twelve days on a repository that is public. Two separate faults with the same root cause. The first: the check that refuses to commit a password, private key or API key was restored to the project on 10 August, but installing it copies the file into a hidden folder — and nobody re-ran the installer, so the copy stayed on the 4 August version, which did not contain the credential check at all. The commit that fixed the lost check did not fix the installed check. The second: the gate that runs any test files you are committing looked for PHP on the Windows machine itself, which deliberately does not have a working PHP setup, so it printed "skipping" and waved every commit through. This is the gate the project's own guide calls the one gate that must never be bypassed, and it had never once run here. Both are fixed. Installing now creates a small pointer to the real file instead of copying it, so it can never fall behind again. The test gate now runs the tests inside Docker, which is where this project's PHP tests are supposed to run, and takes about ten seconds. And when it genuinely cannot check something — Docker not running, for instance — it now stops the commit rather than reporting success, because "I could not check" quietly reading as "this is fine" is exactly how it stayed broken. Proven rather than assumed: five deliberate checks, confirming the credential scan blocks a planted key, does not complain about ordinary text, that a passing test is allowed through, that a failing test is stopped, and that an unavailable Docker stops the commit instead of skipping.
- Mobile app: you can now hide, mute and report things in the feed — none of which was possible from a phone before, and reporting is a safety feature. The "…" menu on a post offered Share, Save and View post: nothing at all about the content itself. The website has had hide, "not interested" and mute since its current feed was built, and the platform has always had a report route that alerts the community's moderators — the phone simply never called any of them. Now the menu offers Not interested, Hide this, Mute <name> (only on someone else's post, and it names them) and Report, which asks for a reason from a short list and tells the moderators. Walked on a phone and checked in the database: a report was filed against a post, and a hide was recorded. Available in all seven of the app's languages, and covered by nine tests, each checked by deliberately re-breaking it. Two smaller fixes came with it: every label in that menu was clipped through the middle of the letters (all of them, on every menu in the app), and hiding a post while viewing it on its own page used to leave an empty page behind — it now takes you back. Mobile readiness moves 503 → 513 out of 1000.
- Mobile app: posting a listing, an event or a group left you sitting on the filled-in form with no confirmation — a trap that invites posting the same thing twice. The post itself always worked; the screen simply never moved on. Now all three land you on the thing you just created, whether you arrived at the form from inside the app or from a link. 🔴 Also walked and checked in the database along the way: posting a request (as opposed to an offer) — the button correctly says "Post request", and leaving out a category is refused inline with "Please choose a category"; your transaction history, with its earned/spent filters and totals matching the ledger; giving to the community fund; and trying to send more credits than you have, which is refused before anything is sent with "You do not have enough time credits for this amount". One gap recorded rather than fixed: tapping a transaction opens nothing, because no transaction detail screen exists — and the website does not have one either, so that is a decision for you rather than a mobile fault. Mobile readiness moves 496 → 503 out of 1000.
- The community fund showed nothing to anybody, on every community, since it was built — and donations were landing in it the whole time. Found by using the mobile wallet: a member donated one hour, the hour left their balance, the fund recorded it correctly in its own account, and every screen reported the fund as empty and switched off. The cause is a one-word mistake in the platform's own permission check: the wallet is registered as a module, and the code asked whether it was a feature. Those are two separate lists; the wallet is not in the feature list, so the answer was "no" for every community on the platform and could never have been anything else. Six addresses were dead this way — the fund's balance, its history, paying in, paying out, its own donate route, and the wallet's transaction categories. Nothing was ever lost: the money was always correctly recorded, it simply could not be seen. Now fixed and confirmed on a phone: the fund reads 1 hour with the donor named in its history. Two tests were added, both checked by deliberately re-breaking the fix — one of which asserts the contents of the answer, because the test that already existed asserted only that the address replied, and it passed happily for as long as the fund was invisible.
- 🔴 Experimental ASP.NET backend: a private message could be written into a group conversation. Development-only; the live platform is unaffected and no real member's message was involved. Sending a direct message found the conversation by matching the two people in it, but never checked whether the row it found was a group conversation — the same two columns are reused for group rows, and the database's uniqueness rule only covers direct ones. Where several group rows existed for the same pair, a private message was written onto a group conversation while the screen read back from a different, empty row. So the message looked as though it had vanished; it had not, it was in the wrong place. Fixed in five places, and 186 message and conversation tests pass. Found by teaching the accessible site's automated test to actually submit a form — no amount of comparing responses would have shown it, because both backends answered "sent".
- Experimental ASP.NET backend: seven of the nine "get something done" journeys on the accessible site now work, measured end to end. Development-only. The accessible site's automated test could open pages but had never submitted a single form, so every journey that changes something was untested. It now fills in and posts the site's own forms, on both backends in the same run, and checks the result rather than the reply: the post appears on a fresh page load, the new listing renders at its own address, an RSVP survives a reload, the message is in the thread, the balance falls by exactly the amount transferred and the note appears in the history, the volunteering application shows as pending, and a changed setting reads back. Two are genuinely broken and both causes are pinned down: joining a group fails for every group — the backend reports your membership alongside the group rather than inside it, so the site never sees it and offers "Join" even to the group's own owner, whose join then fails — and leaving a review cannot work, because the address that saves a review does nothing at all and the form is built without the recipient's identity, so it submits empty. Neither is fixed yet; both are recorded with the exact line to change. Worth noting: the test now stays red while a known fault is open, rather than reporting a clean run.
- The count of do-nothing endpoints was too small, and is now 562 instead of 316. Development-only backend; nothing changed in the product. This is the tally of addresses that answer "success" while performing no work, and it is the number several plans and estimates lean on. It was missing more than it found, in four ways. Most of it: 177 addresses are handled by five catch-all methods that fall through to a shared "write it down and read it back" store — a request is recorded in an audit table and answered "recorded only". Because that touches the database, the counter read it as real work. A method carrying six addresses counted as one, so a single fix could look like six. Addresses written in an alternative form were resolved into paths no client can ever call, which is why five social-login addresses were unfindable — and why they were previously reported to you as "missing" when they existed all along. And, least comfortably, the counter was fooled by the wording of the fake replies themselves: it looked for certain words to decide whether code does real work, and matched them inside the field names of the invented responses, so three genuinely empty addresses were excusing themselves with their own output. The tally is now four separate categories, each of which must shrink and cannot grow silently, and raising it at all requires a written reason recorded in the file. Every part of it was deliberately broken first to confirm it fails, including an attempt to smuggle a real fault onto the "legitimately empty" list, which is caught.
- Mobile app: three more journeys walked — turning down a request, and putting an event on the calendar for someone else to say they are coming — and a pop-up panel that would not go away is fixed. Declining a help request now has a walked path (the requester's note is shown before you decide), and an event was created, published behind a clear confirmation of what publishing does, and then marked "going" by the second member, which moved the card to "1 going". 🔴 The panel fix is a consequence of the earlier one: now that pop-up panels actually open, one was caught still sitting on top of a completely unrelated screen after tapping a link — panels are drawn above the whole app, so they did not disappear when the screen beneath them changed. They now close when you leave the screen that opened them, and a test proves it by deliberately re-breaking it. Also worth recording: two more of today's near-misses were column-name guesses in the database, not real faults — an event's date is stored in a different column from the one that looked obvious, and so is an RSVP. Both would have been reported as "the data was not saved". Mobile readiness moves 491 → 496 out of 1000.
- Mobile app: six more journeys walked on real phones — sending credits, connecting with a member, and creating, joining and posting in a group — and every text field in the app was found to be the wrong size. Each step was confirmed in the database, not taken from the screen: one credit sent member-to-member (86 → 85 hours for the sender, 26 → 27 for the receiver), a connection request sent from one phone and accepted on the other, and a new group created, joined from the second phone, and its first discussion posted. 🔴 Three defects found and fixed along the way. First, every text field in the app was sized to the words inside it rather than to the space available — the "send credits" recipient search and the "start a discussion" title both appeared as small pills, awkward to tap and showing almost nothing. It looked for a while like a problem with the box around the field; painting that box bright red on a phone proved the box was already full width and the field inside it was not. One fix in the shared field component repairs every form in the app. Second, a member looking at a connection request they had just received saw the literal text "connections.status.pending" — a missing translation showing its own internal name. Third, a request that had not been accepted was labelled "Connected 22 Aug 2026", which is exactly the thing that had not happened. Both are now covered by tests that were checked by deliberately re-breaking them. Also recorded, not fixed: the wallet's send-credits panel does not move the field you are typing into above the keyboard. Mobile readiness moves 475 → 491 out of 1000.
- Mobile app: member-to-member messaging is now proved to work on real phones, both ways. Walked on two phones side by side: one member sent a message, the other's phone showed 1 unread, opening the conversation marked it read, and the reply came back. Each step was confirmed in the database rather than taken from the screen. Nothing needed fixing — this journey worked; it had simply never been checked, which is not the same thing. Automated tests already cover the sending path and the unread badge, so all three steps count as fully certified. 🔴 One note for anyone working here next: the screen called "chat" in the code is the AI assistant, not member messaging; member conversations are a different screen.
- Mobile app: a member can now complete a time exchange from their phone — and half of that journey did not exist until today. This is the transaction a timebank is for: one member asks for another's help, the helper accepts, the work happens, both confirm the hours, and time credits change hands. The app could send the request and then do nothing at all with it — no accepting, no declining, no starting, no marking it done, no confirming the hours, and no screen anywhere that listed your own exchanges. The person being asked had exactly one route in, a notification, and tapping it opened the wrong screen and said "Listing not found", because the app used one word for two different things: a listing you browse, and an exchange between two people. Built and walked on two phones side by side: the request was sent, accepted, started, marked done, and confirmed by both members, and one time credit moved — the helper went from 85 to 86 hours, the person helped from 27 to 26 — with the entry appearing in both members' own wallet histories. Every step was checked in the database, not just on screen. Available in all seven of the app's languages. 🔴 Two things found while doing it, both worth knowing. First, the formal exchange process is switched off by default for a community: with it off the button reads "Request this service" and opens a message conversation instead, which is correct behaviour and not a fault — but it means a community must turn the process on before members can use it. Second, a screen showing something two people share can go stale: after one member confirmed on their phone, the other's already-open screen still showed the old state until it was reopened. The two new screens now re-read whenever you come back to them; no other screen in the app does this, which is recorded as a known gap rather than quietly swept across sixty screens. Still not walked: declining, cancelling, and messaging someone about an exchange. The mobile readiness score moves 455 → 468 out of 1000.
- Mobile app: bottom sheets open again, so comments, card menus and every other pop-up panel work — and a comment written from the phone was found in the database. These are the panels that slide up from the bottom of the screen. Every one of them had been unusable across sixteen screens, which took commenting, replying, the "…" menu on a post and the reactor list out of service. The cause was our own workaround, not the panel library. A previous repair opened the panel and then, a fifth of a second later, briefly closed and reopened it — a trick meant to survive a timing problem. Closing it, even for one frame, made the library conclude the member had swiped the panel away, so it closed for real. Removing that trick fixed all sixteen screens at once: no library was changed, no screen was rewritten, forty lines were deleted. 🔴 Why it went unfound for six days, and why four earlier attempts failed. The panel was opening. It slid into view and closed itself in about a third of a second, so every screenshot taken a second or two after the tap showed nothing — which read as a dead button rather than a panel that had come and gone. Capturing frames immediately after the tap caught it mid-slide, and that one measurement overturned the diagnosis. Verified on an emulator: the card menu opens and stays open in three of three attempts (and closes in three of three when the trick is put back), a comment was typed, sent and confirmed in the database, and a form panel inside a pop-up screen opens with its keyboard. Not yet confirmed on the owner's own phone: the fix needs a new app build. A guard test now fails if the trick returns — and it is honestly labelled as checking the code rather than the behaviour, because the behavioural version of that test cannot fail: the test framework collapses the offending sequence, so it reported the fix and the fault identically.
- Mobile app: commenting, replying and "who reacted" are now proved to work from the phone, not just proved to open. With the panels fixed (above), the social journeys behind them were walked on an emulator and checked in the database rather than on screen: a comment written from the app, then a threaded reply to it recorded against the right parent comment, then a reaction added and the "who reacted" list opened showing the right member. The post's own comment count moved from one to two on the card without a refresh. Two of these four are now covered by automated tests as well; the "who reacted" panel is not — it is only ever stubbed out in tests, never actually rendered by one — and that is recorded as the next gap rather than glossed over. The mobile readiness score moves 408 → 455 out of 1000, all of it from journeys measured today.
- Experimental ASP.NET backend: a member can now complete an exchange and the credits actually move — the first journey proved working end to end on the new backend. Development-only; the live platform is unaffected. An exchange is the transaction a timebank exists for: one member requests another's help, the provider accepts, the work happens, both confirm the hours, and time credits change hands. Driven through the app's own screens against the new backend, with the real backend running the identical journey alongside in the same pass: the requester went 15.5 to 14.5 hours and the provider from −1 to 0, and the same journey on the real backend moved 95 to 94 and 30 to 31. The screen was not trusted — the database was read directly afterwards to confirm the exchange was marked complete, both confirmations recorded at 1.00 hours, and a single transaction row moving those credits between the right two people. 36 of the 37 automated steps now behave identically on both backends, with nothing failing only on the new one. 🔴 Worth knowing: not one of the three things blocking this was a fault in the backend. Two were gaps in the test data — one member had completed the sign-up steps and accepted the terms while the others had not, which left the second member's pages rendering a menu and a footer with no content at all: no error, no failed request, nothing in the log, 13 form fields for one member and none for another on the same address. The third was the measuring tool: it could not read a minus sign, so a provider correctly credited from −2 to −1 was reported as "credits moved by the wrong amount" — the tool was accusing the backend of a bug that did not exist.
- Experimental ASP.NET backend: a full count of the staff surface, and it is worse than the running total said. Development-only. Every one of the 1,119 administrator addresses on the real backend was catalogued and compared: 257 of the 1,008 that the admin panel actually calls — one in four — either do nothing or do not exist. Worst areas measured: the AI-agent screens (10 of 10 do nothing), legal documents (15 of 16), volunteering administration (34 of 52), and groups, enterprise and newsletters all between 56 and 60 per cent. 🔴 The count we have been tracking sees only a fraction of the problem. There are three ways one of these addresses can do nothing, and our counter recognises one of them: 108 are plainly empty, but 138 fall through to a shared "write it down and read it back" store — a request is recorded in an audit table and answered "recorded only", and a later read replays it. Nothing is moderated, sent, applied or deleted. Because it writes to the database, our counter reads it as real work. A further six return a fixed answer behind a genuine permission check. On top of that, spot-reading 52 of the addresses still classified as working found three more that are not, suggesting roughly one in fifteen of the remainder is also hollow. All of it is now catalogued per address so the work can be scheduled from evidence instead of guesswork, and the least trustworthy figure is flagged as such: the Caring Community screens report 155 addresses with none broken, which is exactly the family where "the address exists" has historically meant least.
- The accessible site has nineteen administrator functions that lead nowhere. Not a fault anyone can hit — none of them is called from anywhere in the site — but every one points at an address the real backend does not publish, so a maintainer reading that file would reasonably conclude the accessible site has a full administrator surface. It has six addresses. Recorded rather than deleted, since removing unused code is a separate decision.
- Experimental ASP.NET backend: the exchange page could not be displayed at all, so the exchange feature was unusable even though paying for an exchange worked. Development-only; the live platform is unaffected. This is the clearest example yet of why a working server is not a working product. The money side had been finished and proved the day before — two members confirm the hours, the credits move, the ledger balances. None of it could be reached, because the app could not draw the exchange screen. The server was sending the right exchange, under the wrong names: it called the two people "initiator" and "listing owner" where the app looks for "requester" and "provider", it called the hours "agreed" where the app looks for "proposed", and it wrapped nothing in the envelope the app unwraps. The app then tried to format an hours figure that was not there, threw an error, and showed "exchange not found" — while the server reported complete success. Every action button was hidden for the same reason: with the two names missing, the app could not work out that you were the person being asked. The statuses were in a private vocabulary too. The server said requested, inprogress and pendingconfirmation; the app knows pending_provider, in_progress and pending_confirmation, and an unrecognised status does not mislabel a chip — it blanks the whole page. All of it is now translated at the boundary, in one place, with a check that fails the build if a new status is ever added without being taught the app's word for it. The stored values are untouched, so the settlement work from the day before is unaffected.
- Experimental ASP.NET backend: the "Request Exchange" button was missing from every listing in every community. Development-only. The listing page asks the server "does this member already have a live exchange on this listing?" The server answered a different question — "may they start one?" — in a shape the app reads as "yes, one already exists". So on every listing the button was replaced by a link to a non-existent exchange, and the status badge beside it crashed the listing page outright. The exchange feature therefore had no entry point at all. The server now answers the question that was asked, and answers "none" when there is none, which is what lets a member start one.
- Experimental ASP.NET backend: three smaller exchange faults found in the same pass. Development-only. The hours a member typed into the request form were being thrown away and replaced with the listing's estimate — silently, with a plausible number in the reply. The status tabs on the exchanges list did nothing: the server did not recognise the word the app sends for "active", and rather than saying so it ignored the filter, so the Active tab listed completed and cancelled exchanges as well; it now refuses a filter it cannot honour instead of quietly returning everything. And on the dashboard, "your turn to confirm the hours" could never appear, because the one status that produces it was missing from the query behind the card. Two gaps are recorded rather than fixed: preparation time has nowhere to be stored on this backend, so it is accepted and dropped and always reads back empty; and there is no record of an exchange's history, so its timeline is rebuilt from the timestamps that do exist and is shorter than the real platform's. Both need a database change, which is a different piece of work.
- 🔴 A real fault in the live platform, found while doing the above. On a completed exchange the app displays who left each rating by reading a nested "rater" object. The live platform does not send one — it sends the rater's first and last name as separate flat fields — so a completed exchange that carries a rating crashes the exchange page on the live platform as well. Nothing was changed in the app or in the live platform to hide this; the ASP.NET backend now sends both shapes, and the live-platform fault is flagged for a decision rather than quietly worked around.
- Experimental ASP.NET backend: you could not tell who you were about to send time credits to. Development-only; the live platform is unaffected. The recipient picker in the transfer screen showed a first name and nothing else, so two members called Maya were indistinguishable on the very screen that asks you to confirm an irreversible transfer. The cause was not the transfer screen: a piece of middleware rewrites every response the backend sends to an ordinary member, blanking surnames and cutting full names down to the first word. The real backend does have a surname-privacy rule, but it applies it at exactly four places, all of them browsing screens — public profiles, member search, the member directory, and public event organisers. The transfer picker is deliberately not one of them, because confirming a financial counterparty is not browsing. The two search addresses are now exempt, with the four real places recorded alongside so the next reader sees the rule rather than guessing. Nothing was removed and the browsing screens still hide surnames — proven by a companion test that fails if the exemption ever widens. 🔴 Two larger findings recorded, not yet fixed: organisation names are being cut at the first space by the same middleware, so "Bristol Community Trust" displays as "Bristol" (the real backend explicitly exempts organisations); and 74 files in the main app read the surname field, composing a full name in 78 places, so every one of those is a candidate for the same unreadable-name problem. The proper repair is to invert the middleware so it only applies at the four real places — deliberately not attempted yet, because getting that wrong exposes surnames the platform currently hides.
- Experimental ASP.NET backend: sign-up accepted throwaway email addresses and known-breached passwords that the real backend refuses. Development-only. Three checks existed on the real backend and none on this one: whether the address's domain can actually receive mail, whether it belongs to a known disposable-email provider (a 283-domain list, now copied across with a test that fails if the two copies ever drift apart), and whether the password appears in public breach data. All three now refuse in exactly the same way the real backend does — same status, same error code, same wording, and in the same order, so a throwaway address that already exists reports "disposable" rather than "already registered", as it should. Neither website needed changing; both already understood these refusals, and the translations existed in all eleven languages. 71 tests, including a deliberate check that a perfectly good sign-up is still accepted — without which a check that refused everybody would have passed every other test.
- 🔴 A real fault found in the live platform while doing the above, and it is worth your attention. The real backend's email check is documented to let a sign-up through if the domain lookup cannot be completed. It does not. The function it relies on gives the same answer for "this domain has no mail server" and "the lookup did not answer", so the safety valve effectively never opens — meaning during a domain-lookup outage the live platform would refuse every new registration, across every community, until the outage cleared. The platform's own source code notes the ambiguity. The ASP.NET version distinguishes the two cases and lets the member through when the lookup is merely unreachable, recording a warning that the address was accepted unchecked. That is a deliberate difference between the two backends, and it is flagged for an owner decision rather than settled quietly: match the live behaviour, keep the new behaviour, or fix the live platform too.
- 🔴 Experimental ASP.NET backend: the exchange — the whole point of a timebank — could not be started, could not be accepted, and cannot yet be paid. Development-only; the live platform is unaffected, and in production this journey runs on Laravel where it works. Four separate faults, found by driving the real app rather than by reading code: 1. A member could not even reach the request form. One small settings answer from the server was a placeholder that returned four values nothing reads and left out the four that everything reads. The app treats a missing answer as "switched off", so it politely showed "exchanges are not enabled for this community" on every screen and hid the Request Exchange button on every listing. Nothing looked broken: every request succeeded, no error appeared anywhere, and the automated response comparison called it a difference in field names. The whole feature was simply invisible. 2. Accepting a request answered the wrong person's question. The app sends "accept" one way and the backend was only listening the other way, so the request landed on an administrators-only placeholder that does nothing — and told the ordinary member who owned the listing "admin access required". The working code for accepting was sitting right there, unreachable. 3. Three actions let anybody change anybody's exchange. Confirm, decline and start each wrote a new status with no check that you were even involved. Demonstrated live before removing them: an ordinary member reopened a finished, already-paid exchange in one request. All three now go through the one place that checks who you are and whether the change is allowed. 4. Ratings on an exchange were always empty, and rating one crashed. The list was being read from the wrong table — by exchange number against a table of listing reviews — so it returned a real, plausible-looking review belonging to somebody else. And two pieces of code claimed the same address for rating, which makes the server return an internal error that a browser reports as a security-policy problem, sending anyone investigating in the wrong direction. What is still not fixed, and why it is not a quick patch: credits do not move. Paying an exchange requires both people to confirm the hours and agree, and this backend has nowhere to record one person's confirmation — the storage for it does not exist. The code correctly refuses rather than pretending, and now says so honestly instead of returning a fake success. Adding that storage is a database change, which is deliberately done by one session at a time and was not this one's to make. It is the named next step. Fourteen new tests pin all of the above, the count of do-nothing endpoints fell from 317 to 316, and the app's automated browser walk now drives the full exchange between two members — asserting that both balances actually moved by the right amount, not that a status label changed. On the Laravel comparison arm that walk moves credits correctly end to end. One honest gap: the fixes are proved by tests and a clean build, but were not run through the browser against the shared development server, because two other sessions were using it at the time. Also recorded, because each cost time and will cost it again: the tool that answers "is there a do-nothing endpoint on this journey?" said CLEAN for two endpoints that were doing nothing — it missed them because one method carried eight unrelated addresses, a shape its scanner does not recognise, now written down inside the tool. The first attempt at the ratings fix returned the right rows one level too shallow, which shows an empty list at HTTP 200 with the correct data in the payload. The new browser walk reported a failure that was my own test's fault, not the product's: it read the page once after a reload, got nothing because the page was still starting up, and said the accept had not worked — the database said it had. It now retries the read and distinguishes "blank page" from "wrong status". And two of these long comparison runs started close together exhausts the Windows machine's network sockets, which produced nine comparison failures in a row that were purely the host running out of room.
- 🔴 Experimental ASP.NET backend: an exchange can now actually be paid — the credits move. The journey is still not finished, and the reason has changed. Development-only; the live platform is unaffected, and in production this runs on Laravel where it works. The entry above this one ends by saying credits cannot move because there was nowhere to record one person's confirmation. That storage now exists, and this is what it took. Paying an exchange is a two-person agreement, not a button. The live platform records each side separately — when the requester confirmed and how many hours they said, and the same again for the provider — and moves credits only when both have answered and their two figures are within a quarter of an hour of each other. If they agree exactly, that figure is paid. If they are close, the midpoint is paid. If they are further apart than a quarter of an hour, the exchange is marked disputed for a human to look at and nothing is paid. A single database change adds those five pieces of storage, copied field for field from the live platform so the two cannot drift, and the payment itself now goes through the same money-handling code the group-exchange feature already uses: one database transaction, both members locked in a fixed order so two simultaneous requests cannot interleave, and the balance worked out by adding up the ledger rather than trusted from a stored number. What is proved, and how. Ten new tests against a real PostgreSQL database, because the locking cannot be tested without one. They read the ledger back rather than trusting the server's reply: two people agreeing moves exactly two hours from one to the other, as one ledger row with both sides on it; agreeing within tolerance pays the midpoint, and the amount paid is the same number as the figure recorded; disagreeing beyond tolerance pays nothing; one person's confirmation alone pays nothing; someone who is not involved gets "not found" and changes nothing; asking twice after payment is refused and does not pay twice; two confirmations arriving at the same instant settle exactly once — run five times through a starting gate so the two really do collide; and an exchange the payer cannot afford is refused with a clear message while the other person's confirmation survives. The step the app calls "mark complete" now does what the live platform's does — hands over to the confirmation stage and moves no money — and only the person who did the work may press it. 🔴 Still not finished, and this is a different fault from the one it replaces: the app cannot display the exchange page against this backend at all. It asks for the exchange, then immediately reads a field called proposed_hours. This backend calls the same thing agreed_hours, calls the two people initiator and listing_owner where the app expects requester_id and provider_id, wraps nothing where the app expects a wrapper, and spells the statuses requested and inprogress where the app expects pending_provider and in_progress. So the app reads a missing field, throws, and shows "exchange not found" — and every action button stays hidden because it cannot work out which side you are on. This was invisible until now because the journey died earlier, at the payment. Translating that one answer into the shape the app already reads is the named next step, and it is an addition on the backend only; nothing in the app changes. The work list entry for this journey stays marked broken, deliberately, and its recorded cause has been rewritten to the new one. No score moved. Calling it proved would claim the app drives it, which is measurably false.
- Experimental ASP.NET backend: two endpoints told members their legal data requests were being handled while doing nothing at all. Development-only; the live platform is unaffected, and in production these journeys run on Laravel where they genuinely work. A member asking for their account to be erased received "queued"; a member asking for a copy of their data received "pending". Neither queued nor recorded anything — the request was silently discarded. Both now refuse honestly with a "not implemented" response, logged as an error on the server, modelled on the pattern this backend already uses correctly for unhandled payment webhooks. Two details worth recording: the accessible site was already rejecting the fake success, because it insists on a confirmation field the pretend answer never carried, so this makes the backend honest about a failure the member already saw; and a third endpoint of the same kind (recording cookie/consent choices) was found in the same file and is now tracked rather than quietly left. Four tests pin the refusal, and the count of do-nothing endpoints fell from 319 to 317.
- The check that guards every published score enforced nothing, while a comment inside it claimed the opposite. Two markers recording the ASP.NET score and the size of its work list were both declared "no cross-check", so the score was never compared against its own table, the work-list total was never compared against the list, and a comment stated the total "cannot drift silently" when nothing was reading it. It now recomputes each section's score from the individual journey rows, rejects any status outside the list's own vocabulary, checks the totals add up, counts the database migration files on disk against the number the documents claim, and refuses to let a published score fall below its recorded floor. It also scans every maintained document for superseded scores presented as current — which immediately found 28, including the public README and the accessible-frontend status page still publishing a five-week-old figure. All 28 corrected. Every new check was deliberately broken first to confirm it fails, and one rule I specified turned out to be too loose — it would have exempted any document that merely mentioned a retired sibling, including a fully current one — so it was tightened to require the document to declare itself historical.
- Experimental ASP.NET backend: the main app's browser test could not fail, and now it can — plus it finally runs the same walk against Laravel in the same pass. Development-only; the live platform is unaffected. The automated browser walk of the React app against the new backend has 37 steps, and every one of them was being thrown away: a failure printed the word FAILED and the test still finished reporting success, so nothing it checked could ever stop anything. Every step now records its own result, the run prints a table, writes a machine-readable record of the run, and ends with a real pass/fail. Proven deliberately broken before being trusted: pointed at an address with nothing behind it, 36 of 37 steps failed and the test correctly ended in failure. A step that genuinely could not be measured — a form that never opened, a search that returned nothing to click — is now reported as SKIPPED and never counted as a pass. The bigger change is the comparison arm. The test can now start two copies of the unchanged app itself, one wired to the new backend and one to a throwaway Laravel, run the identical 37 steps against both, and label each step: both worked, both failed (so the environment is suspect, not the new backend), failed only on the new backend (a real defect to chase), or failed only on Laravel (the comparison itself is at fault). Only the third kind fails the run, so a shared environment problem can no longer be misreported as an ASP.NET defect. First full comparison run: 31 of 37 steps matched, and not one step failed on ASP.NET only — the new backend passed 35 of 37 with zero failures. The six non-matches are all on the Laravel comparison side: its throwaway test data holds only one community, so its sign-in screen offers nothing to choose and the sign-up step cannot complete, and four steps were skipped on one side or the other. That is a test-data gap to close, not a product fault, and the run says so in those words rather than reporting a clean pass.
- Mobile app documentation rebuilt so the next session starts where this one finished. The old readiness document had grown by adding a new dated section each time something was found, until the top of the file described a different app from the bottom — and it scored the code carefully while barely scoring the product, which is how the app reached 302 green test files with three unreachable controls on the first screen a member sees. It is now four documents, each with one job: a short status page that is the only place a score is stated; a work list of 140 numbered journeys with a fixed total, so progress cannot be faked by quietly adding scope; a plan in ordered phases, each ending in a measurable change rather than a description of effort; and a harness guide recording exactly how to walk a journey on two phones, including every trap that cost time. The old document is kept, marked historical, for its measurements. 🔴 The score is now checked by machine, the same way the ASP.NET one is: inflating the headline without changing the table fails, and promoting a journey without updating the totals fails. I proved both by trying them. The app currently scores 408 out of 1000 — well-built code around a product that is largely unproven, with the honest reasons listed.
- 🔴 Mobile app: pop-up panels do not open, and that blocks a lot of the app. Found while walking the journeys in the order a member meets them. Tapping "Comment" on a feed post fetches the comments from the server — confirmed in the server's own log — and then nothing appears on screen. The "…" menu on a card behaves the same way. Three taps in a row, still nothing. These sliding panels are used by sixteen screens: comments, the card menu, who reacted to something, chat, exchange details, goals, group pages, job details, reviews and five marketplace screens. Anything behind one of them currently cannot be reached. Not fixed yet, and deliberately so — the cause is in a third-party component that has already been repaired four times for this exact symptom, and a proper fix means changing something shared by sixteen screens, which needs its own careful session rather than the tail end of a walk. I ruled out the obvious causes one by one: animations are on, the app's set-up is correct, the library version has not moved, it fails identically without any of my changes, and it is not the known "tap it two or three times" flakiness. You can settle one thing for me in seconds: open the app on your phone, tap "Comment" under any feed post, and tell me whether a panel slides up.
- Mobile app: you cannot write a post to the community feed from the phone. The Create button offers eight things — a listing, an item for sale, a message, an event, a poll, a challenge, a group, a goal — but not a plain post. The website has a proper composer and the server accepts posts; the app simply has no way to send one. Nothing was built, because that is a new feature and your decision. Worth knowing why no check caught it: our app-versus-website comparison works by matching pages, and the website's composer is a panel inside a page rather than a page of its own, so it was invisible to the comparison.
- Mobile app: the volunteering journey now works end to end, and the money adds up. Continuing the two-phone walk: one member logged hours, the organiser approved them, and the credits moved correctly — the volunteer gained 2 hours and the organisation was charged 2, each with a proper entry in its own record. A certificate generated with a verification code, an expense went from the phone to admin approval and back, and a wallet top-up landed correctly. Fixed on the way: the expense amount box was too small to show a figure like "12.50" (a width instruction was being applied to the wrong part of the field — the third time this week the same kind of mistake has hidden in plain sight); a failed action threw away the reason — the server said "you have already logged hours for this organisation and date" and the app said only "could not log these hours"; and the organisation wallet told organisers the opposite of the truth, claiming approved hours "will need manual payment" when they are in fact paid immediately. 🔴 That last one turned out to be worse than wrong wording: the auto-pay switch beside it called an address on the server that does not exist, so it could only ever fail, and it controlled nothing. The website had already removed the same switch. The phone app now matches, which also clears the last remaining mismatch between the app and the server — 402 of 402 addresses now verified.
- Mobile app: an organiser's own balance can drop with nothing to explain it. Found in the same walk and not yet fixed, because it touches money and deserves care rather than speed. When an organiser puts credits into their organisation's wallet, the credits leave their account correctly and the organisation records receiving them — but the organiser's own transaction history shows nothing. Measured: a balance went from 90 to 85 while the personal history still listed a single unrelated entry. Nothing is lost or invented, and an administrator can trace it, but a member looking at their own statement cannot. This is now the top outstanding item.
- Mobile app: four buttons and links that led nowhere, found by running two phones against each other. Two emulators side by side, signed in as two different members, put through the whole volunteering journey: one registers an organisation, publishes an opportunity, the other finds it and applies, the first approves the application. That worked end to end. What did not: the volunteering page offered "Create opportunity" to everyone, and the form it opens cannot be submitted unless you run an approved organisation — so anyone else could fill the whole thing in and find the button dead. And three links into the app opened the right screen and then said "not found": a link to an organisation's dashboard (for an organisation the member owns), a link to a marketplace category, and a link to a blog post. Each was handing the screen the right information under the wrong name. All four fixed. 🔴 Two things worth recording: tapping around inside the app could never have found the link faults, because in-app navigation always used the right name — only an incoming link was broken. And one of our own tests was holding the blog fault in place, asserting the wrong name while looking like protection.
- Mobile app: the screen where you choose your community showed no community names. Every row was a single letter and an arrow — you could not tell one community from another except by remembering the order. This is the screen people use to pick which community they sign into. It was broken on every size of phone, not just narrow ones, and it had never been noticed. Now fixed, and checked on two phone sizes: full-width rows with the community name and a line of guidance underneath.
- Mobile app: the "Save" button was cut off on nine different forms. Anywhere the app asks you to fill something in — editing your profile, changing your password, creating an event, a group, a job, a listing or a volunteering opportunity — the bar at the bottom put the wording and both buttons on one line, and the wording won. On a narrower phone the heading had shrunk to three letters and "Save changes" ran off the edge of the screen. 🔴 Worth recording: my first repair only applied to narrow phones. Looking closely at the wide phone showed the same button clipped there too, so the repair would have looked right while leaving most phones broken. The buttons now sit below the wording and arrange themselves to fit, with no assumption about screen size at all. The jobs page tabs were also unreadable on a narrower phone — "My A…", "My Po…" — and now sit as two rows of two with their full names.
- Mobile app: the community list could go blank and stay blank for five minutes. Separate from the above, and on the server side. The sign-in screen asks the server which communities exist; the server keeps the answer for five minutes to stay quick. It was keeping an empty answer just as readily as a real one — so a single query that came back with nothing would show "No communities found" to everyone, and every request after it would repeat that answer rather than checking again. Nothing would appear in any error log; the platform would simply look empty. Found live on our development server, where the list was empty while the database held four communities. Now an empty answer is never kept.
- Mobile app: two buttons fitted the test phone and fell off a real one. Found straight after the fix below, by opening the same app on two different phone sizes instead of one. On the wide screen we normally test on, everything looked right. On a narrower phone — a very common size, and quite possibly the size of yours — the picture was different: of the eight reactions, only six could be reached, with nothing on screen to suggest the other two existed, and the "save" button had been pushed off the edge of the card and disappeared altogether. Now fixed: on a narrow phone the reactions arrange themselves as two rows of four so all eight are visible at full size, and the buttons under a post drop their word labels to stay on one tidy line. 🔴 Two things worth recording. Nothing we run automatically could have caught this — our picture-comparison check photographs three screens at one fixed size, and our other tests have no concept of screen width at all. And hiding a button's word label quietly removes the name a screen reader announces, so both buttons were given a proper spoken name; without that, this repair would have made the app worse for anyone who listens to it while looking perfect to everyone else.
- Mobile app: the feed was offering three buttons on cards that had nothing behind them, which is why reactions "wouldn't save". Reported from a real phone. All three complaints were true and none was what it looked like. The heart appeared on every card — including the "badge earned" and "level up" cards, which are notifications rather than something anyone wrote. There is nothing there to react to, and the server refuses it, so tapping the heart could only ever fail: the icon flipped, the save failed, it flipped back. Because most of the feed is those cards, it read as "the app can't save my reactions" — while on real posts and listings it always worked. The eight emoji reactions were also invisible: they exist (👍 ❤️ 😂 😮 😢 🎉 👏 ⏰) but only a long press reached them, with nothing to hint at it, so the app genuinely looked like it had one reaction. And "View post" on those same cards always led to "Not found" — there is no post behind a badge. Fixed: those cards now show only Share, real content keeps its heart plus a small hint that more reactions are there, and the dead link is gone. Checked on a phone emulator: a reaction now saves and survives closing and reopening the app. 🔴 Worth noting — the website had already found and fixed the same reaction bug months ago; the phone app simply never received the fix.
- Experimental ASP.NET backend: a brand-new member's complete first day is now proven, and session expiry was genuinely tested. Development-only; the live platform is unaffected. The automated browser walk now takes a fresh registration all the way through: sign-up form, email verification, first sign-in, the legal-terms acceptance screen (which appeared exactly as it should), acceptance, and arrival in the app. Separately, the backend was run with tokens that genuinely expire after one minute to answer "what happens when a session runs out?" — the honest finding is that the app sends you back to the sign-in page rather than quietly renewing in that situation, which is how the app itself is written and would behave the same against Laravel; the renewal machinery itself is proven working by the accessible site's sessions. One test refinement remains parked pending approval, since it needs a one-line change to the main app's code.
- Experimental ASP.NET backend: the accessible site's signed-in pages are now proven too. Development-only; the live platform is unaffected. The accessible site's automated test now actually signs in — through the real login form with all its protections — on both the ASP.NET copy and the Laravel control, and confirms all eight member pages (dashboard, listings, events, feed, groups, volunteering, explore, knowledge base) render properly against the new backend: 19 of 20 page checks identical or rendering correctly, the twentieth being the already-explained test-data difference. Getting the scripted login working surfaced two traps now written into the test for good: the login page issues its security token twice and only the second counts, and the form silently requires a community field whose absence produces a message that reads like a wrong password and isn't.
- Experimental ASP.NET backend: saving your cookie choices always failed, and looked like something else entirely. Development-only; the live platform is unaffected. Every attempt to save cookie preferences from the browser failed with what looked like a cross-origin security block. The truth was stranger: two pieces of code both claimed the same address, so the server crashed on every save, and the crash response happened to be missing the header browsers use to tell the two sites apart — so the browser blamed the wrong thing. One owner remains, and two quieter faults fixed in the same pass: the reply format now matches Laravel's exactly, and the save was reading a differently-named field than the app sends, so the "preferences" choice was being silently recorded as OFF no matter what the member picked.
- Experimental ASP.NET backend: voice messages can finally be played back, and three of the four missing doors are now built. Development-only; the live platform is unaffected. A member could SEND a voice message against this backend but nobody could ever LISTEN to one — the fetch route simply didn't exist; the same was true of message attachments and of downloading your own volunteering certificates. All three now work, with the same privacy protections Laravel applies (no caching of private audio anywhere, strict content-type rules, and downloads only for people actually in the conversation — anyone else is told the message doesn't exist rather than that it's forbidden, so the response can't be used to probe who talks to whom). Certificates also re-check the allowed-document-type list on the way OUT, not just on the way in, so a file that predates the rules can't become retrievable. The fourth missing door — marking event attendance by scanning a signed pass — is honestly recorded as blocked: it needs the whole signed-pass system this backend doesn't have yet, and a shortcut version would accept any pass, which is worse than no door.
- Experimental ASP.NET backend: polls never reached the feed, and two more contract repairs. Development-only; the live platform is unaffected. Creating a poll never announced it to the community feed — twelve polls existed in the development data and not one had ever appeared — so poll cards, and the voting data behind them, simply couldn't show up. Creating a poll now publishes it exactly as Laravel does, and poll cards carry their full voting data, including the fairness rule that hides running totals from everyone except the poll's creator until it closes (so early results can't sway later voters). Separately, the feed sidebar was returning raw internal database records for a "trending hashtags" list that nothing displays — harmless today, but one routine code change away from leaking internal data into a member-facing response — so it now sends exactly what Laravel sends and nothing more. And the feed's offers-versus-requests narrowing now works, with a test that runs against a real database to prove the query actually executes.
- Experimental ASP.NET backend: three fixes found by actually walking the pages. Development-only; the live platform is unaffected. First, the events list ignored everything the app asks for — it now honours the requested page size, the upcoming/past/everything choice (so the dashboard's "Upcoming events" no longer shows last month's events), the group filter (so a group's events tab shows that group's events, not everyone's), and gives the same clear validation errors Laravel gives for nonsense values. Second, every help-page FAQ silently vanished on BOTH websites, because this backend sent them in a flat list while everything expects them grouped by topic — now grouped identically to Laravel. Third, the accessible site finally has a real, repeatable test against this backend: a committed script that starts both copies itself (so the wiring can never be mis-typed again), compares twelve public pages against the Laravel-backed control, and records the result — 11 of 12 identical, with the twelfth explained to root cause (test-data difference, not a fault) and documented so it can only ever be removed by fixing it. The main app's browser test now walks 17 steps including the help page, with no errors.
- Experimental ASP.NET backend: the dashboard crashed, because of an earlier change of ours. Development-only; the live platform is unaffected. Events can be described in two ways — an older, simpler format and a newer, richer one — and each screen asks for the one it understands. Recent work taught this backend the newer format but not how to answer a screen that asked for the older one, so it always replied in the new format. The dashboard, the group page and the clubs panel all ask for the older format; the dashboard in particular could not cope and blanked out completely with an error. It now answers each screen in the format it asked for, and says which one it used. Worth recording how this was found: the automated response comparison rated events as fully fixed the entire time, because it only ever asked in the newer format — it took actually opening the pages in a browser to see the dashboard was broken.
- Experimental ASP.NET backend: the news feed sent 33 fewer pieces of information per item than the app expects, and could not be scrolled. Development-only work; the live platform is unaffected. Each feed item now carries everything the app reads — whether a post was shortened, how many times it was shared or saved, who reacted to it, and the details specific to each kind of item (an event's date, a review's rating, a badge's name). Four of those are now real figures read from the database rather than blanks. Separately, the feed's "load more as you scroll" was broken in a way that looked fine: the app asks for the next batch using a marker the backend never sent back, so scrolling silently re-loaded the first batch for ever. The backend now sends that marker and honours the page size the app asks for. The feed's filter tabs were also doing nothing at all — picking "Events" or "Listings" returned the same unfiltered feed, because the backend was not reading the filter the app sends. They now work. Two gaps remain and are recorded: posts with several photos show only one, because this backend has nowhere to store the extra ones yet; and the "For you" ordering is the same as "Recent", because there is no ranking service on this backend.

### Changed

- Experimental ASP.NET backend: the score has been re-measured from scratch and is now 653 of 1,000 (up from 598). Development-only; the live platform is unaffected. Months of real work had never been formally counted, so the number sat still while the backend improved. Every measurement was re-run today at a fixed code version with all automated checks green on GitHub: the rise banks the feed/events/listings work, the first-ever measurement of saving actions, the browser proof that a member can sign in and actually use the app against this backend, and the removal of one deduction that had been counting a frontend's translation files against the backend by mistake (its honest replacement, covering the backend's real translation gaps, is smaller). From here the score is re-banked every time a batch of work is proven, instead of once a quarter.
- Experimental ASP.NET backend: the paperwork has been completely reorganised. Development-only; the live platform is unaffected. The main status document had grown to nearly 3,000 lines, 81% of it old diary entries, and three different numbers each claimed to be "the current score". It is now a ~170-line page holding only what is true today; the diary moved intact into dated archive files; a new plain-English ROADMAP explains where the project stands without any jargon; and a sweep corrected every stale claim found by audit — references to a frontend deleted a week ago still listed as a "source of truth" in four documents, a "development is paused" fence from July that was lifted in August, links to a retired status page in nine places, and a mislabelled measurement that had been counting a frontend's translation files against the backend's score.
- Accessible frontend: the audit list is finished. Listing and event cards are now one big click target (tap anywhere on the card) with a single keyboard stop, instead of two links to the same place. Personal pages — your inbox, your notifications, your orders, your applications, 27 in all — now say "Nothing here yet" instead of the misleading "No results found" when nothing was searched; translated by hand into all 11 languages, with a test that pins which wording each kind of page uses. Feed posts show one row of respond buttons instead of two stacked rows. And a sweep of all 325 pages against the test environment found zero server errors and one real blemish — the achievements page displayed the literal word "undefined" above its title — which is now impossible by construction, on every page, with a test.

### Security

- Patched a newly published vulnerability in a shared code library (nanoid). Two advisories against the "nanoid" identifier-generator were published and caught by the automated security scan on push. Only one copy in the project was genuinely affected (the root tooling tree, on 3.3.12 — patched versions start at 3.3.16); it is now on 3.3.18 like every other copy. The scanner also flags already-patched 3.3.16+ copies because the public vulnerability database over-matches the whole 3.3 line; those exact false positives are suppressed with version-scoped rules that still fail the scan if a genuinely vulnerable copy ever reappears.
- Patched a newly published vulnerability in a shared code library (fflate). An advisory against "fflate", a compression library, was published overnight and caught by the automated security scan on push — the code had not changed, the vulnerability database had. Nothing here chose fflate: it arrives indirectly, underneath the PDF generator used by the admin impact report, so the fix is a patch-level move from 0.8.2 to 0.8.3 that the PDF generator already permits, and it touched one line of the lockfile and nothing else. Verified by rebuilding the frontend and running the impact-report tests. Two things worth recording: the public npm advisory database does not list this problem at all, only the scanner does; and the advisory is scored 6.6 while the scan is configured to fail at 7.0 and above, so the gate is stricter in practice than it reads.

### Fixed

- Listings — the worst remaining page on the experimental backend — now returns everything the app expects. Development-only backend. It was 50 pieces of information short of the real backend, 42 of them things the app's own code refers to, including the category, the author, the photo, the estimated hours, and whether you have saved it. Now nothing is missing. 🔴 Where this backend genuinely has nowhere to store something, it says so rather than guessing. About fifteen fields have no home here — a price, availability, save and contact counts, the author's rating. Each is now reported as explicitly empty rather than absent, so the app gets a definite answer instead of nothing; and none is invented by guessing from a neighbouring value, which is the mistake that produced fabricated titles and dates earlier in this work. Two such guesses were caught by the compiler while writing it.
- A shared piece of pagination was nearly changed for every page when only one page needed it. The real backend sends different pagination details depending on the page — listings gets a page number and totals, events and groups get neither. Changing the shared code would have made two pages report details the real backend never sends. It is now scoped to the one page that needs it, with the measurement written beside it.
- Mobile app: there is now an undo button for sending out a fix, and the app tells people when a fix is waiting. Sending a fix out had careful safety checks; taking one back had none — the one command you reach for while something is actively broken was the only one that could be pointed at the wrong group of users unchecked. There is now a proper wrapper for it, and an automatic check that every group of users you can send a fix to can also have it taken back. 🔴 Deliberately, taking a fix back does not require a tidy workspace, unlike sending one out: it sends nothing from your machine, and whoever is running it is in the middle of an emergency, possibly with a half-written fix open. It does still make you name the group of users twice, so a rollback meant for testers cannot land on everyone. Separately: the app was already downloading fixes quietly in the background and only using them whenever someone happened to fully close and reopen it — so a fix could sit unused on a phone for days. It now offers "Update ready — restart now", in all seven of its languages. That prompt is deliberately dismissable, unlike the "you must update" screen: a blocking nag for an optional restart is how people learn to ignore the one that matters.
- Mobile app: two screens showed a header above a completely empty page, and the cause turned out to affect a whole class of screens. The rewards/leaderboard screen and the Goals screen both did it. Neither was a data problem — the information was there and there was no error. The cause: a styling instruction that every screen in the app writes on its outermost element does nothing at all, because that element comes from a third-party component the styling system does not recognise. 112 files carry that dead instruction. Most survive it, which is why it went unnoticed — a screen only breaks when something inside it needs the outer element to have a height, and then that something collapses to nothing. Both screens now render properly, and 19 in total were fixed. I checked three of the flagged screens on a phone before and after: the broken one now works, and the two that already worked are pixel-for-pixel unchanged. The ~93 remaining files keep the dead instruction on purpose — rewriting them risks breaking layouts that currently look right, for no visible gain — and a new test blocks any new screen from arriving blank. 🔴 It also solves an older puzzle recorded in the code as unexplained: content on the "link not opened" screen that once rendered at zero size had the same cause.
- Mobile app: the certificate the app trusts had already changed, and nothing noticed. The app pins itself to your server's security certificate so a fake server cannot impersonate it. That certificate is replaced automatically every 90 days — and the app was still pinned to the previous one, about five weeks out of date, with every automated check green. The app kept working only because a second, backup pin was in place, doing exactly the job it was added for. But that left one pin standing with no spare, which is the state that stops an app connecting at all if the certificate authority changes something. Fixed by pinning two long-lived certificates instead of the short-lived one — the short-lived pin was adding no protection while guaranteeing it went stale four times a year. There is now a check that compares what the app trusts against what your server actually presents; run against yesterday's settings it reports the exact problem. This also closes the early-October deadline I flagged: it is done, and the next review is mid-2027.
- Mobile app: if it crashes on someone's phone, you will now know — and this needed nothing from you. Crash reporting was switched off in every single build, so the app's 13 different kinds of internal warning all went nowhere: crashes, failed sign-in storage, an unexpected server reply, a security check, and ten different "the server has changed shape" detectors. Two details made it worse. The warning telling you crash reporting was off was itself hidden in exactly the builds that ship. And one of those reports was sending the whole web address of any link the app could not open — which for a password-reset link means the reset code itself would have been sent to an outside company. Every report now goes to your own server as well, where it is recorded as an error and picked up by the nightly problem-report round-up you already have. Creating a proper crash-reporting account is still worth doing (it adds grouping and readable crash traces), and that remains yours to do — but you are no longer blind while it waits.
- Mobile app: you can now require people to update, and it is proven working on a phone. This was the one thing on the whole list that could not be added later: a copy of the app already on someone's phone can only be told "you must update" if that copy already knows how to ask. Both halves now exist. The server refuses any app version below a minimum you control — and that minimum can be raised in an emergency without a new release. The app, on being refused, replaces its whole screen with "Time to update" and a button, in all 7 of its languages. Tested end to end on a phone emulator: the server refused, the screen appeared, the website was completely unaffected, and putting the minimum back restored normal service. It also cannot lock people out by accident — an app that does not say its version, a garbled version, or a missing setting all mean "carry on", and the "what version do I need?" address is deliberately never blocked, so "please update" can never become a dead end.
- Events now work against the experimental backend, where previously the app discarded every single one. Development-only backend. The app checks each event against the structure it expects and throws away anything that does not match — and the experimental backend was returning an older, flatter shape, so the events list was permanently empty and attending an event was impossible. The structure the real backend builds has now been reproduced faithfully. Problems reported by the app's own checker went from 60 to zero, five events appear where none did, and attending an event works. 🔴 It needed three separate fixes, each hidden behind the last. Fixing the list left the individual event page failing — an event could be listed but not opened. Fixing that left the attendee list failing — the attend button appeared but the people behind it did not. Each screen is checked separately, so "the list works" was not "events work".
- A flaw in our own comparison tool had been distorting the events picture the whole time. The real backend can serve two versions of its event data and picks based on what the app asks for. Our comparison tool never asked — so it was handed the old version while the app receives the new one. Every events measurement ever produced compared the experimental backend against a shape the app never sees, including the figure that justified a whole piece of planned work. One line fixed it, and those addresses went from the worst in the set to matching. Nothing about either backend changed.
- Mobile app: a failed load now offers a way out, and the "Create" tab no longer shows a blank white screen. There was no shared failure state anywhere in the app — the toolkit had a loading state and an empty state but nothing for "this did not load", so every screen improvised and only 19 of 43 large screens offered a retry. Your own profile screen had no failure handling at all: a failed load left you looking at a permanent skeleton with no way out but to force-quit. There is now one shared failure panel, translated into all 7 languages the mobile app carries, adopted on the profile screen first. Separately: eight buttons on the orders screen were permanently greyed-out button-shapes that were never meant to be tappable (they are labels), 15 hard-coded colour fallbacks that could never be reached were removed, and the Create tab drew literally nothing while it redirected. The Create tab and the redirect were both confirmed working on a phone emulator.
- Mobile app: the rewards and leaderboard screen could show a blank page with no explanation, and the reason turned out not to be the one that was obvious. Found by opening a leaderboard link on a phone emulator: a title bar above an entirely empty page, unchanged for 45 seconds. Two genuine faults were found and fixed — the screen never checked whether any of its eight pieces of data had failed to load (so a real failure had no way at all to reach you, not even a message), and it could draw itself before the leaderboard had arrived, which is the very thing a leaderboard link opens on. 🔴 But neither fault explains the blank page, and it is still open. Measuring inside the screen showed the data present and no error at all while the page still drew nothing, and the server returns real data for all three addresses it calls. So this is a display fault, recorded rather than quietly bundled into the fix. The most likely cause is a layout problem that has already caught one other screen in this app twice.
- Mobile app: being signed out mid-session now says so — and the fix for it took three attempts, two of which were wrong. Being returned to the login screen with no message is indistinguishable from a crash, which is exactly how members describe it when they report it. The first attempt made the sign-in machinery ask the pop-up system directly, which killed eight test suites outright. The second wrapped that in a "try, and carry on if it fails" guard; the tests went green and it shipped in a commit called verified — but the code-style gate had not been run, and it correctly rejected the change: that guard makes the app take a different internal path depending on whether the pop-up system is present, which is a genuine latent bug, not a style complaint. The third attempt removes the dependency instead of hiding it: the sign-in machinery now announces the message, and a small piece that lives inside the pop-up system listens and shows it. Both wrong versions are now blocked by tests, not just by the style gate.
- Mobile app: found why the overnight on-device test run has never once passed, with proof. It failed on its first-ever run (2026-08-19) before the phone emulator even started, and it produced no evidence of why. Cause: the test machine has no configuration file, so the app had no signing key for the login token, and login crashed at the moment it issued the token — bad passwords returned a clean "wrong password", so probing the login page the obvious way said the API was healthy. Reproduced end to end locally, and confirmed the fix returns a successful login. Two separate faults destroyed the evidence: the failure report asked for the last 40 lines of the server log, but a crash report there is about 70 lines long, so it printed the tail and cut off the one line naming the cause; and the login check did not tell the API it wanted a machine-readable answer, so the recorded response was 600 bytes of web-page HTML instead of the error. Both fixed. 🔴 Unproven until the next push — this workflow only runs overnight or on demand, so it cannot be verified locally.
- Nobody could sign in to the accessible site when it was pointed at the experimental backend — and the way this hid is the lesson. Development-only backend. The accessible site refuses to start a session unless the sign-in reply contains four specific pieces of information; the experimental backend was leaving one of them out, on every route that signs someone in. The real backend sends it everywhere. Fixed. 🔴 Ten pages had been checked and all ten matched. They were all signed-out pages, and the signed-in ones simply bounced to the login screen — which is exactly what a signed-out check expects to see. "Every page matches" and "nobody can sign in" look the same from outside unless you check the sign-in reply itself. The new test checks that reply directly, and checks the values are real positive numbers rather than merely present, because the site rejects a zero just as firmly as a missing field.
- The accessible site's blog and help pages were never the problem. Both returned an error against the experimental backend. The cause was that the accessible site tells the backend which community it is serving by name when nobody is signed in, and the experimental backend only understood community numbers. The real backend accepts both. So every signed-out page was affected; blog and help were just the two that had been looked at. All ten pages checked now behave identically to the real backend, with real content. The fix is deliberately narrow: the community name is accepted only when nobody is signed in. Once someone signs in, their community comes from their login token and nothing else — otherwise a request could be made to read another community's data. Both halves are locked down by tests.
- Posting to the feed now works end to end against the experimental backend, verified by the post appearing on the feed afterwards rather than by the absence of an error. That makes two member actions proven through the app's own forms — creating a listing and posting — with sending credits reaching an open, working form and attending an event blocked by the separate events-structure work.
- The one remaining untested member action turns out to be blocked, not untested. Attending an event could not be exercised because the events list showed nothing to attend. The reason is now established: the app checks each event against its own expected structure, the experimental backend returns an older shape, and so the app discards every event. The list is empty by design, not by accident. This is scheduled work rather than a mystery, and it stays counted as neither working nor broken until that structure is matched.
- A fifth thing that was quietly failing on the live platform: the mobile navigation menu never loads. GET /api/menus/mobile returns a server error every time. This was confirmed against the live-derived copy of the real database, on a real community with real menu entries — not just the test fixture — so it is not an artefact of test data. The neighbouring menu address works fine, which narrows it precisely: the mobile handler assumes every menu it receives carries a list of items, and the fallback menus it can receive do not, so it fails and the error is swallowed without ever being written to the log. Not fixed — surfaced for your decision, since the live backend is treated as read-only in this work.
- We had been measuring the experimental backend against a friendly sample, and now measure it against what the app actually calls. A tool that reads the entire frontend's source and lists every address it calls had never been run here. Run: the app calls 2,016 distinct addresses, where our comparison list held 170. The honest score against the real list is 77 of 195 (39.5%), where the sample said 46%. Nothing broke — 63 of those addresses had simply never been compared before, and only 10 of them match. Administrator-only addresses are now measured separately, because our test signs in as an ordinary member and those would all answer "refused" on both sides, which proves nothing while flattering the score.
- The script that prepares the comparison environment was silently leaving it half-prepared. One line passed a file path in a form Windows could not resolve, so the step that switches on every optional feature failed — and because of where it sat, the script carried on regardless and reported success. Any measurement taken in that state would have shown roughly 27 addresses "failing" when they were merely switched off. Fixed, plus a guard that now aborts rather than continuing with a half-prepared environment.
- The best measuring instrument turned out to be inside the app all along, and was being thrown away. The app already checks, as it runs, whether each response matches what it expects, and reports exactly what is wrong. The test was cutting that report off after 200 characters and discarding it. Captured properly, it reports 60 problems on a single address — and shows the gap is larger than "a few field names differ": the real backend serves a structured, versioned description of an event, with the organiser, schedule, location, permissions and figures each as their own block, while the experimental backend returns a flat older shape the app rejects outright. Nothing was broken by this discovery; it was already true and simply invisible.
- The accessible site has now been run against the experimental backend as well, and a control run stopped me reporting five faults that do not exist. Development-only backend. Four of six pages behave identically to the real backend. Five pages first appeared to be refused outright — but running a second copy of the accessible site against the real backend, everything else identical, showed the same redirects there: they are the ordinary "please sign in" gate, not a refusal. Two pages, the blog and the help section, genuinely fail only against the experimental backend; the obvious explanations were each checked and ruled out, so it is written down as a precise open question with steps to reproduce rather than a guess.
- A way to tell which of the remaining response differences actually matter. There are 862 individual differences across 63 addresses, and treating them alike is why a genuinely damaging one hid among cosmetic ones for weeks. A new tool ranks them by whether the app ever reads that piece of information, so work can start where it has an effect. Roughly a dozen addresses turn out to have no differences the app reads at all.
- My own test wrongly reported that staying signed in was broken, and the correction matters more than the finding. Simulating an expired login by deleting the stored pass does not test renewal — the app simply finds nothing and returns you to the sign-in page without ever trying. Asked directly, renewal works correctly, including properly refusing a second use of the same pass. The test now says plainly that it reports the app's behaviour and not the backend's health. A genuine difference did surface underneath: the two backends use different addresses for renewing a login, which is recorded as an open question.
- A member can now be shown to actually do something against the experimental backend, not just browse it. A listing was created through the app's own form — typed in, submitted, and confirmed afterwards to exist with exactly the text that was typed. Until now every write had been tested by talking to the backend directly, which skips the form, the security token and the validation the app really uses.
- The test of member actions was itself throttled into meaninglessness. Each full pass makes around 190 requests against a development ceiling of 200 a minute, so the run tripped its own limit and reported failures that were nothing but throttling — two of them looked exactly like backend faults. The development ceiling has been raised; the live one is untouched. The actions themselves remain unproven either way, and are recorded as untested rather than as working.
- The real app has now been run against the experimental ASP.NET backend for the first time, and it works — which immediately exposed two faults that comparing responses side by side had dismissed as cosmetic. Development-only backend; nothing a member uses today is affected. The unchanged app signs in and runs: the dashboard, listings, members and wallet pages all load real content, and 190 requests succeeded with none failing. Nothing in the app was changed to achieve this — only which backend it points at, which is the whole point of the exercise. Why it was worth doing. A tool already existed that asks both backends the same question and compares the answers. It had scored the events list as "differing by a few field names", which reads as tidying-up. In a real browser, one of those names turned out to be load-bearing: the app asks for an event's start_date, the experimental backend called it something else, so the app built an invalid date and the entire "Upcoming events" panel on the dashboard collapsed into an error message. The endpoint was returning a perfectly well-formed answer the whole time. Fixing it took the dashboard's visible content from 1,365 to 6,870 characters. The second: the "Needs your attention" panel was left showing a raw internal label instead of a button word like "Review", because the backend never sent which action was needed. 🔴 Both were fixed by ADDING the missing information, never by removing what was already there. Removing risks breaking something that depends on it, and needs separate evidence per endpoint — that exact shortcut had already cost 82 failing tests earlier the same day. Then the app was pointed at the backend directly, without the development proxy — and it could not sign in at all. Browsers refuse a request whose headers the server has not explicitly permitted, and the experimental backend never permitted the security header the app attaches to everything it writes. Reading worked; every write, including logging in, was blocked before it left the browser. Nothing behind the login was reachable. It also never permitted the app's actual development address, only a substitute port that a documentation note had adopted as a workaround for this very gap. Both are fixed, and an unknown address is still refused — checked, not assumed. Both ways of running the app now work: 188 requests, none failing, all four pages rendering. 🔴 A false pass I nearly reported. My first attempt at that direct test was still quietly going through the proxy, so it passed while proving nothing. The setting I thought I had changed never reached the app. What caught it was checking where the requests actually went. The test now works out and states which way it ran, and says plainly when a run proves nothing about this — so the same mistake cannot be repeated silently. 🔴 Honest about coverage. This proves the app starts and browses against the experimental backend, both through the proxy and directly. It does not prove the accessible site does (never tried), covers five pages rather than all of them, exercises no member actions like posting or transferring credits, and is too short to test what happens when a login expires. No readiness score has been claimed for it.
- Accessible frontend: two more forms completed, quieter feed cards, and small polish. Sellers marking an order as shipped can now include a tracking link and delivery method (the system always accepted them; the form never asked). Organisations approving or declining a volunteer can now attach a note the applicant will see. Both come with hand-written translations in all 11 languages. Quiet feed posts no longer stack "No likes · No comments" and "No reactions" above their buttons — counts appear once there is something to count. The matches pages format their average score correctly for right-to-left and Japanese readers, the header unread counter no longer looks like a phone-app bubble, and star ratings no longer flash a focus ring when clicked with a mouse. All verified against the disposable test environment, including creating a real poll and event through the forms.
- Six things members and employers could try to do have been failing with a server error every single time, and now work. This is the live platform, not the experimental backend. Saving a search, running a saved search, posting an employer review, creating a job-offer template, previewing one, and the AI chat on a job vacancy all failed outright — every attempt, for every person, for months. The cause was one wrong name in six places. A shared helper for reading what the browser sent was renamed during the move to Laravel, and six places were left calling the old name. Calling something that does not exist stops the request dead. All six now use the correct helper, and each was checked against the running system before and after: saving a search now succeeds, and the ones that should refuse incomplete input now say clearly what is missing instead of collapsing. Submitting an idea with no title also failed with a server error, for a related reason — nothing checked the title before the code that needed it. It now returns a plain "title is required". 🔴 Why no safety net caught this, which matters more than the fix. Two nets had a hole in the same place. The code analyser cannot see this class of mistake, because Laravel's base controller has a catch-all that might handle an unknown method at runtime, so the analyser stays quiet. And the only test covering the save-search endpoint checked that it refuses people who are not signed in — it never signed in, so it never reached the broken line, and it passed cleanly the whole time. Proving the door is locked proves nothing about the room behind it. Both holes are now closed. The analyser setting that reports this class of mistake is switched on — measured first as costing zero new findings, and verified against the exact settings the build uses. A new check fails if anything calls a helper that does not exist, and two new tests sign in and confirm a search is genuinely saved. Every new check was deliberately proved to fail when the original fault is put back, because a check that has never failed is not a check. Someone had already hit this once and fixed only their own line, leaving a note about it in another file. The remaining five were never swept for.
- Accessible frontend: the last four audit findings, now translated into all 11 languages. Opening "manage your support" without an active supporter subscription no longer lands on an unexplained page — it now says there is nothing to manage yet. The insurance form gained the amount-of-cover, start-date and notes fields the system always expected but never offered (they were silently sent empty). A plain feed post no longer shows "Post" twice ("Post" tag over a "Post" heading) — the heading now says who wrote it, in the member's own language, where before the fallback was English-only for everyone. And the same time-credit balance no longer reads "100.00 hours" in the wallet but "100.0" on the dashboard — every page now formats hours the same way, keeping quarter-hour precision.

### Changed

- Accessible frontend: one consistent look for the controls members meet on every page. "Load more" was built four different ways with four different gaps — it is now the same next-page control everywhere (19 places). Lists of cards were built four ways, some missing the dividing line above the first card — one treatment now. Section headings used three different spacing rhythms, sometimes on the same page — one now. Empty states ("nothing here yet") used four constructions — all now use the same bordered panel with a heading. Also: timeline pages show a marker dot per entry, wide tables show a soft edge shadow while there is more to scroll, statistics use aligned digits, thumbnails no longer make rows jump as photos load, a right-to-left layout misalignment was corrected, and printed pages gained proper margins, repeating table headers and readable status tags. Nothing needed new translations.

### Fixed

- Accessible frontend: dates in the future no longer claim to be "just now". The shared relative-date formatter only understood the past, so an upcoming event on the dashboard, a listing's expiry date and an open poll's closing date all displayed as "just now". Future dates now show the actual date ("23 Aug 2026"). Proven in a real browser against the disposable test environment, plus a new regression test.
- Accessible frontend: nine member-facing bugs from audit #6. The "Sign out" link on the terms-acceptance page led to a missing page, trapping anyone who declined the terms — it now signs out properly. Six successful event-registration actions (submitting answers, accepting an invitation, adding or cancelling a guest, updating attendance) showed their success message inside a red "There is a problem" box. A refused volunteering-application withdrawal showed no message at all. Two settings pages had "Back" links to a missing page. The onboarding "Skip" button on the safeguarding step saved the very answers being skipped. The member directory printed labels twice ("Hours given — 0 hours given"). The wallet transfer confirmation checkbox is now enforced on the server, not just in the browser.
- Accessible frontend: stylesheet rules that browsers were silently throwing away. Three style rules called helper functions that no longer exist in the design-system library; the highlight colours on the legal version-comparison page and a hover state shipped dead. A build check now fails if this ever happens again. Also fixed: the selected filter tab on Exchanges, Matches, Messages and Connections was visually identical to the unselected ones (now bold with a blue underline), star ratings failed the contrast standard, and a focus outline broke the yellow/black focus convention.
- Accessible frontend: one page-header size across the whole service. 107 pages used a smaller caption over the page title than the other 167, so the header visibly changed size when moving between sections; five settings pages also dropped their title size mid-journey. Every page now uses the same scale. The Messages and Exchanges pages repeated their own title as the caption instead of showing the community name. One events table rendered with no styling at all, a marketplace list showed browser-default bullets, and the marketplace item page showed two competing primary buttons.

### Added

- Three more places on the experimental ASP.NET backend were saving made-up information instead of refusing. Development-only backend, no real community affected. Creating a group task with no title saved it as "Untitled task"; submitting an idea with no title saved "Untitled"; creating a wallet category with no name saved "General". All three now refuse, matching what the real backend does — and the two that the real backend has an opinion about were checked against it word for word, including its exact wording and status code, which differed from what this backend was sending in three separate ways at once. 🔴 One of them had a check that could never fire. The group-task code says "if the title is blank, refuse" — but the line immediately above it filled the blank in with "Untitled task", so the condition was unreachable. It read like validated input and wasn't. That is worth knowing as a shape to look for elsewhere: a placeholder applied above a guard silently disables the guard. 🔴 Two web addresses exist on this backend that the real one does not have, and two of the three faults lived on them. The app doesn't use either, so nothing depends on them; whether they should exist at all is recorded as a separate question rather than settled quietly here.
- Creating a listing on the experimental ASP.NET backend now checks what was sent, and stops inventing the parts that were missing. Development-only backend, no real community affected. It accepted anything: a listing posted with no title at all was saved as "Untitled listing", because the code substituted that text rather than refusing. The real backend refuses the same request, and refuses on rules each community sets for itself — a shortest allowed title and description, whether a category is required, and whether offers or requests are permitted at all — reporting every problem at once rather than the first one. The ASP.NET version now reads those same per-community settings, which it was already publishing to the app but never applying, and answers identically. Both refusal cases the comparison tool tests now match where they previously disagreed.
- The comparison tool can now test a listing being created successfully, not only being refused. A valid listing needs a real category, and the two backends number their categories differently, so a single fixed request could only ever exercise the refusal. The tool now looks up a valid category from each backend just before it runs. That immediately exposed a difference that had been invisible: both backends accept the listing, but the real one replies with 76 pieces of information about it and the ASP.NET one replies with 11.
- Accessible pages now use the correct singular wording and consistent section labels. Fourteen count labels across events, exchanges, listings, discussions, polls, search, volunteering, notifications, marketplace, jobs and organisations now say forms such as “1 reply” instead of “1 replies” in all 11 languages, with genuine Irish and appropriate zero forms. Thirty-two pages that belong to a wider area now show a translated section caption above the main heading; standalone confirmation, error, sign-in, verification and unsubscribe documents remain deliberately uncaptioned.
- The member directory now explains why it lists fewer people than have joined, instead of just looking empty. On a community of 369 members the directory could list a dozen and say nothing about it, which reads as a broken page or a dead community. It is neither: the directory hides people who have opted out of being found, plus whichever profile-completeness rules the community has switched on. GET /v2/users now returns community_total (every active member), directory_total (the subset it may list) and directory_criteria (the visibility rules actually in force for that tenant) alongside the existing pagination meta, computed in one aggregate query that mirrors the listing query's own visibility conditions — both counts exclude the viewer, so they are directly comparable. Both frontends render the explanation: React shows a secondary Alert above the results, web-uk a govuk-inset-text panel, each naming only the rules that community applies and linking the signed-in member to their own privacy settings. The note is deliberately suppressed when nothing is held back, while searching, and in Near me mode, so a community that hides nobody never apologises for an absence that does not exist. React also gained a Showing X of Y members count for the unsearched, partially-loaded case, which previously reported only the number on screen. 🔴 The copy deliberately does not mention recent activity. The directory has never filtered on last login — the shortfall is opt-outs and profile rules only — and both the backend docblock and the tests record that so the wording cannot drift into implying otherwise. New members.coverage_* strings across all 11 locales in both catalogues (lang/*/govuk_alpha.php and react-frontend/public/locales/*/common.json), with hand-written Irish. Regression coverage: UsersControllerTest (meta contract, criteria reflect tenant config), MembersPage.test.tsx (note shown only when a gap exists, hidden while searching and in Near me), and a new web-uk/tests/members-directory-coverage.test.js exercising the wording builder directly.
- Recorded a 62-value improvement in the PHP translation ceiling. .github/php-lang-untranslated-baseline.json drops from 258 to 196 untranslated values, retiring the Irish govuk_alpha_courses (48), govuk_alpha_federation (13) and govuk_alpha_commerce (1) entries that earlier translation work had already cleared but never re-baselined.
- Fixed: text in the mobile app no longer slides up under the clock. On any screen you could scroll, content passed behind the status bar with nothing between them, so what a member had typed collided with the time and battery icons and both became hard to read — worst on the sign-up form, where the surname field and the clock overlapped. Every scrollable screen was affected. The cause is that the app draws right to the edges of the screen — the default for our Expo version and a requirement of Android 15 — so the status bar is see-through and the app shows through it. There is now a small strip of the app's own background colour behind it. It cannot be tapped, so it never swallows a button press, and it shrinks to nothing on a phone that has no status-bar gap. Checked that it does not hide anything, which was the obvious risk in painting over the top of the app. One screen (Create) sets no top spacing of its own, so I looked there specifically — the strip covers only empty background. And the More menu after the fix is identical to a picture taken before it, with the card's top edge and heading fully visible.
- Fixed: the feed's filter row no longer cuts off mid-word. The row of filters across the top of the feed (All, Following, Saved, Posts, Exchanges…) was sliced through a word — "Exchan" — with empty space after it and nothing to suggest it slides sideways. It read as broken text. The row now runs to the edge of the card, so the last filter is cut at the card's own rounded boundary, which is the normal way an app says "there is more this way". A soft fade would be nicer still, and our design library has a component for exactly that, but it needs an extra piece of native software installed and the app rebuilt — not worth it for a fade. Noted in case that gets added for another reason.
- Checked the mobile app at large text size for the first time, and it holds up well. Large text is usually where labels get chopped off, so this was the most likely place to find more problems. Mostly it was not: the sign-up form stays readable with the terms line wrapping onto two lines, the More menu shortens long descriptions with "…" instead of clipping them, the Create screen wraps all six descriptions cleanly, and the bottom tab labels still fit. One thing seen but not confirmed as a fault: at large text the More menu's small heading looked sliced at the top. It is not caused by the new strip — I checked that directly — and may just be a picture taken mid-layout. Written down as something to reproduce properly rather than claimed as a bug. Not covered: the two largest text settings Android offers, where wrapping usually gives out.
- 🔴 The mobile app can now take on each community's own colours — proven working on a phone. This is the groundwork for the change you asked for: one colour throughout, and that colour being the community's own rather than the design library's purple. Tested with the Agoris community, whose brand colour is teal. On the sign-in screen the logo tile and the Sign in button are now both teal. Earlier the same screen showed a blue logo beside a purple button. Measured on the device to be certain: the button is exactly the teal generated for that community, not an approximation. Why it needed building rather than configuring. Thirteen parts of the design library decide their own colour from a single value, and the library offers no way to set it. That value is fixed when the app is built, so each community's colours have to be prepared in advance. There is now a small file listing each community's colour, a script that turns it into what the app needs, and a switch that picks the right one once the app knows which community you are in. Adding a community is a one-line change plus an over-the-air update — no app-store release. And until that update lands, a brand-new community simply gets the platform's default colours, which looks deliberate rather than broken. That fallback is not a nicety: without it the app would crash on launch for a community it did not recognise, and there is a test holding it in place that I confirmed goes red when removed. One detail worth knowing. The label on a coloured button is worked out rather than assumed. A community might pick a pale colour that white text cannot be read against, so the script measures the contrast and picks white or near-black accordingly. The website hardcodes white and its own notes admit that is only checked for the default colour. It already matters: in dark mode the teal button uses dark text, because white would have failed — and so does the default blue one. A yellow, mint or pale-blue brand colour would get dark text too, with tests proving it rather than leaving the branch unexercised. 🔴 This took three attempts to make safely, and the reason is worth recording. Lightening a colour for dark mode makes it a different colour from the one the app paints things with by hand — so each attempt broke something: 79 buttons that painted their own background, 91 more that did it conditionally, and 72 icons that hardcoded white beside a label that had become dark. Every one of those is now fixed, and each fix was worth making on its own: the 72 icons were already wrong for any community with a pale brand colour, invisible on the main button of every screen, and nobody had noticed because both current colours happen to be dark. What is still to do: the colour list currently holds two communities, because the real colours for the other nine live in the production database and I have not read them. And roughly 128 places still set an icon's colour by hand — those become unnecessary once the single colour is in place, and removing them is the next step. So this is the mechanism proven, not the job finished.
- A flaw in my own checking, worth recording. I had been verifying type-safety with a command that printed "typecheck OK" whether or not it passed — the way it was written, the success message ran unconditionally. Real errors did still appear in the output, and I caught and fixed each one I saw, but the reassuring line at the end was meaningless. That is the fourth instance this week of the same shape: something reporting success while proving nothing. The others were in the app and its tests; this one was in how I was checking my own work. Verification now gates on the actual result, and every check was re-run properly.
- 🔴 Found by accident, and more important than the fix that found it: the screenshot check could not see most changes to the app. After fixing the rounding, I compared the app against its saved reference pictures to confirm the change. The check reported "0 pixels different — everything matches". I had just measured the corners changing from 26 to 18 points, so both things could not be true. Counting the differing pixels by hand gave 9,168 — the check was reporting 40. The cause is a tolerance setting: the check ignored any colour difference smaller than a certain amount, and this app is largely light grey panels on white cards, which are only a shade apart. So an entire class of change was invisible to it: a panel moving, a corner rounding differently, one surface swapped for a similar one. It would have said "perfect match" to all of them. This matters beyond one setting. The mobile readiness report described those screens as "checked to the pixel", and that was overstating the protection considerably — I had been relying on a check that was mostly asleep. Now tightened, and the number was chosen by measurement rather than taste: at the new setting an untouched screen still reports exactly zero, while both genuinely changed screens report a clear difference. One step tighter and an untouched screen reports 1.7% difference from ordinary rendering noise, which would have everyone ignoring it within a week. It immediately earned its keep. With the tolerance corrected, the check caught a second screen I did not know had changed (the More menu), and its difference pictures marked exactly the twelve corners involved and nothing else. Both screens' references have been updated after I looked at those pictures. There is now a test that fails if the tolerance ever drifts back, and I confirmed it goes red with the old settings restored. The pattern across this week is worth naming: three separate faults where something reported success while doing nothing — a styling name that emitted no styling, a container that crushed an icon to a few pixels, and now a check that compared images and saw nothing. In each case the green result was the problem. I have started treating "this passed" as a claim needing evidence rather than a result.
- Fixed: a rounding value the app asked for 625 times had never been created — and a correction to what I told you about it. The app asks for rounded panel corners 625 times across 99 files, using a name that was defined nowhere: not in the app's styles, not in the design library, and not at any point in this project's history. It was written against a convention nobody ever built, and the styling tool silently ignores a name it does not recognise. 🔴 I first reported this as "632 square corners". That was wrong. The design library's own cards and buttons already round their corners, so an ignored instruction still leaves a rounded corner. Counted properly, by looking at what each one sits on: 382 asked for the same rounding the element already had — no visible fault at all 171 asked for the smaller inner rounding and got the larger one — slightly too round 69 sat on plain elements with no rounding of their own — genuinely square So it is 69 square corners and 171 slightly wrong ones, not 625. I also said it was "probably the most visible single improvement", and that was an overstatement — it sits well below the colour work. The fix is the same either way: one line defining both values, chosen so the 382 that were accidentally right stay exactly as they are. A third name, used once, turned out to be undefined too. Removed rather than invented, since the button already had the rounding it was reaching for.
- Fixed: colours that had already failed a readability check were still being used in six places. Five colours were replaced back in August for being too faint to read. The old values were left behind — three of them filling the coloured tile on the forgot-password, reset-password and verify-email screens, which is exactly where somebody already locked out of their account has to read something. Those now use the corrected colours, measuring comfortably above the standard. The other three were dead code: a fallback colour written in case the theme had no value, when the theme always has one. Worth noting for later — every such fallback in this app is dead, and there are about a dozen more. They are harmless but misleading, so they are folded into the colour cleanup rather than done piecemeal. Two decorative uses are deliberately left alone: they are per-item icon tints in a menu, part of a larger palette that needs a proper decision rather than a mechanical swap.
- A new check that catches the whole class of "styling name that does nothing". It reads the app's own styling instructions and fails if any rounding name is not actually defined, naming it and where it appears. Two flaws in my first version of that check, both worth recording, because both are the same trap as the bug it guards against — a check that looks like it passes while examining the wrong thing. It read whole files, so it matched a name written inside a code comment explaining why that name had been removed, and reported a problem that no longer existed. And one valid style stripped down to an empty name and was reported as a phantom. Both fixed; it now reads only the actual styling attributes.
- 🔴 Fixed: password-reset emails did not work on the Android app. If a member had the app installed and tapped the reset link in their email, Android opened the app, the app could not understand the address, and it quietly threw away the reset code and dropped them on the sign-in screen. There was no error and nothing to tell them what had happened. Verified fixed on a real device: the link now opens straight onto "Set a new password". This was one symptom of a much bigger fault. The app tells Android it can handle every address on app.project-nexus.ie, but it only actually knew 27 kinds. Of 254 member pages, 169 opened to a blank framework error screen — and 65 of those had a perfectly good screen in the app that no link could reach. Among them: every marketplace page, jobs, blog posts, the whole federation section, and identity checks. All 125 pages that have a mobile screen now open properly. Confirmed on a device for password reset, marketplace collections, a job, and the wallet.
- Fixed: a link the app genuinely cannot open now explains itself instead of showing a developer error page. Some pages only exist on the website — staff tools, for instance. Those used to land on the app framework's own "Unmatched Route" screen, which is a diagnostic for programmers, and a signed-in member was simply left stuck on it. There is now a proper screen that says the link cannot be opened here and offers two ways out: open the real page in the browser, or go back to the app. Never a dead end. Staff consoles and the two mid-sign-in redirects (identity and social login) are now deliberately declined by the app so they continue to the browser, because swallowing them left the member holding a code the app could not finish using.
- Fixed: fifteen buttons were showing raw code instead of words. Five Cancel buttons in confirmation dialogs and five Retry buttons on error screens displayed text like common:cancel — the internal name of the phrase rather than the phrase itself. Those are the two worst possible places for it, since a member is already stuck when they see them. Four places showed common:unknown where a name was missing, and one close button had no label for screen readers. The translation files were 100% complete in all seven languages — the callers were simply asking for the wrong names, so every existing check passed. There is now a check that reads the app's own code and fails if any phrase it asks for does not exist, which is a class of bug this platform could not previously see.
- Two new checks, both proven to fail when the fault is put back. One reads the list of every member page and fails if a page has a working mobile screen that no link can reach — the check that would have caught the password-reset fault years earlier. The other verifies every phrase the app asks for actually exists. Both name the exact file, line and fix in their failure message. One honest note: while building the "cannot open this link" screen, its own explanatory text did not appear on the phone for two attempts, even though it appeared correctly in tests. Wrapping the content in a vertically-centred container turned out to be the problem. I could not narrow it down to a single cause with certainty, so I have written down exactly what I know and what I don't in the code, rather than a tidy explanation I can't back up.
- Second look at the mobile app: 31 screens in both light and dark, up from 16 in light only. The first sweep stopped early twice, both times because of how it was written rather than anything wrong with the app. It pressed "back" to leave screens, which on Android walks out of the app when there is nothing to dismiss — that lost 22 screens and photographed the phone's home screen as though it were the sign-up page. It also reached each screen by tapping through the previous one, which worked until the Groups screen, which opens over the tab bar and took ten screens down with it. Each screen is now visited independently, so nothing can cascade.
- The two-colour problem is worse than first reported: it happens inside single buttons, 128 times. The clearest example is on the Wallet screen, where the Back button's arrow is blue and the word "Back" is purple — one button, two brand colours. The cause is one pattern repeated across the app: the icon is explicitly given the community's brand colour while the text beside it is left to the design library, which uses purple. Counted properly: 128 controls across 48 files. "Donate" on the wallet has a blue heart and a purple label; "Send credits" next to it is a fully blue button. Some buttons are forced to the community colour by hand — Polls' "Create poll" and the "+" on Listings are correctly blue. That the workaround exists in some places and not others is what makes the app look careless rather than simply purple. 🔴 Still not fixed, and deliberately so. Repairing a two-tone button means putting its icon and its label on the same colour — but which colour is exactly the decision that is outstanding. Doing it now means either touching 128 places twice or guessing on your behalf.
- Found a second systemic problem: text scrolls up underneath the clock. On any screen you can scroll, content passes behind the status bar with nothing behind it, so what a member has typed collides with the time and battery icons and both become hard to read. Clearest on the sign-up form, where the surname field and the clock overlap. This is not one screen's mistake. The app draws edge-to-edge — the default for our Expo version, and required by Android 15 — and nothing paints a background behind the status bar. The usual fix is a thin shaded strip the height of that bar. Left alone for now because it belongs in the app's root layout and changes every screen, which deserves a deliberate decision rather than being slipped in during a bug hunt.
- Two alarming-looking things checked and cleared. A pale stripe down the right edge of five dark screenshots turned out to be the Android scrollbar — sampling that column across all 62 images showed it on the same five screens in both light and dark, darker than light content and lighter than dark. And a completely blank screenshot of the More menu was the sweep's own stray "back" press, not a rendering fault; the screen was confirmed fine by a separate check. Recorded because both would have been reported as serious faults, and neither is real.
- For balance: most of the mobile app is fine. Of 31 screens photographed, most had nothing wrong. Polls is a good example — clear heading, correctly coloured button, and a proper "No polls yet" message instead of a blank space. The sign-up form's password hint correctly says twelve characters, which is the real rule. The two systemic findings above account for most of what makes the app feel unfinished, and both are single decisions rather than long repair lists. Honest coverage: about 106 of roughly 137 screens still have no picture. Nothing was tested at a large font size, on a small screen, on a tablet, on a real phone, or on a broken network — all places where more will turn up.
- Every listing card in the mobile app had a blank grey circle on it. It was meant to be an arrow. Nobody had ever looked at the app's screens, so it shipped that way. The cause is worth knowing because it will happen again: the shared card component from our design library adds 16 points of padding on all sides by default, and setting a size on it does not remove that padding. The circle was 36 points across, so 32 of those went to padding and the arrow was drawn into the 4 points left over. It did not error or disappear — it drew a few pixels wide, which on a phone looks like an empty button. Measured rather than guessed: on the same card the heart icon drew at 45×47 pixels and the arrow at 10×12. After the fix the arrow measures 48×50 and is visible. There is now a check that scans for the same mistake anywhere else in the app and names the file, line and fix — and it was confirmed to go red when the fix is removed. 🔴 No unit test could have caught this. The icon is present, the structure is correct, and the test runner has no concept of layout, so it cannot tell that a box is 4 points wide. It was found by looking at a picture.
- Found the reason the mobile app "looks off": it shows two different brand colours at the same time. On the sign-in screen the logo is blue and the Sign in button is purple. On the More screen one card manages four brand colours at once. On Listings, "Recommended" is a blue pill sitting next to purple text. The app has two sources of brand colour and no connection between them: our design library's buttons and tabs use a fixed purple, while everything the app colours itself uses the community's own brand colour. The website had this exact problem and fixed it — it pushes the community's colour into the design library at start-up. Mobile cannot currently do the same: I checked both libraries and neither offers a way to change that colour while the app is running. Not changed, because it needs your decision. Every option repaints the whole app, and one of them means dropping per-community branding on mobile — which matters, since a community whose colours are green or orange currently gets purple buttons regardless. The options are written up with what each costs.
- A visual sweep of the mobile app now exists, and ran for the first time. Before this, 6 of roughly 137 screens had ever been photographed. There is now one command that walks the app and collects screens to look through, kept deliberately separate from the three screens under automatic pixel comparison so live data cannot break that check. Honest about coverage: it reached 16 screens of 26 attempted. After the Groups screen the tab bar could no longer be tapped, so seven screens plus the wallet were never reached. Dark mode has not been swept at all, and about 121 screens still have no picture of any kind. This is a start, not an audit. One blank screenshot that looked like a serious fault turned out to be the sweep's own doing — a stray "back" gesture had walked the app out to the phone's home screen. Verified separately that the screen renders correctly. Recorded because that is the second time a stray back press in this codebase has produced a convincing false alarm.
- Event check-in without a signal is now properly tested — including the case that could credit a member twice. The mobile app lets a steward check people into an event with no phone signal, holding the check-ins on the phone until it can send them. Attendance turns into time credits, so this code decides who gets paid for showing up, and it can fail in two directions: a dropped check-in is unpaid work, and a duplicated one is credit nobody earned — worse, because it has to be taken back off a member who did nothing wrong. It was the least-tested important thing in the app at 32.67%; it is now at 94.97%, with 41 tests, and the floor was raised so it cannot slide back. The branch worth knowing about: when the phone tries to send its queued check-ins and the connection dies, the send may well have arrived and only the reply been lost. The app deals with this by remembering the batch it was sending and reusing the same reference when it retries, so the server recognises the work it has already done. Nothing was testing that. Had it broken, every person scanned at that event would have been credited twice, and the app would have looked like it was working perfectly. Also now pinned: a pass from last week's session of a recurring event is refused; an expired pass says "expired" rather than "invalid", which is the difference between a steward knowing what to do and hunting a fault that is not there; the same pass scanned twice is refused; a revoked steward device cannot pick up a fresh guest list and carry on; and members' names never appear unencrypted in the file on the phone. The signatures in these tests are real, not stand-ins. Faking that check would have made the forgery test pass while proving nothing — which is exactly the failure the test exists to catch. All 41 tests were then verified to be capable of failing, by deliberately breaking the code four ways and confirming each break turned the right test red. 🔴 Honest limit: this is proven on a workstation, not on a phone. Nothing has been run with aeroplane mode on a real or emulated device. It shows the decisions are correct; it does not show the phone behaves when the radio actually drops.
- Two things in the mobile readiness report were wrong and have been corrected. Both were my own measurements, and both made the app look worse than it is. Accessibility was marked "Weak" on a figure that measured the wrong thing — it counted files that mention any accessibility setting, which marks a plain layout file as a failure for having nothing to press. Measured properly: of 44 things a member can actually tap, 2 have no name for a screen reader, and both are harmless — one already reads out its visible text, and the other is in a file nothing uses. What genuinely is unchecked is button size and whether a screen reader makes sense of the app on a real device, and the report now says so instead. Three components are unused entirely — the app's own Badge, Card and Divider wrappers have no users anywhere; screens use the ones from the component library directly. They still carry tests, so they contribute passing tests for code no member can reach. Recorded rather than deleted: removing them is a judgement call about whether they are groundwork for something, and that is not mine to make.
- Adding the four missing mobile languages turns out to be two jobs, and Arabic is blocked. Dutch, Polish and Japanese are ordinary work — about 17,200 phrases, and no payment or account is needed for the translation. Arabic is not, and doing it now would make the app worse rather than better: the mobile app has no right-to-left support at all. Arabic text in a left-to-right layout means back arrows pointing the wrong way and labels on the wrong side of the screen — which reads to an Arabic speaker as broken software, rather than as software that simply does not speak their language yet. Right-to-left support is a layout project of its own across all 189 screens, not something to slip in behind a translation. Recommendation: add Dutch, Polish and Japanese, and hold Arabic until that work is scoped. Nothing has been created for either — this needs your decision first.
- The riskiest decision in the mobile app is now tested, and one real security gap in it was closed. Every time the app starts it decides where to send you: to sign-in, to your feed, or to whatever a notification was about. Get it wrong and members are either locked out or dumped on the home screen every time they follow a link. That decision lived inside the app's 759-line start-up file wrapped in six layers, and could not be tested at all. It is now a small standalone piece with 49 tests covering every branch — including the one that makes a notification tap open the thing it was about rather than losing the race to the home screen. The security gap: the same file strips your login token out of crash reports before they leave the phone. It only removed the exact spellings Authorization and authorization — but that name is case-insensitive, and equipment in between can rewrite it as AUTHORIZATION, which was being sent to the crash-reporting service verbatim. It now matches regardless of case, and also strips cookies and proxy credentials. A failure here looked exactly like success, because crash reporting keeps working either way. Behaviour is unchanged; all nine end-to-end tests were re-run against the rebuilt app to confirm the app still starts.
- One flaky test recorded honestly rather than papered over. The registration test fails roughly one run in four. The cause is now understood: it dismisses the on-screen keyboard between fields, and on Android that same gesture navigates backwards when no keyboard is open — enough of them and the app quietly exits to the home screen, after which the test hunts for a form field in an app that is no longer running. Three fixes were tried and all were worse, so the test is unchanged and the diagnosis is written into it instead. Using the dedicated "hide keyboard" command is no safer — it is the same gesture underneath. Removing the dismissals made it fail every time, because the lower fields really are covered by the keyboard. And a guard checking the form was still on screen fired constantly, because the form scrolls. A real fix needs something the test tool does not currently expose. Until then: if that one test fails, re-run it before concluding the app is broken.
- The members directory now says how many of the community it is showing, and why it is not showing everyone. A community with hundreds of members could list only a handful and give no hint why, which reads as though the directory is broken or the community is empty. It now says plainly how many people have joined against how many are listed, and — only when people really are being held back — explains what puts a member in the directory: being happy to be found, and whatever profile requirements that particular community has switched on. It stays quiet when nobody is being held back, so no community apologises for an absence that does not exist. Members also get a link to check whether they are listed themselves. One correction worth recording: the shortfall was assumed to be members who had not signed in for a while. It is not. The directory has never filtered on last login, and a member who has not signed in for years is still listed — there is now a test holding that true. What actually removes someone is having opted out of being found in their privacy settings, having a suspended or pending account, or failing a profile-completeness rule the community has turned on. The wording says that, rather than the assumption. The same explanation appears on the accessible frontend, in all eleven languages.
- The mobile device tests can now run automatically each night, though that has not yet happened once. Everything built for the mobile app this week has been operator-run — someone has to start it by hand. That is exactly how the nine end-to-end tests came to sit unused for months, so leaving it that way would have let the work decay. There is now a nightly job that stands up a database, the server, an Android emulator, builds the app and runs all nine tests against real data. Being clear about its status: it has never actually run. Every individual step was tried by hand on this machine first, but the job description itself is untested, and the first run should be treated as a test of the job rather than of the app. Checking it against reality already caught two real mistakes — the standard command for serving the site serves the wrong folder in this project, and the health-check address returns "not found" under the simple server the job uses. Both are fixed. That two errors surfaced from desk-checking alone is a fair sign the first real run will need another pass. It deliberately does not fail on the screenshot comparison. The reference images were taken on this machine, and pixel-exact comparison does not carry across to a different machine — text is drawn slightly differently. It photographs the screens and saves them for you to look at instead. Turning it into a real check needs reference images captured from a successful run of the job itself. It also runs nightly rather than on every change, because it takes around 35 minutes; a check that slow on every commit gets ignored within a week.
- Three key screens of the mobile app are now protected against looking wrong, and three more are photographed but honestly excluded. The sign-in, profile and wallet screens are photographed on every check and compared pixel-for-pixel against an approved copy — they reproduce exactly, run after run, in both light and dark mode. The wallet matters most: it shows balances and time credits, and it is where the same faint-text problem was first spotted on the website. Being straight about the other three. The feed, listings and messages screens are photographed too, but they are deliberately not compared, because they genuinely cannot be. The feed is ordered by a recommendation algorithm, so two identical checks return a different order — an 18% difference with nothing actually wrong. Listings and messages animate their contents in one after another, so a photograph taken a moment early catches them mid-move; two separate attempts to wait it out did not fix it. All three also show relative times like "6d ago" that change daily. They are still captured, because looking at them by eye catches a broken layout, and the check says out loud on every run that they were skipped. Making them comparable needs test data with fixed dates and a predictable order — real work, not done, and not pretended. A check that fires at random is worse than no check, because people learn to ignore it. That is why those three were excluded rather than hidden behind a loose tolerance. Two transient things are cropped out before comparing, for the same reason: the clock in the status bar, and the scrollbar that fades in and out down the right edge — that scrollbar alone caused a 1% false difference on the dark sign-in screen. Everything between those two edges is compared strictly. The check was also confirmed to still catch real changes: comparing a dark-mode photograph against a light-mode reference reports 94-97% different and fails, as it should.
- The nine end-to-end tests now all pass, driving the real app on a phone emulator against your local server. They had never been run. Getting there meant fixing four separate things, each of which failed silently — which is almost certainly why nobody ran them. What was wrong. The test suite's own settings file contained nothing but comments, and the test tool refuses to start on that — so running the whole suite had never worked, though running one test at a time skipped the file and seemed fine. The app was forbidden from talking to your local server at all: a security rule blocks unencrypted connections for every build, and it silently overrides the development exemption that was supposed to allow them. That same rule was also why the app showed a blank screen earlier — it could not fetch its own code. And a release build deliberately refuses a local server address and uses the live one instead (a sensible guard added in June after a stale setting shipped to production), so the tests were logging into the live site where the test accounts do not exist. What changed. Development builds may now make unencrypted connections to local addresses only — not a blanket exemption, so a test build still cannot quietly send plain traffic to any server on the internet. Production is untouched: it keeps its certificate pinning and still refuses unencrypted connections entirely. Because that split is one careless edit away from disabling production's protection, a new automatic check asserts both halves and blocks the build if either slips; I confirmed it fails when I deliberately weakened production. There is also a new one-command runner that checks all four prerequisites before running anything and names whichever one is wrong, rather than reporting a missing button nine times. If a prerequisite is unmet it says plainly that nothing was tested — which is not the same as passing. A trap worth knowing: the screenshot tooling turns animations off to make images comparable. That makes Android report "reduced motion", which makes a warning banner appear across the bottom of the screen — directly over the tab bar. Five tests then failed because taps meant for the "More" tab hit the banner instead. The tooling now puts animations back, and the test runner restores them defensively. Result: 9 of 9 passing, covering sign-in, sign-out, browsing listings and groups, viewing events, messages, profile, search and registration.
- The mobile app can now be built and run on a phone emulator on your machine, and a check now catches it looking wrong. You were right that Android Studio was installed — I had only checked for the toolkit it downloads separately, found that missing, and reported the two as the same thing. Studio was there; its toolkit had never been downloaded, and confusingly the settings pointing at it were already in place, aimed at a folder that did not exist. Everything needed is now installed and, importantly, proven by using it rather than assumed: the phone emulator starts, the app compiles (first build 6 minutes), installs, launches, and shows its real sign-in screen. Also installed: the app-driving test tool the nine existing end-to-end tests need. The visual check works. Two separate photographs of the same screen come out pixel for pixel identical, which is what makes comparison trustworthy — and a deliberately altered screen is caught at 97% different. Both directions were tested, because a check that cannot fail is decoration. Sign-in screens in both light and dark mode are saved as the reference to compare future changes against, and both were looked at by eye before being accepted. Being straight about how much this covers: one screen. The sign-in screen needs no password, which makes it a dependable starting point. Every screen behind sign-in needs the app driven there automatically and some test data set up first. So this is a working mechanism with very little coverage yet — the honest description is "the check exists and fires", not "the app is visually tested". Six setup traps were found and written down, because each would cost the next person an afternoon. The most valuable: the toolkit installer reports success while installing nothing if its licences are unaccepted, so its result cannot be trusted. Android Studio's own bundled Java is too new for the app's build system and fails with an unhelpable message. The app-driving tool's own documentation says it needs a Linux compatibility layer on Windows — it does not. And a local release build fails on a crash-reporting upload step that has no account configured, which is why the cloud build settings all switch it off. One local misconfiguration fixed along the way: the app was pointed at the wrong port for your local server (8088; it is 8090), so it could never have reached it from the emulator.
- There is now a tool for putting back the groups the old tidy-up job hid — and it has deliberately not been run. It works from the group history log, so each group goes back to exactly what it was before the job touched it rather than to a guess. It refuses to run platform-wide: you name one community at a time. It reports and changes nothing unless you explicitly tell it to write, and it skips any group somebody has dealt with since, so it cannot quietly overturn a decision a person made. Checked against production: 431 of hOUR Timebank's 434 groups are still sitting where the faulty job left them (427 archived, 4 dormant), all of them were active before it ran, and not one has been touched by hand since — so nothing would be overwritten. Nine tests cover it, including the two cases where it must leave a group alone.
- Muted and coloured text in the mobile app was too faint to read, and now it isn't. This is the same problem that was just fixed across the website, found the same way — by measuring instead of looking. The mobile app has one place where its colours are defined, so one bad colour is not one bad screen, it is every label of that kind everywhere in the app. Seven combinations failed the accessibility standard. The worst was the colour used for all secondary and muted text, which came out at 2.45 against a required minimum of 4.5 — barely half, in light mode, on every quiet label in the app. Also failing: green "success" text on its own pale green background (2.91), amber warnings (3.04), red errors on a pink background (3.95), and blue information text on pale blue (4.24). Two dark-mode combinations failed narrowly and were also corrected; the rest of dark mode was already comfortable and is deliberately untouched, so it looks exactly as it did. The replacement colours were calculated, not chosen by eye — each is the lightest shade of its own colour that still meets the requirement on the darkest background it ever appears on, so the app keeps as much colour as the standard allows. Four of them are the exact values the website settled on, so the two now match rather than drifting apart. A new automatic check recalculates all seventeen colour combinations on every change and fails if any drops below the standard; it recalculates rather than comparing against saved numbers, because a saved number quietly becomes wrong the moment someone updates it to match a fault.
- The parts of the mobile app that talk to the phone itself are now tested. These were the app's blind spot, and the pattern was consistent: everything touching the screen was well tested, and everything touching the phone was not — because a test environment replaces those parts with stand-ins. Now covered from nothing (or nearly nothing) to complete: the store holding your login (which is what logs people out unexpectedly if it fails), live messaging connection and its authentication, both Stripe payment paths, the setting that decides which server the app talks to, the vibration feedback, the screen shown when something crashes, and the jobs section's server calls — which was the only one of 46 such modules with no test at all. That is 131 new tests; the suite is now 1,694 tests and still passes completely with none skipped. Two things were discovered while doing it, both of which had been hiding real gaps. Four files were not merely untested — no test could reach them, because a shared test setup file replaces them for the entire suite; they showed as zero and looked neglected when they were actually substituted. And the two payment files meant for web were never being tested at all: the test tool silently loaded the phone versions instead, because of how it picks between files that share a name. Both are now documented where the next person will trip over them.
- Corrected something stated in yesterday's mobile testing guide. It said the mobile app's server-call layer never reports failures by raising an error, and that error-handling code around those calls is therefore dead and can be deleted. That is true of the website and false of the mobile app — they are different implementations, and the mobile one does raise errors. Acting on the guide as written would have deleted working error handling and turned failed actions, including accepting a job offer, into ones that report success. The guide now spells out the difference.
- The mobile app now has the same kind of safety net the rest of the platform has. It was the one part of the platform with no readiness standard and no way of noticing when it fell behind. Four things are new, and all four run automatically on every mobile change. It now notices when the app calls something the server no longer has. Every screen in the mobile app talks to the server through 46 small modules, and the tests for those modules use a stand-in server rather than the real one. That is normal and fast, but it means a web address that the server has renamed or removed still passes every test, gets approved by an app store, and then fails on somebody's phone — which for a phone app can mean weeks before anyone notices. There is now a check that lists all 403 addresses the app uses and confirms each one still exists. 402 do. It found a real fault the first time it ran: the mobile organisation dashboard has an "auto-pay" switch that calls an address the server does not have, so tapping it fails. The web app deliberately removed that same switch, because payments now happen automatically when hours are approved and the switch no longer meant anything. Mobile kept it. Whether to remove the switch or add the missing server address is a product decision, so it has deliberately been left for you rather than changed quietly — it is recorded, and the check will keep reporting it until it is dealt with. It now notices when the web app grows a feature the mobile app does not have. All 254 member pages on the web app have been written down with a decision against each one: covered on mobile (125), deliberately not on a phone with a stated reason (65), genuinely missing (33), or not yet judged (31). If someone adds a new page to the web app, the mobile check fails until a decision is recorded for it. "Not needed on a phone" is a perfectly good answer — saying nothing is not, because that is exactly how a phone app drifts a whole release behind without anyone spotting it. This same blind spot cost real time on the accessible site earlier this month. Test coverage is now measured honestly and can only improve. The mobile app has a genuinely strong test suite — 1,563 tests, all passing, none skipped or excluded, and it finishes in under 90 seconds, which is better discipline than the web app can currently claim. But the coverage figure was flattering itself: files that had no test at all were quietly left out of the calculation instead of dragging it down, including the app's 759-line start-up file. Fixing that lowers the honest figure from 74% to 71.7%, and it revealed that the untested parts are consistently the parts that touch the phone itself — saved login tokens, live messaging, payments, offline event check-in, and the start-up file. Coverage is now tracked area by area rather than as one average, so a slide in any one of those shows up instead of being hidden by the 10,000 well-tested lines elsewhere. Floors can be raised, never lowered. The mobile checks now actually run when they need to. The mobile part of the build only used to wake up when a mobile file changed — which would have made the two new checks decorative, since both of them watch for changes outside mobile. They now also wake when the web app's page list or the server's address list changes. Two written guides go with this: a readiness score for the mobile app across 14 areas, each with the measurement behind it and no credit given for effort that nothing verifies; and a testing guide covering how to run each check, what it does and does not prove, and which parts genuinely cannot be tested on your machine. Three stale instructions were corrected along the way — a build file referenced in the mobile end-to-end test setup does not exist and never has, and the mobile readme twice told you to replace placeholder artwork that is in fact real. Being straight about what is still missing, since this is the point of having a score: nothing checks that the app looks right — there is no screenshot or visual comparison testing of any kind, which is exactly why visual bugs are getting through. The nine end-to-end tests that drive the real app have to be started by hand, so nothing automated proves the app even opens. And no iOS version has ever been built by anything, here or in the build system, so every claim about iPhone is unverified. Those are the next three pieces of work, and they are the ones that need decisions from you rather than just time.

### Fixed

- A shared change to every write response was half right, and the wrong half was taken back out. Development-only ASP.NET backend. Two things were bundled as one: adding a small block of information the real backend always includes, and removing a flag the real backend never sends. The first is safe — at worst it leaves an unused extra key. The second takes something away, and applied to every write it turned 82 tests red in one run. Eleven measurements showed those eleven responses from the real backend leave the flag out; that is not the same as proving it should be stripped from every write here. So the removal is back to applying only where it was actually counted, and the addition stayed. The deciding fact: with the removal taken out, the write score is unchanged at 10 of 18 — it had cost 82 failing tests and gained nothing. The real backend genuinely does omit that flag on the three administrator responses I measured, so that remains a real difference, written down as something to fix one endpoint at a time rather than by one sweeping rule.
- The comparison tool was under-reporting how different two responses are, by a factor of up to eight. It printed at most eight missing field names with nothing indicating the list was cut short, so a response missing sixty-nine pieces of information was reported as missing eight — and reviewing that list looked like reviewing the whole difference. It now always prints the true count alongside the sample and says how many more there are. This is a measurement fix, not a code fix: nothing about either backend changed, but three write-side differences turn out to be far larger than recorded (a created listing 69 fields short, a created poll 38, a profile update 24).
- Closed the broker-shaped half of the same events escalation, and corrected the severity recorded for its admin half. EventConfigurationService::canCreate() ends $role === 'admins' ? $isAdmin : ($isAdmin || $user->role === 'broker'). The admin half became tenant-scoped via TenantAdminScope, but the broker half stayed a bare role-string comparison, so "is a broker somewhere" satisfied a community that had restricted event creation to its own brokers and administrators. Now requires the broker's account row to belong to that community. Deliberately not TenantAdminScope: a broker is an operational role with no hierarchy — it reaches no subtree, and AdminTier refuses it outright — so this is a plain same-community test; a broker needing cross-community reach would hold a platform flag, which the admin half already covers. tests/.../EventConfigurationCanCreateTest gains test_staff_policy_refuses_a_broker_of_another_community (verified failing before the fix) and test_staff_policy_admits_a_platform_admin_from_elsewhere; test_staff_policy_admits_a_broker_but_not_a_plain_member now uses a LOCAL broker, since what it pins is broker-yes/member-no and the actor's home tenant was incidental to that. 🔴 Severity correction to the TenantAdminScope entry above: that defect was a service-layer defence-in-depth failure, not a hole reachable through the API, and the wording overstated it. App\Http\Middleware\Authenticate rejects any actor whose tenant_id differs from the resolved tenant with 403 tenant_mismatch, on both of its auth branches, unless the actor is a PLATFORM admin (is_super_admin, is_god, or those role strings) — and its own comment records the intent: "Allow platform super admins to access any tenant. Tenant super-admins are still scoped to their own tenant." So a plain admin of another community, a network admin, and a cross-tenant broker are each stopped before any events service runs. The nineteen-ability measurement was taken against the policy directly, which is exactly how the cross-tenant actor suites are written and why they bypass HTTP. The fixes stand regardless — these services are also reached from commands, queued jobs and internal callers that are not behind that middleware, and a service must not depend on a caller it cannot see — but no deployed or pushed build was ever exploitable this way by a non-platform actor. 🔴 A consequence worth recording for whoever reads 5373940c8 and 6adcd4ff6 next: their stated beneficiary, "network admins acting on a sub-tenant", cannot reach those paths over HTTP at all, because is_tenant_super_admin is not in the middleware's cross-tenant exemption. The actor those commits actually unblock in production is the platform admin — which TenantAdminScope tier 1 still admits unconditionally, so the reported Partner Demo symptom stays fixed.
- Closed a cross-tenant privilege escalation in the events area: an admin of one community held full admin authority over every other community's events. Never released — introduced by 5373940c8 earlier the same day and caught before deployment (production was on 4e8c77288, which predates it). 5373940c8 correctly stopped tenant-scoping the acting user's identity (an organiser whose account row lives on another tenant must still reach their own event), but the deleted line in EventPolicy::hasValidContext() was also the only thing scoping event admin authority, and isTenantAdmin() was AdminTier::allows() — a predicate that reads roles and flags and is deliberately tenant-unaware. Measured on a tenant 2 event with an actor whose home tenant was 999: all nineteen abilities, including viewRoster, viewWaitlist, messagePeople, exportPeople, manageFinance, reconcileCredits and transferOwnership. A cross-tenant plain member was unaffected (view only), so the fault was specific to admin markers — and reproduced for role='admin', is_admin, role='tenant_admin' and is_tenant_super_admin alike. The fix moves the tenant question off the identity check and onto the authority decision, via a new canonical predicate App\Support\Authorization\TenantAdminScope::allows($user, $tenantId): platform admins (is_super_admin/is_god, or those role strings) reach every tenant; a network admin (is_tenant_super_admin) reaches its own tenant plus its subtree by materialised-path prefix, matching SuperPanelAccess's regional level; a plain community admin (role='admin'/'tenant_admin', or is_admin) reaches its own tenant only; everyone else, including broker/coordinator carrying a stale admin flag, reaches nothing. It fails closed on a missing tenant_id, a non-positive tenant, an unknown target tenant and a lookup error, and takes the actor explicitly rather than reusing SuperPanelAccess::canAccessTenant(), which reads $_SESSION and memoises the first answer in a static — wrong inside a policy evaluating several users in one request. Applied at every events authority decision that had been de-scoped: EventPolicy::isTenantAdmin() (now authorising against the event's own tenant_id rather than ambient context), EventService::isTenantAdmin() (which also gates whose unpublished events appear in the directory listing — tenant_id added to its SELECT, since a partial row fails closed by design), EventPublicationWorkflowService::isTenantAdmin() plus its locked approve/reject actor re-read, and EventConfigurationService::canCreate() (a foreign community's admin could create events wherever creation was restricted to admins; the members setting still admits everyone by design). EventRoleService was examined and deliberately left alone — its actor lookup is still tenant-scoped, so it was never exposed, and adding the check there would have failed closed for legitimate same-tenant admins. 🔴 Crucially the original bug stays fixed: an organiser whose account row lives on another tenant still gets all nineteen abilities on their own event, verified directly. Ownership is decided separately in hasImplicitFullAuthority() and was never the tenant question. 🔴 The failing test was right, and had been dismissed twice. EventPolicyTest::test_standalone_event_permission_matrix_separates_detail_roster_and_meeting_access asserted no abilities for a cross-tenant admin and had been red on main since 5373940c8; both that commit and 6adcd4ff6 recorded it as pre-existing noise needing "separate attention". It was reporting this. Its expectation is now ['view'] — a published event is visible to any viewer, the public index shows it to anonymous visitors too, so withholding view would be stricter than the public contract; assertOnlyAbilities asserts the other eighteen are refused. Note assertOnlyAbilities reports only the first mismatched ability, which is why CI showed a single vague Unexpected view decision line for what was a nineteen-ability breach. Regression coverage: new TenantAdminScopeTest pins all four tiers in 15 tests, including a network admin reaching its own branches but not its parent and not a sibling, and the fail-closed paths. It builds its own hub-and-branch hierarchy rather than reading the dev database's tenant 2/101/102 fixture, which does not exist in nexus_test or CI and had silently turned all 15 into skips. Verified against the whole Feature/Events + EventsControllerTest failure set before and after the change: zero new failures there, one resolved. That directory has 73 pre-existing failures when run together, which is a separate, untouched problem. 🔴 That verification was not wide enough, and CI caught what it missed. Seven tests under tests/Laravel/Unit/Services/ — the cross-tenant actor suites written the same day to pin 5373940c8 and 6adcd4ff6 — were never run before pushing, and six of them broke. Five were genuine fixture defects: they describe their subject as "the exact production shape: role='admin', is_tenant_super_admin=1, home tenant elsewhere" — a NETWORK admin overseeing another community — but expressed it only as an admin whose tenant_id is 999, a tenant with no row at all and no relationship to the tenant being acted on. That is an unrelated stranger holding an admin flag, and it passed only because authority was tenant-unaware. The new Tests\Laravel\Concerns\MakesNetworkAdminHierarchy trait now builds the relationship the description implies (home tenant becomes the acting tenant's parent, rolled back with the transaction), so those tests assert their stated intent instead of an escalation. The sixth, EventPublicationWorkflowCrossTenantActorTest::test_cross_tenant_admin_can_approve_a_pending_review_event, used a bare role='admin'; its docblock says it exists to pin the LOCKED ACTOR RE-READ, for which the persona is incidental, so it now uses the network shape — with a plain outside admin it would have asserted the escalation rather than the re-read. A new test_plain_admin_of_another_community_cannot_publish_here pins the security property directly, in its strongest form: an admin of the PARENT community, without the network flag, is still refused. The seventh, EventConfigurationCanCreateTest::test_admins_only_policy_admits_an_admin, was a deliberate design assertion rather than a fixture defect and was reversed on an explicit owner decision: it asserted that a bare role='admin' on an unrelated tenant may create events in a community that had restricted creation to its own administrators, which is the same escalation in a different door. It is now test_admins_only_policy_refuses_a_plain_admin_of_another_community. What the surrounding tests legitimately pin is untouched and still passes — the actor LOOKUP stays global, so a cross-tenant actor is found rather than missed before the members short-circuit (the original bug), and platform and network admins still reach the tenant. Two positive cases were added because the file had none: every test in it used a cross-tenant actor, so nothing would have caught the admins policy refusing the community's OWN administrators — now covered by test_admins_only_policy_admits_a_local_admin and test_admins_only_policy_admits_a_platform_admin_from_elsewhere.
- Closed a cryptography advisory published upstream on 2026-08-18: paragonie/sodium_compat v2.5.0 -> v2.5.1. Advisory PKSA-32g2-byr9-drtw, "Incorrect Ed25519 public key validation" (affects >=2,<2.5.1 and <1.24.1, no CVE assigned). The package is a transitive dependency of pusher/pusher-php-server, whose ^1.6|^2.0 constraint already permitted the patched release, so this is a lockfile-only change with no composer.json edit. composer audit --locked now reports no advisories, and Ed25519 detached sign/verify was exercised against the upgraded package. This is what turned the Security Vulnerability Scan workflow red on main; the same scan passed on the preceding commit because the advisory did not yet exist. The lockfile's content-hash also changes, correcting pre-existing staleness rather than anything in this fix: c56b8c442 (chore(release): 1.6.1) bumped the version field in composer.json, which feeds the hash, without regenerating the lock. composer validate now passes.
- Approving event registrations, managing the waitlist and viewing the attendee list now work for admins whose account lives on a different community. Registration was the last part of the events area still carrying the fault fixed in the two entries below, and it had been left alone deliberately because registration is not quite like the rest: usually a member is signing themselves up, not managing somebody else. Looking at it properly showed the two halves need opposite rules, and only one half was wrong. Four lookups resolved the acting person "inside the event's community", so an admin working on another community was refused when approving, rejecting or cancelling a member's place, using the bulk actions, or simply opening the attendee list. Fixing the outer one alone would have changed nothing — two of the four sat deeper in, and re-checked the same thing after permission had already been granted. All four now identify the person by who they are; what they may do is still decided by the event's own community rules, and strangers, suspended accounts and events belonging to other communities are all still refused. The other half is deliberately left as it was, and is now written down as such. A member can only sign up for events in their own community. That is a real rule, not an oversight: signing up runs safeguarding checks between the member and the organiser that only make sense inside one community. The lookup that enforces it was starting to look like the same bug, so there is now a comment at it explaining why it stays, and tests that fail if someone "finishes the job" by removing it. Ten new automatic tests cover admin approval, cancellation, the waitlist, the attendee list, both same-community rules, strangers, suspended accounts and cross-community reachability. Seven were confirmed to fail against the old code. One further problem was found and NOT fixed, because it is not safe to fix blind. If the person who created an event has an account on a different community, nobody can sign up to that event at all — not even a local member signing themselves up. Since the publish fix below now lets such an event be created and published, this is reachable. It is left alone because the obvious fix would skip a safeguarding check rather than apply the right one: the safeguarding code has a separate, distinct check for people in different communities, and quietly using the same-community one instead would weaken it. That needs a decision, so it is pinned by a test that describes it and will start failing the moment it is fixed.
- Marking who attended an event now works for organisers and admins whose account lives on a different community. This is the last corner of the same fault fixed below in the publish machinery: four lookups on the check-in path (marking someone attended, bulk check-in, and undoing a check-in) resolved the acting person "inside the event's community", so an admin working on another community — or the event's own cross-community organiser — was refused with "Only the organizer or admin can mark attendance". All four now resolve the person by who they are; what they may do is still decided by the event's own community rules, strangers and suspended accounts are still refused, and events on other communities remain out of reach. Six new automatic tests cover organiser check-in, admin check-in, undoing a check-in, strangers, suspended accounts and cross-community reachability — three were confirmed to fail against the old code.
- An event you create can no longer get stuck as an invisible, unpublishable draft when your account lives on a different community. Every new event starts life as an unpublished draft, and going live is a separate "Publish" step on the event's own page. Nine separate lookups across five files in the publish machinery resolved the acting person "inside the event's community" — so an admin working on another community (the exact situation on Partner Demo) was treated as a stranger to their own event: bounced off its page seconds after creating it, shown no Publish button, and refused if anything called publish anyway. All nine now resolve the person by who they are, not where their account row lives; what they may do is still decided by the event's own community rules, and events themselves remain strictly separated per community. Fourteen new automatic tests cover the organiser, network admins, platform admins, strangers, suspended accounts and cross-community reachability — six of them were confirmed to fail against the old code.
- Your own draft and pending-review events now appear in the events list, clearly badged. Before, a draft was hidden from the list even for the person who created it — the code that was meant to show you your own drafts was cancelled out by an older filter, so a freshly created event simply looked lost. Your unpublished events now show in your list with a "Draft event" or "Pending review" badge (nobody else sees them, and single dates inside a repeating series still stay tucked behind their series entry). Six new automatic tests pin who sees what.
- Community admins are now emailed when an event is submitted for review — the alert used to be silently swallowed by a default setting. The bell notification for "an event needs review" already worked. The matching email never went out to anyone who hadn't opted in, because the platform's default frequency for event emails is "off", and that default muted even this operational alert. A review request is now treated as operational mail: it sends immediately unless the admin has personally switched event emails off or chosen their own frequency — personal choices still win. Two new end-to-end tests walk the whole journey from a member pressing "Submit for review" to the admin's bell and inbox.
- The main app's "your session is about to end" countdown is now accurate when you come back from another tab. The 30-second countdown in the warning dialog used to tick down on a browser timer, and browsers deliberately slow those timers in background tabs. So if the warning appeared while you were in another tab, the number on screen fell behind reality — you could come back to a dialog cheerfully showing 25 seconds when the session had in fact already run out. The countdown is now anchored to the actual clock, and the moment you return to the tab it re-checks the real time: if the session ran out while you were away you are signed out straight away, otherwise the dialog shows the true seconds remaining. This is the same clock-anchoring fix already applied to the accessible frontend (below). Your session itself was never at risk — the server always expired it on time — this fixes what the dialog told you. Covered by three new automatic tests that simulate a tab being hidden.
- The accessible frontend's "your session is about to end" warning now works properly — and its "stay signed in" button no longer signs you out. Three faults were found and fixed together, all verified in a live browser and covered by new automatic tests. The warning now appears on time, even after you've been in another tab. It used to be run entirely off browser timers, and browsers deliberately slow those down in background tabs (and pause them while a laptop sleeps). So the warning fired minutes late — usually at the exact moment you came back to the tab, which is why it seemed to "only activate sometimes when activating a tab". The warning is now anchored to the actual clock: the moment you return to a tab it checks the real time. If the session has already run out it signs you out immediately; if the warning is due it shows it with the true time remaining, not a fresh countdown it can't honour. This matters most for shared council machines: before this fix, a signed-in tab left in the background was never signed out at all. "Stay signed in" actually worked against you. The button called a server endpoint that had a naming clash with the session library's own internals, so every call crashed mid-response and never finished. The page treated that as "the session could not be extended" and sent you to the login screen — the button did the opposite of its label. A new test proved the crash first, then proved the fix. Being active now genuinely keeps you signed in. The session cookie previously kept its original 30-minute expiry from the moment you signed in, no matter how active you were. It now renews with activity, activity in one tab counts for your other tabs too, and the page quietly confirms with the server every few minutes while you're working so the two never disagree. The restore is done and verified. All 431 groups are active again, each returned to exactly the status it held before the faulty job touched it, read from the group history log rather than guessed. hOUR Timebank now has 434 active groups and none archived. The whole geographic structure is back with its membership intact — Munster (170 members), Co. Cork (151), West Cork (114), Leinster, Ulster, and every county and town group beneath them. Every restore was itself written to the history log, so this change is as reversible as the one it undid. Not one group had been touched by hand since July, so nothing anybody decided was overwritten. And the nightly job that caused it will not run again. It is no longer scheduled at all. The command still exists and now reports by default — it will tell a community which groups look dead, and it cannot change one unless a person explicitly asks it to. Hiding a group is now something a person does, in the admin panel, one group at a time. The measurement that settled it. The four protections added earlier the same day were tested against the live database on the evening of the restore. They work: 153 groups were protected by them. But the run still wanted to hide 278 of the 434, and was stopped only by the rule that refuses any sweep touching more than a fifth of a community. In other words the last line of defence was the only thing standing between a restore and a repeat within hours. That is an argument against having the automation at all, not for tuning it — which is the decision taken. For the record, the thresholds it used were 90 days with no activity to hide a group, and 180 days to archive it, checked nightly at 03:30 for every community. Three things about that design are worth remembering if it is ever revisited: both states hid the group equally, so the two stages gave a member no warning; nobody was ever notified, before or after; and a group owner had no way to undo it, only an admin.
- More accessible-site polish: raw data, wrong-size numbers, empty gaps, and a stylesheet nearly half of which was dead. Things members were reading that they shouldn't have been. Listing descriptions showed a literal <br> between every line and &amp; for every ampersand — that affected every listing on the site. Marketplace item pages showed raw database values like "like_new" and "local_pickup" instead of "Like new" and "Local pickup", even though the readable versions were already prepared and simply unused. The seller profile printed two facts twice ("Member since March 2024 | March 2024"). The polls category filter mangled labels into things like "Local_events". And a poll option with one vote said "1 votes", a show with one episode "1 episodes", a club with one member "1 members". A rating bar that only worked in English. The seller rating bar was being given "4,5" in languages that use a comma as the decimal point, which isn't a valid value, so it rendered empty for those members. Layout consistency. Leaderboard statistics rendered at the wrong size because two styling rules were fighting; the numbers came out half again too large. Three empty gaps are gone: a "Closed polls" heading sitting over blank space, an empty button row leaving an unexplained gap above your notifications, and three stacked single-link paragraphs on the group page that are really one list. On the event page, an organiser met up to eight identical grey blocks before any event content; they're all links to other pages, so they now look like links. Error pages. "Skip to content" didn't actually move keyboard focus — the one page where someone lost most needs it to work. The licence and attribution text in the footer was also rendering at the wrong size, on every page of the site. One person, one face. The member directory showed grey circles and a member's own profile showed a blue one at a different size — the same person, one click apart. Now consistent, and photos that aren't square are cropped rather than squashed. Nearly half the stylesheet was dead. 1,030 lines removed; the file is down from 2,643 lines to 1,414. That dead weight is what hid the 37 missing styles for so long. Worth being straight about a near-miss: an earlier check of mine reported the whole abandoned system as unused, and it wasn't — live code styles the session-timeout warning, every loading spinner and the star ratings with it. Deleting it all, as first proposed, would have broken visible things. Only the rules nothing references were removed, and every surviving style was checked by name in the finished stylesheet afterwards. Still to do, honestly: thirteen more counted labels are still wrong the same way ("1 replies", "1 hours", "1 results"). None has an existing translated equivalent to copy from, so each needs new plural wording written in ten languages — including Irish, Arabic and Polish, where guessing the grammar would ship a different kind of mistake. They're left for a translator rather than guessed at. Forty-two pages are also still missing their section caption, for the same reason.
- The accessible site's biggest cause of "pages look a bit off" is fixed: 37 pieces of its own styling were missing. The pages were asking for 37 named styles that had never actually been written, across 73 pages. A missing style doesn't cause an error — the page just falls back to the browser's plain defaults — so instead of one obvious break you got widespread, low-grade wrongness that was easy to see and hard to name. What you should notice now: the member directory reads as rows again (photo, name and "View profile" side by side) instead of everything stacking down the page; photos sit beside titles on events, listings, marketplace and groups instead of above them; facts like "Location / Hours given / Rating" run along one line instead of each value dropping onto its own indented line — that one alone affected 31 pages; replies in a conversation are indented with a thread line, so a reply no longer looks identical to a new comment; the reaction you picked is now visibly marked, and your own row in a leaderboard is highlighted, neither of which showed at all before; skill tags wrap along the line instead of one per line; progress bars look like part of the site instead of a small grey operating-system widget; and blog, help and legal articles are properly typeset instead of rendering as a wall of browser-default text. Both new "this one is yours" markers use an outline or a bar alongside existing wording, never colour on its own, so they don't rely on being able to distinguish colours. One bug my own change introduced, found by checking in a real browser. Six pages use a bright green "success" panel as permanent decoration — including the home page, where it was squeezed into a narrow column. Toning it down to a calm grey box turned the text inside it white-on-nearly-white: invisible, and worse than what it replaced. The cause is subtle (the green panel doesn't set a text colour directly, it changes a variable everything else reads), and no test would have caught it. It is fixed and the text now sits at 17.65:1 contrast, far above the 4.5:1 minimum. Worth saying plainly because it is exactly the kind of thing that only shows up by looking. A new automatic check now fails the build if any page ever again asks for a style that hasn't been written — and it reads the finished stylesheet, so a rule that exists but never reaches the browser still counts as missing.
- The accessible site's pages now agree with each other — the back link stops moving, and long pages stop running the full width of the screen. The back link sat in two different places depending on the page. The Design System puts it above the main content; only 22 pages did that, and the rest opened it inside the page body, which renders it about 40px lower. Walking between two otherwise identical pages made the link jump. 137 pages moved; the link now sits in the same place everywhere. A further 79 were deliberately left alone because their back link isn't a simple single line and moving it mechanically would have risked breaking the page — those are listed rather than guessed at. 60 text and form pages now hold their text to a readable column width instead of running the full width of the screen. This is a large part of the "wall of text" feeling. It was applied carefully rather than everywhere: 108 pages that contain tables, card lists or tabs were left full width on purpose, because narrowing those would make them worse, not better. Two section menus (Jobs and Ideation) are now proper navigation landmarks, matching Courses, Marketplace and Federation. Someone navigating by landmark could previously jump to the section menu on three of those areas and not the other two. Search results and notifications now read as one list — they were missing the line above the first item that every other list on the site has — and the messages screens no longer carry two copies of a hand-written layout style that also made their filter links too small to tap comfortably. A hidden fault found on the way. The phase banner at the top of every page was stored in the same slot the back link belongs in, so any page putting its back link there deleted the banner unless it remembered a specific extra line. One page — the AI chat page — had already lost its banner this way. Moving 137 back links into that slot would have spread the problem across the site, so the banner was made impossible to delete first. That page has its banner back.
- Five faults on the accessible site where a control looked fine and quietly did nothing. Found by a fresh audit and fixed together. Each one was invisible in the page itself, which is why none had been noticed. Choosing "Private" or "Secret" when turning an idea into a group gave you a public group. The privacy choice was never sent to the server at all, and the group name you typed was discarded too (the group silently took the idea's title instead). Anyone who used this expecting a private space did not get one. This is the most serious of the five and was fixed first. Saving or publishing an idea draft always failed. The form's boxes and the code that read them had drifted onto different names, so the server received an empty title every time and refused it — the page just said the draft could not be saved. "Publish" could never publish at all. Drafts now save, publishing works, and if you leave the title empty you get the specific "enter a title" message the page was already written to show. Choosing "Every 2 weeks" for a repeating event silently created a weekly one. The small piece of code that tells the server "every second week" was blocked by the site's own security rules, so the setting never left the page. Organisers would have found their event repeating twice as often as they asked. Now verified in a real browser: choosing "Every 2 weeks" sends the right value, and switching back to weekly sends the right value too. The "Print" button on an event check-in pass did nothing when clicked. Blocked by the same security rule. It works now, and it is hidden for anyone whose browser cannot run it rather than being offered and doing nothing. The session warning was missing entirely from 110 pages. Two invisible pieces of the page layout — the one that powers the "your session is about to end" warning, and the one that translates the "you have N characters remaining" counter — were being dropped by any page built a particular (and perfectly reasonable) way. On those 110 pages a signed-in member got no warning before being signed out, and on 22 of them the character counter spoke English no matter which of the eleven languages they had chosen. This pairs with the session-timeout fixes above: those made the warning correct, this makes it actually appear. A page told members they had made no data requests, right after they had made one. The "Requests you have made" section on the data rights page could never show anything — there is no way for the site to read your own requests back, only staff can — so it always displayed "You have not made any data rights requests yet", directly underneath the confirmation saying the request had been received. Someone could reasonably have submitted the same request over and over. The section has been removed; the confirmation message remains. Why a green test run missed all of these. In three cases the tests were written from the code rather than from the form, so they filled in field names the real page never uses and agreed with the bug. In one case a single test asserted both the confirmation message and the contradicting "no requests yet" line on the same page without anyone noticing. The tests now use the real form fields, and three new automatic checks make these classes of fault fail the build in future: one refuses any page code that the security rules would block, one proves the shared layout pieces survive however a page is built, and the updated ones pin the corrected field names.
- On the accessible site, member photos had no size control at all, and the member profile page showed no photo whatsoever. Two faults, found together once images started loading. The profile page. Viewing a member showed their initials and never their photo, while the members directory showed photos correctly. The shared piece of markup the profile page uses to draw an avatar contained no image at all — it could only ever draw initials. The directory writes its own image markup, which is why one page worked and the other could not. That shared piece now draws the photo when there is one and initials when there is not, so the messages pages that also use it gain the same thing. The sizing. Nothing anywhere asked for an image at the size it was being displayed at, so a full-size upload was being sent to a browser to be drawn in a small circle. Measured against production: one real profile photo is 160,561 bytes, and the same picture requested at the size it is actually shown is 2,260 bytes — 71 times smaller, and returned in a more modern image format automatically. The platform already had the means to do this and the React app has been using it all along; the accessible site simply never called it. It now does, at 29 avatars and 5 photo galleries, each asking for the size it draws. And the reason there was "no mechanism" at all: six of the image styles the pages refer to did not exist in the stylesheet. Not wrong — absent. Feed photos, card cover images, organisation logos, the largest avatar size and the two-factor QR code all named a style that was never written, so those images had no size rule whatsoever and were drawn at whatever size they were uploaded at. A photo a few thousand pixels wide then pushes the page sideways, which fails the accessibility standard on reflow (WCAG 2.2, 1.4.10) — it is not a tidiness problem. Feed photos were also sitting in a plain bulleted list, because the grid style they named had never been written either. All six now exist, sizes are declared in the markup as well as the stylesheet so the page does not jump as pictures load, and round avatars now crop a rectangular photo instead of squashing the face in it. Being straight about two things. The same audit found about thirty more styles the pages name that do not exist — whole layout patterns for member rows, listing rows, comment threads, timelines and progress bars. They are not images and fixing them changes how pages look, so they are recorded for a decision rather than changed quietly. And this was verified by checking the produced HTML and the compiled stylesheet, plus 25 new tests among the 2,544 that pass — not by looking at the pages in a browser, which for this site needs the separate throwaway environment because the local database holds real member data.
- A mobile safety check has been reporting a false alarm on every automated run, and nobody could see it. The check confirms the mobile app is not calling server addresses that no longer exist — the thing that otherwise ships to an app store and fails on somebody's phone. To know whether its record of the server is current, it fingerprints the three files that define the server's addresses. It fingerprinted the raw bytes, and Windows and Linux store line endings in text files differently: the developer machine writes one form, the build system reads the other. So the fingerprint taken on the dev machine could never match the one computed in the build system, for all three files, on every single run. The check therefore said "the record is stale, nothing was verified" every time it ran in the build system — while passing on the machine it was generated on. It was found the only way it could be: by forcing a complete check run before a deploy, which is exactly what that gate exists for. Line endings are now levelled before fingerprinting, and the fix was confirmed by checking the stored fingerprints against what the build system actually sees, rather than by rerunning it and hoping. With the check genuinely working: 402 of the app's 403 server addresses exist. The one that does not is the organisation auto-pay switch already recorded as needing your decision — it is unchanged and still waiting. Being straight about one gap: there is no test pinning this fix. The mobile test suite covers app code, not build scripts, and inventing a home for one while another piece of work is in flight in that area risked more than it protected. The working check in the build system is the guard for now; a proper test belongs with the next mobile task.
- "Send Now" on a newsletter reported "Request Timed Out" even though the newsletter went out. Sending was doing all of the work while the admin's browser waited: every email was addressed, rendered in the recipient's own language, and handed to the email provider before the page got an answer. There is a deliberate quarter-second pause between emails so the platform is not treated as a spam source, so the wait grew with the size of the list. The newsletter sent to hOUR Timebank on 18 August took 184 seconds for 256 members. The admin screen gives up waiting after 30, so it showed a failure for a send that was working perfectly — and an admin who believed it and pressed the button again was only stopped by a separate safeguard. Sending now delivers a first few emails immediately and hands the rest to the every-minute background job that already finishes scheduled and repeating newsletters, so the button answers in seconds and says how many members were queued. The newsletter's own statistics page now refreshes itself every ten seconds while a send is in progress, so the count climbs in front of you instead of freezing part-way and looking stuck. "Resend to non-openers" had exactly the same fault and is fixed the same way. The browser is also allowed 60 seconds rather than 30 for these two actions, so a slow email provider cannot make a working send look broken again. Two further problems were found and fixed while doing this. A scheduled newsletter was sent by the same all-at-once code, and it holds a lock that stops every other timed job on the platform while it runs — so a large scheduled newsletter could quietly hold up the 8am digests and the midnight leaderboard for as long as it took to send. And the background sender had a 45-second limit written into it that never actually took effect, because the step it was meant to bound drains the entire queue in one go before the limit is ever consulted; the limit now works as its own comment always claimed. Two tests pin the new behaviour: that a send stops at its time limit and leaves the rest properly queued, and that the background sender, which has no such limit, still empties the queue in one pass.
- A nightly tidy-up job silently archived a whole community's groups, and the groups page has been showing three of them since. On the night of 13 July 2026 the automatic "mark inactive groups dormant or archived" job archived 415 of hOUR Timebank's 434 groups in a single run, including the entire geographic structure the community is organised around — Munster (177 members), Co. Cork (156), West Cork (117), Leinster, Ulster, and every county and town group beneath them. Archived and dormant groups are both hidden from the groups directory and from sub-group lists, so from a member's point of view the community's groups simply disappeared. The job was doing what it was written to do: it judged a group "active" only by discussion threads, replies to them, and the date people joined. A geographic hub group has none of those by design — it exists as structure, not conversation — so on the day the 180-day line was crossed they all failed the test together. Four things now prevent a repeat. A group that has sub-groups underneath it is never demoted automatically, because demoting it hides the whole branch. A group that has more than one active member is never demoted automatically — automatic hiding is for groups nobody is in. A group the community has deliberately featured is never demoted automatically. And any single run that would demote more than a fifth of a community's groups is refused outright, changes nothing, and records an error — a sweep that size is a fault in the test for activity, not a tidy-up. That fifth is counted against every group the run examined, and the limit never drops below five groups, so a community with only a handful of groups can still be tidied at all — which does mean a single run in a very small community can still touch more than a fifth of it. Separately, "activity" now also counts events, feed posts, announcements, files and shared media, so a group that is busy anywhere other than the discussions tab is no longer read as dead. Six tests cover the four protections and the widened signal. Not fixed here: the 427 groups already archived in production are still archived. Every one of those changes was recorded in the group audit log with its previous status, so they can be restored exactly — but that is a change to live member-visible data and needs the owner's go-ahead, not a side effect of a code fix.
- On the accessible site, no profile pictures and no photos were loading — anywhere. Every avatar and every picture posted to the feed showed as a broken-image icon, and the same was true of member photos in the directory, dashboard cards, marketplace items, club and organisation logos, job candidate photos, and your own photo in settings. The cause: the API gives these back as a partial address like /uploads/…, which a browser completes using the address of the site it is currently on. The accessible site does not hold the pictures — the API does — so every one of them was requested from the wrong place and came back "not found". Confirmed against production rather than assumed: the platform's default profile picture returns "not found" on accessible.project-nexus.ie and loads normally from api.project-nexus.ie. Two things had masked it. The community logo in the site header was already being completed properly, so the page did not look obviously broken; and inside the feed, avatars on comments were handled correctly while avatars on the posts directly above them were not, which made it look like a data problem rather than one consistent mistake. All member-uploaded pictures are now completed the same way the header logo already was. Pictures hosted elsewhere (a partner community's own server) are deliberately left pointing at that server, matching how the main app behaves, so this fix cannot blank them out. Community branding keeps its stricter rule, which stops a community pointing the site header at an outside host — the two rules are now separate on purpose and a test holds them apart. Eleven existing tests had been written around the broken behaviour and were expecting the wrong addresses; they now expect the working ones, and a new test pins the fix. Known limit, deliberately not changed here: the accessible site's security policy only permits images from itself and the API, so a picture genuinely hosted on an outside server still will not display. Allowing that would share every viewer's IP address with that outside host, which is a decision for the owner rather than something to slip into a bug fix.
- Status colours for text now come from one place, and a new check stops the faint ones coming back. Until now every page picked its own shade of green, amber, red or blue from the raw colour palette, which is how the same too-faint text kept reappearing in different places — each one had to be found and fixed individually. There are now four named colours — success, warning, danger and information — defined once and used everywhere, and 137 places across 68 files were moved onto them. Light mode gets the fix; dark mode is untouched, because the shades already used there were measured and already pass, so keeping them means the appearance you see in dark mode does not change at all. The colours were calculated rather than chosen by eye: each is the lightest shade of its hue that still meets the standard on the darkest background it appears on, so the platform keeps as much colour as the requirement allows. A new automated check now blocks a faint palette colour being used for text anywhere in future — and it was deliberately built to be precise rather than blunt: it does not complain about decorative icons, about colours used on dark backgrounds, or about shades that genuinely pass, because a check that cries wolf gets ignored.
- The same too-faint colouring was fixed everywhere else it appears in text, across 49 pages and components. The Wallet page was where an accessibility check happened to catch it, but the same pale amber, pink, red and green were used for text in 84 other places — status labels, totals, error messages, counts and figures. All now use a darker shade of the same colour. Decorative icons were deliberately left alone: the standard sets no contrast minimum for them, and darkening them would have restyled the app for no benefit to anyone. Dark mode is untouched, since there the requirement runs the other way. The shade was not chosen by eye — the contrast of every combination was calculated, which showed that one step darker is not enough where a colour sits on a stronger tint of itself, so those cases go two steps darker. Two limits worth stating: this covers text the checker could identify with certainty, and roughly 200 further uses are written in a form where the colour and its background are decided separately, so each needs looking at individually. Those are not yet done.
- Coloured figures and labels on the Wallet page were too faint to meet the accessibility standard. The pending-credits chip, both Donate buttons — the one on the page and the one on the community fund card — the three summary cards and the credit/debit amounts in the transaction list were drawn in a light amber, pink or green on a pale tint of the same colour. Measured against the 4.5 minimum they came out at 2.95, 3.24, 3.89 and 2.46 — not marginal misses; the amount figures in particular were roughly half the required contrast. All now use a darker shade of the same colour, measured at 4.67 to 5.37, so the page keeps its colour coding while the text is actually readable. A fourth case was found by calculation rather than by the test: the green summary card measured 3.33 and would have failed too, but it did not appear in the test run, so nothing had flagged it. The colours are unchanged in dark mode, which was already fine.
- The highlighted label in the phone navigation bar was too faint to meet the accessibility standard. The label under the currently-selected icon — "Listings", "Messages" and so on — was drawn in the community's accent colour at a very small size. On the default colour that measures 4.46 against a required minimum of 4.5, so it failed the standard, narrowly but genuinely. It affects every language and every page, since it is the main navigation on a phone; a community using a lighter brand colour would fail it by more. The label now uses the normal text colour, which is safe whatever colour a community picks. The selected tab is still clearly marked: the icon stays in the accent colour, keeps its highlighted background, and is drawn slightly larger and heavier. Found only because an earlier fix let an accessibility check reach this screen for the first time.
- On a phone, ten of the main pages did not tell a screen reader what page you were on. People using a screen reader typically jump straight to a page's top-level heading to find out where they are. On a wide screen these pages have one, inside the banner at the top. On a phone that banner is deliberately removed to save space and the page title moves up into the bar beside the logo — but it moves there as ordinary text, not as a heading. The result was that on a phone, Listings, Events, Groups, Members, Search, Resources, Volunteering, Marketplace, Feed and Exchanges had no heading at all: the page announced nothing, and there was nothing to jump to. Each now carries a heading that is invisible on screen and read out by screen readers, using the same title already shown in the bar, so it is correct in all eleven languages with no new wording to translate. Nothing changes visually, on any screen size. This follows the approach the Blog page already used for exactly this situation. Found because a newly added Irish accessibility check failed on Listings; the check itself was also wrong in a separate way and has been corrected.
- If saving a member's language choice failed, nothing was recorded and their language silently reverted. Choosing a language does two things: it changes the screen immediately, and it saves the choice to the member's account. When a signed-in member is next recognised, the account's language is applied — so if that save failed, the member saw their language change, and then on their next page load it quietly went back to the old one, with nothing written to any log to explain it. The error handling that was supposed to record the failure could never run: it waited for the request to raise an error, but this app's network code reports a failure by returning a result that says "failed" rather than raising anything. So the handler was unreachable code and every failure was silent. It now checks the result properly and records the failure, including a note that the choice will revert. Two tests pin this — one for the failure being reported, one for success staying quiet — and the failure test was confirmed to fail against the old code before being kept. Rare, but it made someone using the platform in their own language look like the platform was ignoring them.
- Dependency updates had stopped reaching the web app entirely, and nobody was told. The automated service that proposes dependency updates — including security ones — had been failing on every run. It could not read the web app's dependency file, because postcss was listed twice: once as a normal dependency, and once in the block that forces a safe version on other packages that pull it in. Package managers reject that combination, so the service gave up before proposing anything, for all packages rather than just that one. The duplicate was removed, keeping the entry that does the real work (the forced-version one, added in May to close a security finding) and dropping the redundant one added later alongside the page builder. Confirmed afterwards that the safe version is still being forced on packages that pull it in, and that the site still builds.
- The mobile app's release check was failing on five packages that had drifted a patch version behind. Nothing was broken by anyone: the Expo toolkit published new patch releases and the app's locked versions stayed where they were. It surfaced only because the mobile release check is normally skipped — it runs when the mobile folder changes, and the release gate counts a skipped check as passed, so the drift had been accumulating unseen. Brought back in line and verified: 18 of 18 toolkit checks pass, the mobile type check is clean, and all 1,563 mobile tests across 263 suites pass.

## 1.6.1 - 2026-08-17

### Changed

- The experimental ASP.NET backend's readiness score has been re-measured and is now 598/1000, down from 712/1000. This is a development-only backend serving no real community, so nothing a member sees is affected. The fall is a correction to the measurement, not a decline in the software — and the distinction matters. The old 712 was set when nothing existed that could compare an actual answer from the two backends; readiness was inferred from counting whether web addresses existed. A tool built this week can now ask both backends the same question and compare the replies, and on the 170 addresses the main app actually uses, only 64 give a matching answer. Scoring 87% for that would have been indefensible. Meanwhile the backend genuinely improved in the same period: the community hierarchy and the rule confining a regional administrator to their own communities now exist; three payment and identity webhooks that were silently throwing events away now refuse them so the sender retries; and the number of endpoints that reply "success" while doing nothing fell from 349 to 319. Every figure in the documentation was regenerated from live code rather than copied forward, the older score is kept as an audit trail rather than overwritten, and the score for test evidence rose from 45 to 85 because the full suite now passes on every shard in a clean automated run.
- The accessible frontend's readiness score is now 920/1000, rescored against live production evidence. Two rows on the readiness scorecard had been deliberately held back in August pending real production confirmation rather than session claims. That confirmation now exists and was measured directly: the old Blade accessible frontend's code is gone from the repository, and all three accessible web addresses now serve the new frontend live. So "retire the old accessible site" moved from 5/50 to 45/50 (the remaining gap is that no person has yet done a full sign-off pass over a longer settling period), and "production deployment and rollback" moved from 87/100 to 91/100 (the per-community domain cutover is done; the open items are that switching back to a previous version has now been rehearsed off-production but not yet on the live production server, and that it is more delicate because the old quick-fallback path was removed with Blade). Live checks on both community accessible domains then closed the route and URL-shape row's final three-point deduction. No implementation code changed — this is an honest re-measurement, not new work.
- Laravel is now explicitly documented as the continuing production authority, with ASP.NET retained as an optional future alternative rather than an assumed scale-driven replacement. The architecture decision records, public architecture and portability guides, repository overview, monorepo boundaries, and agent instructions now state that growth in users or traffic does not automatically justify a backend cutover. Any future ASP.NET production proposal must first prove unchanged-client contract identity, security and tenant equivalence, representative Project NEXUS performance and endurance results against an optimized Laravel baseline, operational and total-cost benefit, and a safe migration and rollback plan, followed by a separate explicit owner decision.

### Fixed

- The complete member-facing Irish Matches catalogue has been semantically reviewed. Every accidental cluiche (game) and meaitseanna form is replaced with consistent meaitseáil/meaitseálacha; mutuality, match quality, location thresholds, notification summaries and first-person dismissal now retain their intended meaning, with only distance and percentage displays unchanged.
- The complete member-facing Irish Messages catalogue has been semantically reviewed. Read and sent receipts, archiving, per-user and everyone deletion, voice and automatic translation, group conversations, restrictions and coordinator-mediated contact now preserve their UI states. Safeguarding copy now says precisely that community confirmation is missing and explicitly prohibits sending DBS certificates or criminal-record information through NEXUS; a whole-catalogue gate leaves only the character counter unchanged.
- The complete member-facing Irish Exchanges catalogue has been semantically reviewed. Requests, provider responses, broker approval, completion, hour confirmation, cancellation and time-credit displays now use consistent workflow language and Irish u hour abbreviations; a whole-catalogue gate leaves only the numeric preparation-time placeholder unchanged.
- The complete 830-source-value member-facing Irish Settings catalogue has now been semantically reviewed. Identity verification, notifications, passwords, 2FA, passkeys, insurance metadata, safeguarding consent, GDPR rights, linked-account capabilities, accessibility, blocked users and supported-message audit controls are covered; a whole-catalogue gate limits English-identical values to two product names and three functional input examples.
- Irish Settings now preserves member control throughout linked-account support. Member-approved capabilities are clearly separated from staff-recorded guardian arrangements; pending support remains inaccessible until approval, message viewing requires the member's own consent and stays auditable and withdrawable, and prepared listings or transfers remain fail-closed until the supported member agrees.
- Irish Settings account-rights and destructive-action wording now matches the live controls. GDPR access, portability, restriction and permanent account deletion are distinguished accurately; password feedback now states the real 12-character minimum instead of eight, and profile, privacy, 2FA, marketing and data-request status messages restore complete accented Irish and actionable failure guidance.
- Irish Settings now distinguishes passkeys from passwords throughout device setup. Windows, Mac, iPhone, iPad, Android and Linux instructions consistently use pas-eochair, retain the real Authy product name, and clarify relying-party domains, credential limits and successful registration; focused tests prevent this security-critical terminology from regressing.
- The complete member-facing Irish Stories catalogue has been semantically reviewed. Story creation, camera and recording controls, reactions, viewer states and highlight management now preserve their actual product actions; a whole-catalogue gate protects the five reviewed typography and seconds-format invariants.
- The complete member-facing Irish Dashboard catalogue has been semantically reviewed. Dashboard, listing, matching, activity, endorsement, exchange, review, like and comment wording now reads naturally and preserves the intended product meaning; a whole-catalogue test limits English-identical values to the five percentage, distance and XP display formats.
- Five more lists on the experimental ASP.NET backend were shaped so the app would have shown them as empty. Development-only backend, not the live site. Each of these five — a member's own job applications, their membership dues, their volunteering donations, their volunteering training record, and the discount-coupon list — handed back a plain list where the real backend wraps it in a container with the list inside. An app reading the real backend looks inside that container, so against these five it would have found nothing and shown an empty page rather than an error. The wrapper is not one shared shape, which is the part worth recording: the applications and training lists carry a "where to continue from" marker and a "is there more" flag, dues carries a total count, donations carries a differently-named continuation marker, and coupons carries nothing but the list. Which one applies cannot be worked out from the response, so each of the five was read off the running real backend rather than inferred — deliberately, because over-generalising one such shape across endpoints is what caused an earlier round of 22 wrong endpoints.
- The "Create event" button is no longer offered to members who are not allowed to create Events. A community can restrict Event creation to "Brokers and administrators" or "Administrators only", but the member-facing app never knew that: the button appeared for everyone, in the events page hero, the phone control bar, the empty state, the navbar create menu, the mobile quick-create sheet, the feed sidebar's quick actions, the search overlay's command list, the Explore empty state, and a group's Events tab. The refusal only arrived after the whole form had been filled in and submitted. GET /v2/users/me now reports can_create_events, resolved server-side by the same check POST /v2/events enforces, and every one of those entry points hides the Create action when it is false. Going to /events/create directly shows a short explanation and a way back instead of an unusable form. Two things are deliberate: editing an existing Event is unaffected, because that is governed by the Event policy (organiser, delegated staff, or admin) and not by the creation setting — an organiser whose community later restricts creation can still edit what they already own; and a client that has not yet seen the new field keeps the button, because the common case by far is the open default and the server refuses anyway, so a stale client costs one clear error rather than a missing feature. Both are pinned by tests that were each verified to fail against the opposite behaviour.
- A shared test helper's typing made supplying a signed-in user an error in hundreds of test files. The @/contexts mock factory declared its default user as literally null, so any test overriding it with a real user was a type error — 123 of them, recorded in the type baseline rather than fixed, which is how a new one could hide among them. Typed as User | null, which is what it always meant. No runtime behaviour changes; the test-type baseline drops from 1,932 errors across 648 files to 1,809 across 583.
- Five broken tests were making the ASP.NET test suite report failures on every run. They came from two earlier changes, not from new work: three tests were checking for a response format that only applies to newer-style web addresses, on three older addresses where it never applied (and which the real backend does not even have); two more were still looking for a page-count field on lists that had correctly been changed to report "how many per page, and is there more". All five now check what the real backend actually sends. The suite is back to a clean run.
- Five lists on the experimental ASP.NET backend were shaped so a page looping over them would show nothing. Development-only backend. The real backend puts these rows inside a container alongside their "is there another page" marker; the ASP.NET version returned the rows as a plain list. Any page walking the list would come up empty when pointed at the real backend. Affects the coupon list, a member's job applications, their membership dues, their donations and their safeguarding training. Notably these five use four different container shapes between them, so there was no single rule to apply — each was read from the live backend, which is exactly the check that a previous sweep skipped and got wrong.
- Nine pages on the experimental ASP.NET backend were handing back raw database records; four of them were doing something worse than looking untidy. Still the development-only backend, which serves no real community. Handing back a raw record is the same mistake behind the password-hash leak below, so all nine were rewritten to choose their fields deliberately. Three turned out to be real faults rather than formatting: the group list had no filter for who may see a group, so private, secret, inactive and child groups would all have been listed once such groups existed; the "were you active this month" history was reading the wrong table entirely, returning individual point awards instead of monthly activity; and the member spotlight returned one person instead of several, always the same person, where the real backend picks at random each day. The transaction category list also skipped a switch it should have honoured. Correction to an earlier draft of this entry: it said the transaction category list "was not filtered by community at all" and returned every community's categories. That was wrong and is withdrawn. Queries in that backend are scoped to the community automatically, by a rule applied to every table rather than written out at each query, so the list was correctly limited all along — confirmed by adding a row for another community and checking it did not appear. No data was exposed. For the same reason, the group list problem was a missing check in the code rather than anything actually disclosed: the test community contains only ordinary public groups.
- A shared piece of response-handling code was itself renaming fields, breaking pages it was meant to fix. Development-only backend again. Code added recently to standardise responses converted them into a plain map along the way, which quietly discarded each field's declared name and fell back to its internal spelling — so total became Total, per_page became PerPage, and so on, on every page built that way. The affected page was correct; the shared code was corrupting it. Now fixed at source, and it makes no difference to pages that were already fine.
- The comparison test environment was switching three core modules off by accident. The script that turns every feature on for comparison built its list from the "features" table only, and its output replaces the whole setting — so wallet, listings and messages, which are modules rather than features, were dropped and read as switched off. That made the comparison report differences that were nothing but the two test environments disagreeing. Same shape of trap as the web-address allowlist: writing a replacement value silently throws away whatever the defaults would have supplied.
- A seller's earnings panel on the experimental ASP.NET backend could show one meaningless total mixing different currencies. Again development-only, not the live site. Laravel deliberately refuses to add up money in different currencies: if a seller has been paid in more than one, it reports each separately and leaves the single headline figure blank rather than pretending euro and sterling are the same thing. The ASP.NET version added everything together and labelled the result "EUR". It also never sent the per-currency breakdown at all — which the seller's own page prefers to show — so that panel was falling back to the single figure. Both are fixed. While doing so, the response stopped including a raw copy of the seller's internal profile record, which carried their Stripe account reference and a suspension note, and had a slot for their whole user record that was one line of code away from being filled in.
- A "people you may know" page on the experimental ASP.NET backend was handing out password hashes. This is the development-only ASP.NET backend, not the live site — no member was ever exposed, because this backend serves no real community. But the fault was real and serious: the suggestions endpoint returned each suggested member's entire database record to any signed-in member, including their scrambled password, their two-factor authentication secret, their email address, their email verification code, and whether they are an administrator. A scrambled password can be attacked offline at leisure, so it must never leave the server. Laravel sends seven harmless fields and the ASP.NET version now sends exactly those. The underlying mistake — handing back a raw database record instead of choosing the fields — is the same one behind an earlier member-search leak, so the new test checks the whole response text rather than a list of field names; that way a field added in future cannot slip out unnoticed. Found because the comparison fixture was given realistic content: the fault was invisible while the endpoint was being compared against an empty list.

### Added

- The development-only ASP.NET comparison fixture now holds realistic content, so 22 more endpoints are genuinely being compared. This affects only the throwaway test environment used to check the experimental ASP.NET backend against Laravel; no production code, and nothing a member can see. The comparison tool had been reporting 39 endpoints as "matches, but we could not actually check the contents" — the test Laravel held four users, one listing and nothing else, so lists like events, groups, posts, polls and transactions came back empty and the shape of the rows inside them was never compared. It now seeds one realistic row for each thing the React app reads (an event, a group, a repair-cafe volunteering opportunity, a poll with real options and votes, wallet transactions, a job application, and so on). Every filter those rows have to satisfy was read off the running Laravel's own query log rather than guessed, because a row that quietly fails a filter seeds nothing. Result on the 170-endpoint set: unchecked-contents fell from 39 to 17, and 20 endpoints moved into "genuinely different" — those are row-level differences that were invisible before, not new breakage.
- Event Settings now has its own entry in the admin sidebar. The page holding "Who can create Events" (plus default capacity, registration, waitlist and reminder policy) was reachable only by going to Module Configuration, finding the Events card and pressing Configure — so a community that had restricted Event creation gave its admins no findable way to see or undo that. It now appears under Community, directly beneath Events, and is matched by sidebar searches for the policy itself ("who can create events", "creation policy", "event permissions", "default capacity", "waitlist", "reminders"). Translated into all eleven languages. The entry is hidden when the Events feature is off, and both the presence and the search route are covered by tests.
- A safe rehearsal for the "switch back" (rollback) step of a deploy. The zero-downtime deploy can flip the site back to the previous version by swapping one Apache setting, but that specific switch had never actually been run — only configured. A new disposable rehearsal (scripts/test/rehearse-bluegreen-rollback.sh) stands up a throwaway web server and proves, using the real deploy code, that: the switch is accepted and moves live traffic; switching back works; a rollback to an older version that predates the accessible site still starts up cleanly (a specific worry that was written down but never tested); and a broken configuration is rejected and the previous good one restored automatically. All 11 checks pass. This tests the logic, not the exact production server, so a final one-line check on the live server at cutover is still sensible — but it is no longer a leap of faith.
- A safe, scheduled way to run that final check on the real server. A new helper (scripts/deploy/verify-prod-apache-rollback-configtest.sh) lets the deploy operator confirm the same behaviour on the production server in one command. It only checks the configuration (it never reloads, restarts, or switches traffic), tests both the normal and the pre-accessible-site states, and restores the config file automatically — even if interrupted. It is scheduled as a required gate for the next deploy window in the release runbook, with a result field to fill in afterwards. Nothing runs against production until the owner authorises that window.

### Fixed

- Creating an Event was refused on any community other than the admin's own, even when that community allowed all members to create Events. The refusal read "your community only allows authorized staff to create Events" while the community's own Event Settings page correctly showed "All active members" — so the message named a restriction that was not in force. EventConfigurationService::canCreate() looked the acting user up with WHERE id = ? AND tenant_id = ? against the Event's community and returned false on a miss, and that miss was evaluated before the "all members" short-circuit. Any actor whose account row lives on a different community than the one they are acting in was therefore refused: platform admins, network (is_tenant_super_admin) admins, and anyone administering a sub-tenant. Confirmed on production against the partner-demo community (a sub-tenant) by an admin whose only account row is on another community. The actor is now resolved by authenticated id alone — auth is global, only resources are tenant-scoped, and the id comes from requireAuth(), so there is no IDOR risk — and the admin-tier decision for the two restricted options now uses the canonical AdminTier predicate instead of a partial inline role list, which also fixes super_admin/god role strings and is_admin being ignored there. The restricted options are unchanged in effect: brokers still count as staff, coordinators still do not, and suspended or soft-deleted accounts are still refused. Ten regression tests assert the service directly, and were verified to fail against the old lookup.
- Status words on the accessible frontend showed in English to members using another language, and the translations for them already existed. A sweep of every place the accessible frontend keeps a list of display words found 43 such lists holding 187 English words — and 183 of those 187 already had a proper translation sitting in the language files, in most cases written specifically for that list and then never connected up. Two ways it went wrong: some lists were printed straight out in English, and others were translated only when the page happened to hand the translator in, falling back to English when it did not. Now fixed and checked by rendering the pages in German: the status of a course you teach, the status of a course you are taking, podcast show and episode status, and a marketplace item's condition, delivery method and pricing — the last three appear on search results, which every member sees. Two podcast messages were falling back to something worse than English: the internal name of the translation itself. The fragile "only translate if a translator was passed in" wiring has been removed from those pages entirely, so no path is left that can fall back. Not finished, and worth stating plainly: 36 lists holding 161 English words remain. Most of those translate first and only use English if a translation is genuinely missing, so they are a latent risk rather than a live one — but 57 places across six other pages still use the fragile wiring that caused the podcast leak, and the marketplace advanced-search filters are still a block of English. Those have not been checked page by page and are not fixed.
- Group exchanges could not be started on the accessible frontend, which left them stuck and nobody notified. A group exchange is one where several members swap time at once. There was no way to start one, so it stayed a draft for ever — and starting it is the only thing that tells the people taking part that they need to confirm their hours. So they were being waited on without ever having been asked, and a member looking at the same exchange in the main app saw no way to respond either. Nothing was wrong on the server; the button and the connection to it simply did not exist here. Fixing it uncovered three more faults in the same workflow, all corrected together because starting is no use without them: (1) two of the statuses the page checked for — "pending" and "approved" — are not real statuses at all, and the real one that means "still gathering people" was not checked, so an exchange in that state could be neither edited nor started; (2) the page asked people to confirm an exchange that had not started, and their confirmation could not lead anywhere; (3) four of the eight statuses had no translated wording and appeared in English in all eleven languages — three now reuse the wording already written and reviewed for one-to-one exchanges, so both kinds of exchange describe the same situation the same way, and the fourth was newly written in all eleven. Cancelling stays available at every stage, including when an exchange is in dispute. Two existing tests had recorded the broken sequence as correct and have been corrected.
- Items priced in either money or time credits hid the money option everywhere on the accessible frontend. Some marketplace items can be bought two ways — pay cash, or pay in time credits — and the buyer chooses which. Every item card in every list showed only the time-credit price, so a member browsing could not see they had the option to pay with money, and the price then changed when they opened the item (the item page had it right). The cause was two separate pieces of code doing the same job, only one of which knew about two-way pricing; there is now one, so the card and the page cannot disagree again. The same fix corrects the colour-coded price tag on the search results page, which always said "priced in time credits" regardless. Alongside it, three pieces of wording that had been written into the code in English — the words "time credits" on every price, and the label "Free" — now come from the translation files and appear in the member's own language; a fourth string, the two-way price itself, was going through a translator locked to English and now follows the member's language too. The new "time credits" wording for each language was taken from that language's existing reviewed translation of the two-way price, so nothing was machine translated. One existing test had recorded the fault as correct and has been corrected. Separately noted for a translator rather than changed here: the German word currently used for "Free" means free-as-in-unoccupied rather than free-of-charge.
- Six places on the accessible frontend saved the wrong thing and told the member it had worked. None of them showed an error, which is why they had gone unnoticed. (1) Editing any discount coupon quietly widened it to the seller's entire catalogue, because a scope value the form never displays was being resent on every save; it is now sent only when the coupon is created. (2) Adding someone to a group exchange that splits hours by share silently ignored the hours typed in and reset the share to one, which also moved everyone else's share; the number entered is now used as that person's share. (3) An organiser could complete a group exchange that was under dispute, which moved the time credits; Complete is no longer offered on a disputed exchange, while Cancel remains. (4) Buying an item marked for community delivery saved no delivery method at all, so the order could never be picked up by a volunteer driver. (5) The buyer's "Active" orders tab was permanently empty and the "Completed" and "Cancelled" tabs each hid a whole category of orders, because the tab names were being sent to the database as if they were order statuses. (6) A donation to another member that failed sent the member back to the community fund form with their amount already filled in — so pressing Donate again gave those time credits to the fund instead of the person. It now returns to the right form with the recipient, amount and message intact, and pins the recipient to the person actually chosen. Each fix has a regression test naming the wrong write it prevents; six existing tests that had recorded the faulty behaviour as correct were corrected alongside.
- Listing cards showed "User" instead of the member's name. The listings index endpoint (GET /v2/listings) never sent author_name or author_avatar, although the listing detail, saved and featured endpoints all do. The React listing card read author_name only, so every card in the grid and list views fell back to the generic "User" label. ListingService::formatListingItem() now emits both fields (organisation accounts use their organisation name, as elsewhere), and the card also falls back to user.name. Regression tests cover both ends.
- Long addresses broke the listing card footer. In the grid view the member row and the hours/location/distance row shared one line, so a long address wrapped onto a second line — squeezing the member's name to an ellipsis and leaving uneven space at the bottom of each card. The footer is now two fixed rows: the member above, the meta below, with the address truncated to a single line and the full text available on hover. Card footers are the same height regardless of address length. The list view's address now truncates at a wider limit and also carries the full text on hover.
- Irish and WCAG 2.2 coverage now reaches the real browser gates. React's blocking accessibility suite explicitly runs axe's WCAG 2.2 AA rules and exercises Irish sign-in plus five core authenticated member routes. The Irish journeys also prove the application's large-text mode at a 320 CSS-pixel viewport without horizontal overflow, while a forced-colours login journey checks native high-contrast semantics. The accessible frontend now browser-tests Irish sign-in, registration and the accessibility statement for Irish document metadata, exact catalogue headings, narrow reflow and serious or critical axe violations. A static CI contract prevents these matrices and profiles from being silently removed.
- Irish localisation is now quality-gated across both maintained frontends without Google Translate. The accessible frontend's course, federation, commerce, volunteering, event and safeguarding catalogues no longer contain unreviewed English fallbacks, broken question marks or misleading literal terms. React's Irish admin, federation, safeguarding, notification, account-support and commerce copy has also been repaired, including corrupted accented text and clearer wording for logging in as another user, vetted-member restrictions, read-only access and burnout. A shared terminology glossary and CI audits now block these regressions and explicitly prohibit Google Translate for Irish.
- React's remaining English-identical Irish values are now exhaustively reviewed and pinned. Sixteen genuine UI gaps across administration, help, caring-community and social catalogues now use authored Irish, while all 737 intentionally unchanged names, identifiers, examples and technical formats are recorded by exact key, value and review reason. The Irish audit rejects any new unclassified English fallback, changed invariant or stale exception; Google Translate remains prohibited, and the accessible frontend's generated Irish catalogue remains fully synchronized with its Laravel source.
- Irish and accessible-frontend documentation now matches the measured implementation. The public index no longer claims that the deleted Blade frontend still exists, current-status links point to the canonical 920/1000 Web UK scorecard, React's 737 reviewed invariants and Web UK's 9,348-key Irish integrity result are documented separately, and historical statements that Irish blocked character-count, date, timeout, federation, or event-validation work are explicitly superseded without rewriting the audit trail.
- The complete 24-value React Reviews namespace has received Irish semantic review. Average ratings, anonymous reviewers, review deletion, empty states, completed exchanges, review recipients and star accessibility text now retain their actual meaning; malformed wording that referred to recruits or simply “more” reviews has been replaced. A whole-catalogue gate rejects English residue and corrupted values.
- The one-value React Endorsements namespace has received Irish semantic review. “Most Endorsed” now uses the Irish verb for endorsing rather than incorrectly saying that the greatest number of people agreed, and a complete-catalogue test locks the distinction in place.
- The complete 39-value React Connections namespace has received Irish semantic review. Connection nouns no longer use an imperative verb, disconnect actions no longer imply dissolving something, empty and search states read naturally, and sent-request outcomes now preserve acceptance, refusal, cancellation and link-removal meaning. A whole-catalogue gate rejects English residue, whitespace and invisible corruption.
- The complete 289-value React Community namespace now has a whole-catalogue Irish semantic-review gate. Every English source value is covered, all translated values are checked for surrounding whitespace and invisible corruption, and the only permitted exact-English values are the three functional email/URL examples. This closes the Community namespace review rather than relying on aggregate key coverage.
- The remaining 26 Irish Community strings for recommended shifts, impact certificates and page labels have completed semantic review. Personalised recommendations now refer naturally to shifts and percentage compatibility; recommendation and certificate failures retain their retry action; certificate copy clearly distinguishes approved volunteer hours, verification and generation; and closing-date and registration-page labels now read naturally.
- Irish Community emergency-shift alerts and group sign-ups have completed a 35-string semantic review. Alert urgency, pending requests, expiry dates and failures now use complete member-facing language. Group reservations clearly explain volunteering together, invitation delivery and invalid-email failures without importing English singular-they grammar; the example email remains unchanged deliberately.
- Irish Community organisation discovery, registration and detail pages have completed a 109-string semantic review. Eagraíocht terminology is now consistent; loading, empty and retry states are complete; registration distinguishes administrative approval from generic permission; and public-listing terms state the organiser's authority and responsibilities clearly. Opportunity counts follow Irish numeral grammar, while applications and organisation reviews now distinguish reviews from administrative inspection and translate the remaining Rating and Comment labels.
- On the accessible frontend, 25 form and messaging texts now show real Irish instead of English. The validation messages, the sign-in/password-reset prompts, the password-strength meter wording, and several direct-message labels had been added in English for Irish while the other ten languages got proper translations. A translation-quality check (which the earlier commits had not triggered) flagged them; they are now translated into Irish, matching the other languages. This also clears a failing check on the shared build so the whole build passes again.
- On the accessible frontend, the date-and-time fields on events, the event agenda, podcasts and marketplace pickup slots now work again. The shared helper that reads and re-displays those "date + time" fields was missing its time-handling half in the committed code, so several organiser pages could error out. The helper is now complete, so those pages load and save correctly.
- On the accessible frontend, the courses pages now show their status messages in the member's own language. The pop-up confirmations and errors on courses (for example "You are now enrolled", "Lesson marked as complete", "Your course was published", "You do not have enough time credits to enrol") — about 40 messages — and the eight labels on the course-analytics page ("Total enrolments", "Completion rate", and so on) were hard-coded English shown to everyone. They are now translated into every language (Irish falls back to English for now, as agreed).
- On the accessible frontend, the direct-messages page and the group-delete confirmation now appear in the member's own language. On a one-to-one conversation, the "Regarding:" label, the "Attachments" heading and its hint, the fallback names ("Unknown", "Listing", "You"), and the screen-reader label for the message list were all hard-coded English; so was the "confirm you understand the group will be permanently deleted" error on the group-delete form. All are now translated into every language (Irish falls back to English for now, as agreed).
- On the accessible frontend, more messages now appear in the member's own language instead of English. The federation status messages (connected, left the network, request sent, message sent, and their failure notices), the "enter your community code" / "enter a new password" / "password must be at least 8 characters" errors on the sign-in and password-reset forms, and the "you do not have permission" title on a blocked marketplace page were all hard-coded English. They are now translated into every language (Irish falls back to English for now, as agreed).
- On the accessible frontend, a set of smaller rough edges have been smoothed. Opening a single post by its link no longer logs you out if your sign-in just expired (it quietly refreshes, like other pages). A poll page still loads if its comments briefly fail to load. Trying to leave an event waitlist now only says "done" when it actually worked (while "you were already off the list" still confirms cleanly). The onboarding "add a bio" step no longer blames your bio for an unrelated hiccup. A job's applications page shows a proper "service unavailable" message rather than "Forbidden" during an outage. A linked account that has only an email address now shows that email instead of "Unknown member". Two small accessibility tidy-ups: the footer's link groups are now a proper navigation landmark, and the organisation-name field offers autofill. Covered by tests.
- On the accessible frontend, the federation "opt-in" page no longer loops for a community that has federation switched off. If a community had not enabled federation, opening the opt-in page could bounce endlessly and end in a "too many redirects" browser error — on the very page you'd use to turn it on. It now shows the opt-in page normally. Covered by a test.
- On the accessible frontend, an expired session on "Discover" or "Nearby" members now sends you to log in, and a job's own page shows the owner their controls. If your sign-in quietly expired, the Discover and Nearby member pages used to show a dead-end "we couldn't load members" box and leave you stuck; they now send you to log in like every other page. Separately, the job detail page looked up "is this my posting?" using a broken call that always failed, so the person who posted a job could be shown none of their own manage/edit controls; that lookup is fixed. Covered by tests.
- On the accessible frontend, the "cancel this session" reason box on an event agenda now has a working character counter. A template typo meant the little "you have N characters remaining" counter was wired to the wrong element, so it did not work and could trip the counter's script. It now works, and a new check across every page prevents the same typo class from recurring.
- On the accessible frontend, a "Back" link on the placeholder pages can no longer be pointed at another website. Those pages read a "return" address from the web link used to reach them and put it straight into their "Back" link. A crafted link could set that to an outside address (an "open redirect", useful for phishing) — and, in theory, to a script link, though the site's security policy already blocks that from running. The address is now checked and only accepted if it's an ordinary link back into this site, exactly as every other page already does. Covered by a test.
- On the accessible frontend, every page view no longer makes an extra, avoidable request to the backend. Each page asked the backend afresh for the community's own settings (name, which features are switched on), even though that information barely changes. It is now remembered for 30 seconds. The care is in how it is remembered: the memory is keyed to both the community and the language, so one community can never be shown another's settings and a German visitor can never be served the English version — the two ways this kind of change goes wrong. Each page also gets its own copy, and a failed lookup is never remembered, so an unknown community or a brief backend outage cannot stick. Covered by eleven tests, including the two isolation cases.
- On the accessible frontend, a member clicking a good verification or unsubscribe link is no longer told it is broken. Both pages answered "this link is invalid or has expired" for every failure, including the backend simply being unreachable or overloaded — and the verification page then suggested requesting another link, which would have failed the same way. Only the backend can judge whether a link is genuinely bad, and it now only says so when it actually has; anything else reports that we could not check right now and to try again, in the member's own language, with the unhelpful suggestion withheld. Covered by tests.
- On the accessible frontend, the event organiser analytics page now reads correctly in every language. All its percentages were formatted the American way for everyone (a full stop for the decimal), its counts were ungrouped, and the "generated at" line showed a raw machine timestamp such as 2026-07-13T10:00:00Z. All three now follow the reader's language — a German organiser sees 50,0 %, 1.234 and 13. Juli 2026. The English wording is unchanged. The ideation outcomes totals, which come straight from the backend and have no upper limit, are now grouped the same way. Verified by rendering both pages in German.
- On the accessible frontend, an organiser can now correct an event's accessibility details — and saving a repeating event no longer erases them. Two problems, one cause. The edit form for a one-off event was missing all ten venue-accessibility fields, so step-free access, an accessible toilet, a hearing loop, a quiet space, seating, parking, transport details, an assistance contact and notes could be set when creating an event and never corrected afterwards. Worse, the form for repeating events — the one our own release notes held up as proof this couldn't drift — was reading those details from the wrong place, so it showed every answer as "not known" and, on save, wrote that emptiness back over the real information. Any organiser editing a repeating event was quietly deleting the very details a disabled member relies on to decide whether they can attend. All three event forms now read the details from the same place, through shared code, so they cannot fall out of step again. No new wording was needed in any language.
- On the accessible frontend, asking for an exchange twice no longer creates two of them. Requesting an exchange moves time credits once both people confirm, and this was the only money form on the site with no protection at all — no double-click guard, no one-time token, nothing. The backend didn't check for duplicates either, so a double click, a back-button resubmit, or an impatient retry on a slow connection created two pending exchanges for the same listing, and if both were confirmed the credits moved twice. Two protections now sit on the server, so they work even with JavaScript switched off: the form carries a one-time code that is used up the moment it is submitted, and if you already have an exchange for that listing you are taken to it instead of starting another. No new wording was needed in any language.
- On the accessible frontend, a seller setting a money-off coupon no longer creates one worth a hundredth of what they intended. The form asks for "the amount", but the value was passed straight through to a field the backend counts in pence — so a seller wanting £5 off typed 5, created a 5p coupon, and was shown "5.00" as confirmation, with no currency to hint anything was wrong. The amount is now converted both ways, so what you type, what is saved, and what is shown all agree, and coupon values display as real money with their currency. Percentage discounts are unaffected. Three existing tests had to be corrected because they had absorbed the fault — one of them treating a 3p coupon as if it were €3.
- On the accessible frontend, two marketplace bugs that charged the wrong amount are fixed. First: if a seller received an offer of £50 and countered at £80, the page still showed them an "Accept" button — and pressing it sold the item at £50, in one click, with no confirmation. The counter amount wasn't even displayed, so neither side could see the figure actually in play. Now a countered offer is the buyer's to accept, the seller only acts on offers they haven't countered, and both people see the counter amount. Every offer button also gained double-click protection; none of them had it, and they move money. Second: the purchase page showed the price of one item while the checkout charges price × quantity — so buying 3 of a 5-credit item said "5 time credits" and took 15. The page now shows a Total, and the quantity box has its own "Apply" button so the total you see is always the amount you'll be charged. No new wording was needed in any language.
- On the accessible frontend, a volunteer's accessibility needs no longer overwrite each other. If a member had recorded more than one need — say a mobility need and a separate hearing need — the page showed only one description, and pressing Save wrote that one over the other, permanently. This was on the accessibility page of the site built for disabled members, and it had been happening every time such a member saved. Each need now has its own description, its own list of what would help, and its own emergency contact, exactly as the app has always stored them. Two further ways data could vanish were closed at the same time: a need of a kind this version doesn't recognise is now carried through untouched instead of being deleted, and if someone submits an old copy of the page held in their browser, the save is now refused rather than quietly wiping everything they had entered. No new wording was needed in any language. Three tests added, one of which fails against the old code — and two existing tests had to be corrected because they had been asserting the faulty behaviour was right.
- We now know how close the accessible frontend is to the React app, and it is written down. Until now the only parity figure was "707 of 707 routes matched" — which measures the accessible frontend against the old Blade site that was deleted, so anything the React app can do that Blade never did was invisible to it. Measured against React for the first time, the accessible frontend covers 71% of member-facing pages (179 of 251). Nothing React can do is missing in the other direction. Two decisions are recorded so they are not re-argued later: a platform admin panel is not required on the accessible frontend (which takes 364 staff/admin pages out of the comparison entirely), and the Caring Community module is experimental and deferred — it is switched on for 2 of the 11 communities, so the deferral is made with that known, and five specific questions are written down for the investigation. The remaining work is listed in priority order, starting with identity verification.
- On the accessible frontend, a community's own pages now actually appear. If a community wrote its own page — local guidance, a policy, a campaign — the React app showed it and the accessible frontend returned "page not found", silently, with nothing in a log to notice. The page list it was reading from was empty. Those pages now load, in the member's own language, with the community's own name and heading; page content is cleaned before display, so formatting from the editor survives but anything unsafe does not. Found by comparing the accessible frontend against the React app for the first time — the existing comparison only ever measured it against the old Blade site, which never had this page either, so it could not have shown the gap. Fixing it also uncovered a fault in that comparison tool: it read this one file in a special way that missed real pages entirely, so the accessible frontend was under-reported by a page. Both fixed.
- A deploy will no longer go ahead without proof the accessible frontend's tests passed. The pre-deploy safety check reads the results of the main automated pipeline — but the accessible frontend's tests, code style, branding check and accessibility checks run in a different pipeline, and the one job in the main pipeline that used to cover the accessible site was removed along with the old Blade version on 14 August and never replaced. So for three days, a deploy could have shipped the accessible frontend — now the only one, on three live web addresses — with nothing confirming its 2,428 tests had passed. The safety check now reads both pipelines. Two opposite dangers were guarded against and then actually demonstrated rather than assumed: it still lets a good deploy through (checked against the current live version, where it correctly reuses recent evidence), and it genuinely refuses when the accessible frontend's evidence is missing. The paused experimental backend that shares the second pipeline is deliberately still unable to block a deploy. Three documents that said the opposite have been corrected.
- The written instructions no longer tell an operator to do the thing that would take the accessible site offline. A sweep for anything still assuming the old Blade accessible site exists found the working parts all clean — the backend, the deploy scripts, the web server template and the automated checks were all updated correctly when it was deleted. The problem was the documents. The deployment guide's routing table said the accessible web address is served by the PHP application, and the custom-domains guide contained a web-server snippet pointing at the PHP port; anyone rebuilding a web server entry from either would have taken the live accessible site down. The document all the others defer to contradicted itself in four places, still describing the changeover as half-finished. The instructions file that AI coding assistants load automatically still said the accessible frontend "is not deployed yet" and pointed at a deleted folder as the thing to copy — which is how an assistant ends up refusing correct work or hunting for files that no longer exist. The front-page architecture diagram also had a broken leftover box. All corrected against live measurements taken today, with the old wording kept and dated so nobody restores it by mistake.
- A routine deploy can no longer forget the accessible frontend, and it now says so before pushing. Including the accessible frontend needed a --with-webuk flag, and leaving it off means the accessible addresses have nothing to fall back to since the old Blade site was deleted. The live server already refuses such a deploy (a marker file recorded the August cutover, verified present), but it refuses only at the last step — after your code has been pushed and after the full wait for GitHub's checks. Including the accessible frontend is now the default, and excluding it has to be typed out as --without-webuk, which prints exactly which addresses that would take offline. Separately, a check in the deploy test file had been quietly failing since 14 August, because it searched for a warning about "falling back to Blade" that was deliberately removed when Blade was — the code was right and the test was out of date, which is the dangerous way round.
- Both community accessible web addresses are now monitored, and the monitor now checks which service answered. Only the main accessible address was being probed every 15 minutes; the two community accessible domains have been serving members since 14 August with no up/down check, so a fault affecting just one community's own address would not have raised an alert. Also, the check named "not a silent fallback" only ever compared status codes — and the React app answers "200 OK" on any address, so a misrouted domain could have looked healthy while showing members the wrong application. Each check now confirms the response identifies itself as the accessible frontend. Proven both ways: all nine checks pass against the live sites, and the new check correctly reports a failure when pointed at the React app.
- On the accessible frontend, the "this page has expired" tab title is now translated. When a form session times out, that page's heading and body already appeared in the member's language, but the browser tab — and what a screen reader announces as the page name — stayed in English. Every other error page (not found, forbidden, too many requests, file too large, service unavailable) already did this correctly, so this was a single missed line rather than missing translation; the wording already existed in all eleven languages.
- On the accessible frontend, five more create forms now keep what you typed if the submission is rejected. When a new idea, campaign, course, volunteering opportunity, goal or poll was rejected (for a validation problem or a backend hiccup), the form came back blank and everything had to be retyped. All of these now re-display with your entered text — title, description, options, dates and the rest — so you can fix the one problem and resubmit. This matches the behaviour the wallet, jobs and events forms already had. Covered by tests.
- Irish Community waitlists and volunteer wellbeing have completed a 59-string semantic review. Queue membership, position loss, open-place notifications and retry failures now use complete actions. Mood check-ins, wellbeing risk, burnout, suggested rest and self-care guidance now distinguish ordinary tiredness from exhaustion, replace malformed self-care wording, and describe feeling overwhelmed naturally.
- Irish Community credential verification and shift swaps have completed a 60-string semantic review. Credential states now distinguish verification from generic confirmation, expiry copy no longer uses a broken credential(s) construction, police/background checks remain jurisdiction-neutral, upload limits and retry actions are complete, and shift-swap requests consistently identify shifts, sent/received states, acceptance, refusal and failures.
- All 188 member-facing Irish Caring Community values are now covered by a completion gate. Trust levels, caregiver wellbeing, respite cover, care providers, emergency alerts, Federation discovery, onboarding, support relationships, data export and the Warmth Pass retain their reviewed meanings; only the Swiss Spitex service name may remain identical to English. The separate 707-value staff panel and administration surface remains queued for the administration phase.
- Irish Caring Community emergency alerts and Federation discovery now use the intended member actions. Dismissing an alert is a clear close action, the emergency severity is a noun rather than a dangling adjective, and Federation community discovery uses the platform's established Cónaidhm terminology with a natural manual-slug fallback and count label.
- The complete 671-source-string Irish Legal catalogue has now been semantically reviewed. The final 67-string Trust & Safety pass removes English fallbacks and Ireland-only emergency advice, restores the global local-emergency-services instruction, translates cross-region Federation, background-check and vetting guidance, and clarifies what the platform verifies, stores, insures and does not guarantee. Exchange steps, first-meeting precautions, disputes, member responsibilities and RGCS rights now use natural Irish. A whole-catalogue gate permits only seven reviewed Sentry, Pusher and browser product names to remain identical to English.
- The complete 105-string Irish Community Guidelines and Acceptable Use Policy have received semantic review. Respect, identity, privacy, in-person safety, confidential reporting, proportionate enforcement and independent appeals now read naturally and retain their safeguards. Fraud, impersonation, fake time-credit activity, non-consensual sexual content, intellectual property, spam, artificial engagement, platform interference, account security and serious-breach reporting are repaired; both documents now describe themselves as clear Irish rather than “plain English.”
- The 44 Irish strings for legal acceptance, custom documents, Swiss data-protection consent and Legal page metadata have completed semantic review. Updated-document notices now distinguish singular and plural review actions, the consent statement names the actual Accept control, and policy names match the reviewed Legal documents. The Swiss FADP notice preserves explicit consent and the choice to retain basic features without AI, while search metadata no longer describes Irish guidance as “plain English.”
- The 58 strings identifying the Project NEXUS platform-provider legal documents have completed their Irish semantic review. Platform terms, privacy and disclaimer navigation now uses consistent policy names; the provider notice remains explicitly separate from each community operator's terms; the authoritative-English notice retains its legal effect; and controller/processor, RGCS rights, liability, operator responsibility and governing-law headings use accurate Irish. A focused gate covers all four platform sections.
- The Irish Legal hub and document version history have completed their 57-string semantic review. Policy cards now use the same natural terminology as their reviewed documents, distinguish community policies from Project NEXUS platform-provider documents, and describe Trust & Safety boundaries in plain language. Revision history now uses fully accented Irish for documents, effective dates, summaries, comparisons and change counts, with a focused gate covering both sections.
- The 45-string Irish Accessibility Statement has completed its semantic review. The commitment, keyboard, visual, screen-reader and responsive-design guidance now uses fully accented Irish; partial WCAG 2.1 Level AA conformance is explained accurately; and feedback, technical specifications, browser and assistive-technology recommendations preserve their intended meaning. A focused gate prevents the previous corrupted and accent-stripped wording from returning.
- The standalone Irish Cookie Policy has completed its 80-string semantic review. Required, analytics and preference categories; consent storage; expiry periods; Sentry fault reporting; optional performance and session replay; third-party controls; and browser-management instructions now use fully accented Irish and preserve the actual consent boundary. Pusher and Microsoft Edge are no longer translated as ordinary words, CSRF and JWT purposes remain technically accurate, and disabling required cookies clearly says that sign-in and core features will stop working; seven browser and service product names remain unchanged by design.
- The Irish Legal catalogue's Terms of Service and Privacy Policy core has received a complete semantic repair pass. The 214 strings covering time-credit rules, account responsibilities, prohibited conduct, safety, liability, termination, collected data, processing purposes, profile visibility, security, GDPR rights, cookies, retention and safeguarding metadata now use fully accented, natural Irish. Material mistranslations are repaired: discrimination is no longer “ideology”, spam is no longer “drilling”, impersonation is no longer “fake personality”, legitimate interest and data-device terminology are restored, and members' access, correction, erasure, portability, restriction and consent rights retain their legal meaning. The remaining Legal sections are still under review and are not represented as complete.
- On the accessible frontend, a rejected profile save no longer throws away everything you typed. If a profile edit was rejected (for example, a blank name, or a backend hiccup), the page quietly reverted to your old saved profile — so a long "about me" bio, your tagline, phone and location all vanished and had to be retyped. The form now keeps what you entered when it re-displays, so you can fix the one problem and save again. Covered by a test.
- On the accessible frontend, the donations dashboard "total raised" no longer shows 0.00 once real money comes in. The total was added up from the on-screen formatted figure rather than the underlying number, so as soon as an amount reached the thousands — or in any language that writes decimals with a comma (German, French, Spanish, and others) — the sum broke and displayed as 0.00. It now adds up the real amounts, so the figure is correct in every language and at any size. The same fix was applied to the volunteering-expenses totals. Covered by a test.
- The complete 353-value member-facing Irish Ideation catalogue has been semantically reviewed. Challenge discovery, idea submission, drafts, voting, comments, favourites, evaluation guidance, attachments, campaigns, templates, project-team conversion, outcomes, tasks, documents and chat channels now use consistent accented Irish. The review removes extensive accent-stripped and malformed copy, distinguishes comments from notes, voting from withdrawal, attachments from generic media, and ideation from literal “idea composition”; destructive confirmations and implementation outcomes now state their effects clearly, with only the two functional URL inputs unchanged.
- On the accessible frontend, the unread message and notification counts in the header can no longer briefly show another member's numbers. To avoid re-asking the server on every page, the site keeps each member's unread counts in memory for a few seconds under a key built from their sign-in token. That key was taken from the first part of the token — but for the kind of token the platform issues, that first part is the same for every member on every community. So for a few seconds after one member loaded a page, the next member using the same server could see that first member's unread counts in their header (the numbers only, never the messages themselves), and clearing one member's cached counts wiped everyone's. The key is now a unique fingerprint of the whole token, so each member's counts stay their own. Covered by a test.
- The complete member-facing Irish Goals catalogue has been semantically reviewed. Private and community goals, templates, progress check-ins, reminders, history, milestones, streaks and goal-buddy support now consistently use sprioc, clárú isteach, meabhrúchán and minicíocht. Corrupted accents and template wording are repaired, the untranslated software term “cadence” is removed, overdue status refers to the goal deadline, and gentle buddy nudges remain distinct from scheduled reminders; a whole-catalogue gate leaves only two percentage displays unchanged.
- The complete member-facing Irish Profile catalogue has been semantically reviewed. Profile identity, biography, skills, listings, achievements, availability, connections, reviews, blocking, activity and accessibility labels now use natural wording consistent with the reviewed member journeys. A biography is no longer described as biology, availability is no longer hospitality, retry controls are actions rather than trials, blocking copy is gender-neutral, verified identity has clear agency, and NexusScore remains an established product name; a whole-catalogue gate limits English-identical values to two functional display tokens.
- The complete member-facing Irish Wallet catalogue has been semantically reviewed. Transfers, donations, community-fund balances, transaction history, exports, ratings and the venue pass now use natural financial and time-credit wording. The maximum-transfer warning no longer contains the Irish word for weather/time, donation actions consistently describe giving credits to a person or fund, hour amounts work without brittle English plurals, and screen-reader transaction summaries retain their direction and amount; a whole-catalogue gate leaves only the numeric donation placeholder unchanged.
- On the accessible frontend, donating time-credits is now as protected against accidental double-sending as transferring them. Sending credits to another member already guarded against a double-click, a slow retry, or pressing back-and-resubmit sending twice. Donating — which moves credits out of your wallet in the same way — did not: the main donate box stopped a rapid double-click, but a back-button or slow-connection retry could still donate twice, and the donate form on the wallet management page had no double-click guard at all. Both donate forms now carry the same one-time key transfers use (so the server can ignore an accidental repeat) and both have the double-click guard. Covered by tests.
- On the accessible frontend, the live password-strength message when signing up or resetting a password now appears in the member's own language. As you type a password, a message updates ("Use 12 or more characters…", "Checking against known data breaches", "Strong enough", "This password appears in a known data breach…"). It is also read aloud by screen readers. It was hard-coded English, so a member using the site in Irish, German, Arabic and so on heard English in the middle of an otherwise-translated page. It is now translated into every language. (The "add N more characters" countdown became a simple "enter at least 12 characters", because a counted-down message can't be translated correctly across every language's grammar.) Covered by a test.
- On the accessible frontend, a community that runs shorter sign-in sessions now gets a correctly-timed warning instead of being ignored. The "your session is about to end" warning is set up assuming a five-minute lead. If a community's server was configured with a session of five minutes or fewer, the code quietly ignored that and pretended the session was the usual 30 minutes — so a member on a genuinely short session would be signed out by the server with no warning, or a warning still counting down. The warning lead is now always fitted inside whatever session length the server reports, so the warning fires before the session ends regardless of how short it is. (In practice sessions are 30 minutes, so most communities see no change.)
- A small hardening note recorded in the code: the accessible frontend's helper that turns typed line breaks into on-screen line breaks now lives in one place with a dedicated test proving it escapes any HTML a member types before adding the line breaks — so a member cannot sneak working markup into, for example, a description. Behaviour is unchanged; this just locks the safe behaviour down so it cannot quietly regress. The reason the page style rules stay slightly relaxed (needed by the GOV.UK design components) is now written down beside the setting, with a note that the more important script protection is already fully locked.
- The complete 1,358-source-value Irish Broker catalogue is now semantically reviewed and protected as a whole. Its help centre now states the platform's most important safeguarding boundary accurately: NEXUS stores only the community decision and controlled metadata, never certificates, disclosure numbers, dates of birth, document references, results or free-text evidence. The confirmed and revoked status tokens remain exact operational values, unsupported jurisdiction data fails closed, and escalation, retention, guardian assignments, police contact and troubleshooting guidance are complete. Its seven English-identical values are explicitly limited to keyboard labels, AccessNI, an em dash, ID, an example email and the km display format; a whole-catalogue gate prevents any new English fallback.
- Irish Broker match approval and moderation now describe decisions rather than people or machine-generated fragments. Match approvals are no longer “match approvers”; proposals use natural smart-matching wording, associated listings use liostú, low scores mean weak matches, and approval, rejection, empty, not-found and review-date states are explicit. Shared statuses distinguish approval from generic permission. Content, feed, comment, review and report moderation now use complete action sentences, while safeguarding declaration options consistently use the platform's established cosaint terminology.
- Irish Broker configuration now preserves the difference between tenant-wide policy and broker-operated thresholds. The panel no longer leaves tenant in English, and admin-only settings identify their audience clearly. Message-copy rules, listing exchange requirements, automatic low/high-risk handling, maximum hour variance, exchange timeouts, approval validity, vetting and insurance enforcement, new-member monitoring, random sampling, archive retention and high-risk notifications now describe the actual setting rather than word-for-word machine approximations. The public example email remains unchanged deliberately.
- Irish Broker review archives now describe immutable compliance evidence accurately. Archived message copies are inléite amháin rather than already read, and conversation snapshots are preserved views rather than photographs. Listing, flag, decision-maker, decision-note and sent-date labels are corrected; the filter is no longer phrased as an instruction to review it; and frozen-record guidance clearly says the record is retained exactly as reviewed and cannot be changed.
- Irish Broker risk and insurance controls now retain their fail-closed and verification meaning. Risk tags consistently apply to liostuithe, distinguish approval from permission, and explain that retired role-vetting requirements remain safely closed because messaging contact attestation cannot satisfy them. Risk levels, financial categories and empty/error states are corrected. Insurance creation, verification, rejection and deletion now use completed-action wording; “verified at” is no longer confused with “verified by”; expiry labels use real Irish day units; and certificate types and retry guidance are natural.
- Irish Broker message review and member monitoring now distinguish flags, decisions and restrictions consistently. Flagging is no longer blurred into generic marking, message-copy metadata separates who reviewed a record from when it was reviewed, and archive labels no longer present nouns as commands. Approval-and-archive controls describe both actions, high-risk references use liostú, and queue counts and empty states are grammatical. Monitoring now identifies members under oversight, removes a machine-generated g(h) gender placeholder, and states expiry, messaging restrictions, failures and retry actions directly.
- Irish Broker member details and exchange decisions now say exactly what staff will change. Verification and password-reset actions identify the email being sent, two-factor reset and profile changes use completed-action wording, and time-balance guidance explains positive credits and negative deductions rather than presenting incomplete accounting nouns. Exchange controls consistently distinguish approval from generic permission, use liostú, display hours as hours of time, and provide grammatical success, rejection, empty, history and queue-failure messages.
- Irish Broker vetting now states the privacy boundary and community decision accurately. It instructs staff to record only operational scope and the internal decision, and explicitly forbids uploading or pasting certificates, certificate numbers, disclosure results, identity documents or criminal-record information into NEXUS. Policy packages, attestation schemes, private notes, review dates, revocation outcomes and resolved member contact now use natural labels; a corrupted certification heading and an English “Close” control are repaired, while AccessNI remains protected as a scheme name.
- Irish Broker member approval, onboarding and safeguarding controls now preserve the action being taken. “Never logged in” is no longer an instruction never to log in; bulk approval, suspension and selection buttons use real imperatives; confirmations name the member rather than importing English singular-they grammar; and success notices say what happened. Onboarding consistently uses ionduchtú, its funnel distinguishes first listings and returning members, and safeguarding tabs distinguish flagged messages, guardian assignments and recorded member preferences with natural error and empty-state wording.
- Irish Broker navigation and its working dashboard now describe safeguarding work rather than machine-translated approximations. Match approvals are no longer labelled as people who approve matches, onboarding flags are no longer sign-ins, and moderation is no longer left in English. Pending-review descriptions, partial-metric failures, message flags, listing risk tags, plural work counts and compact elapsed times now retain their operational meaning. A focused semantic gate protects the shell and dashboard while the rest of the 1,358-value Broker catalogue remains under review.
- The complete 562-source-value Irish Federation catalogue is now semantically reviewed and protected as a whole. Reputation and review text now puts counts before Irish nouns, distinguishes Federation reputation clearly for assistive technology, and uses natural verification wording. The final opt-in notice no longer says that opting out is required. Its six remaining English-identical values are deliberately limited to two km units, the compact estimated-hours format, and three score/count display patterns; a catalogue-wide test prevents any new English fallback.
- Irish Federation listings, settings, events, groups and member transfers now use complete, natural instructions. Listings consistently use liostuithe; settings no longer describe a disabled feature as a disability and now preserve the grammar of dynamic enabled/disabled notices; service range distinguishes remote availability from willingness to travel; event and group counts put their numbers in the correct place with Irish mutations; and external-member profiles, pending transfers and successful credit amounts no longer contain malformed or English wording.
- Irish Federation member discovery and messaging now preserve explicit opt-in and communication meaning. The member directory uses natural service-range, remote-work, travel, loading and profile actions. Messaging no longer translates “opt in” as “opt out” or describe joining as being towed into the Federation; composing a message no longer uses a poetry verb; and automatic translation, delivery status, reply fields and change actions now state exactly what the controls do.
- Irish Federation connection and onboarding controls now preserve consent, visibility and status choices. Connection-request tabs and outcomes use complete Irish; declining a request is no longer translated as economic decline; and unavailable partner pages clearly identify what could not be found. Federation setup now presents profile visibility, search, skills, location, reviews, messaging, transactions, email notifications and travel range as deliberate controls, with natural on/off states and service-area wording.
- Irish Federation discovery now describes partner communities, shared services and network status accurately. The hub and partner directory consistently use the genitive form of the Federation and liostuithe; partner-community counts put the number before the member noun with Irish mutations; service exchange says members use their own time credits; and empty, loading and detail actions are complete. Disabling federation no longer uses disability terminology, while partner dates no longer translate “since” as “because.”
- The complete 769-value React Marketplace catalogue has received a contextual Irish review. The final pass repairs free-item giving, group listings, hybrid cash-and-time-credit pricing, AI reply suggestions, map discovery, approximate locations and Stripe seller setup. Listing results no longer use generic list terminology; onboarding consistently describes account and payment setup rather than workplace induction; payout and identity requirements preserve their financial meaning; and a whole-catalogue test permits only seven reviewed numeric, distance, counter and placeholder formats to remain language-neutral.
- Irish Marketplace community delivery, pickup and stock controls now explain fulfilment clearly. Community members offer to deliver an item in exchange for time credits, with the buyer or seller reviewing the offer; pickup screens distinguish collecting an order from organising a collection and use natural pickup periods rather than software slots or windows; and inventory guidance explains that stock tracking prevents selling more items than are available. Low-stock badges now say only a small amount remains rather than labelling the inventory itself as low.
- Irish Marketplace collections, saved searches and promotions now describe their actual member actions. Collections organise the items a member likes rather than generic favourite market goods; empty and failure states use complete sentences; saved-search alerts explain that newly listed items match the search; and the activation control says it turns an alert on or off. Promotion choices consistently refer to a liostú, replace feathered-listing wording, and identify the homepage carousel rather than a car show.
- Irish Marketplace seller and owner pages now keep listing ownership, renewal and negotiation actions clear. Seller profiles, editing, My Listings and My Offers consistently use liostú; renewal and removal outcomes identify the listing; destructive confirmation says the action cannot be reversed; and empty states speak directly to the seller. Counteroffers now use one unhyphenated term and a monetary suim, while starting checkout for an accepted offer is correctly described as beginning payment rather than inspecting an exit.
- Irish Marketplace creation and discovery now use natural listing, condition, delivery and filter language. The Marketplace hub and category pages consistently describe liostuithe rather than generic lists; featured items are no longer described as feathered or explicit; listing creation has clear upload limits, validation, AI-description and publishing actions; and search filters distinguish collection from sending an item without calling ordinary shipping navigation. Relative dates, saved-item feedback, empty states and compact timestamps now read as complete Irish phrases instead of title-cased or invented abbreviations.
- Irish Marketplace listing details and offer negotiation now use consistent commerce wording. Listing pages no longer call an individual listing a generic list, describe negotiable items with a malformed word, translate featured as feathered, or expose literal HTML-video terminology. Reporting, saving, seller messages and listing counts now use one consistent liostú vocabulary, while offer amounts, price comparisons, counteroffers and payment actions clearly preserve who is offering, responding and paying.
- On the accessible frontend, "please fill this in" style form errors now appear in the member's own language on the event and group forms. When someone left the title, description or number-of-places field blank (or typed too much, or a non-number), the little error that pops up next to the box was always written in English, even for a member using the site in Irish, German, French, Arabic and so on. Only the sign-in form got this right before. The event-create, event-edit, group-create and group-edit forms now show those instant errors in the same language as the rest of the page, using shared wording translated into every language. Locked by a test so new forms can't quietly regress.
- Irish Marketplace checkout, delivery and order management now preserve the buyer and seller journey. Secure checkout no longer reads as a security check; shipping and discount use real commerce terms; delivery options identify couriers and local collection accurately; buyers can track purchases, confirm receipt and rate an order with natural actions; and sellers create a listing, record dispatch details and see that the buyer supplied a rating rather than that the buyer was rated. Malformed price formatting and literal checkout, auto-completion and undo wording are also repaired.
- Irish shared member activity, survey, provider and story controls now describe their real actions. Dashboard activity says that a member shared a listing or post, posted about an event, or created a pobalbhreith instead of displaying noun fragments and voting terminology; satisfaction choices repair malformed Irish; provider phone and email labels use the correct contact wording; cover care is identified as replacement care; and compact story timestamps no longer invent single-letter Irish time units.
- On the accessible frontend, pages that show your private information are no longer kept in the browser's cache. Your wallet, messages, profile, notifications, settings and downloaded statements are personal to you. Some of these pages did not tell the browser "don't store a copy", which meant a saved copy could, in theory, be shown to the next person to use the same device (for example by pressing the back button after you signed out). Every page shown to a signed-in member now instructs the browser and any shared cache never to store a copy, while public pages stay cacheable as before. Covered by a test.
- The complete 636-value React Feed catalogue has received a contextual Irish review. Feed discovery, creation, templates, scheduling, cards, polls, hashtags, sharing, video, stories, carousels, reactions, recommendations and analytics now use consistent natural Irish instead of words for life, individual votes or expelling people; literal English retry text; vocal noises for reactions; misleading comment and sharing labels; or unmuted audio described with disability-related language. A whole-catalogue test permits only four reviewed numeric, counter and distance formats to remain language-neutral and rejects the documented failure patterns.
- On the accessible frontend, an idle sign-in no longer times you out early with a countdown that lied. The "your session is about to end" warning appears five minutes before the timeout, but its countdown only ever counted 60 seconds — so it signed you out four minutes early, while the on-screen message still claimed "60 seconds" remaining. A 30-minute session was ending at about 26 minutes. The countdown now runs the full five minutes, the warning and the timer always agree on the time left, the message reads naturally in the member's own language (for example "4 minutes and 30 seconds"), and the sign-out can no longer fire twice. Locked by a regression test.
- Irish Feed cards and polls now distinguish reactions, comments, achievements and pobalbhreitheanna accurately. Removing a like no longer reads as disliking a post; muting uses neutral wording; earned badges, comment actions, sharing outcomes, content-detail links, event countdowns, volunteer-credit plurals and rating labels describe the displayed action; and poll loading, expiry, status and results consistently refer to a pobalbhreith rather than an individual vote.
- Irish Feed templates, scheduling and media controls now read as complete member actions. Reusable post, listing, event and goal prompts use natural community wording; voice input identifies browser support directly; scheduling buttons use Irish action forms; accessibility labels identify the post content type; and upload failures, validation messages, video limits and file-count plurals use accurate post and media terminology.
- Irish Feed composer assistance and formatting controls now give complete, natural instructions. AI generation and exhausted-credit messages identify what happened; restored and cleared drafts use action wording; image compression and reordering no longer read as isolated software commands; and bold, italic, underline, bulleted-list, link-preview and media-format controls use accurate publishing terminology with accessible file-size spacing.
- Irish Feed creation forms now distinguish a marketplace listing from subscribing or registering. Post, poll, listing, event and goal creation use natural completion and failure messages; listing labels consistently refer to a liostú; public-goal guidance says other members can support the goal itself; image and example text is cleaned up; and group-member counts use the appropriate Irish number mutations.
- The Irish Feed core now preserves discovery, moderation and realtime actions accurately. Listings and polls no longer use words for lists and votes; feed filters no longer call the feed “life”; draft recovery avoids literal composer terminology; and muting a user uses neutral wording rather than language associated with disability. The realtime new-post banner once again tells members to tap to view the posts across every Irish plural form, and the “like” failure now identifies the actual reaction rather than affection.
- Irish member-shell controls now use the platform’s actual content and clearer recovery language. Global search now looks for listings and members rather than generic lists and committee members; quick actions, clearing filters, quick creation and error recovery use complete natural instructions; and identity badges distinguish a verified email address from an email message while stating unverified identity as an action that has not happened.
- Irish wellbeing, caregiver-load and match-dismissal wording now speaks clearly from the member’s perspective. Low mood and fair wellbeing scores no longer read like object states or moral judgements; caregiver load and rest guidance identify the caregiver and avoid system-log language; and dismissal reasons now say “I am not interested”, “too far from me”, or that a match is outside the member’s skills instead of describing an unnamed third person.
- Irish local-discovery and regional-points journeys no longer contain misleading literal translations. Club schedules now say when a club meets rather than that it “hits”; marketplace rates use a complete per-hour label; and the regional-points summary no longer describes lifetime earnings as floods or member IDs as wall identifiers. The malformed transfer amount example and missing negative transaction empty state are repaired, with the numeric format explicitly reviewed as a language-neutral invariant.
- Irish skill discovery, personal-data export and problem reporting now preserve their full member-facing meaning. Skill search now searches for skills rather than categories and uses clearer member and proficiency wording; the data-export introduction restores the omitted explanation of machine-readable JSON and the right to move data to another service; and technical-support reports now distinguish blocked core tasks, diagnostic information and reference numbers accurately. Focused assertions guard the restored data-portability promise and repaired actions.
- Irish coupon, time-credit redemption, saved-collection and thank-you journeys now describe the actions members actually take. Coupon QR expiry and in-store redemption, merchant discounts and usage limits now use natural retail wording; the member ledger identifies time-credit redemptions rather than abstract loyalty; saved-item empty states and visibility controls are grammatical; and public versus private thank-you notes clearly identify who can see them. Focused shared-catalogue assertions protect the repaired meanings.
- The React donation journey now stays a donation journey in Irish. Support levels, recurring and one-off donations, checkout returns, payment recovery, cancellation and supporter recognition no longer describe a paid “premium” subscription or promise extra paid features that the English source explicitly says are not being sold. The shared-catalogue test now protects that distinction.
- Irish support journeys in the shared React catalogue now use clear, people-centred wording. Asking for help by voice, gifting or transferring banked hours, viewing support relationships and future-care balances, recording an informal favour, and accepting an invitation no longer use gender-slashed recipient text, cheque terminology for a check-in, literal URL-slug or logging jargon, or misleading machine translations for reciprocity and care. Focused assertions protect the repaired wording while the wider common catalogue remains under semantic review.
- The settings pages now say "we could not load this" instead of showing empty or default settings when the server is down. On the main settings page and the linked-accounts, appearance and availability pages, a backend hiccup used to make it look as though you had no linked accounts, no saved availability, or default preferences — which could mislead you into thinking your settings had been lost or reset. Each now shows a clear "could not load" notice at the top when a section fails to load, so you know it's a temporary problem rather than your real settings. Sign-in problems still send you to log in. Covered by tests for each page.
- Deleting a marketplace listing or a podcast episode now asks you to confirm first. Both used to delete immediately on a single click, with no way back. They now take you to a short "Delete this listing? / Delete this episode? — this is permanent and cannot be undone" page with a clear "Yes, delete" button and a Cancel link, so a stray click can't wipe a listing or episode. The confirmation is a shared page reused by both, translated into every language, and its delete button is also guarded against a double-click.
- If your session expires while opening a volunteering page, you're now sent to log in instead of a "service unavailable" or "couldn't load" page. On the volunteering list and a volunteering opportunity's detail page, an expired or revoked sign-in used to show a dead-end error page rather than the login screen every other page sends you to. Both now redirect to login, so you can sign back in and carry on. Covered by a test for each page.
- Uploading a too-large file now shows a clear "the file is too large" message instead of a blank error page. On the accessible site's ~21 upload forms (photos, documents, resources, event and listing images, and so on), attaching a file over the size limit produced a generic "problem with the service" page. It now shows a proper "the file is too large — go back and choose a smaller file" page, translated into every language. (Re-filling the rest of the form automatically after an over-size upload is a separate, larger change and is not part of this fix.)
- More money and delete buttons on the accessible site are now protected against an accidental double-click, and a member-to-member transfer can no longer skip its safeguards. Completing a group exchange, depositing into an organisation wallet, buying a shop item, making a marketplace offer, subscribing to premium, and deleting a marketplace listing or a podcast episode now all carry the same double-click guard the wallet transfer already had — so a quick double-tap can't fire the action twice. Separately, a member-to-member credit transfer now checks its confirmation box and its duplicate-protection key on the server, not only in the browser, so a transfer can't be sent unconfirmed or without a working safeguard against being counted twice. A ratchet test now lists every money/destructive button so a new one can't be added without this guard. (A full "are you sure?" confirmation step for the two delete buttons — beyond the double-click guard — remains a separate follow-up, since it needs new wording translated into every language.)
- The Jobs pages now say "we could not load this" instead of pretending you have nothing when the server is down. On the main jobs list, your saved opportunities, your applications, and (for employers) your postings, a backend hiccup used to show a clean "you have none" — so your saved jobs or applications appeared to have vanished. They now show the same "could not load" notice used elsewhere on the site. Sign-in problems still send you to log in. This was the same fault fixed on the dashboard and Explore earlier; these four pages were missed because the same file already handled it correctly on its other pages. Covered by a test for each of the four pages.
- Password-reset links and search terms are no longer written to the accessible site's server logs. The access log and the error log recorded the full web address of every request, including its query string — which meant a member's one-time password-reset token (carried in the reset link) and their search terms were being written to the logs. Anyone able to read those logs could in principle have lifted a still-valid reset token and taken over that account. Both log points now record the path only, without the query string — the same choice already made deliberately for crash reporting. Covered by a test that logs a reset-link URL and confirms the token never appears.
- Shared React journeys have received another contextual Irish repair pass. Caring Community help and banked-care copy now describes neighbours, intergenerational support, companionship and reciprocity accurately; marketplace collection uses natural pickup terminology; member sorting, accessibility navigation, installation guidance, overdue fees and retry actions no longer use literal machine wording. The same pass repairs developer and analytics terminology for OAuth2, webhooks, scoped endpoints, footfall and privacy buckets, and preserves the machine-readable CSV import header and example instead of translating functional field names. The common catalogue remains under review; this release note records the completed bounded slice rather than claiming the full namespace is finished.
- The React navigation and member-directory shell now uses accurate Irish labels and route descriptions. Donate, Ideas, Saved items, What’s on, Volunteering, Organisations and the WCAG 2.2 AA route now describe the destinations they actually open; the dashboard is no longer described as a personal house, and the accessible route no longer calls the retired interface alpha. Cookie controls, help centres, source attribution, problem reporting, member search, empty states, location filters, joining dates and exchanged-hour labels now use natural Irish. Project NEXUS remains unchanged as a product name and is explicitly reviewed as a language-neutral invariant. The wider common catalogue remains under semantic review.
- The complete 847-value React Events catalogue has received a contextual Irish review. Event discovery, details, recurring-event setup, registration, waitlists, manual attendance, agendas, delegated teams, calendar feeds, reminders and public event pages now use consistent event and attendee terminology and natural action wording. The review repairs corrupted punctuation, roster/menu and assignment/task mistranslations, restores the private-feed security warning, preserves the RRULE protocol token, and replaces literal English constructions for recurrence, no-shows, check-in and immutable audit history. A whole-catalogue test permits only ten reviewed protocol, token, distance, product-name and RSVP values to remain language-neutral.
- The entire 693-value React Public catalogue has completed its contextual Irish review. The changelog, Home, About, Contact, FAQ, maintenance, knowledge-base, community chooser and app-installation journeys now use natural Irish instead of literal wording for “one hour at a time”, opting out, saved apps, browser actions, negative balances, no-shows and guided help. The complete feature inventory now restores omitted provider and version details and correctly describes build profiles, paseochracha, service listings, marketplace payouts, saved vacancies, fochuntais, event rewards, social prescribing, trust levels, Copilot, cron tasks, performance queries and community provisioning. Whole-catalogue tests permit only 11 reviewed email, punctuation, operating-system, technology and CRM values to remain language-neutral and reject the documented detector-blind failure patterns.
- The entire 404-value React About catalogue has completed its contextual Irish review. The timebanking guide, impact summary and report, partnerships, social prescribing pathway, and 2026–2030 strategic plan now use natural Irish instead of literal machine phrasing, malformed accents and grammar, incorrect public-health and SROI terminology, or book-chapter wording for community branches. The review also repairs a corrupted exchanged-hours statistic while preserving 13 names, brands, figures and monetary formats as reviewed language-neutral invariants; a whole-catalogue test rejects the documented failure patterns.
- The entire 239-value React Authentication catalogue has completed its contextual Irish review. Registration, invitations, waitlists, account approval, sign-in, two-factor authentication, paseochracha, password recovery, email verification and identity verification now use consistent natural Irish instead of English organisation and surname examples, mixed password terminology, literal wording for an available waitlist place, malformed sign-in metadata, or verification sentences with unclear agency. A whole-catalogue test permits only six reviewed email, security-code and weak-password examples to remain language-neutral and rejects the documented failure patterns.
- The entire 392-value React Listings catalogue has completed its contextual Irish review. Browsing, creation, editing, discovery filters, maps, listing details, saving, sharing, expiry, renewal, analytics, reciprocal matches and safety reports now use consistent listing terminology and natural Irish instead of English hour abbreviations, list wording for individual listings, malformed accents, emotional wording for experience, literal machine copy, or Ireland-specific location examples on a global platform. A whole-catalogue test permits only 11 reviewed distances, numeric examples, character counters and radius formats to remain language-neutral and rejects the documented failure patterns.
- The member-facing React Caring Community journeys now use consistent, people-centred Irish. Trust levels, caregiver links, respite cover, provider contact actions, personalised onboarding, support relationships, personal-data export and the Warmth Pass no longer mix layers, series and trust levels; describe a care dashboard as chores; retain English words such as “Reach”, “Handoff”, “Search” or “Since”; run placeholders into surrounding words; or imply that the member rather than the community is the person placing trust. The catalogue's 14 English-identical values are now explicitly limited to formats, identifiers and established Swiss-domain terms. A focused member-journey test protects this slice while the administration half of the mixed Caring Community catalogue remains under review.
- Irish shared React controls no longer fall back to English, and passkeys are no longer described as passwords. The 2,603-value common catalogue now translates its remaining generic remove, confirm, delete, user, yes/no, theme and level controls, while its 50 language-neutral matches are explicitly limited to age bands, units, formats, symbols, examples, technical metrics and established product or Swiss-domain names. The same review repaired malformed navigation and loading terms, caregiver workload spacing, provider accessibility labels, an English CRM timestamp, and every device-specific passkey instruction. A focused gate prevents new exact-English shared fallbacks and rejects these detector-blind failure patterns; the broader semantic review of the rest of this large shared catalogue continues separately.
- The entire 419-value React Gamification catalogue has completed its contextual Irish review. Achievements, badges, challenges, collections, the XP shop, goals and goal buddies, community statistics, leaderboards, member journeys, featured members and NexusScore now use natural Irish instead of English fallbacks, missing accents, posts described as jobs, streaks described as streams, totals used for completed actions, check-ins described as inspectors, malformed day suffixes, or literal machine wording. A whole-catalogue test permits only 13 reviewed XP, unit, score-format and placeholder values to remain language-neutral and rejects the documented failure patterns.
- The entire 811-value React Groups catalogue has completed its contextual Irish review. Discovery, membership, welcome templates, invitations, discussions, member administration, announcements, challenges, files, media, webhooks, Q&A, the group wiki, scheduled posts, analytics, exports and branding now use natural Irish instead of untranslated controls, accentless welcome copy, list-management wording for invitations, meeting terminology for joining, a pole for webhook deletion, malformed time suffixes, analysts for analytics, or Ireland-specific location examples. A whole-catalogue test permits only 13 reviewed formats, units, URLs and placeholder-only values to remain language-neutral and rejects the documented failure patterns.
- The remaining React Volunteering feature sections have completed their contextual Irish review. Donations now describe current fundraising campaigns, card payments and offline bank-transfer pledges rather than obsolete giving-day behavior; safeguarding, accessibility accommodations, community projects, credentials, certificates, emergency requests, wellbeing guidance and the organisation wallet now use accurate, sensitive terminology instead of accessories, downloads for uploads, English burnout and shift fragments, or malformed automatic-credit instructions. Together with the core journey gate, focused tests now cover the complete 986-value source catalogue and its 15 reviewed language-neutral invariants.
- The core React Volunteering journeys now use context-aware Irish for members and organisation managers. Opportunity discovery, applications, logged-hour approval and automatic credits, shift registration, waitlists, swaps, credentials, group reservations, receipts and check-in wording no longer contain missing accents, English controls, software-application terminology, “apply” phrased as putting something into effect, or shift and decline mistranslations. A focused catalogue test classifies the 15 remaining language-neutral formats, currencies, URLs, brands and placeholder-only values and rejects these known semantic failures.
- The entire 946-value React Jobs catalogue has now received a contextual Irish review. Vacancy discovery, applications, employer tools, pipeline automation, analytics, interviews, hiring teams, moderation, talent search, AI assistance and onboarding now use natural job-specific language instead of malformed day suffixes, software-application terminology, inconsistent AI initials, missing accents, literal flag nouns, mistranslated hiring language or broken page metadata. A whole-catalogue test permits only 25 reviewed currencies, formats, brands, URLs and placeholders to remain language-neutral and rejects known corruption, whitespace and invisible characters.
- The entire 230-value React Podcasts catalogue is now contextually reviewed for Irish. Podcast Studio, publishing readiness, media states, show lifecycle messages, episode metadata, visibility and moderation labels now use natural Irish instead of English fallbacks, verbal “display” mistranslations, malformed archive actions or incident terminology. A whole-catalogue test permits only the reviewed A–Z and playback-speed formats to remain language-neutral and rejects whitespace and invisible-character corruption.
- The React podcast listening experience has received a contextual Irish repair. Browsing, show following, episode counts, transcripts, chapters, reactions, episode types and player errors now use natural Irish instead of English fallbacks or misleading words for display actions and incidents. A listener-catalogue test permits only the reviewed A–Z and playback-speed formats to remain language-neutral.
- Seventeen more signed-in pages on the accessible site now have an automated accessibility check. The browser-based accessibility gate only covered the dashboard, account and a handful of other signed-in pages. A fast new check now also guards the marketplace, jobs, ideas, courses, resources, exchanges, goals, connections, matches, federation, premium, coupons, polls, blog, reviews, skills and volunteering pages — confirming each has exactly one main content region, a working "skip to content" target, a page heading, and no duplicated element identifiers (a common cause of confusion for screen readers). It runs in every language with no browser needed, so a future change that breaks one of these basics is caught immediately.
- The native app's entire 46-string marketplace offer workflow is now reviewed for Irish. Offer creation, received and sent states, acceptance, rejection, withdrawal and counteroffers now use context-aware negotiation language instead of wording for opposition or a physical counter, and the malformed amount example was repaired. A whole-section test permits only the reviewed numeric amount format to remain language-neutral.
- The native app's entire 50-string marketplace pickup workflow is now reviewed for Irish. Customer reservations, collection windows, QR-code instructions, seller slots, recurring schedules, scanning and pickup statuses now use natural context-aware Irish instead of mixed English fragments or misleading literal wording. A whole-section test permits only three reviewed date and capacity examples to remain language-neutral.
- The rest of the accessible site's numbers now follow the reader's language too. The same US-only number formatting was fixed everywhere else it appeared: time-credit hours on group exchanges, volunteer hours and expense amounts, member and course star ratings, course costs, goal figures and coupon amounts. A German or French reader now sees "1,5" where an English reader sees "1.5", consistently across the site. English output is unchanged, and the shared locale-aware formatter used here is the same one proven by the German-price test in the previous change. (One low-traffic organiser-only analytics percentage on the event dashboard is left for a follow-up, as it needs proper percent formatting rather than a simple separator fix.)
- Marketplace and premium prices now use each language's own number format. Prices, offers, order totals and subscription costs on the accessible site were always formatted the US way — a full stop for the decimal and a comma for thousands (for example "USD 1,234.50"). On a site serving eleven languages that is wrong for readers who expect the opposite (German and French write "1.234,50"). Prices now follow the reader's chosen language, and time-credit amounts and seller ratings do the same. English is unchanged. Proven by a test that renders a price in German and checks it uses a comma decimal.
- Irish marketplace text no longer contains accidental leading spaces. Thirty-three labels, hints, placeholders and states across forms, offers, orders, coupons, seller tools, search, shipping and Stripe onboarding were normalized, with several adjacent malformed phrases repaired. A catalogue-wide whitespace test prevents the formatting defect from returning.
- The native app's entire 32-string marketplace map workflow is now reviewed for Irish. Search-area guidance, coordinate states, radius and nearby-result labels now use context-aware Irish; broken markup, placeholder copy, a malformed latitude example and marketplace grammar were repaired. A whole-section test permits only five reviewed coordinate and distance formats to remain language-neutral.
- Marketplace order, seller and promotion terminology in the native app has received a contextual Irish repair. Fourteen mistranslated or English labels now correctly describe parcel tracking, order ratings, delivery methods, seller messaging, average ratings and promotion placement. A focused test guards against the previously misleading words for “song,” “September,” and property listings returning.
- The native app's entire 77-string Ideation catalogue is now reviewed for Irish. The final eighteen challenge filters, lifecycle states, idea outcomes and sorting labels now use context-aware Irish, and a stray space was removed from the date-time example. A whole-catalogue test verifies every key and permits only that reviewed date-time format to remain language-neutral.
- Idea proposals and voting in the native app now stay in Irish. Thirteen proposal-form labels, submission outcomes, vote controls and empty states now use context-aware Irish. A focused test protects the complete member-contribution slice.
- Ideation challenge discovery in the native app now stays in Irish. Fifteen challenge-search, category, count, disabled, empty and error strings now use context-aware Irish while preserving dynamic counts. A focused test protects the complete discovery slice.
- The native app's entire 86-string Goals catalogue is now reviewed for Irish. The final fifteen support-companion, milestone, history, check-in, streak and reminder-frequency strings now use context-aware Irish while preserving names and dates. A whole-catalogue test verifies every key and permits only the reviewed percentage display to remain language-neutral.
- Goal details, progress updates and reminders in the native app now stay in Irish. Nineteen detail labels, not-found and failure states, progress controls, save states and reminder settings now use context-aware Irish while preserving dates and numeric examples. A focused test protects the complete interaction slice.
- Goal templates in the native app now stay in Irish. Twelve template-discovery, category, target, duration, selection and creation-error strings now use context-aware Irish while preserving titles, values and day counts. A focused test protects the complete template workflow.
- The native app's entire 91-string Gamification catalogue is now reviewed for Irish. The final twenty-five XP-shop and profile-showcase labels, balances, purchase states, selection controls and errors now use context-aware Irish. A whole-catalogue test verifies every key and permits only seven reviewed numeric XP, score and progress displays to remain language-neutral.
- Gamification challenges and learning journeys in the native app now stay in Irish. Thirteen challenge, journey, completion, reward-claim and error strings now use context-aware Irish while preserving raw XP and progress displays. A focused test protects both progression workflows.
- Progress, Nexus Score and daily rewards in the native app now stay in Irish. Twenty-eight progress summaries, statistics, reputation signals, streak labels, reward actions and errors now use context-aware Irish while preserving XP, score-fraction and percentage displays. A focused test protects the complete progress-and-rewards slice.
- The native app's entire 197-string Members catalogue is now reviewed for Irish. The final seventeen appreciation-wall labels, empty and error states, sign-in guidance and reaction controls now use context-aware Irish. A whole-catalogue test verifies every key and permits only four reviewed functional formats and identifiers to remain language-neutral.
- Saved-item collections in the native app now stay in Irish. Thirty-two collection titles, controls, visibility labels, empty states, errors and item metadata strings now use context-aware Irish while preserving names, dates and counts. The generated type-and-ID fallback remains unchanged as a reviewed functional format, and a focused test protects the complete collection workflow.
- Member discovery and public profiles in the native app now stay in Irish. Thirty-two listing, trust, review, achievement, appreciation and collection-navigation strings now use context-aware Irish while preserving names, counts and URLs. The profile share format, numeric transfer range and established XP label remain unchanged as reviewed functional values, and a focused test protects the complete profile section.
- A new shared load-error message on the accessible site is translated into Irish. The reusable failure state now tells Irish readers that the content could not be loaded and to try again, instead of falling back to English.
- The native app's entire 290-string Jobs catalogue is now reviewed for Irish. The final 34 owner-analytics labels, measures, comparisons, trends and prediction states now use context-aware Irish while preserving count, percentage, salary and market placeholders. A whole-catalogue test verifies every key and permits only twelve reviewed form examples, ranges and number-only formats to remain language-neutral.
- Job applications and the employer hiring pipeline in the native app now stay in Irish. Eighteen stage, candidate, application-message, history, withdrawal and error strings now use context-aware Irish while preserving dates and stage-name placeholders. A focused test protects both sides of the workflow.
- Job alerts in the native app now stay in Irish. Thirty-four tab, search, filter, status, date, success and error strings now use context-aware Irish, including time-commitment and remote-role wording. A focused test protects the complete alerts journey.
- The native app's entire 237-string Settings catalogue is now reviewed for Irish. The final 38 identity-verification labels, payment instructions, document requirements, progress states and errors now use context-aware Irish while preserving Stripe and fee placeholders. A whole-catalogue test verifies every key and permits only the reviewed date format, JSON identifier and example email address to remain language-neutral.
- Linked-account settings in the native app now stay in Irish without blurring consent boundaries. Thirty-four request, approval, delegated-access, status and permission labels now use context-aware Irish and describe member-approved account capabilities rather than staff-recorded guardian arrangements. The email example remains unchanged as a functional value, and a focused test protects the workflow.
- Blocked-user controls and personal-data exports in the native app now stay in Irish. Thirty-nine privacy labels, explanations, confirmations, warnings, formats, progress states and errors now use context-aware Irish. The JSON format name remains unchanged as a technical identifier, and a focused test protects both sensitive workflows.
- Language and content preferences in the native app now stay in Irish. Thirty-two account labels, feed-ordering controls, automatic-translation settings, save states and language names now use context-aware Irish. A focused test protects the complete preferences slice.
- The native app's entire 303-string Volunteering catalogue is now reviewed for Irish. The final 25 shift-swap labels, requests, actions, errors and statuses now use context-aware Irish. A whole-catalogue test verifies every key and permits only seven reviewed functional formats for dates, times, currency and percentages to remain language-neutral.
- Volunteer organisation wallet and settings controls in the native app now stay in Irish. Twenty-one balance, automatic-payment, deposit, transaction and organisation-editing labels and errors now use context-aware Irish. A focused test protects both management panels.
- The native app's volunteer organisation dashboard no longer falls back to English in Irish. Forty-seven dashboard labels, states, tabs, statistics, roles, application actions, hour-approval controls and volunteer summaries now use context-aware Irish while preserving names, dates, hour totals and application counts. A focused test protects the complete organisation-operations slice.
- The accessible dashboard, Explore and search pages now say "we could not load this" instead of pretending you have nothing. When the backend had a hiccup, these pages quietly showed empty cards or "no results" — so a member couldn't tell an outage apart from genuinely having no listings, events or matches. Now, if part of a page fails to load, a clear notice appears at the top; the search page shows the same notice rather than a misleading "No results found". Sign-in problems still send you to the login page as before. The notice is translated into all ten languages (Irish shows the English wording for now). Covered by tests that fail each page's data load and check the notice appears — and that a fully working page shows none.
- An empty chat now has a proper heading, and a one-to-one thread with no messages says the right thing. When you open a conversation that has no messages yet, the page showed only a sentence with no heading, so a screen-reader user moving heading by heading skipped past it. It now shows a "No messages yet" heading (translated into every language) above the message. Separately, a one-to-one thread with no messages was showing the wrong sentence — "There are no conversations to show", which belongs on the inbox list — and now correctly says "No messages in this conversation yet." A contract test keeps the heading on both the direct and group conversation pages.
- A member's profile page now says "we could not load this" if one of its sections fails to load. A profile is built from several parts — badges, reviews, skills, listings, recent activity, endorsements — each fetched separately. If one had a backend hiccup it used to come back empty, so a member with plenty of reviews or skills could look as though they had none. The core profile still shows as before, and now a single notice appears at the top if any section could not be loaded. The deliberate safety behaviour is unchanged: if we cannot confirm whether someone has blocked you, we still assume they might have, and sign-in problems still send you to login. Covered by a test that fails a section and checks the notice appears — and that a fully loaded profile shows none.
- The accessible activity, leaderboard, achievements and nexus-score pages now say "we could not load this" too. These pages had the same problem — a backend hiccup showed an empty timeline, an empty leaderboard, no badges or a zeroed score, none of which a member could tell apart from genuinely having none. All fourteen of their views now show the shared notice at the top on a load failure, while any single card that fails still leaves the rest of the page working. Sign-in problems still redirect to login. Covered by a failure-path test for each of the four sections.
- The accessible wallet donate form no longer throws away what you typed when a donation is refused. Like the transfer form before it, a rejected donation to the community fund — too large, not enough credit, and so on — reloaded with the error but wiped the amount and message you'd entered. Now both are filled straight back in, ready to correct and resend. They're kept server-side for the reload rather than put in the web address, since they're financial input. Covered by a test that fails a donation and checks the amount and message come back.
- The accessible wallet transfer form no longer throws away what you typed when a transfer is refused. If a transfer failed — too large, not enough credit, and so on — the page reloaded with the error but the recipient, amount and note you'd entered were all gone, so you had to search for the person and type everything again. Now the recipient reappears and your amount and note are filled back in, ready to correct and resend. The amount and note are kept server-side for the reload (never put in the web address, since they're financial), and a fresh anti-double-submit key is issued on the retry so there's no risk of sending twice. Covered by a test that fails a transfer and checks the values come back on the right recipient.
- Volunteer expense claims and donations in the native app no longer fall back to English in Irish. Forty-one financial labels, prompts, validation messages, categories and statuses now use context-aware Irish. The EUR currency code and percentage format remain unchanged as functional values, and a focused test protects both workflows.
- Volunteer shifts and certificates in the native app now stay in Irish. Twenty-nine labels, actions, empty states and errors now use context-aware Irish, including approved-hour certificates and accessible action labels. Three language-neutral date and time formats remain unchanged, and the existing software-style translation of “My applications” is corrected to member-facing Irish. A focused test protects the reviewed slice.
- The native app's entire 300-string Profile catalogue is now reviewed for Irish. The final 31 English descriptions for balances, messages, recommendations, marketplace tasks, jobs, events, ideas, volunteering and federation now use context-aware Irish. A whole-catalogue test verifies every key and rejects any exact-English fallback.
- Profile navigation and photo updates in the native app now stay in Irish. Forty-one section labels, marketplace shortcuts, account headings, permission prompts and upload states now use context-aware Irish, including the distinction between a member's profile image and a generic avatar. A focused test protects this navigation slice.
- Matches and reviews in the native app no longer switch to English for Irish users. Forty-seven labels, filters, actions, empty states and errors now use context-aware Irish for opportunity recommendations and member feedback, while score, rating and exchange-title placeholders remain intact. A focused test protects both Profile journeys.
- The remaining Irish support summaries in the native app now cover cookies, accessibility, and trust and safety. Twenty-four headings and paragraphs explain device storage, canonical web cookie rules, accessible native and HTML-first experiences, reporting, member protection and the limits of platform tools in context-aware Irish. The legal-summary test now protects all five policy sections.
- The native app's Terms and Privacy summaries no longer fall back to English in Irish. Sixteen legal-summary headings and paragraphs now use context-aware Irish while continuing to identify the full web terms and privacy policy as canonical. A focused test protects both summaries.
- The native app's Irish support hub, About summary and Contact guidance no longer display English. Forty navigation labels, policy descriptions and member-support paragraphs now use context-aware Irish, including urgent-harm guidance and the AGPL source-availability summary. This pass also catches a stale English description that differed from the current source and therefore escaped exact-match counting; a focused test protects the reviewed legal-support slice.
- The native app's entire 325-string Groups catalogue is now reviewed for Irish. The final 40 English fallbacks in group analytics now cover membership, engagement, retention, comparisons and activity breakdowns in context-aware Irish. A whole-catalog test verifies every key and permits only the two deliberately unchanged latitude and longitude examples.
- Group tasks and marketplace tools in the native app now stay in Irish. The task board's 39 labels, statuses, priorities, due dates and errors, plus the group marketplace, member roles and detail statistics, now use context-aware Irish with dates, titles and counts preserved. A focused test protects all four reviewed sections.
- Group questions, answers and wiki pages in the native app are now fully Irish. All 59 labels, prompts, voting actions, answer states, wiki editing controls, version-history messages and errors now use context-aware Irish with count and title placeholders intact. A focused test prevents either knowledge-sharing section from reverting to English.
- Group files and media in the native app no longer switch to English for Irish users. All 32 labels, empty states, permission prompts, upload/download actions and deletion confirmations now use context-aware Irish while preserving filenames such as {{name}}. A focused test protects both detail sections.
- Core group pages in the native app now stay in Irish. The group overview, discussions, announcements, upcoming events, owner tools and twelve detail tabs no longer display their 57 English fallbacks. Counts and interpolation placeholders are preserved, and a focused test protects this member-facing slice.
- Creating and editing groups in the native app no longer falls back to English in Irish. The 43 user-facing fields, hints, validation messages, image errors and save states in the group form now use context-aware Irish, together with the Groups landing-page description, statistics and post count. Coordinate examples remain unchanged as functional numeric values, and a focused test protects the reviewed slice.
- The native app's Irish Explore screen is now fully translated. All 64 discovery labels and descriptions now use context-aware Irish, including recommendations, popular and nearby listings, community statistics, upcoming events, groups, people, volunteering, organisations, jobs, polls and resources. A focused catalog test prevents the whole screen from silently reverting to English.
- Irish event-safety wording is clearer and no longer contains English status labels. The native app now translates the six remaining English guardian and acknowledgement statuses, and a semantic review also replaces machine-like safeguarding-policy and member-block phrases, improves guardian relationship wording, and removes invisible zero-width characters from participation-block messages. Matching wording in the PHP event-safety catalogue is aligned where the same concepts are shown.
- Irish mobile account recovery, verification and registration no longer fall back to English. The 45 remaining English values in the native app's authentication catalogue now use context-aware Irish, covering password reset, email verification, registration confirmation, password requirements, required-field errors and the platform terms acknowledgement. Functional examples such as international phone-number and email formats remain unchanged, and a focused test now prevents this catalogue from silently reverting to English. Independent native-speaker review is still recommended.
- The measured Irish fallbacks in the accessible and PHP translation catalogs are cleared. The final 49 verbatim-English values covered counts and plurals across activity, comments, reactions, feeds, polls, goals, groups, jobs, messages, organisations, resources, saved items, search and volunteering, plus three "Post" labels, three shift labels, two reaction labels, a notification content type and a PDF not-applicable value. They now use context-aware Irish while preserving Laravel choice syntax and placeholders. The shrink-only untranslated audit now reports zero Irish values identical to English unless they are intentionally allowlisted; independent native-speaker review is still recommended and React/mobile semantic review remains separate work.
- Development-only ASP.NET backend: whole features were switched off without anyone being told. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. The information a page fetches when it first loads was missing 20 of the platform's on/off switches — marketplace, courses, podcasts, identity verification, two-step sign-in and fingerprint sign-in among them. The app treats a missing switch as "this feature is off", so on this backend those features simply did not appear, with nothing anywhere to say why. Also missing were the community's currency (so every price and time-credit figure had nothing to format with) and the list of available languages (so a member had no way to change language at all). All now present, using the main platform's own default settings rather than any one community's choices — copying a single community's settings would have quietly made its preferences everyone else's defaults.
- Development-only ASP.NET backend: we can now check the pages you only see once you are signed in — and they were much further out than they looked. None of this is deployed. Until today the two backends could only be compared while signed out, because the only local copy of the live database holds real member data and must not be logged into. Building a throwaway copy — the shared database structure plus a handful of invented test accounts, no real data at all — made the rest visible. Signed out, 164 of 170 pages matched. Signed in, it was 17. That is not a step backwards; it is the first honest look behind the login. It is now 57, with another 31 correct in structure but not yet fully checkable because the test community only has three people and one listing to compare. Four things were being handed to signed-in members that the live platform refuses. Talent search listed every member who had agreed to be seen by employers to anyone at all. Community federation — which shares a member's profile beyond their own community — was serving people who had never opted in, so the opt-in was doing nothing. Marketplace promotions ignored the separate switch that controls them. Paid push campaigns, which send notifications to a community and charge for them, had no check whatsoever. Two pages accepted nonsense. One answered "yes, you can go ahead with this exchange" when the request did not name a listing at all. The knowledge-base search returned every article when the search box was empty. Sixteen list pages reported page numbering in a form the app cannot read. They sent a page count and a row total; the live backend sends how many fit on a page and whether another page exists. Anything built to show "next page" from the live backend's answer would have found nothing here. Two shared pieces of code were responsible for most of them. Nearly every page was missing a small block of information the live backend attaches to its answers, and 41 carried an extra flag it never sends. Both are now handled in one shared place, mirroring how the live backend does it. That change corrected 130 pages at once and required updating 122 tests that had been quietly recording the wrong behaviour as correct.
- Development-only ASP.NET backend: every page the React app loads now answers exactly like the live one. None of this is deployed — the ASP.NET backend is a development-only second implementation of the same platform, built so the app could one day run on either. Measured against the live backend across the 170 addresses the app calls, it now gives the same answer on 164 and differs on none; the other six could not be compared because one side had no example data to compare. It was 136 at the start of the day. Web addresses ending /v2/ were letting people in without a login. The backend publishes many pages at two addresses. The second address was being built in a way that quietly dropped the "you must be signed in" rule, so eleven pages ran for anyone who asked. Ten showed nothing only because the community that owns them had that section switched off; one crashed instead. This was invisible to every existing check, because both addresses exist and a list of addresses cannot tell you one of them forgot to ask who you are. A community's feature switches were invisible to half the platform. Switches are stored under a name, and two different spellings of that name had grown up. The part that decides whether to serve a feature read one; the part that tells the app which features exist read the other. So a community could switch its public events page ON, have the backend serve it, and have the app still hide it — or switch a feature OFF and have the app keep advertising a page the backend refuses. All forty feature switches were affected. Both spellings are now read, so no community's existing setting is lost. The sign-up page was told the wrong thing about whether sign-ups were open. Three answers were fixed values rather than being worked out from the community's actual setting, so a community that had closed registration was still told it could register. Smaller fixes to match the live backend: the community picker, the skills categories list (which was answering from the wrong list entirely), the categories list, public events paging, and seven pages that were missing a small block of information the live backend includes.
- Development-only ASP.NET backend: the last nine pages that answered strangers are now closed. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. Course categories, marketplace categories, featured listings and promotions, jobs salary benchmarks and talent search, community stories, and the live-updates connection settings all returned data to callers with no login, where the main platform requires one. They returned little only because the demo data is thin; with real data they would have exposed it. A ninth, the paid membership tiers, was different: the main platform hides it when a community has not switched that feature on, while this backend advertised paid tiers to everyone regardless. All nine now behave exactly as the main platform does — including refusing in the same way, since "not available here" and "please sign in" are different answers and the app treats them differently.
- Development-only ASP.NET backend: a fourth "please sign in" shape, and eight more screens fixed. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. After unifying three of them, measuring the main app's own list of requests found a fourth: when the "not signed in" reply came from the error handler rather than the page itself, it used a completely different format again, and in development it attached a stack trace to what is not an error at all. Eight screens were affected — saved job profiles, interviews, offers, blocked members and linked accounts among them. All now return exactly what the main platform returns.
- Development-only ASP.NET backend: "please sign in" came back in three different shapes. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. Depending on how each page was protected, a request without a login got one of three different replies: the correct one, a raw technical error page, or a third variation. The app reads that reply to decide what to do next, so it could not treat them as the same thing — meaning a member could be signed out on one screen and see a confusing failure on another. All of them now return exactly what the main platform returns, fixed in one place so it cannot drift apart again.
- Development-only ASP.NET backend: three pages were locked that should be open, and a jobs list was open that should be locked. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. Clubs, popular listing tags and skill categories are all public on the main platform, but this backend demanded a login — so a signed-out visitor browsing a community saw nothing on three pages that work elsewhere. Two of those are marked as deliberately public in the main platform's own routing, so there was no ambiguity about the intent. Going the other way, the jobs list was readable without a login here while the main platform requires one; that is now closed too, though the reason it slipped through is not yet understood and has been written down rather than glossed over.
- Development-only ASP.NET backend: members' names and email addresses were readable by anyone. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. Checking the accessible site's requests against both backends for the first time found that the member search returned names and email addresses to a caller with no login at all, where the main platform requires one. Five more pages behaved the same way: courses, podcasts, jobs, and the two resource-category lists. All six now require a login, matching the main platform. This was only findable by asking both backends the same question — the accessible site had never been checked against this backend before, and the earlier check covered the main app only.
- Development-only ASP.NET backend: it was giving away things the main platform keeps behind a login. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. A new side-by-side comparison tool asks both backends the same question and compares the answers, and the first run found that the resources list handed real community content — titles, descriptions, files — to someone with no login at all, while the main platform requires one. Marketplace listings and volunteering organisations did the same, and looked harmless only because the demo data is empty. One case ran the other way: categories are public on the main platform but demanded a login here, so a signed-out visitor lost a page that should work. All four now match. This mattered because no previous check could see it — every existing tool compared lists of web addresses, and both backends "have" these addresses; only asking them both the same question revealed that one answers to strangers.
- Development-only ASP.NET backend: backing a community project was entirely cosmetic. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. Tapping "support" nudged a number the page had made up, saved the fact into an unlabelled settings entry, and the list of projects did not carry a supporter count at all — so the number reset the moment the page reloaded, and nobody organising a project could see who had backed it. Support is now recorded properly: the count is real and shared, whether you have backed it is yours alone, backing twice still counts once, and withdrawing support takes the count back down.
- Development-only ASP.NET backend: archiving a conversation did nothing, and a wellbeing check-in never reached the people meant to follow it up. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. Archiving a conversation saved a note into an unlabelled settings entry, while the inbox works out its "archived" tab from the messages themselves — so the conversation reported as archived and stayed exactly where it was, every time. It now moves properly, and archiving is personal: filing your own copy away does not hide the conversation from the other person. Separately, a volunteer's wellbeing check-in was being saved into another unlabelled entry, even though a fully working version already existed at a slightly different address — so a volunteer could say they were struggling, be thanked for checking in, and the follow-up list that exists to notice exactly that would never see it. Check-ins now reach it, and a very low mood is flagged for follow-up automatically rather than depending on someone reading every entry.
- Development-only ASP.NET backend: an expense claim reached neither the volunteer nor a reviewer. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. Submitting an expense claim saved it into an unlabelled settings entry, while the list of claims on the very same screen read the proper records store. So a volunteer submitted a claim with a receipt, was told it was submitted, and it appeared nowhere. It could not have worked in any case: the submit step expected a different kind of request from the one the screen sends, so the claim never even arrived. Claims now go to the proper store, arrive as awaiting review rather than approved, keep their receipt, and are refused if they have no amount or no description — a reviewer cannot approve a claim that does not say what it is for. A claim without a receipt is still accepted, because refusing it outright pushes people to submit nothing at all.
- Development-only ASP.NET backend: a member's donation never reached the people running the community. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. Recording a donation saved it into an unlabelled settings entry; the member's own list of donations stayed empty; and the staff donations screen read a completely different place. Three ends, no two of them connected. Donations now go to the same place staff already look, so a member sees their own and staff see them too, with the amount stored exactly and impossible amounts refused. Importantly, recording a donation is not the same as taking a payment: every donation is saved as awaiting payment, nothing here contacts a card provider, and it is the existing staff "mark complete" step or the payment provider's own confirmation that moves it on. Marking money as received when it has not been is the money version of the same false-success problem being cleared elsewhere.
- Development-only ASP.NET backend: a reported safeguarding concern reached nobody. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. This is the most serious problem found in it. A volunteer could report a safeguarding concern — a disclosure, an allegation, a worry about a child or an at-risk adult — and be told "Incident recorded". Nothing recorded it anywhere anyone could read: the report went into an unlabelled settings entry, the volunteer's own list of reports stayed empty, opening any report showed a made-up "open" case that did not exist, and no staff screen read reports at all. So the concern was accepted, acknowledged, and lost, and neither the person who raised it nor a safeguarding lead had any way to notice. Reports are now stored properly and reach a staff queue that lists the most serious first. Nobody can delete a report — not even the person who made it — because a raised concern is a record; withdrawing it is a decision a safeguarding lead records with a reason. A volunteer sees only their own reports. Only staff can change a report's status, and closing one now requires a reason, since "why was this closed?" is the first question any review asks. Telling the designated safeguarding lead, and notifying an outside authority, are both recorded with the time they happened.
- Development-only ASP.NET backend: volunteers' certificates, accessibility needs and reviews had nowhere to be stored. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. Three volunteering screens had no database tables behind them at all, so each showed an empty list that looked exactly like "you have none". The certificates one was the worst: a volunteer could upload their first-aid certificate, be told it worked, and never see it again — and the download button handed back a made-up text file containing the words "Credential 5" instead of the real document. Certificates now work properly: upload, list and delete, with the deletion limited to your own so nobody can remove someone else's safeguarding evidence. Police-check and vetting documents are now refused by the server as well as the browser, because those must never be stored here. And a certificate that has passed its expiry date now shows as expired when read, rather than still saying "verified" — which matters, because "verified" is what a coordinator relies on when deciding who may work with children. The accessibility and reviews screens now work too. Accessibility needs were the strangest case: saving them did store something, but in a place no screen could ever read, so a volunteer recorded the support they need in order to take part, was told it was saved, and it vanished. Those needs are now stored properly, come back when the volunteer returns, can be withdrawn as well as added, and are visible only to the volunteer themselves. Organisation reviews now appear on the organisation's page instead of every organisation looking as though nobody had ever reviewed it; a second review from the same person updates their first rather than stacking, and impossible star ratings are refused both by the site and by the database itself.
- Development-only ASP.NET backend: "sign out this device" and "sign out everywhere" did nothing. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. Both replied that the sessions had been ended while leaving every one of them fully working. "Sign out everywhere" is what someone uses when they believe another person has got into their account, so answering "done" while the intruder stays signed in is the worst possible response to that question. Both now genuinely end the sessions, are recorded so it stays answerable who signed out and when, and stop at your own account — one member cannot sign another out, and a request that does not say which session to end is refused rather than guessed at.
- Development-only ASP.NET backend: the super-admin community controls did nothing at all — including one that claimed to delete a community. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. All six buttons for managing communities (create, edit, deactivate, reactivate, allow sub-communities, move) replied "done" and changed nothing. The worst was the delete button: it answered "Tenant deleted" while the community stayed fully live, which a platform administrator could reasonably have believed. All six now work properly, with the safeguards the main platform has: the master community can never be deleted or moved; deleting is a deactivation, not a wipe, and is refused while a community still has active sub-communities; a community cannot be moved underneath one of its own sub-communities; moving a community correctly moves everything beneath it; a community name that would clash with a platform page (like "admin" or "api") is refused because it would be unreachable; and turning off a community's ability to have sub-communities also removes the network-administrator status that only existed because it had them. Testing this against a running copy also revealed that newly set-up demo communities were being created without their position in the community tree recorded, so nothing could be moved to them — now fixed. The two screens that show communities were fixed in the same pass: the list was a hardcoded empty list, and the detail page returned a blank record with a made-up creation date for whatever community you opened. The empty list mattered more than it sounds, because four moderation screens (feed, comments, reviews and reports) use it to fill their "filter by community" menu — an empty menu reads as "there are no other communities" rather than "this did not load". Opening a community that does not exist now says so instead of inventing one.
- Development-only ASP.NET backend: two overdue safety routines that were never running now run. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. First, copies of messages that a safeguarding reviewer had looked at were being kept for ever. They are now deleted once their retention period passes — 90 days for a message reviewed and cleared, a year for one that was flagged, since a flagged message may be needed as evidence. A message nobody has reviewed yet is never deleted, at any age, because that would quietly throw away a safeguarding concern no one has read. Second, when a member reported a marketplace listing and no moderator responded within 24 hours, nothing happened at all: the report sat untouched, the member was never told, and the seller never learned their listing had been reported. Responding within a day is a legal requirement, so the platform now acknowledges the report itself and tells both people — the member that their report is with the moderation team, and the seller that one of their listings is being reviewed and stays available unless a problem is found.
- Development-only ASP.NET backend: group messaging can now exist at all, and a privacy feature had been quietly renaming groups. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. That backend stored a conversation as exactly two people, so a group conversation could not be represented, while the app has a working "create a group" screen: a member could fill in the form, choose people, and get an error. Adding someone, removing someone and renaming a group all replied "done" and changed nothing. Groups are now real — any number of members, each either an organiser or a member, able to join and leave — and every existing one-to-one conversation was carried over so nobody loses access to their own message history. Only organisers can add, remove or rename; someone outside a group cannot read it or post to it; leaving keeps a record of who could see what and when. Separately, this uncovered that the feature which hides members' surnames was treating a group as if it were a person, and cutting the group's name at the first space — so "Alpha Bravo Charlie" appeared in the list as "Alpha". Groups are now exempt, surnames are still hidden, and that surname feature has its first tests after having had none.
- Development-only ASP.NET backend: an audit found endpoints that reported success while doing nothing, and several member-facing faults were fixed. None of this affects the live platform — the ASP.NET backend is a development-only second implementation and is not deployed. An adversarial re-audit of it found, and this work fixed: members being logged out every time their sign-in token expired (the app's "keep me signed in" call reached an endpoint that had been retired, and the app reads that as "your login is bad"); two browser tabs refreshing at once logging a member out everywhere, rather than being recognised as a harmless race; a terms-and-conditions dead end where a member was blocked and the only two endpoints that could unblock them were not registered at the address the app calls; and a fake endpoint that reported a member had accepted the terms when no such record existed. Three webhook handlers — card payments, identity checks, and email bounce reports — replied "received" and threw the data away, which tells the sender never to retry; they now refuse honestly so the event stays in the sender's queue. On the carer/supporter feature: the emailed "confirm this action" link, which needs no login and authorises a time-credit transfer, had no rate limit and was brute-forceable; and a permission check answered "yes" to any unrecognised permission name, including for someone granted nothing. On guardian consent for children attending events: a consent link worked for every community rather than only the one that issued it; consent expired 24 hours after it was requested regardless of when the event was, so approval lapsed and the child was blocked; and a child could approve their own consent.

### Added

- A blocking check that counts backend endpoints which report success while doing no work, and only lets that number shrink. The audit's central finding was that 351 endpoints in the development-only ASP.NET backend answer with convincing "success" responses while performing no work at all. That class of fault is invisible to route comparisons and to the existing tests, which only check that a web address exists and responds — which a do-nothing endpoint satisfies. The new check reads what each endpoint actually does, and fails the build if the count rises (or if it falls without the recorded figure being updated in the same change). It was verified by deliberately adding a do-nothing endpoint and confirming the check caught it. Full findings and the remediation order are recorded in aspnet-backend/docs/PRODUCTION_READINESS_REMEDIATION.md.
- Event agenda session times now use the accessible GOV.UK date-and-time fields, completing the changeover. Every session's start and end time on an event's agenda is now the date boxes plus a "Time" box. With this, every date-and-time picker on the accessible site — wallet slots, podcasts, event messages, registration campaigns and settings, event create/edit, recurring edits, and now the agenda — has moved off the browser's built-in widget to the GOV.UK Design System pattern the standards recommend. Two inputs are deliberately left as-is and recorded: a poll's closing date (its browser minimum is the only guard against a poll closing in the past) and the availability slots' plain time boxes (GOV.UK has no component there). Saved values are unchanged throughout; all covered by tests, with the main event-create form additionally checked end-to-end against a live backend.
- Editing a repeating event's times now uses the accessible GOV.UK date-and-time fields (eighth conversion). Same saved value, same timezone handling; covered by tests.
- Creating and editing an event now use the accessible GOV.UK date-and-time fields (the two most-used organiser forms, sixth and seventh conversions). The event's start and end are now three date boxes plus a "Time" box instead of the browser's date-and-time widget. What's saved is unchanged — the accessible frontend sends the same value it always did and the server does the timezone work — so existing events and their times are unaffected. Verified two ways: automated tests confirm the four fields recombine into exactly the value the event API receives, and a real event-create was run against the disposable test copy to confirm the round-trip works against a live backend. Seven of the site's seventeen date-and-time inputs (ten of eleven counting each occurrence) are now on the accessible pattern.
- Event registration settings (the opening, closing and cancellation-cutoff times) now use the accessible GOV.UK date-and-time fields (fifth form converted). These times are stored in the event's own timezone; the accessible frontend passes the same value it always did and the server does the timezone work, so nothing about stored times changes. A part-filled time now shows a clear error instead of being silently cleared.
- Event registration-invite campaigns now use the accessible GOV.UK date-and-time fields (fourth form converted): both the "invites expire at" time when sending now and the "send at" time when scheduling a campaign. Same saved values as before; covered by tests. Six of the site's seventeen date-and-time inputs are now on the accessible pattern.
- Scheduling an event message now uses the accessible GOV.UK date-and-time fields (third form converted). When an organiser schedules a broadcast to attendees, the "send at" time is now the date boxes plus a "Time" box. A bonus safety improvement: if someone half-types a time, they now get a clear "there is a problem" instead of the message quietly being treated as "send now". Blank still means send now, exactly as before; the saved time is unchanged.
- Podcast episode scheduling now uses the accessible GOV.UK date-and-time fields too (second form converted). Setting when an episode publishes is now the three date boxes plus a "Time" box, on both the "add episode" and "edit episode" forms, instead of the browser's date-and-time widget. The shared component gained the ability to appear more than once on a page without clashing, which these two forms need. Same saved value as before; covered by tests.
- The marketplace pickup-slot times now use the accessible GOV.UK date-and-time fields (first form converted off the browser's built-in date-and-time picker). Setting a slot's start and end is now three plain number boxes for the date plus one "Time" box that accepts what people naturally type ("9:30am", "17:00"), instead of the browser widget the GOV.UK Design System advises against. What gets saved is exactly the same as before, so existing slots are unaffected; the change is verified by tests covering both the form and the save. The remaining date-and-time forms (events and other organiser screens) follow the same way, one at a time.
- Groundwork for replacing the browser's date-and-time pickers with the GOV.UK pattern. The accessible site's remaining date-and-time fields (on event and organiser forms) still use the browser's built-in picker, which the GOV.UK Design System advises against. This change adds the reusable building blocks to convert them — a shared "date plus a single time field" form component and the server-side code that turns those fields back into the exact value the system already stores, plus the "Time" label, hint and error wording translated into all eleven languages. Nothing member-facing changes yet: the pieces are in place and fully unit-tested (time parsing accepts "9:30am", "14:30", "9.30" and rejects impossible times), and the forms will be converted one at a time in follow-up changes so each can be tested end to end.
- The connections pages now say "we couldn't load this" instead of pretending you have no connections. When the connections service had a problem, both the connections page and the network page silently showed empty lists — a member would reasonably read that as "I have no connections and no pending requests," when in fact the data just failed to load. Both pages now show a clear "We could not load your connections. Please try again." notice (translated into all eleven languages) when the load fails, and still show the real empty state only when you genuinely have none. (A wiring bug meant the network page's notice would never have appeared — caught and fixed, with tests covering both pages.)
- Empty pages on the accessible site now have a heading, so screen-reader users get a landmark. Across ~54 places — the leaderboards, saved items, search results, resources, courses, clubs, goals, jobs lists, marketplace and its orders, groups, ideation, federation browse pages, volunteering alerts, and more — a page (or a whole region of a page) that had nothing to show displayed only a sentence with no heading above it. A screen-reader user moving through the page heading by heading would skip straight past it. Each now shows a "No results found" heading (already translated in all eleven languages, so no new wording was needed) directly before the empty message. This was done with care: pages whose empty message already sits under its own section heading were deliberately left alone to avoid duplicate headings, and empty chat threads were left for a future pass because "No results found" is the wrong wording there. A contract test now keeps a heading on each of these empty states.
- The marketplace category page's "Back to marketplace" link is now translated. It had one last hardcoded English fallback label; it now uses the existing translated wording, so it reads in the member's language like every other back link.
- Two date/number display bugs on the accessible site. A knowledge-base article showed its "Last updated" date as a raw machine date (2026-07-06) instead of a readable, translated date ("6 Jul 2026", and the equivalent in each language) — it now uses the site's standard date formatter. And marketplace coupon discounts were always formatted in US number style regardless of the reader's language; they now format numbers in the reader's own locale.
- The accessible site now prints cleanly. It had no print styling at all, so printing a page (a wallet statement, an event ticket, a volunteering record) carried the full site chrome — header, navigation, footer, cookie banner, language switcher and buttons — and wide tables were clipped to one screen width. A print stylesheet now strips that chrome, prints in black on white, shows the destination of content links, and expands scrollable tables so the whole table prints. It lives entirely inside the browser's print mode, so nothing about the on-screen site changes. (Not yet checked on a physical printer — the stylesheet compiles and the on-screen site is unaffected.)
- Wide tables on the accessible site now scroll properly on small screens and can be reached with a keyboard. 33 data tables — wallet history, event lists and analytics, leaderboards, marketplace slots and coupons, skills, and the volunteering records (hours, expenses, donations, wellbeing, safeguarding and more) — could overflow the screen on a phone with no way to see or scroll to the hidden columns. Each is now wrapped in a focusable scrolling region that a keyboard user can tab into and scroll, and that a screen reader announces by the table's own heading (which is already translated, so no new wording was needed). This meets the GOV.UK "make tables scroll" pattern and the WCAG reflow requirement. A contract test now makes sure every data table ships inside the scroll region.
- The last scattered pockets of English on the accessible site are now translated. A deeper sweep found a handful of phrases still hardcoded in English on otherwise-translated pages: the "Community code" field and its hint on the sign-in, registration and forgot-password pages; the "Communities in this network" heading, its intro line and each "Community link" on a community's home page; the "no messages yet" line and the hidden "Your message" label in a conversation; the "job alerts couldn't be loaded" and "exchange options couldn't be checked" notices; the "we couldn't load the communities" notice on the community chooser; and the word "Important" on every information banner across the whole site. Ten phrases were added to the translation system and machine-translated into nine languages (the banner "Important" reused wording that already existed). The Irish fallbacks were then rewritten in context-aware Irish, with placeholders such as the community name and link preserved. With this, the Ideas, Courses and these scattered strings together clear the bulk of the untranslated text the audit found.
- The Courses (learning) section of the accessible site is now translated. Its pages — browsing and course detail, my learning, the lesson player, and the instructor's own screens including the grading queue and course analytics — were almost entirely English built straight into the pages, so speakers of the other languages saw English throughout. That text now lives in the translation system: 79 phrases, machine-translated into nine languages (German, French, Italian, Portuguese, Spanish, Dutch, Polish, Japanese, Arabic) and subsequently rewritten in context-aware Irish rather than routed through Google Translate. Counts like "Based on N reviews" and "This course costs N time credits" use language-appropriate grammar. The Irish course journey no longer falls back to English, while independent native-speaker review remains advisable before describing the wording as professionally certified.
- The Ideas (ideation) section of the accessible site is now translated. Its twelve pages — challenges, ideas, drafts, outcomes, campaigns, tags and the management screens — had large amounts of English text built straight into the pages, so speakers of the other languages saw English throughout. That text now lives in the translation system: 36 new phrases, machine-translated into nine languages (German, French, Italian, Portuguese, Spanish, Dutch, Polish, Japanese, Arabic), with those phrases and four existing count messages subsequently rewritten in context-aware Irish rather than routed through Google Translate. Placeholders and Laravel plural-choice syntax are preserved, and independent native-speaker review remains advisable. (Many of the ~155 English fragments the audit counted turned out to reuse phrases the section already had, which is why 36 new ones covered it.)
- Three safeguarding messages on the linked-accounts settings page now show in the reader's language. When linking an account was blocked for a safeguarding reason, the explanation was shown in English to everyone. The same wording already existed, fully translated, elsewhere in the platform; those three messages now use it. (The other ~25 status messages on that page were already translated — the audit over-counted.)
- Browser-tab titles on the accessible site are now correct and translated. Every community's home page showed the tab title "Accessible - Project NEXUS Accessible" — the community's own name never appeared and the word "Accessible" showed twice. The tab now shows the community's name. Separately, 96 "not found" pages (a missing event, listing, group, and so on) showed a translated heading but an English tab title in every non-English language; the tab title now matches the heading in all eleven languages. Both were fixed centrally, reusing wording that already exists — no new translations needed.
- Success and status messages on the accessible site are now announced properly by screen readers. About sixty on-page notification messages were missing the small pieces that make a screen reader read them out and give them a name: roughly 23 weren't announced at all, a dozen success messages weren't flagged as announcements, three information notices were wrongly flagged as urgent, and about twenty had no accessible name. All corrected by a simple rule — a success message is announced; an information message is a named region — including the shared message box used across the marketplace, so several pages were fixed at once.
- Long text boxes across the accessible site now show a live "you have N characters left" counter, in the reader's own language. 53 more text boxes — cancellation and decision reasons, comments, appreciations, safeguarding and wellbeing notes, bios, applications, reviews and more — gained the standard GOV.UK character counter that updates as you type and is announced to screen readers. Before, a member writing a note simply hit an invisible limit and their text stopped. The counter's wording is supplied in all eleven languages, with the correct grammar for each (Irish has five number-forms, Arabic six), so a screen-reader user does not hear an English sentence. The browser still enforces the limit for anyone without JavaScript. A floor check stops the counter being stripped off in future.
- Screen readers now announce "Error" in the reader's own language, on 36 form fields. The design system the accessible site is built on inserts the English word "Error" before each error message, baked into its own code. On sign-in, registration, password reset, event create and edit, listing create and the message box, a member using a screen reader in one of the ten non-English languages heard the English "Error" immediately before the translated message. All 36 now use the translated word, two that showed a doubled colon ("Error::") are corrected, and a whole-site check stops the English one returning.
- Screen-reader and assisted-input gaps across the accessible site, found by the same deeper audit. Hint text sitting under individual radio and checkbox options — the event category filter, the "edit one occurrence or the whole series" choice, the remote-attendance checkbox, the event-registration guest and questionnaire options, and the two message-delete options — was visible on screen but never read out by a screen reader, because nothing connected the hint to its input; it is now announced with the field. On the getting-started page, the photo-upload and "about you" error messages appeared on screen but were likewise not read out with their fields; they now are. Two single checkboxes on the group announcements pages were wrapped in a nameless grouping element, which accessibility checkers rightly flag as a failure — the pointless wrapper is gone. The jobs list skipped a heading level, which breaks the common screen-reader habit of jumping through a page heading by heading, and the volunteering credentials table had no announced name — both fixed. Separately, five fields that ask a member for their own details — an email address, a display name, and three website addresses — now let the browser offer to fill them in automatically, which the accessibility standard requires because retyping your own details is a real barrier for people with motor or memory difficulties. No wording changed anywhere, so nothing needed translating. All 2,227 accessible-site tests pass.
- Three faults on the accessible site that could genuinely hurt a member — found by a deeper audit, now fixed. Buttons that spend credits or delete things could act twice on a double-click. No money or destructive button on the site had the standard guard against being pressed twice, and the credit-transfer form would accept a blank duplicate-protection code. A quick double-click, or going back and resubmitting, could send a transfer twice. Every such button now ignores a second press, and the transfer refuses a blank code. A whole-site check now makes sure no future money or delete button can ship without the guard. The "show off my badges" form never worked. It was the one form on the whole site missing its security token, so every save was silently rejected as "this page has expired". Fixed, and a whole-site check now makes sure no form can ever ship without its security token again. Two "done!" messages that lied, and a sign-up page that could lock people out. Marking a group of notifications read, or clearing them all, showed a success message even when the action had actually failed — so the notifications stayed there. And the registration page disabled its own "Create account" button until an outside password-breach-checking service replied; if that service was blocked or slow to answer, nobody could sign up, with no explanation. Both fixed: the success message now only appears on real success, and the sign-up button always works (the password rules are still enforced when you submit).
- More accessible-site bugs from the deeper audit, in the behind-the-scenes code. Switching language on a half-finished page no longer wipes your choices. When starting a group message, the people you had picked were being silently thrown away the moment you changed language — the list survived for ordinary text boxes but not for multi-select choices. Fixed, and the same fix protects marketplace filters and anything else that lets you pick several things at once. Closed a way to trick the server into calling the wrong internal address. On the courses pages, a carefully-crafted web address could make the site send your own request to a different internal destination than intended. Course addresses are now strictly checked to be plain numbers, and anything else is turned away with a "not found". A tenant-loading failure now shows a proper error page instead of a blank one. If the site briefly could not load a community's settings, the error page itself broke and showed a bare "Internal Server Error" with no styling or way back. The safety-net that dresses those pages now runs early enough to catch this. A sign-in rate limit that wasn't really limiting. The limit on repeated sign-in attempts was counted per email address as well as per computer, so someone trying many different email addresses from one place got a fresh allowance each time — the opposite of the intended protection against guessing which emails have accounts. It now counts per computer, as intended. Two pages that could show the wrong thing when the server hiccuped. A member's profile could briefly show someone as "not blocking you" when the block status couldn't be confirmed, and the dashboard could hide the "finish setting up your account" prompt from someone who still needed it. Both now err on the safe side, and an expired login correctly sends you to sign in again rather than showing a half-loaded page. Removed two dead files that served no pages but showed up in audits and looked live. Not changed this round, and why: making oversized-file uploads show a friendly "that file's too large" message (rather than the current styled error page) needs new wording translated into every language, which is on hold behind the Irish-translation question — so it's grouped with the translation work. The current behaviour is a proper error page with a way back, not a crash, and a suspected temporary-file leak was checked and found not to be real.

### Removed

- The old accessible website has been taken out of the platform completely. For a while there were two accessible sites: the original one built into the PHP application, and the newer standalone one that has been serving accessible.project-nexus.ie since 12 August and both community accessible addresses since 14 August. The old one is now deleted — its pages, its routes, its controllers and its build. Before deleting it, all three live accessible addresses and all eleven communities were checked one by one and confirmed to be served by the new site. One thing this costs, stated plainly: until now, if the new site had broken, changing a single line of web-server configuration sent every accessible address back to the old site within seconds. That escape route is gone, because the old site is no longer in the release. Recovering from a bad release now means undoing this change and deploying again — around fifteen to twenty minutes. The owner decided to accept that.
- Six things the deletion left behind, all of which would have broken something. The removal was reviewed before being published and was found to be incomplete in ways that a passing test run did not reveal. The production image recipe still tried to copy a folder that no longer existed, which meant the live server image could not be built at all. An error-page handler still tried to render a template from the deleted site, which turned a harmless "page not found" from an old bookmark into a hard server error — and did so from inside the error handler itself, which is the worst place for it. Four leftover test files referred to deleted code and would have failed. A documentation check insisted on an index file for the deleted folder and could never pass again. The route-coverage check silently started reporting zero pages instead of failing, which would have made it useless. And a local test-running script listed nineteen deleted files and refused to start at all. All six are fixed. The route-coverage check has been kept alive rather than switched off: the old site's complete list of 707 pages is now frozen in the repository, so the check still notices if the new site ever stops serving one of them.
- Two screenshot tools that compared the new site against the old one have been retired, since there is no longer another site to compare against. Their safety checks were not lost: the surviving screenshot tool — which captures signed-in pages that get committed to a public repository — previously had no tests at all, and now has nine, covering every case in which it must refuse to run. It must refuse if the database it is pointed at contains even one real-looking account, if the database is empty, and if the database cannot be inspected. Each refusal was proven by deliberately breaking the check and watching the test fail.
- A stale sign-in token file for the deleted site was removed from the local working folder. It was never committed and never at risk of being published, but it was a live credential for an application that no longer exists.
- A seventh leftover, found by our own checks rather than by searching. One more tool compared the new accessible site against the old one, and when the old one vanished it did the right thing: it refused to publish a comparison against nothing, and turned a check red. That is exactly the behaviour you want. It has been changed to publish two plain lists — what the old site served on its final day, and what the new site serves — with no verdict at all, because it turned out to disagree wildly with the tool that does this properly: it claimed 406 pages were missing where the authoritative check says none are, simply because it is bad at finding the new site's pages. Publishing both numbers would have been worse than publishing neither. Its own test, which had quietly rotted to the point of being unrunnable and was not part of the automated checks, was rewritten to assert the one thing that matters: that no verdict is ever published here again.

### Fixed

- The link to the accessible site in every community's top bar is now properly covered by tests, and the two communities with their own accessible address were checked against the live database. The link was already working, and already tested — but only for the ordinary case, where a community uses the shared address with its name in the web address. The case that was never tested is the one that matters to the two communities that have their own accessible address: if that address stopped being passed through, the link would keep working and would silently fall back to the shared address. No error, no broken page, and the existing test would still pass. Three new tests close that. Confirmed on the live server the same day: exactly two communities have their own address stored, both correct, and the other nine correctly have none.
- Two test files were checking for wording the application stopped showing. Both the desktop top bar and the mobile menu tests carried their own private copy of the button's wording, and both had drifted: they looked for "Accessibility (alpha)" while the site has said "WCAG 2.2 AA Version" for some time. Three assertions were therefore passing against text that does not appear anywhere. Corrected to the real wording, with a note to copy such values from the translation file rather than typing what you expect them to say.

### Added

- Dates on the accessible website are moving to three plain boxes — day, month and year — instead of the browser's built-in date picker. GOV.UK guidance is blunt about why: the built-in picker looks and behaves differently in every browser, a phone calendar is close to unusable for a date years in the past, the device decides the format rather than us, and we cannot present the error message in our own style. The goal deadline fields are converted as the reference implementation, and everything needed to convert the rest is now in place: a shared building block, a shared reader that turns three boxes back into the single date the platform already expects, and a counter that stops any new built-in date picker being added while the remaining 36 are worked through. It is deliberately being done in stages — each conversion changes how a form is read when submitted, so doing all 37 at once would be a large change nobody could review. Proven end to end rather than assumed: typing 27 / 3 / 2027 into the real form on a real server stored exactly the right date. The reader is fussy in the right places — it accepts "3" or "03" and ignores stray spaces, but refuses 31 February instead of quietly turning it into 3 March, which is what the underlying date code does if you let it, and refuses a two-digit year rather than guessing the century. A half-filled date is an error rather than silently thrown away. 🔴 Two faults were found by rendering the thing in Irish instead of trusting it: a screen reader would have announced the English word "Error" immediately before an Irish message, and the fix for that produced a doubled colon ("Earráid::") because the design system adds its own. Both are fixed and both are covered by tests.
- Long text boxes on the accessible website now tell you how much room you have left, in your own language. Six boxes with a length limit — cancelling a ticket, two event check-in reasons, an agenda cancellation, a group file description and a moderation decision — now show a live "you have 340 characters remaining" count that updates as you type, and is announced to screen readers. The interesting part is the grammar. The design system this site is built on ships that sentence in English inside its own code, so without care an Irish or Arabic speaker would have heard English read out to them regardless of the language they picked. Every one of the eleven languages now supplies its own wording, and — this matters — its own number forms. English has two ("1 character", "2 characters"). Irish has five and Arabic six. The site now picks the correct one for the actual number, so Irish gets its softened and eclipsed forms (charachtar after 1, gcarachtar after 8) and Arabic gets its special form for exactly two. That is not decoration; it is the difference between reading naturally and reading as broken. The counter is an addition, not the enforcement — the limit is still enforced by the browser for anyone without JavaScript. 🔴 Two traps are recorded in the code: the component crashes if a small required element is missing, and silently writes its own English sentence into that element if it is left empty, so both are now filled deliberately. 🔴 And a note on my own testing: my first version of the grammar test passed while the code was deliberately broken, because it compared whole sentences that differ by the number anyway. It now checks the actual word forms, and I proved it fails when the language rules are ignored.
- The public Features page now states plainly that a community may not have every module switched on. The page is a catalogue of what the software can do, not an inventory of what any one community has enabled, and members were reading it as a promise about their own site. A prominent accent notice now sits directly beneath the heading, above the maturity key, written without the words "module", "feature flag" or "tenant": it says what the page is, that a community chooses which parts it uses, that a missing item is normal rather than a fault, and that what a member can actually use is whatever appears in their own menus. HeroUI's alert--accent renders on a plain white surface with a near-invisible 8%-black border, so the tint and heavier border are set explicitly and were checked in both light and dark themes.
- The Features page now lists the modules shipped since v1.5 and corrects its stale version and stack claims. Twenty-five entries were missing entirely, including the GOV.UK-based accessible frontend (Beta, live on its own domain), Discover, pluggable map and address-lookup providers, Courses, Podcasts & Community Audio, Clubs & Associations, Public Events, Attendance Rewards, Partner Venues, Collections & Saved Items, Guardian Consent, Donations & Support, Message Translation, AI Agents, choice of AI provider, request-help and offer-favours, care relationships, hour gifting and transfers, the Warmth Pass, trust tiers, the care provider directory, concern reporting, surveys and community projects, the community market, local time-credit redemption, municipality reporting, performance monitoring, the Partner API and developer portal, local advertising, Swiss FADP mode and regional analytics. The member-facing half of the Caring Community layer had been absent altogether, so a roughly twenty-five-route module read as an admin add-on. The maturity chip for each entry is taken from its real default: modules that ship switched off are Preview or "Built, not enabled" rather than GA.
- The Features page is now searchable and filterable by category instead of one long scroll. It carries 119 entries, which read as an undifferentiated technical wall. There is now a search box that matches on the visible titles, descriptions and notes — so searching "credits" finds Attendance Rewards, whose key mentions nothing about credits — plus a single-select category chip row, a live "showing X of Y" count, an empty state, and a clear-everything control that only appears once a filter is active. The availability notice and the maturity key stay above the controls so a first-time reader still meets them first. Tabs and accordions were considered and rejected: /features is on the public prerender allowlist, and both patterns unmount the content of every inactive panel, which would drop most of the page out of the SEO snapshot and out of the browser's own find-on-page. Filtering in place keeps the full list in the DOM until the reader deliberately narrows it, and a regression test asserts that every entry renders up-front.
- The Features page now covers the broker application, the network portal, and the real depth of Events and Hiring. A second application of more than twenty pages had no entry at all: the broker and coordinator workspace, used when a safeguarding or compliance concern is live, covering exchange oversight and match approval, moderation queues, risk tags and watch lists, message review for monitored members, insurance certificates and reporting — with a note that a broker is an operational role in its own right and is deliberately refused the general admin panel. The network and partner portal is likewise now listed, noting that its external-partner sections stay off with the rest of external federation. Events had a single line while shipping thirteen separate areas, so an Event Operations entry now covers ticketing, waiting lists, recurring-event blueprints, agendas, offline QR check-in, accessibility, safety, attendee messaging, templates, analytics and lifecycle history. Hiring gained the pipeline, talent search, employer branding and feed detail it already had, plus a Hiring Bias Audit entry. Merchant coupons, two-factor login with passkeys, and new-community onboarding were also added.
- Features page version and stack copy corrected. meta_title, meta_description and subheading had said v1.5 for three minor releases while the release chip beside the same heading read v1.6.0; the stale version was also present in all ten translated locales. The stack rows now name HeroUI v3 and Vite 7, add the accessible frontend (GOV.UK Frontend 6.4 + Express + Nunjucks), and record that the AI layer supports Anthropic, Google Gemini and self-hosted Ollama alongside OpenAI. The "500+ PHPUnit Tests" entry understated the suite by roughly thirty times and has been replaced with an "Automated Test Suite" entry citing the measured figures (~16,500 PHP test methods across ~1,600 files, ~1,300 frontend suites).

### Fixed

- Several Features page descriptions described modules that do not work the way the copy claimed. The entries were written from feature-flag key names rather than from the platform's own wording, and have been rewritten against the canonical module_name_* / module_desc_* copy in admin_config and the member-facing page copy. member_premium was presented as "Premium Membership" offering "paid membership tiers", when the module is Donations & Support — its own pricing title is "Donate", its management page is "My Support", and its canonical description ends "not paid features"; nothing on the platform is placed behind a payment. Hour transfer was described as moving credits between linked household accounts when it exists for a member moving to another community running NEXUS. The Warmth Pass was described as a door-scanned warm-space pass when it is a portable trust credential earned at Trusted tier. Discover was titled "Explore & Discover" rather than the name members see. AI Agents were said never to act, where the canonical description has them running jobs as well as proposing actions. The Caring Community layer was introduced as a "pilot-readiness governance layer" rather than the integrated mutual-aid hub it is. All ten locales were re-translated from the corrected English rather than left holding the earlier wording, and the measured translation-fallback baseline remains zero.
- Social Prescribing was described on the Features page as platform tooling, which it is not. It read "Information and tooling for community health integration workflows" and carried a Preview chip. There is no referral endpoint anywhere in the platform and its navigation entry is restricted to a single community, so it is neither tooling nor an opt-in module. The entry now describes what it actually is — a managed pathway for a GP, social prescriber or community health worker to refer a patient into the timebank, with a coordinator handling welcome, profiling, matching and follow-up, and outcome data returned to the referrer — and states the published outcomes and the alignment with Sláintecare and the HSE Social Prescribing Framework. The maturity chip was removed rather than changed, because none of the four labels fits something that is live for one community and not switchable by any other; a note carries that distinction instead.
- The Features page group icons were assigned positionally. icons[index] meant that adding or reordering a group silently reassigned the icon of every group after it. Icons are now keyed by group.
- URL slug terminology now remains technical and grammatically correct across every translated web locale. A detector-blind audit corrected more than 700 strings in German, French, Italian, Portuguese, Spanish, Dutch, Polish, Japanese, Arabic and Irish where automated translation had rendered slug as an animal, a metal ingot, a fault, a slider or other unrelated words. Labels, validation errors, placeholders and administrator guidance now use established technical terms with language-appropriate articles, gender and sentence structure. This is a contextual translation-quality repair; it does not claim that native-speaker review of every catalogue is complete.
- The measured React translation fallback baseline is now zero across all ten non-English web locales. The final 46 Italian, Portuguese, Spanish, Polish, Japanese, Arabic and Irish values were reviewed in context: twelve genuine fallbacks now use target-language wording, while 34 proper names, international technical terms, currency figures and true cognates are recorded as intentional identities in both translation audit tools. The same review found a larger detector-blind fault: the German label KI-Agenten remained embedded in 62 English and non-German AI-agent strings even though the counter could not flag phrases that otherwise differed from English. Those headings, explanations, tab labels and help entries now use AI Agents in English and natural terminology in French, Italian, Portuguese, Spanish, Polish, Japanese, Arabic and Irish. Irish examples now use m.sh. rather than English e.g., and frequency, partner API, tandem and automatic-slug guidance no longer expose untranslated or literal machine wording. This lowers the accepted React fallback baseline from 46 to 0; zero is a measured fallback result, not a claim that native-speaker review of every catalogue is complete.
- The French web catalogs no longer contain any unreviewed English-identical values. Fifteen administrator strings now use correct French typography and relationship language, including spaced Total : labels, page-HTML and distance headings, provider interpolation, and recommendation counts instead of sports-style “matchs”. Ten of these repairs were detector-blind formatting or context faults, such as missing spaces around {{count}}, {{name}} and {{provider}}. Twenty-eight remaining matches were reviewed as valid French or technical vocabulary—such as Option, Contacts, Notifications, Version, Image, Ad hoc and French-identical date/count forms—lowering the accepted React fallback baseline from 79 to 46 while keeping both audit tools aligned.
- The Dutch web catalogs no longer contain any unreviewed English-identical values. Twenty labels and help phrases now use natural Dutch terminology for AI agents, menu placement, community connectors, podcast episodes, YouTube URLs, Stripe providers, civic-match scores and interviews. This also repairs seven detector-blind machine artifacts, including German KI-Agenten leaking into Dutch and “master on/off” becoming the person-like “Meester aan/uit” instead of a main switch. Twenty-two remaining matches were reviewed as valid Dutch, proper names or established technical vocabulary, lowering the accepted React fallback baseline from 114 to 79 while keeping both audit tools aligned.
- The German web catalogs no longer contain any unreviewed English-identical values. Eleven administrator labels now use German wording, including the platform-super-administrator role, private podcast shows and automatic slug guidance. Forty-three remaining matches were reviewed and recorded as valid German or established technical vocabulary—such as Status, Option, Layouts, Single Sign-On, Open Source and format examples—rather than being needlessly rewritten. This lowers the accepted React fallback baseline from 168 to 114 while keeping both audit tools aligned.
- Counts and dynamic member actions now stay translated for every supported plural form. Likes, comments, recurring-event dates, search-result announcements, named-group links, managed-organisation callouts and expandable-section labels now use context-written wording across all ten web languages. This replaces 142 English values, including detector-blind Arabic, Irish and Polish zero, two, few and many variants that could previously switch language only at particular counts. A literal audit also protects 455 exact routes, fake addresses, account examples, filenames, identity/storage protocols, webhook event names, currency notation and infrastructure labels from destructive translation, lowering the accepted React fallback baseline from 701 to 168.
- Marketplace, onboarding, polling and group recommendations no longer fall back to English for core headings and actions. Search and shipping headings, step-progress announcements, the vote action and recommendation match scores are now localized across all ten translated web languages, replacing 50 visible English values. Translation tooling now also reports exact remaining keys with --keys and protects the required-field * marker as syntax rather than text, removing ten false positives and lowering the accepted React fallback baseline by 60.
- Member-facing loading states now remain in the selected language. The caring-community hub, civic digest, ideas area and municipal-feedback journey previously displayed the English word “Loading…” in every translated web locale. All 40 states now use natural progressive wording in German, French, Italian, Portuguese, Spanish, Dutch, Polish, Japanese, Arabic and Irish, lowering the accepted React fallback baseline by 40.
- The partner-timebank workspace now uses translated navigation and actions without corrupting the Credit Commons product name. Account fallbacks, overview and settings sections, partnership and neighbourhood pages, analytics, breadcrumbs, create/open actions and optional-step labels are now localized across all ten translated web languages. This replaces 177 English values, all of which were short labels invisible to the fallback detector. The audit also protects Credit Commons as an exact product name, removing 33 false positives across partner and administrator catalogs without sending the name through a translator and lowering the accepted React fallback baseline by 33.
- Sentry error triage now happens on its own every night, instead of only when the owner asks for it. Every look at Sentry until now started with the owner asking for one, which made triage sporadic and made an agent's account of "what Sentry says" impossible to check. There is now a script that fetches the unresolved issues, ranks them by how many members they hit rather than by raw volume, and writes both a machine-readable queue and a plain-English report — so the list can be read without an agent at all. A nightly task then works that queue: it fixes small, clearly-caused crashes, adds a regression test for each, and commits only when the local checks pass; if they fail it saves the attempt as a patch file, puts the code back, and reports the failure rather than hiding it. Nothing is ever deployed by it. Two guards are written into the script rather than left to instructions: any issue whose text touches money, permissions, safeguarding, consent, deletion, encryption or migrations is classified sensitive and cannot be fixed automatically — it is diagnosed and handed to the owner; and the only way it can close an issue is Sentry's "resolve in next release", so an issue closes when the fix actually ships and reopens by itself if the error happens again. That means "resolved" reflects what production did, not what an agent believed. Decisions are kept in a committed ledger, so judging something as accepted noise is reviewable and is not re-investigated every night — and an issue previously marked as fixed comes straight back into the queue if it recurs, which is the honest check that the fix worked. Verified against live Sentry when built: both open issues were correctly classified as sensitive, and the GDPR data-request alarms were recorded as the owner's own admin testing so they stop being re-raised.
- Matching now uses relationship language rather than sports terminology across every web locale. The nine remaining translated journeys now localize 207 labels covering mutual matches, actions, relevance and feasibility scoring, trust, proximity, availability, reviews, dismissal reasons, preferences and notification cadence. Context review also replaces Google-style sports translations such as German “Spiele”, Italian “partite”, Spanish “partidos”, Dutch “wedstrijden”, Polish “mecze” and Japanese “対戦” with language appropriate to matching people, listings, groups and opportunities. This removes 36 measured React fallbacks while correcting substantially more detector-blind wording.
- Knowledge-base navigation, counts, search feedback and failure states now stay in the selected language. Breadcrumb labels, article and category plurals, result announcements and searching states are localized across all ten web languages, including Polish, Arabic and Irish plural forms. Sixty older dotted-key error, empty-state and feedback values in French, Dutch, Polish, Japanese, Arabic and Irish now match their localized nested equivalents instead of retaining hidden English copies. This removes 59 measured React fallbacks and another 60 detector-blind English values.
- Irish matching pages no longer mix English into core actions and preferences. Mutual matches, listing and event sources, remote participation, messaging and joining actions, relevance and feasibility signals, trust, proximity, availability, reviews, dismissal reasons, matching preferences and notification cadence now use natural Irish wording. This repairs the full English-identical cluster found in that journey, including nineteen single-word labels that the fallback counter deliberately cannot detect.
- The translation audit now distinguishes administrator copy from values that must remain exact. OAuth client IDs, example inboxes, robots.txt rules, route and tenant slugs, Apache/Plesk, Gmail API and Google Gemini are protected as operational values instead of being sent to a translator. Genuine labels beside them are localized: Meilisearch status and queued-job titles in German, automatic recaching in Portuguese, component score and platform-super-administrator wording in Dutch, and the webhook URL label in Japanese. This removes 129 false or real fallbacks from the accepted React baseline while keeping configuration examples usable. Both the regression checker and translation helper now use the same reviewed protections for these values.
- The feature directory no longer falls back to English in translated “coming soon” pages. Job vacancies, caring communities, knowledge bases, ideation challenges, regional points, group exchanges, member directories and the AI assistant now use natural terminology in all ten web languages, with community-centred Irish wording. The Italian upload hint also localizes its maximum file size. This removes a further 81 entries from the accepted React fallback baseline.
- Core navigation and sign-in translation debt is now measured accurately, and verification status is fully translated. Search-result announcements—including Irish and Arabic plural forms—plus email, phone, identity, DBS and administrator verification badges are now localized across all ten web languages. Authentication examples such as you@example.com, masked passwords, backup-code shapes and the deliberately weak P@ssw0rd! example are now protected as functional literals instead of being misreported or sent to a translator. The same protection covers age bands, the Command symbol, Project NEXUS and its exact AGPL copyright notice. This removes 150 entries from the accepted React fallback baseline without corrupting form examples or legal text.
- There are now pictures of the accessible website in the project, including the pages you have to be signed in to see. Twenty of them: ten pages at desktop size and at the narrowest phone width, covering the dashboard, listings, a listing, messages, the time wallet, members, profile settings and exchanges. There were none before, and not for want of trying — every signed-in picture would have contained real members' names, and this project is public. Now that we have throwaway data, the people in them are invented. The tool that takes them refuses to run unless every account it can see is invented and the page it captures shows the invented member's name; either check failing stops it dead, and those two checks are the only thing standing between that folder and a public leak. Each picture is recorded with whether the page had to be scrolled sideways at phone width, which is an accessibility failure — none of the twenty did.
- The connections between the accessible website and the platform are now tested against a running platform, rather than read off the code. There is a file recording 696 of these connections, but it was written by reading source code, so it could only say "this call exists and the platform has a matching address". It could not say what actually comes back, or whether an address hands data to someone who is not signed in. 120 of them are now called for real, twice each — once signed out, once signed in — and what came back is written down. 112 correctly refuse a signed-out caller, none fail with a server error, and every answer is in the expected shape. 22 correctly refuse because the test community has those features switched off, which is recorded separately so nobody reads the file as "everything works". Three things it first flagged turned out to be the check being too strict, and each is now written down as a deliberate exception with the reason: the public statistics address is meant to be public (checked — it gives only totals, no personal information), and two others correctly return a spreadsheet and a calendar file rather than data.
- Podcast details and studio fields no longer switch back to English across the ten translated web languages. Host bylines, episode and reaction counts, show attribution, chapter navigation, podcast-studio headings, media-status labels and artwork/audio URL fields are now translated with their interpolation placeholders intact. This removes another 112 accepted fallbacks from the React translation baseline. The A–Z sort label is now treated as a functional ordering symbol rather than text to send through a translator.
- Broker moderation and mobile resources no longer fall back to English in otherwise translated journeys. The broker workspace's moderation queue, feed, comments, reviews, reports and safeguarding navigation is now translated across all ten web languages, removing 133 accepted fallbacks from the React translation baseline. The native app's Resources area is now fully worded in German, Spanish, French, Irish, Italian and Portuguese, removing a further 108 English fallbacks while preserving interpolation values. Translation tooling now also recognises the broker email example and the Command-K shortcut as functional literals rather than text to translate.
- Translations now protect technical identifiers and reach the accessible website's live interactions. The platform now rejects corrupted translation encoding across React, PHP and mobile catalogs, keeps peer slug identifiers and literal examples unchanged in every admin language, and repairs the six mobile AI-chat catalogs that contained broken accented characters and English-only results and feedback controls. The accessible website's partner-community hub, event-form validation, session-expiry dialog, screen-reader countdown announcements and bounded text-area character counts now use the visitor's selected language; the countdown uses each language's own plural rules. The accessible and PHP untranslated checks now share one reviewed invariant allowlist, and both translation utilities refuse the bulk Google route for Irish so a provider accepting ga cannot silently weaken Irish copy.
- We can now test the accessible website against throwaway data instead of a copy of real members. Every check on the new accessible site has until now run against the ordinary local database, which is a copy taken from the live platform — real names, real email addresses, real messages. That quietly blocked three whole kinds of testing: we could never try deleting anything, because it would have destroyed a real record; we could never save screenshots into the project, because they would publish real members' names in a public repository; and uploads wrote real files. There is now a separate, disposable copy of the platform — its own database, its own uploaded-files storage, its own web address — filled with four invented accounts and one invented listing, and nothing else. One command builds it, one command wipes it back to a clean state, and it can be thrown away entirely at any time. Signing in and reaching a member's dashboard through it has been done for real, not assumed. It cannot reach the live platform: the file says so in writing, and must never be added to the deployment configuration. 🔴 Two traps were found by building it, and both are now guarded rather than remembered. The platform reads two different names for "which database", and prefers the one we had not set — so on the very first run the setup wrote into the database the automated test suite owns, instead of the throwaway one, and reported success while doing it. Nothing of value was harmed (one row, in a database that is rebuilt from scratch by every test run), but it is exactly the collision this work exists to prevent. The setup now asks the platform itself which database it has connected to and refuses to write anything until the answer is the throwaway one — reading the container's settings is not good enough, because the settings said one thing and the platform did another. There is also a check that every account present looks invented; if a real-looking address ever turns up, it says so and warns against publishing screenshots.
- The accessible website now has complete wording ready in all eleven languages for five blocked accessibility improvements. This adds the character-count announcements used by text areas, three-part date labels and errors, the session-expiry warning and countdown, member-friendly partner-community wording, and event-form errors. Irish was written directly rather than sent through the automated translator, including its five different number forms; the other nine translations were generated through the existing pipeline and then reviewed for grammar, placeholders and context. The wording is prepared for the separate feature work and does not yet change any page. Internal “federation” language was replaced in these new member-facing phrases with “partner communities”.
- The admin panel now has a documentation link pinned at the bottom of its sidebar, and so does the sales site's main menu. Administrators are the people most likely to need the platform documentation and had no way to reach it from inside the admin panel. It sits in the small pinned strip at the very bottom of the sidebar — always visible, never scrolling away, and deliberately not placed beside "Help FAQs", which is the tool for editing the help articles members read and is a completely different job. It works collapsed as well as expanded: when the sidebar is narrowed to icons the label disappears but the link stays reachable, with the name still announced to screen readers, and a test covers both states so a future layout change cannot quietly drop it. It is a plain link rather than an internal one, because every other entry in that sidebar is an internal page and routing to an external address would have produced a dead admin path — the code says so, so nobody "tidies" it back. Opens in a new tab with the usual security attributes. Translated by hand into all eleven languages. On the sales site at project-nexus.ie, "Docs" now sits in the main menu alongside Platform, Features and Live App. That one is deployed and live — checked on the real site at both desktop and phone widths: visible in the header on desktop, and inside the menu on a phone as a full-width entry comfortably above the minimum tap-target size, with the destination confirmed to load. 🔴 Five links on that site still named the old repository and were repointed at the same time, including the licence source link and the NOTICE link, which should not depend on a forwarding rule.
- The footer now links to the documentation site, and the sentence beside it was cut down to make room. The platform has a full documentation site and nothing in the app pointed at it, which is a missed opportunity: for a community deciding whether to adopt this platform, published documentation is one of the strongest signals that it is a serious piece of software. The link is deliberately not prominent. It sits in the small release strip at the very bottom of the footer, alongside the existing Features and Changelog links — the "about this platform" line, not the member navigation. That was a judgement call worth recording: module guides and an API reference are for communities evaluating us, for community administrators and for developers, not for a member offering an hour of gardening. Putting "Documentation" in the main menu would make the product feel like a developer tool, so it is not there, and it is not in the Help Centre either, because the Help Centre answers "how do I offer an hour?" and mixing the two would confuse the people it exists to serve. It opens in a new tab, keeps the security attributes that stop the new page tampering with ours, carries the small external-link arrow, and tells screen-reader users it opens in a new tab. The sentence that shared the line was long — "Project NEXUS is actively evolving, with feature maturity shown transparently across the platform" — and is now simply "Always evolving.", which says the same thing in two words and leaves room for the new link. The address is on its own domain on purpose, and there is a comment in the code saying why: it used to be built from the GitHub repository's name, so renaming the repository broke every link to it with no forwarding. All wording is translated into the other ten languages by hand, not machine-translated — short technical words are exactly what the translator mangles, as the "Windows"/"Mac" and "React Native" corrections recorded below show. Verified in a running browser rather than assumed: the strip reads "Generally Available (v1.6.0) — Always evolving. · Features · Changelog · Documentation", and the link carries the right address, opens in a new tab and has both security attributes. A new test fails if the link disappears or stops opening in a new tab; 34 footer tests and 108 tests across the four layout suites pass, typecheck is clean, and the translation gate confirms no language was left behind. 🔴 The same link on the sales site at project-nexus.ie is NOT done, because that site's code is not in this repository — it was removed in 72889b550 and no copy exists on the platform server. It needs to be done wherever that site now lives.
- The "Get the app" page now explains what a native app actually is, says the real one is nearly finished, and answers "why doesn't this work on Apple?" — and an audit of mobile/ settled what we can honestly promise. The page previously described four kinds of app, one of which was the retired web-wrapper wrapper. That was both confusing (three things to choose between) and out of date, because the real native client is the Expo/React Native app in mobile/ — a different codebase, at version 1.2.0, 257 screens, 217 test files, and still being worked on as of 11 August. What the audit established, so the page does not over-promise: mobile/app.json configures both platforms — an iOS bundle identifier with permission strings and associated domains, and an Android package — so it is genuinely one app for iPhone/iPad and Android. But only the Android release path actually exists: eas.json has website (signed APK) and production (Play Store bundle) profiles, an EAS-managed keystore and Android push credentials, and the only build/submit scripts in package.json are Android ones. The iOS half of the submit config is still the literal placeholder APPLE_APP_STORE_CONNECT_APP_ID, docs/DISTRIBUTION.md documents Android channels only, and Apple requires a developer account and reviews every app before publication. The page therefore says Android is closest and iPhone follows shortly after, and publishes no download link for either. The retired web-wrapper wrapper is deliberately not mentioned to members because that project was abandoned; the page code says so in a comment, and a test asserts the word "retired web-wrapper" does not appear, so it cannot creep back in unnoticed. The Apple explanation is the other half. Telling members "it does not work on Apple" without a reason reads as our fault, so there is now a "Why is it harder on Apple devices?" section giving Apple's three rules in plain words: there is no one-tap install on Apple at all (the browser-provided install button simply does not exist in Safari, which is why the withdrawn banner could never have worked there); on iPhone and iPad only Safari can do it, and a Mac needs Safari 17 or newer; and Apple keeps the saved app's data separate from Safari, so it opens logged out and you must sign in again inside it — which is confirmed by our own code, since the session token lives in browser storage that Apple does not share with the saved app. The section ends by pointing at the native app as the real answer for Apple users. 🔴 Not verified on a real Apple device — nobody here has one to hand; the three points above are Apple platform rules and our own storage design, not a reproduction of a member's failure. 🔴 A separate, real defect was found while checking this and is NOT fixed here: the install manifest served for app.project-nexus.ie with no community in the address comes back with start_url and scope of /admin/, because the platform's own master community has the slug admin. Anyone installing from that address would get an app that opens the admin path and treats every ordinary page as outside itself, opening it in the browser instead. Community domains are unaffected — minehead-and-coast.timebank.global correctly returns /. Also noted for separate work: the public Features page still describes the native app as "iOS and Android builds from the same React codebase via retired web-wrapper", which is no longer what mobile/ is.
- There is now a "Get the app" page that tells members the truth about which devices can install the app, and the install banner that promised otherwise has been taken down. The banner across the top of every page offered a one-tap install. On an iPhone, iPad or Mac that one tap cannot deliver — Apple only allows it in Safari, and it is unreliable there at the moment — so a large share of members were being offered something that would not work for them, on every single page, with no way to explain why. The banner is gone (its code is kept, unrendered, so it can come back once Apple installs work). In its place, the "Install app" item in the profile menu and the mobile menu now say "Get the app" and lead to a new page at /install-app, which does four things a prompt cannot. It states plainly what works today — Android with Chrome, and Windows with Chrome or Edge — and what does not: iPhone, iPad and Mac, which we say we know about, are working on, and will update the page about. It explains the four kinds of app in ordinary words rather than jargon: using it in a browser with nothing installed; saving it to your home screen (the page says "the technical name for this is a PWA, short for Progressive Web App" rather than assuming anyone knows); our own Android app, which it describes as a retired web-wrapper app and openly says is built but not released; and Google Play and the App Store, which we are not in yet. It gives numbered, per-device instructions with the reader's own device pre-selected, and warns on the Apple tabs that this is the path currently misbehaving. And it only shows a one-tap install button when the browser has genuinely offered one — otherwise it shows instructions, so the page can never promise a button that does nothing. 🔴 No download link is published for the Android app, deliberately. The address the app's own updater points at (/downloads/nexus-latest.apk) was checked and does not serve a file — one host returns "not found" and the other quietly returns the website instead. Publishing it would have handed members a broken download. Three things were found while wiring it up. The new address had to be added to the three separate lists of "path segments that are not a community name" (React, Laravel and the accessible site, whose own test requires the last two to match exactly) — without that, /install-app on the shared address was read as a community called "install-app" and showed "Community not found", which is exactly what happened on the first attempt. The old "Get App" entry in the Explore menu pointed at /mobile-download, an address this app has no page for, so it has been repointed at the new page. And the page is now listed in the sitemap so members can find it by searching. All wording goes through the translation system in all eleven languages. 🔴 The machine translations needed hand correction, and this is worth recording: the device labels came back as ordinary nouns — "Windows" became Fenêtres, Finestre, Ramen, ventanas, Okna and 窓, and "Mac" became Impermeable (Spanish for raincoat) and Prochowiec (Polish for trench coat). Brand names are now forced to stay in Latin script in every language, and two English labels were reworded because they translated as verbs ("Working today" became "I work today" in four languages). Verified by using the page in a browser at desktop and phone widths in both light and dark: the correct device tab is chosen automatically, switching tabs works, the Apple warning appears where it should. 10 new page tests, 17 tests in total across the page and the menu entry, and the suites touched by the change — 339 tests across layout, routing and the install components — all pass.

### Changed

- The old accessible site is now recorded as historic, and no longer the thing we copy. Until today the old site was treated as the definition of "correct" — if the new one differed, the new one was assumed wrong. That has been reversed by decision: the old site is falling behind, so the new one now follows GOV.UK guidance for how pages should look and behave, the React app for what members can actually do, and the platform's own interface for the rules. A difference from the old site is no longer automatically a fault; it may simply mean the old site is behind. The old site stays switched on and serving members, and retires only once we are certain nothing is still needed from it. Written into the three places an AI assistant or a person would look, including a plain note that the route comparison list is now a coverage record rather than an alarm.
- 🔴 Corrected a readiness score I had inflated. I recorded 787 out of 1,000 when the honest figure was 730. The mistake: I awarded points for fixing four real faults, but those faults had never been counted as marks lost in the first place — so repairing them restored accuracy, it did not earn credit. The automated check could not catch it, because it only verifies the column adds up to the stated total, which it did. The rule is now written at the top of that document: a row's score is its maximum minus the deductions listed against it, and nothing else may raise it.

### Changed

- Updated the GOV.UK design system the accessible website is built on to the current release (6.4.0, published days ago). Everything was re-checked afterwards rather than assumed: 2,142 automated checks, the accessibility scan on 24 real signed-in pages (still completely clean), high-contrast mode, phone width, dark mode, and the visual comparison against the old site — which actually got closer, from about 13% of pixels differing down to 10%. 🔴 The instruction file said we were on 6.1.0 when we were really on 6.3.0; that note is corrected, with a warning not to trust a version written in prose.

### Fixed

- The build server went red because a type error in a test had been fixed. One of our safety nets counts known type problems in test files and only ever allows that count to go down — so when a fix reduces it, the new lower number has to be written down in the same change, otherwise the net still expects the old higher one and stops the build. The events broadcast-preview fix repaired one such problem in EventCommunicationsWorkspace.test.tsx without recording the improvement, so the count is now locked in at 1,933 across 648 files, down from 1,934 across 649. Nothing about the product changed. Reproduced on this machine first and got the identical numbers to the build server, so the recorded figure is not a local artefact.
- The branding guard on the accessible website could not run on the build server, and had never really run there. That guard exists to stop the licensed UK government typeface getting into the accessible site — using it would wrongly imply the site is an official government service. It used to look only at the page templates, which cannot contain a typeface at all, so it reported "passed" while checking nothing. It was changed earlier the same day to inspect the finished stylesheet instead, and to stop with an error if that stylesheet is missing, because "the file isn't there" must never be reported as "the check passed". That immediately turned the build server red — correctly — because the build server was running the guard before it built the stylesheet, and the stylesheet is produced by the build rather than stored in the project. The build order is now the right way round. 🔴 Two things worth stating plainly: this broke the build for a few hours on any change touching the accessible site, and one commit in between looked fine only because it did not touch that site so the job was skipped, which hid the pattern. And the ordering mistake was invisible for as long as the guard checked nothing — which is exactly the empty pass the change was written to stop. Checked both ways on a real machine: with the stylesheet deleted the guard refuses; after building it, it passes.
- Saving your profile said it had failed when it had actually saved — and blamed your photo. On the accessible website, saving your profile settings also wrote your newsletter choice every single time, and if that one step failed the whole save was reported as failed. Your name, your photo and your privacy settings had all saved perfectly by that point, but the message said "Your profile could not be updated" — so the natural thing to do is try again, and get the same message. It was worse than that: the message it picked was "Upload a JPG, PNG, GIF or WEBP image smaller than 10MB", so you were told your photo was the wrong sort of file when your photo had uploaded fine and something else entirely had gone wrong. This was not a rare edge case: the newsletter step fails on any installation where the newsletter consent record was never created, and nothing in the project creates it. Three fixes. The newsletter choice is now only written when you actually changed it — so nothing fails when you did not touch it, and an explicit sign-up is still always recorded. The platform now replies "that value is not recognised" rather than "the server broke", which was raising false alarms in our error monitoring and left the website unable to tell a bad value from a real outage, which is exactly why it assumed the worst. And the photo is only blamed when the photo is genuinely the thing that failed. 🔴 Worth checking on the live platform: whether that newsletter consent record exists there. It does not exist in our local copy, and I have not assumed one follows from the other.
- On the accessible website, opening another community's page while signed in showed you your own data under their name. If a member of one community followed a link to another community's dashboard, wallet or member list, they got a normal-looking page — but the information on it was their own, from their own community, wearing the other community's address. Every link on that page then kept them there, so they stayed in that mixed-up state until they edited the address bar by hand. Nothing leaked, and that was checked rather than assumed: the member records and wallet balance shown were the signed-in member's own throughout, because the platform works out who you are from your sign-in, not from the address. Members are now sent back to their own community's version of whatever page they asked for. Browsing another community's public pages while signed in still works, as it should, and partner-community browsing is untouched.
- Previewing an event announcement failed for the organiser. Before sending a message to people involved in an event, the organiser gets a preview showing how many people it will reach. That preview was being thrown away by our own app: the web app insisted the server report a count for all four groups of people (registered, on the waiting list, attended, did not attend), but the server sensibly only counts the groups you actually asked about. So previewing for one or two groups — the normal case — failed, and the organiser saw an error instead of the numbers. It broke on the live site on 2 August. Two things worth recording: the phone app already had this right, so only the website was wrong; and there was no test at all on this preview, which is exactly why the mistake went unnoticed. There are now three, one of which checks that the fix did not also make the app accept a group name that does not exist.
- The "you have made too many requests" page on the accessible website crashed instead of appearing. The site limits anyone to 100 page requests every fifteen minutes. When somebody reached that limit, the page meant to explain it politely could not be built at all, so they got a bare error instead — and the platform recorded a fault. The cause: that limit is checked very early, deliberately, before the site has worked out which language the visitor reads, and all six of the site's error pages need the language to be ready before they can be written. So the error page became a second error. This was happening on the live site (spotted 13 August, on a scanner probing the site, but an ordinary member browsing quickly hits the same limit). Every error page — including "page not found" and "something went wrong" — can now always be built, in the visitor's own language, no matter how early in a request things go wrong. The fix is tested from both directions: the test proves the page now appears, and also proves it would have failed before, so the problem cannot come back unnoticed.
- Our own alarms were burying the alarm list. Six of the platform's nightly self-checks (overdue data-protection requests, missing backups, stuck Stripe payments, the exchange-completion target, and the weekly "the alarms still work" heartbeat) write their message with live numbers in it — a count, an age in days, a percentage. The error tracker groups by that message text, so the same unresolved problem appeared as a brand-new alarm every single night. Four overdue data-protection requests had produced six separate alarms in six days. That is not just untidy: a nightly-respawning alarm cannot be snoozed, shows no history of how long it has been going on, and pushes genuine new faults down the list. Each check now declares its own fixed identity, so one ongoing problem is one alarm whose age you can read at a glance. A test refuses any future self-check that forgets to do this.
- The accessible website was being killed mid-sentence on every deploy. When the platform switches between its blue and green copies, the old one is told to stop. The site had no instruction for what to do when told to stop, so it exited instantly — cutting off any page it was part-way through sending, and dropping its connection to the session store without closing it. Members reading a page at that moment would get a truncated one. It now stops accepting new visitors, lets the pages it is already sending finish, closes the session connection properly and only then exits — with a ten-second cap so it can never hold up a deploy. 🔴 Verified for real, not just in tests: sending the stop signal to the running container logged "closing server to new connections" then "Shutdown complete".
- Set an explicit limit on how much data a form can post (256KB). It was previously relying on an invisible default buried in a library, which matters here because the code keeps a full copy of every submitted form in memory. File uploads are unaffected — they go through a separate route with their own 10MB limit.
- The page where someone is most likely to be lost had no way to get help. On the shared address — the page that asks you to choose your community — the entire footer was being removed, taking the Help centre, Contact, Knowledge base and About links with it. That breaks an accessibility rule (WCAG 2.2, "consistent help") which says a way to get help must sit in the same place on every page, because people rely on remembering where it is. Help and Contact now appear there, in the same footer position as everywhere else. Community-specific links still only show once you are inside a community, which is correct. 🔴 The old site has the same gap; this deliberately does better, per your decision that GOV.UK guidance wins.
- The "you're about to be signed out" warning could fire at the wrong time, and nobody would have noticed. The countdown was told the session lasts 30 minutes by a number written into the page's own script, entirely separately from the real setting on the server. Change the server's session length and the warning would quietly start appearing minutes too early — or worse, after the session had already ended, so the first a member knew about it was being thrown out mid-form. It now reads the real figure from the server on every page, with a safety check so the warning can never be scheduled after the session has already gone. The server was already working this figure out and passing it to the page; nothing was reading it.
- A member whose session ran out was told "You have signed out", with a green success tick. They hadn't signed out — their session expired — and it certainly wasn't a success. The timeout has always told the server it was a timeout; the server just ignored it. It now says "Sign in to use this page" and presents it as something to act on rather than something that went well. 🔴 Uses wording that already exists in all eleven languages, because adding new wording is currently blocked on Irish translation.
- Screen readers now announce straight away when a form has a problem. GOV.UK's rule is that when a form comes back with something to correct, the page's name must start with "Error:" — because that name is the very first thing a screen reader says when the page loads. Without it, someone using a screen reader is told the page name, then has to go hunting to discover anything went wrong at all. Neither accessible site did this. Now the sign-in, contact, and create-an-event/listing/group pages all announce it, translated: in Irish the page name begins "Earráid:". It is worked out in one place rather than sprinkled across the thirty-odd places a form can fail, so the next new form gets it automatically. 🔴 Deliberately not applied to pages that merely failed to load something — there is nothing for the reader to correct there, and crying "Error:" on those would be noise.
- Two more question pages got a "back" link, in the standard place at the top: the password-reset page and advanced search. 🔴 The sign-in and registration pages deliberately have none — there is no previous step to go back to, and GOV.UK back links are for stepping back through a process.
- The "hide cookie message" control is now a button, not a link. GOV.UK specifies a button, and the difference is not decoration: a link is announced as "go somewhere", while this control's actual job is to dismiss a message. It still works with JavaScript turned off.
- 🔴 The new accessible website was asking browsers for the official UK Government typeface. Its compiled stylesheet named GDS Transport 63 times. That font is licensed for GOV.UK services only, and this platform is explicitly not one — the project's branding rules forbid it by name. The old site sets a plain system font and this one never did, so the design system's default flowed straight through. Nothing looked wrong, because the font file is never actually served and text fell back to Arial — which is exactly why it went unnoticed. Both sites now render identically in Arial, confirmed in a browser. 🔴 The branding check that is supposed to catch this reported "passed" the whole time, because it only ever looked at page templates and a font lives in the stylesheet. It now checks the compiled stylesheet too, refuses to pass if that file is missing, and I proved it can fail by putting the font name back and watching it catch it.
- Keyboard-only members can definitely submit forms — now proven rather than assumed. This was recorded as unproven for months. It works three ways: pressing Enter in a text box submits the form, and a submit button reached by keyboard responds to both Enter and Space. 🔴 An empty form looks unresponsive, but that is the browser correctly refusing to send an incomplete form, not a fault.
- A visual comparison set between the two accessible sites now exists — 32 full-page comparisons, same member, same page, at desktop and phone widths, with an automatic pixel-by-pixel difference measurement. Desktop differs about 6%, phone about 20%. 🔴 The phone figure is not a defect: the two are laid out identically (same text size, spacing and column width), so a single 23-pixel offset near the top pushes everything below it down and counts as "different". Treat these numbers as a baseline for spotting future changes, not a quality score. 🔴 The images themselves are deliberately not stored in the repository: the local database is a copy of real data, so the screenshots contain real members' names.
- Filter rows on five pages were unusably small targets, and rendered as bulleted lists instead of a filter row. The wallet's "All / Earned / Spent / Pending" filters — and the same control on Exchanges, your Network and both Matches pages — were 19 pixels tall against an accessibility minimum of 24, and packed too closely together. That is a real failure of an accessibility rule (WCAG 2.2, target size), and it lands on the people this website exists for: anyone with a hand tremor, limited dexterity, or using a touchscreen. The cause was simple — the styling for that control was never carried over from the old site, so the browser fell back to a plain bulleted list. The old site's layout is now in place and the links are 34 pixels tall. 🔴 This deliberately goes further than the old site, which fails the same rule; matching it exactly would have kept the fault. Found by running an automated accessibility scan against real signed-in pages for the first time.
- The sign-up page's tests were switched off, so the consent-tickbox fix had nothing guarding it — they now run. That test file was on the list the build deliberately skips, and it turned out to be failing for a plumbing reason, not a real one: the test stood in for the shared network module but forgot one of the things it hands out, and the sign-in-with-Google buttons on the page ask for exactly that. The test framework treats a missing piece as fatal, which wiped out the whole page render — so any check needing the full form failed, while checks that only needed the first step passed. Supplying that one missing piece makes all six original checks pass. The file is off the skip-list, the allowance was reduced in the same change so it cannot quietly refill, and a new check now guards the two faults the coordinator reported: that the consent tickbox is not drawn at the smallest size, and that the Terms and Privacy links open in a new tab. 🔴 That new check was verified by deliberately putting the old fault back — it failed, as it should, and passed again once restored. Worth stating because a check that cannot fail is worse than none, and my first attempt at it was exactly that.
- Another 34 page names on the new accessible website now appear in the reader's language. Courses, Blog, Polls, Hashtags, Blocked members, Announcements, Discussions, Invite members, the ideation pages and the community chooser all had their name written into the code in English. Checked in a browser: Blog now reads "Blag" in Irish, Polls "Pobalbhreitheanna", Blocked members "Comhaltaí bloicthe". Only names whose translation is word-for-word identical were changed, so nothing shifted meaning. 🔴 22 remain and are deliberately left alone, with the reasons written into the check that tracks them: the specific "Event not found" / "Listing not found" style names would become a vaguer "Page not found" if switched to the only translation that exists, which would tell a member less; and a handful of others would be silently reworded. Those need their own translations added, which is a separate piece of work.
- Error pages on the new accessible website were always in English, in every language. "Page not found", "You do not have permission to view this page", "Sorry, the service is unavailable" and "Too many requests" were written into the code in 75 places across 22 files, so a member reading the site in Irish, Polish or Arabic hit an English page exactly when something had already gone wrong. They now use the wording that already existed in all eleven languages — checked in a browser: the "page not found" title reads "Níor aimsíodh an leathanach" in Irish. Two of them also read better now: the bare word "Forbidden" is replaced by "You do not have permission to view this page". 🔴 These lookups are deliberately written to survive a missing translator. Several of them fire from error handlers, rate limiting and permission gates that can run before the page's language is worked out; asking for a translation there without a guard would crash the error handler itself and turn a handled "not allowed" page into a broken one.
- 🔴 Correction to a figure reported earlier in this session. The count of "253 hardcoded page titles" was wrong — it was over-counted. Many of those are a deliberate arrangement, not a mistake: the page passes an English fallback and a separate translation key, and the layout does the translating. The jobs section uses that arrangement for all 16 of its pages, and a test asserts the exact shape. Acting on the bad number, those 16 were "fixed" and the arrangement broke; that has been undone. The check that tracks this debt now understands the pairing and ignores it, so the honest remaining figure is 56, not 253 — and it still can only go down.
- Three more gaps between the two accessible websites, closed. 1) Your own profile was missing its "Recent activity" and "Availability" sections — every other member's profile showed both, so you could see everyone's availability except your own, and had no way to check how what you had published actually reads to other people. The old site shows both. The shaping code is now shared between the two pages so they cannot drift apart again. 2) The AI assistant page looked usable when no assistant is configured. The old site says plainly "The AI assistant is not available at the moment"; the new one said nothing, so a member would type a question and get nowhere. It now asks the platform whether an assistant is actually set up — which is a different question from whether the community has switched the feature on — and says so when there isn't one. If that check itself fails it assumes the assistant is available, so a momentary blip never wrongly tells someone the feature is gone. 3) Form section titles were not proper headings. On the create-a-listing, edit-a-listing and create-a-group forms, headings like "What kind of listing is this?" and "Who can join this group?" were plain text rather than headings, so someone using a screen reader could not jump between the sections of a long form. They are now headings, matching the old site and the GOV.UK pattern. The "Partner communities" page also had seven pieces of English text baked in, including two section titles that differed from the old site's wording; those now use the existing translations.
- Two faults on the sign-up form, both reported by a coordinator doing a real sign-up. First, the "I agree to the terms" tickbox was almost invisible on desktop and on iPhone. It was being drawn at 12 pixels — the smallest size our own wrapper offers — which is the last place to save space on a control that carries legal weight. It is now 20 pixels with a clear coloured fill, so a tick is unmistakable. The newsletter tickbox next to it got the same treatment. Second, opening the Terms or Privacy link wiped the whole form. Sign-up is a four-step form and every answer lives in the page's memory, so leaving to read the terms and coming back threw all of it away — and the step it happens on is the one holding the password, so nothing could safely have been saved and restored either. Those links now open in a new tab, leaving the part-finished form untouched. 🔴 This fix is not covered by an automated test: the sign-up form's test file is on the known-failing list that the build deliberately skips, for reasons that predate this change, so nothing would catch a regression here. Worth fixing that suite next.
- A latent fault meant admins whose authority is a boolean flag would never have been told a new member had registered. 🔴 Correction, and it matters: this was first written up here as the cause of a live report from a coordinator who said she received no alert. It was not. Checking the production delivery records afterwards showed her email was delivered and her bell notification was created and opened; her account carries role = 'admin', which the original query already matched, and across the whole platform no account was actually being missed. What was really wrong is a separate thing, fixed in the entry below. The fault described here is real but had not yet bitten anyone, and the fix is kept because it will: the code chose who to tell by reading the single word in the role column on each account. On this platform that word is frequently just "member", because coordinator, tenant admin, super admin and god authority are all carried by separate yes/no flags instead. Any account set up that way would have been skipped: the fan-out would run, find nobody, quietly log "no active admins found" and stop — no email, no bell, silently, on every registration. No such account exists on the platform today, which is why nobody had hit it. The recipient list now matches how authority is really stored: the role words as before, or any of the four admin flags. Brokers and coordinators stay included even though the platform's main admin check deliberately excludes them, because they are the people who actually work the approval queue — the reasoning is written next to the code so neither half gets "tidied" away. Prevention: the query was lifted out into its own named piece so it can be tested against real accounts, and there are now 13 tests covering it — one per flag, one per role word, plus the exclusions that stop the wider net from spamming ordinary members, suspended accounts, or admins of a different community. This matters because the listener's existing tests could not have caught it: they need stand-in versions of the email and notification services, which made them order-dependent, so both were switched off long ago. The new tests need none of that. Confirmed properly rather than assumed — with the old one-line query restored, exactly the four flag tests fail and the other nine still pass.
- A member waiting for coordinator approval was told, in red, that their sign-in details were wrong. Reported by the same coordinator. Signing in before approval produced "Sign-in failed. Please check your details and try again." in a red alert box, with the actual reason — that a coordinator has yet to approve the account — demoted to small amber print underneath. The natural response is to assume you mistyped something and try again, forever. Because requiring approval is the default for a newly set-up community, this was not an edge case but the ordinary experience of joining. Waiting for approval is now presented as what it is: a calm green panel with a clock, headed "You're registered and waiting for approval", keeping the existing explanation of who approves and how long it usually takes, plus the link to contact the community. The misleading "check your details" line is not shown at all in this case. It is also announced to screen readers as a status rather than an error, so it does not sound like a failure either. A genuinely wrong password still produces the red alert, unchanged. Prevention: this page had no test coverage of sign-in outcomes whatsoever, which is why it went unnoticed; there are now three — one that the real reason leads and the misleading line is absent, one pinning the polite announcement at the markup level so a restyle cannot turn it back into an error, and one guarding the opposite direction, that a bad password still says so loudly. Verified by disabling the fix and watching the right two fail while the bad-password guard kept passing. The new wording is translated into all eleven languages by hand.
- The sign-up form told people to enter "a valid international number" without saying how, and the tickbox outline was too faint to see even after it was enlarged. Two follow-ups to the sign-up fixes above. The phone field's guidance now explains the actual rule in plain words — start with + and your country code, then drop the first 0 of your local number — both in the hint shown before anything goes wrong and in the message shown when the number is rejected. The coordinator had asked for it to say that UK numbers start +44 and lose the leading zero; the wording deliberately states the principle instead of naming one country, because this platform serves timebanks worldwide and must not read as UK-only, and the example stays a neutral international one. Separately, enlarging the consent tickbox from 12 to 20 pixels turned out not to be the whole story: measured in a browser, its outline was drawn at about 1.2:1 contrast against the card, where the accessibility standard asks for 3:1 on the edge of a control you have to find and click. It is now about 3.95:1 in light mode with a matching treatment in dark. 🔴 Deliberately fixed on these two tickboxes only, not platform-wide. The faint outline comes from a shared value used by every input on the platform; raising it everywhere is a design decision for the owner, not something to slip into a bug fix, so the override is local and the code says to delete it if that global change ever happens. The same faintness therefore still affects every other checkbox in the app. Prevention: a test was added for something nothing had ever checked — that clicking the visible box toggles it. Every existing test in that file clicks the hidden native input, which works no matter how the visible parts are put together, so none of them could tell you whether a member can actually tick the box they can see. It passes.
- A coordinator could not find where to edit activity categories, because the page was filed under "Content" and the menu search could not match it. She reported searching both the admin panel and the broker panel and finding nothing. The page exists and always did, at /admin/categories, and the categories themselves are genuinely per-community (each community owns its own rows, so renaming or adding one affects nobody else). Two things hid it. It sat under "Content", beside Blog posts, Pages, Landing page and Menus — a section that reads as website material, not as the categories a member picks when offering an hour. It now sits under Listings, where somebody would actually look. And it carried no search keywords at all, while the items around it did. The sidebar search matches an item's label, its section name and its keywords, so an item labelled "Categories" in a section called "Content" could only ever be found by searching the word "categories" — searching "activity" or "activities", which is what she and the system she is migrating from both call them, matched nothing. Measured against the real search function before and after: "activity", "activities", "activity categories" and "offer" all returned no match before and match now. Keywords were written by hand in all eleven languages rather than machine-translated, because they are exactly the short everyday words the automatic translator mangles. 🔴 Kept reachable rather than simply moved: these categories are not listings-only — the same table serves events, volunteering and resources — so a community with the listings module switched off keeps the item under Content instead of losing the page entirely. Also confirmed while checking: no community anywhere has an "Other" category, so her request for one is a single row to add rather than a feature to build.
- A member waiting for approval was left staring at a login form they cannot get past, with nowhere to go. The calm green "waiting for approval" panel added earlier explained the wait but offered only "Contact the community" — so the member's options were to email a coordinator or give up. On a community that approves members, and approval is the default for a new one, that dead end is the ordinary experience of joining. The coordinator who reported it put it exactly right: it would be better for them "to be able to see something, even the home page that they first saw". The panel now leads with Back to home alongside the contact link, so somebody who has just been told to wait can go and look around the community they have joined. 🔴 Honest about the size of this: the community home page is public and the login page has always carried a "Back to home" link at the very bottom, so this was never technically impossible — the link was page furniture, nowhere near the message, with nothing connecting it to "you are waiting". This makes the way out sit inside the panel that just told them they cannot come in. The same class of problem as the categories entry above: not a missing capability, a capability nobody could find. A test pins the link inside the panel specifically, and was confirmed to fail when the button is removed. 🔴 One part of her suggestion is deliberately NOT done: she also suggested pointing waiting members at the activity-preferences questionnaire to fill in meanwhile. That needs a signed-in session, which is precisely what a pending member cannot have, so it would mean letting unapproved accounts part-way in — a real change to who gets a session, not a wording tweak. Worth doing, worth designing properly, not worth slipping into a bug fix.
- "Back to home" told ten languages to go back to their house. Found while wiring the link above. The phrase had been machine-translated as the physical dwelling rather than a website's home page in every non-English locale — German "Zurück nach Hause", French "Retour à la maison", Spanish "De vuelta a casa", Japanese "家に戻る", Arabic "العودة إلى المنزل" and the rest. It appears twice, once on the login page and once on the sign-up page, and both were live. All twenty values corrected by hand to the page sense ("Zurück zur Startseite", "Retour à l'accueil", "Volver a la página de inicio", "ホームページに戻る"). This is the same failure recorded when "Windows" became Fenêtres and "Mac" became Impermeable: short, common, context-dependent words are exactly what the automatic translator gets confidently wrong, and nothing in the pipeline can detect it because the key exists and is non-empty in every locale.
- 🔴 The new-member alert never said an approval was needed, and the admin panel never showed how many people were waiting. This is the real fault behind a coordinator reporting she got "no email, notification or badge alert" when somebody signed up — see the correction above for what it was wrongly blamed on first. The delivery records settled it: the email went out and was delivered within two seconds of the sign-up, and the bell notification was created and opened. Nothing was missing except the one fact that mattered. The alert read like routine news, not like a job. Its subject was "New member registered", its body said "log in to view their profile", and its button went to that member's profile — on a community that requires approval, where that person is locked out of their account until a coordinator acts. So she did what anyone would: ignored it as noise and went hunting in the admin panel instead. On a community that requires approval, the subject now says an approval is waiting, the wording says plainly that the member cannot use their account until you approve it, the email is styled as a warning rather than an informational notice, and the button goes to the pending-approvals list instead of the profile. Communities that do not require approval are untouched and still get the neutral wording — being told to approve somebody who needs no approval would be its own bug. Brokers and coordinators are deliberately routed somewhere different: they are refused /admin/* by design, so linking them to the approvals queue would hand exactly the people who work that queue a dead link; they keep the broker members list, while still being told an approval is outstanding. The second half is the badge. The admin panel lists "Pending approvals" with no number beside it, and it turns out the reason is that three separate pieces existed and were never joined up: the sidebar's menu items have always supported a badge, the renderer that draws one has always been there, and a backend service that counts pending approvals had been written, unit-tested and registered — with no route, no controller and no caller anywhere. That service is now exposed and the sidebar reads it, so a waiting member shows as a count the moment a coordinator logs in, in the red "someone is stuck" tone rather than as decoration, refreshed each minute. 🔴 One trap fixed while wiring it: the counter counted members whose status was pending, while the screen it links to lists members who are not approved — two different columns that drift apart, and the same controller already carries a repair for approvals that leave the status stuck. A badge saying "1" that opens an empty screen is worse than no badge, so the two now use exactly the same test, with a test that fails if they diverge again. Prevention: the wording and routing decisions were lifted out of the send loop into a pure function so they can be asserted directly — the existing fan-out tests need stand-in mail and notification services, which made them order-dependent, and both have been switched off for a long time, which is precisely why this went unnoticed. Nine new tests cover the copy in each direction, the routing for admins, flag-only admins, brokers and coordinators, the count matching the list, and the count staying inside one community. A badge is never drawn for zero, and a failed fetch leaves the sidebar alone rather than showing a stale number.
- 🔴 Event organisers on the new accessible website could not record anything about venue accessibility. The form for creating an event was missing all ten accessibility questions the old site asks: step-free access, an accessible toilet, a hearing loop, a quiet space, seating, accessible parking (and parking details), how to get there by public transport, who to contact for assistance on the day, and free-text notes. This is the exact information a disabled member needs in order to decide whether they can attend — missing from the website built for them. The questions existed on the form for editing a repeating event, so the platform could store the answers all along, and the part of the code that saves a new event was already passing them through; nothing was asking the organiser for them. The questions now appear on the create form, sharing one common block with the edit form so the two cannot drift apart again, and an organiser's answers survive a validation error instead of being wiped. Tested field by field.
- Eight of the most-used pages kept their English name when a member chose another language. The page name is the browser tab title, the bookmark name, and what a screen reader announces when the page opens. On Feed, Listings, Events, Groups, Ideas and the three "create" forms it stayed in English on the new site while the old site translated it — confirmed by comparing all 41 core pages in Irish, then again in German, where every one now matches. The wording already existed in all eleven languages; the code simply was not using it. The "Federation" page was worse: it showed our own internal word for the feature instead of "Partner communities", untranslated. 🔴 Being honest about what is left: there are 253 hardcoded English page names in the new site's code, and only the nine proven to reach members are fixed. A new check records that number as a ceiling that can only go down, and fails the build if it rises, so the remaining debt is visible and cannot quietly grow. One file (the jobs section) holds 77 of them and is the obvious next job.
- Four faults on the new accessible website, found by walking it side by side against the old one as two real members. Route coverage between the two has been complete for a while (707 of 707), but matching route lists is not the same as matching behaviour, and none of these four showed up as a failing test. 1) Members' messages were attributed to "Community member" instead of the person who sent them — the heading said "Conversation with E2E UserB" while every incoming message underneath was signed by a placeholder. The old site never had this problem because in a one-to-one conversation any message that is not yours is, by definition, from the other person; the new one was looking for a sender name that the message data does not carry. 2) The safeguarding notice was missing entirely. The old site tells members, on every conversation, that their messages may be read by a coordinator or by someone they have trusted to support them. The new site did not show this at all — the wording was translated and ready, but nothing displayed it, so members were not being told who can read their messages. It is now shown in the same place, with the same wording, and deliberately keeps the old site's fail-safe behaviour: if the information about who can see the conversation cannot be loaded, the notice is shown anyway rather than hidden. 3) "Browse organisations" could never work. It always showed "We could not load the organisations", for every signed-in member, because the request was being sent without proof of who was asking and the platform refused it. Two pages were affected. 4) The "Give feedback" link went to an email app instead of the community's own contact form. The old site deliberately sends people to the community's contact page, and only falls back to an email link on the platform-wide pages. On the new site every community got the email link — which is a dead end for anyone without an email app set up (disproportionately the people this website exists for), sent community feedback to the wrong inbox, and skipped the spam protection on the contact form. Each fix has a test that fails without it; two of the existing tests had been pinning the broken behaviour and were corrected.
- The platform's own API was leaving the level name out of a member's progress, so no app except the old accessible website could show it. A member at level 3 is a "Contributor", and the old accessible site displayed "Level 3 (Contributor)" because it reads the progress information directly inside the platform. Everything that has to ask over the network — the new accessible site, the main app, the mobile app — only ever received the number, because the endpoint hand-builds its own copy of that information and had drifted from the original by omitting the name. The name is now included. The existing test for this endpoint only checked that it replied successfully, which is why the omission survived; it now checks the level name is actually there.

### Changed

- The older accessible website is now frozen: we are no longer building on it, only reading it. Project NEXUS has two accessible websites — the original one built inside the PHP application, and the new one that took over accessible.project-nexus.ie on 12 August. The owner decided on 13 August that all building effort now goes into the new one, and the older one is kept read-only as the reference for building it. It retires once the new one is judged finished. This is recorded in the three places a person or an AI agent would look: the project-wide agent guide, the takeover document that is the single place this status is stated, and a banner at the top of the older site's own developer guide. The distinction written down is deliberate and narrow: reading the old site is the point — it is the specification the new one is being built against — while writing to it stops, apart from a security fix, a fault making a live page unusable for a real member, or an unavoidable repo-wide sweep such as licence headers or translation-key parity. 🔴 Frozen does not mean switched off. The older site is still deployed and still serving real members on the community accessible domains and every /{community}/accessible/... address, so nothing was deleted, no routes were removed, and its tests and build stay in place. Retiring it remains a separate change with its own review, after a soak period. This reverses the direction of eight commits in the preceding week that added features to the old site; that work stays, but work of that shape does not start there again.
- Corrected the readiness score for the new accessible website, which the takeover document had recorded as two different numbers at once. That page is meant to be the single place the status is stated, and it claimed 640 in one paragraph and 592 in its own summary table, while the document that actually owns the score said 651 out of 1000. All three numbers described the same measurement, so any reader would have quoted whichever they happened to read first. Both wrong figures now read 651, and the page carries a note telling readers to take the number from the owning document rather than restating it. 🔴 The automated check could not have caught this, and that is worth knowing: it compares specially-marked score values embedded as hidden comments, and both wrong numbers were ordinary prose with no marker. Two further stale claims on the same page were corrected at the same time — a section saying the new site's deployment files were "not on the server yet" and a table row saying its deployment path "is not built yet", both of which were overtaken when it was actually deployed and cut over on 12 August.
- The public repository is now called Project-NEXUS instead of nexus-v1, and the documentation website has moved to its own permanent address. The old name read like a version number for a first attempt, which undersold a platform in production with real communities on it. The rename itself took seconds; the work was in the 115 places across 42 files that named the old one — including the licence attribution links the AGPL obliges us to show, the NOTICE file, the terms and About pages, the security contact file, the issue templates, and the documentation links the API hands to anyone integrating with us. GitHub forwards the old address permanently, so nothing broke at the moment of the change, and the production server keeps deploying normally because it follows that forwarding. Two traps were handled deliberately rather than discovered later. A check that runs on every push compares a link inside the changelog against a copy of the same link inside a script — changing either alone turns the build red, so both moved in one commit and the check was run to prove it. And the private backup repository is a separate repository that GitHub does not rename automatically; it was renamed to match and the restore instructions updated. Its name contains the old name as a prefix, so it was replaced first, before the shorter one — the other order silently mangles it. 🔴 The documentation website was the one thing that genuinely could not survive a rename. Its address was built from the repository name and GitHub does not forward the old one, so every existing link to it would have died silently. Rather than move it to a new address that would break again next time, it now has a permanent home at docs.project-nexus.ie, and the deploy writes that address into the published site on every run — without that step GitHub quietly reverts to the old style of address. Verified rather than assumed: the version-consistency, documentation-hygiene and score-consistency checks all pass; the 23 frontend tests and 824 accessible-site tests covering the attribution links pass; and a search of the whole repository for the old name returns nothing except a single line of history. That one line was left alone on purpose: an entry from May records that a panel linked to an address which did not exist, and rewriting it would have described a broken link that was never broken. Links meant to be clicked were updated; a sentence describing what an address was at the time was not. 🔴 The attribution link inside the live app still shows the old address until the next deploy, because it is baked in when the app is built. It forwards correctly meanwhile, so members are unaffected. The local working folder was deliberately left unchanged — it is not the repository name, and renaming it would break the container setup.
- The community logo in the footer is now close to the size it is in the header, instead of being shrunk to the point of illegibility. The footer used the smallest of the three logo size presets, which capped a wide logo at 28 pixels tall and 150 wide — enough for a plain wordmark, but a lockup like Minehead and Coast Time Bank's (a roundel beside two lines of text) became unreadable at that scale. The preset now allows 40 pixels tall and 220 wide for wide logos, with proportional increases for landscape and square/stacked shapes, so the footer mark carries roughly the same visual weight as the header's. The aspect-ratio-aware sizing is unchanged: a narrow or stacked logo still gets more height than a long wordmark, so no shape ends up squashed. Only the footer uses this preset — the header and mobile drawer use the larger ones and are untouched. The reserved intrinsic dimensions were raised to match, so the space kept for the logo before it loads still cannot be too small (which would make the page jump as it appears). Verified in a browser against the real tenant logo: the footer mark measures 40 pixels tall against the header's 44, where it was previously 28.

### Fixed

- The small coloured badge above a page title ("Community exchange board" on Listings) was breaking onto two lines, with its little icon stranded above the words instead of beside them. Reported by the owner on hour-timebank.ie/listings. The badge is shared, so the same fault appeared above the title on Listings, Volunteering, Blog, Search, Resources, Groups, Members, Marketplace, Organisations, Events and the Help Centre. Root cause: the styling framework we use resets every icon to behave like a paragraph, which starts a new line. The badge component is built to take an icon in its own slot, sitting beside the text; here the icon had been written inside the text instead, so the reset applied and pushed the words down. Measured on the running site: the badge was 34 pixels tall where it should be 20. It is now 28 pixels tall on one line, with the icon and the text centred against each other. The same mistake was found in 34 other badges across the platform — legal pages, achievements, skills, leaderboard, job listings, marketplace seller badges, the broker tools and parts of the admin panel — and a further 13 where someone had patched around it by hand, one badge at a time. Rather than repeat that patch 34 more times, one styling rule now keeps any badge with an icon on a single centred line, and the 13 hand-patches were removed so every badge is spaced the same. The rule is deliberately written to touch only badges that contain an icon, so the several hundred text-only badges are unaffected. Prevention: a test on the shared page-title badge fails if the icon is ever moved back inside the text. Checked in a real browser on two live pages, one for each of the two fixes, and the icon and text are confirmed to share the same centre line. Typecheck and the tests for every affected page pass; the three failures seen alongside them are pre-existing and already on the project's known-failing list, or are a known intermittent unrelated to this change.
- The Explore page printed the raw text achievements.xp_value under every member in "Top Contributors", instead of their XP total. Spotted live on hour-timebank.ie/explore. Translated text is stored in files grouped by area ("namespaces"), and to keep the app starting quickly only four of them are loaded up front — every other page asks for the ones it needs when it loads. The Explore page asked for its own file but not the gamification one, then tried to use a gamification phrase for the XP figure. When a phrase's file has not been loaded, the app has nothing to show and falls back to printing the phrase's internal name, which is what members saw. The page now asks for both files, so the figure renders as "1,234 XP" as intended, in every language. The same mistake was found in three other places and fixed at the same time: the Caring Community home page's "change your answer" link, the support-relationships page (pause / end / resume buttons, the pause dialog and its three confirmation messages), and the caring-community onboarding dialog's title and introduction — all four would have shown internal names to members. Prevention: a new check, react-frontend/scripts/check-i18n-namespaces.mjs, refuses the build when a file uses a phrase from a translation file it never asked for. It was confirmed to fail on the original Explore page fault and to pass once fixed, and it reads the list of always-loaded files from src/i18n.ts rather than repeating it, so the two cannot drift apart. It runs as part of npm run build. Verified in a real browser rather than by reasoning: with the gamification file unloaded the phrase does not resolve, and after loading it the same call returns "1,234 XP".
- The documentation website's "Accessible Frontend" section described a version of the platform we stopped serving the day before, and did not mention the site that replaced it. Anyone landing on that section of docs.project-nexus.ie saw two GOV.UK research documents and nothing else — no mention that there are now two accessible frontends, no mention of which one answers which addresses, and no link to the page that actually records the changeover. That page was listed, but buried in a different table further up, so the reader most likely to need it was the least likely to find it. Worse, the architecture document it did link still described the accessible frontend as "an isolated, HTML-first Laravel frontend" and had not been reviewed since 14 July — weeks before the Node application took over the main accessible address. So the only architecture description on the documentation site was of the frontend that is retiring, presented as though it were current. Three corrections. The section now opens by saying plainly that two accessible frontends are live, which serves what since 2026-08-12, that /version is the only way to tell them apart, and that the changeover page is the one place status is stated — with that page listed first in the section rather than buried. The architecture document now opens with the same warning, keeps its GOV.UK rules (branding prohibitions, approved packages, HTML-first) which still bind both frontends, and has its architecture section retitled so it cannot be mistaken for a description of what serves the site today. And the monorepo boundaries document, which still said the Node site was "not deployed" and that its deployment isolation stood "until a deployment path is deliberately built", now records that it was deployed and cut over, that it is a production track deployed from this repository, and that the isolation rules it describes apply to the second backend rather than to it. Documentation hygiene, score-consistency and lint checks all pass.
- The phone app was missing the source-code link the AGPL licence requires, and mobile/README.md claimed it was already there. The app did show a licence line — "Project NEXUS is open-source software licensed under AGPL-3.0-or-later.", translated into all seven of its languages — at the foot of the Profile hub and five other screens. But that line was the whole notice: there was no link to the source code and no copyright notice anywhere in the app, and a repo-wide search confirmed the repository URL appeared nowhere under mobile/app/**, mobile/components/**, mobile/locales/** or mobile/native-locales/**. AGPL-3.0-or-later Section 7(b), and the NOTICE file's canonical wording, require an interactive interface to carry both the attribution notice and a source-code link. mobile/README.md stated the notice "is displayed in the Profile/About screen" and "must include a copyright notice and visible source repository link" — the first half was half-true, the second half was not true at all, and the app has no About screen. What changed: a new mobile/components/SourceRepositoryLink.tsx is now the single definition of the notice and of the repository URL. It renders the licence line, a copyright notice (Copyright © 2024–<year> Jasper Ford, with the year interpolated rather than baked in), and a tappable link to https://github.com/jasperfordesq-ai/Project-NEXUS — matching what the three web clients already do (react-frontend/src/components/layout/SourceRepositoryLink.tsx, accessible-frontend/views/layout.blade.php, web-uk/src/views/partials/footer.njk). It replaces the six copies of the old bare licence line, so the notice can no longer drift between screens. All new wording goes through the common namespace in all seven mobile locales (en, ga, de, fr, it, es, pt); the URL itself is never translated. The link has a screen-reader label and a 44 px minimum tap target. mobile/README.md now describes where the notice actually lives instead of asserting a compliance claim that was not met. Prevention: mobile/components/SourceRepositoryLink.test.tsx fails if the licence line, the copyright notice, the "Built on Project NEXUS by Jasper Ford" wording, the accessibility label or the link disappear, if the link stops opening the canonical URL, if any locale is missing a key, or if the URL is ever hardcoded into a translation string; mobile/app/(tabs)/profile.test.tsx additionally asserts the link is present on the Profile hub itself.
- The community home page on the accessible site could hang forever instead of showing an error, and nobody would have been told. This is the busiest page on the new accessible site, which took over accessible.project-nexus.ie on 12 August. If the platform's API answered that page with a server error, the page did not fail — it stopped. No error page, no "try again", nothing: the visitor's browser simply spun until it gave up. And it was silent on our side too, so this could have been happening without appearing anywhere we look. The cause is a difference between two halves of the same piece of code. That page handles two cases: the "choose a community" list on the shared address, and a single community's own home page. The first half was carefully written to catch failures and show a proper page. The second half was not. The routine it calls deliberately tolerates the API being unreachable — it shows the page without the statistics band — but passes every other kind of failure back up to be dealt with, and nothing was there to deal with it. Because of how the web framework works, a failure at that point means the reply is never sent at all, and our crash reporter only sees failures that reach the end of the chain, which this one never entered. The second half now catches failures the same way the first half does: a signed-out visitor is sent to sign in, a missing community gets the not-found page, and a genuine server fault gets the error page and is reported to the crash reporter. Verified by reproducing it, not by reasoning about it. A test was written that makes the API return a server error and then asks for a community home page. Against the old code the request timed out with no reply, and the site's own request log recorded no result code at all — which is the fault's signature. Against the fixed code it returns a proper error page. That test is now part of the suite, and it was confirmed to fail when the fix is removed, so it genuinely guards this and cannot pass by accident. The full accessible-site suite — 73 files, 2,123 tests — passes, along with the code-style and branding checks. Found during an audit of the accessible site, which also confirmed the good news: all 707 pages the old accessible site has are present on the new one, with none missing, checked twice by two different methods.
- Three checks on the accessible site were being trusted but not actually run, and the copyright notice was missing from 68 of its files. All three came out of the same audit as the home-page fault above, and all three are the same shape: something everyone believed was protecting us was only happening when a person remembered to do it by hand. The page-coverage check had quietly gone out of date. The accessible site keeps a generated list comparing every page the old site has against every page the new one has, and the documentation calls it a "live alarm" for a page that was never rebuilt. It was not live — nothing regenerated it — so it was only ever as current as the last time somebody ran it manually, and it had already drifted: it recorded 721 addresses where the code had 722. That particular gap was harmless (the extra one was an internal address used to check which site answered), but a genuinely missing page would have gone unreported in exactly the same way. The site's automated checks now regenerate the list on every change and fail if it no longer matches what was recorded, and separately fail outright if any page of the old site has no counterpart. 🔴 This is deliberately not a straight file comparison: the generated files stamp the date, the code version and the folder they were made in, all of which differ on the build machine, so comparing the files themselves would have failed every single time and been switched off within a week. It compares the counts, which are the part that means something. The accessibility checks were not running either — 24 of them, on a site whose entire reason for existing is accessibility. They now run on every change. They use the self-contained version that starts its own stand-in for the platform and refuses anything other than read requests, so nothing touches real member data; the fuller version, which signs in for real, is deliberately excluded. When they fail, the report and screenshots are now saved so the failure can actually be diagnosed instead of being just a red cross. And the licence headers were missing from 68 files. Every source file is required to carry the AGPL copyright notice, and the checker that enforces that only looked at PHP and TypeScript — so the whole accessible site, which is JavaScript and templates, sat outside it. 5 code files and 63 of its 335 templates had no notice at all. All 68 now do, the checker covers them, and the tool the guide tells people to run to fix such failures was taught the template comment style, so the documented fix now actually works. Verified rather than assumed, in the way this repository has learned to insist on: each new check was confirmed to fail when the thing it guards is broken — a licence header was removed and the gate went red, then passed again when restored — because a check that cannot fail is worse than no check, having previously hidden a real defect here. The full accessible-site suite (73 files, 2,123 tests), the accessibility checks (24), branding, code style and stylesheet build all pass after the change. 🔴 One count moved and it is worth knowing why: the internal /version address is now classified as machinery, alongside the health check, rather than as "a page the new site has that the old one doesn't". It is what the deploy check and the changeover check match on, not something a member can visit, so counting it as a page was miscounting the one number that list exists to keep honest.
- The front page of the public repository advertised three technologies out of a stack that has about fifteen, and described the accessible site as software we stopped serving it with the day before. The row of small badges at the top of README.md — the first thing anyone evaluating the project sees — listed only PHP, Laravel and React. Missing from it: TypeScript, HeroUI, Tailwind and Vite (the web app); Node 22, Express, Nunjucks and GOV.UK Frontend (the accessible site, which is a separate application, not a template folder); Expo and React Native (the mobile app, its own codebase); .NET 10, PostgreSQL and RabbitMQ (the second backend); and MariaDB, Redis and Meilisearch, which every install depends on. The badges are now grouped under headings — Backend, Web app, Accessible site, Mobile, Second backend — so the shape of the platform is legible at a glance instead of being understated to a third of its real size. The more serious half of this is accuracy, not completeness. The stack table said the accessible site at accessible.project-nexus.ie was rendered by Laravel Blade, and that the Node application was "retired 2026-08-10, container stopped" and "never deployable from this repository". All three statements were true when written and all three were wrong by the time anyone read them: the Node application took that address over on 2026-08-12, it is deployed from this repository, and every deploy from now on must carry the --with-webuk flag or it refuses to run. Checked against the live site rather than the documentation — asking accessible.project-nexus.ie which application answered returned the Node one. The tables, the architecture diagram and the repository map now say that two accessible frontends are live at once, which one answers which addresses, that /version is the only way to tell them apart, and that the changeover is unfinished. Two whole directories were absent from the repository map: mobile/, the Expo / React Native app, and contracts/events/v2/, the shared event contracts both backends have to satisfy. Both are now listed. And two claims about the mobile app were corrected to match the audit already recorded above — it is a separate codebase rather than a wrapper around the website, only the Android release path is complete, and neither store version is published. The retired web-wrapper wrapper is named once, as history, with the note that its project directory is not in this repository. Docs hygiene, version consistency and markdown lint all pass; the diagram was rewritten to avoid two constructs that could have failed to draw on the repository home page. The two secondary tracks were also described in a way that undersold them, and that has been corrected on the owner's steer. Calling the ASP.NET backend "secondary, kept for contract comparison" reads as a spike someone abandoned. It is 254 controllers, 165 migrations and 3,386 tests standing at 712/1000 against a fixed rubric whose single question is whether it is externally contract-identical to Laravel — substantially built, paused rather than abandoned, with a finite ordered queue remaining. The section is now headed "The second backend" and states the actual point of it: the clients are built to switch backends by configuration (VITE_BACKEND_TARGET=laravel|dotnet, with backend differences confined to adapter modules), the goal being two frontends by two backends where neither frontend changes behaviour when its backend changes. The README also says plainly what remains true — not certified, not the production default, not deployable from here — because both facts matter at once. The score is quoted with a pointer to the one canonical document that owns it, and with the warning that two separate 1,000-point rubrics exist and must never be added together. And the accessible site is now described as a full application rather than a template folder: its own HTTP server, routing, middleware, session store, view layer, asset pipeline, brand checks, 74 test files and production container, talking to the platform over the same public API the React app uses — which is precisely why it, too, can be pointed at either backend. The badge headings said the wrong thing about which track is being worked on, and the takeover document had gone stale. "Second backend (contract comparison, service retired)" was both dismissive and misleading: the enforced status markers show the two workstreams were paused together on 2026-07-15 and have since diverged — the accessible frontend's pause was lifted on 2026-08-11 and it went live the next day, while the second backend is still paused. The headings now read "Accessible site (the second frontend — live, in active development)" and "Second backend (switchable, substantially built)", and the README says in words that one is being built out and the other is not, so neither status can be read off the other. 🔴 And the document the README points at for that status still said the Node site was "not yet deployed" and that "nothing has been deployed to the server" — written the day before the cutover and not revised after it. Since it declares itself the single place the changeover status is stated, that made it the most misleading page in the repository. It now records the cutover, that both accessible frontends serve live traffic, that /version is the only way to tell which answered, and that the phase turns on Blade being switched off rather than on the first address moving — so this is still Phase A. The GitHub repository description was updated to match the README for the same reason: it also said "Laravel 12 + React 19" and nothing else.
- Two live web addresses were never having their cache cleared after a deploy, so they could keep serving the old site indefinitely. After every deploy we tell Cloudflare — the service that keeps copies of our pages around the world to make them fast — to throw away its stored copies, so visitors get the new version. That step was working, and it reported "all 7 domains purged successfully" every time. The problem was the number 7. The list of addresses was typed into the script by hand, and the account actually has nine. The two that were missing are not spare or parked addresses: timebanks.us serves the main platform app, and pairc-goodman.com serves the app and the API. Both are set up on the production server and both answer normally. So for as long as that list has been out of date, anyone visiting those two addresses could have been served an old copy of the site, with no expiry we control, while the deploy log cheerfully reported complete success. The fix is not just adding the two. Adding them by hand would leave the same trap for the next address anyone adds, so the script now asks Cloudflare which addresses exist and clears all of them, meaning a new one is covered automatically with no code change. The hand-written list is kept underneath as a floor, so if Cloudflare cannot be reached, or the access key is not permitted to list addresses, everything we already knew about is still cleared — and in that case the script now says loudly that its coverage may be incomplete, instead of quietly doing less and still printing success. It also announces any address it finds that was not on the list, so the two worlds cannot drift apart unnoticed again, and there is a new --dry-run option to show what would be cleared without clearing anything. Verified against the real account, not assumed: the new script was run in dry-run mode on the production server using the live access key, and it discovered and listed all nine addresses including the two that were missing. 🔴 This needs a deploy to take effect — the deploy runs the copy of the script that is on the server, so until the next deploy the old seven-address version is still what runs. 🔴 A second, unrelated fault was found on pairc-goodman.com while checking this and is deliberately NOT fixed here, because it is a server configuration change rather than a code change: that address is wired to fixed port numbers belonging to one of the two halves of our zero-downtime deploy setup, instead of following whichever half is currently live. It is therefore serving the previous release right now — confirmed by reading the build identifier it returns, which is the older one — and it will keep doing so. Worse, the next deploy rebuilds that half, so that address can be expected to break for the duration of the build. It needs the same follow-the-active-half setting the other addresses use, which requires an authorised change on the server.
- Installing the app from app.project-nexus.ie produced an app that only covered the admin panel — now fixed, and pinned by tests. This is the defect recorded but deliberately left alone in the "Get the app" work above. When a phone or computer installs the app, it asks us for a small settings file that says which address the app should open at and which pages count as "inside" the app. On the main platform address that file said the app should open at /admin/ and that only pages beneath /admin/ were inside it. So an installed app opened the admin panel, and every ordinary page — dashboard, listings, messages, wallet — was treated as an outside website and opened in the browser instead, losing the installed app's appearance and behaviour. The cause was a slug being used as a path. The platform's own master community is stored with the name admin in the place a community's web-address segment normally goes, and the code that works out the prefix only returns "no prefix" for communities that have their own web address. The platform host has no such community, so it fell through and used admin as if it were a path. The prefix is now worked out inside the install-file code itself, where the platform host is explicitly treated as covering the whole site, and a community with its own web address still covers the whole of that address. The same fault had a second route in and that is closed too: requesting the install file while sitting on an admin page (/admin/dashboard) matched the master community by name and reproduced the identical /admin/ result — the master community is now never matched that way, because its stored name is a platform address, not a community's. Community web addresses were correct throughout and are unchanged. Verified rather than assumed: two new tests cover the platform host at the root and on an admin page, alongside the existing tests for a community reached by path and a community on its own web address. All five pass — and the two new ones were confirmed to fail with the old behaviour put back, reporting /admin/ where / is expected, so they genuinely catch this and not something adjacent.
- The Features page described our native mobile app as a piece of software we do not use, and overstated how far along it is. The public Features page said the mobile app was built with retired web-wrapper, a tool that wraps the website up as an app. That is not what our native app is: the real one is a separate app written with Expo and React Native, living in its own part of the codebase and talking to the same API the website does. The retired web-wrapper project is not even present in this repository any more — it was removed in March and is deliberately excluded. So the page was describing software that is not here, to anyone reading it to decide whether to work with us. The technology line now names Expo and React Native, and the module description says plainly that it is a separate app sharing one set of code for both Android and iPhone. The honesty about release state matters as much as the correction. The old wording said native builds were "tested but not yet under continuous release", which is vaguer than what we actually know. It now says neither store version is published yet, that Android is closest because its signing, build settings and notifications are all in place, and that the iPhone version additionally needs an Apple developer account and Apple's review before it can be published — the same account of the position given on the "Get the app" page, so the two pages no longer contradict each other. All three pieces of wording were translated into the other ten languages. 🔴 Two brand names were mangled by the machine translator and corrected by hand — the same class of problem as the "Windows"/"Mac" mistranslations recorded above: Irish turned "React Native" into "React Dúchasach" (translating "native" as an ordinary adjective), and Arabic translated "App Store" into ordinary words. Every language was then checked for all five brand names, and all eleven now keep them intact. Also found and not changed here: the admin-facing white-label sales copy still describes an "iOS and Android retired web-wrapper app", which needs the same decision about retired web-wrapper's future that the "Get the app" page is waiting on.
- The "provided by" logo in the footer was blown up far bigger than the picture itself, which is why it looked blurry. The footer only limited how tall these logos could be, never how wide. A tall, squarish logo is fine with that — it hits the height limit and stops. But a long, thin wordmark like Timebanking UK's has to get very wide before it gets tall, so it stretched to about 470 pixels across, which is wider than the uploaded picture actually is. Stretching a picture past its own size is exactly what makes it look pixelated, so the fault and the blurriness were the same thing. There is now a limit on the width as well, and the two work together automatically: a square logo is still stopped by the height and gets a generous 112 pixels of room, while a wide one is stopped by the width instead. Measured on the running page rather than assumed — a square logo comes out 112×112, the long Timebanking UK shape comes out 272×64 instead of 470×110, and neither is stretched beyond the uploaded picture any more. The community partner logo in the neighbouring column had the identical fault and would have blown up the same way the moment a community uploaded a wide logo; it is now limited to the same size as the dashed placeholder box that sits there beforehand, so the footer no longer jumps about when a real logo replaces it. No uploads need redoing.
- The accessible site is now live on its own web address, and watching it for ten minutes found two things worth having. accessible.project-nexus.ie was switched from the old accessible site to the new one, with the community web addresses deliberately left alone. Twelve checks were run straight afterwards and all passed, including the two faults fixed beforehand, old bookmarked addresses, and proof the rest of the platform was untouched. 🔴 Then the new site's own log showed an error I had introduced the same day. Every visit to an old bookmarked address raised an application error. The redirect itself still worked — anyone with an old bookmark landed in the right place — but the code carried on running after the reply had already been sent, and then failed trying to add a security header to it. The cause is exactly the mistake I had found and fixed a few hours earlier in the neighbouring piece of code, and then made again here: "no answer yet" and "already answered" both look like nothing, so treating nothing as "this community does not exist" ran the not-found path on a request that was already finished. Fixed, with three checks that fail if it comes back — and confirmed by putting the fault back and watching them fail. 🔴 And the safeguard that stops a routine deploy quietly undoing the switch had not actually switched itself on. It is designed to record "the new site is live" the first time it sees the new site answering on a real web address — but that check runs during a deploy, and the switch happens afterwards, by hand. So it looked, correctly saw the old site, correctly declined to record anything, and left the protection dormant for exactly the window in which an ordinary deploy would have reverted the switch with nothing failing. There is now a command to switch the protection on at the moment the switch is made, it can only do so if the new site genuinely answers, and it has been run — so the protection is armed. Also recorded but deliberately not fixed: the "report a problem" link in the footer adds the current address to itself, so on the contact page each click makes the address longer, without limit. That is not new and not caused by the switch — the old accessible site does exactly the same, which is why the new one copied it. It is logged as its own piece of work.
- The browser tests were checking the "create a listing" page against a page that does not exist — 23 times. Same wrong address as the two buttons below, and the same reason nobody noticed: it does not produce an error, it produces "Listing Not Found". So these tests spent their whole life filling in a form on a not-found page. Correcting the address was only half the job, and this is the part worth recording. Most of them were written as "if the field is there, check it" — so on the not-found page there were no fields, nothing was checked, and they passed. Pointing them at the real page exposed the second fault: they looked for fields by an internal name (name="title") that this platform never produces, because the component library generates those names automatically. Fields have to be found by their visible label instead — the same conclusion reached last week when six other tests were repaired. So the tests were wrong twice over, and either fault alone would have hidden the other. Every selector was read off the running page rather than guessed. The identical fault in the same file for events was fixed too — five tests pointed at a create-an-event page that likewise does not exist. 🔴 Two traps found while doing it. A test that navigates and checks immediately fails against a page that is merely still loading: the app briefly shows "Checking authentication...", which outlasts the tests' default five-second patience, and the resulting "element not found" reads exactly like a broken feature. There is now one shared step that waits properly. And the category chooser defeated three reasonable-looking selectors in a row — the obvious one matches an invisible button one pixel wide, which the test tool quite correctly refuses to click. All 31 tests in the two files now pass, verified by running them.
- The category chooser can be driven after all, and that unblocked the three most valuable tests. Creating, editing and deleting a listing all need a category, and the control had defeated every attempt — clicking an option left it unselected with the menu stuck open, which then made the next click fail in a way that looked like a broken submit button. Reading the component rather than guessing at it gave the answer: the menu contains a search box, and that is where the keyboard focus goes, so the list has to be typed into before a choice can be made. It was easy to miss because that box is not a plain text field and does not show up as one. Those three tests are now switched on and passing, which means creating, editing and deleting a listing are covered end to end for the first time.
- A third wrong address was found in the same file: the edit page is reached one way round and the test asked for the other, so it sat waiting for a form that was never going to appear.
- The four feed tests were checking for something the app does not have. They looked for a typing box on the feed page. There isn't one — the feed's "What's on your mind?" is a button that opens a window with tabs, and the writing area inside it is a rich-text editor, not an ordinary text box. All four have been rewritten against what is really there and pass. One of them had also been checking for success in a way that a failure could satisfy, the same false-pass shape removed from the shared list last week; it now waits for the window to close, which is the app's own signal that the post was accepted.
- Two more tests that had been failing for a while are fixed: one was looking for a page heading before the page had finished loading, the other allowed only one heading where the page legitimately has two.
- The stale check in the Future Care Fund page's own tests is fixed too — it looked for the word "loading" on screen when the page actually says "Calculating your fund...", and had been failing quietly since the tests began using the real English wording.

🔴 **One honest caveat.** Run in parallel against a local development server, two of the event tests can still fail — the page's code is fetched on demand and, when several browsers ask at once on a cold server, it arrives too late and an error page shows instead. Run one at a time, all 31 pass. This does not apply to the real build, where nothing is compiled on demand. Test data created during verification was removed afterwards and the local database confirmed back to its exact starting state.

- Two "create a listing" buttons led to a dead end that looked like a missing listing. Both pointed at an address the app does not have — the create page lives at /listings/create, and these two asked for /listings/new. The reason nobody noticed is the interesting part: it did not produce a "page not found". The address for viewing a single listing is /listings/ followed by that listing's reference, so /listings/new was read as a request to view a listing whose reference is literally the word "new". There is no such listing, so the member got "Listing Not Found — Listing not found or has been removed". Anyone who clicked it would reasonably conclude something had been deleted, rather than that the button was wrong. The two were the "Create Listing" button shown on the Discover page when a community has no listings yet — so it greeted exactly the members a new community most needs to encourage — and the "Offer help now" button on the Future Care Fund page. Every other create-listing button in the app already used the correct address; these two had been missed. Confirmed by using the pages in a browser rather than by reading the code: before the fix the old address really did produce the not-found page, and after it both buttons land on the working "Create New Listing" form. Two tests now hold each button to the correct address, and each was checked by putting the fault back and confirming the test failed. Found while repairing the browser tests' shared lookup list, which contained a dead entry pointing at the same wrong address.
- Audited the accessible-site switchover before doing it, and found two faults that would have gone live. Both were the same kind: the new site answering "yes, here is a page" where the old one correctly says "that does not exist". 🔴 A mistyped community name showed a full page instead of "not found". The old site returns not-found; the new one returned a complete 24 KB page. No other community's information was exposed — it showed generic platform content — but somebody who mistyped an address was told nothing was wrong, and every misspelling became a page search engines could index. 🔴 Every page was also reachable without naming a community at all, and in that state every one of the community's on/off switches was ignored. The platform deliberately refuses those addresses; the new site served them. Nothing was exposed in the live setup, because it happens to have no default community configured — but the local development setup does set one, so any deployment inheriting that would have served one community's information on the shared address. Both fixed, with fourteen new checks covering them, including the case that must not change: when the platform is unreachable the site must not tell members their community does not exist, because that is a false statement about their own data and worse than a thin page. Also confirmed working, rather than assumed: the accessibility links in the main app point at addresses the new site genuinely serves; communities with their own web address are untouched by this change; old bookmarked addresses still redirect correctly; and the terms-acceptance screen keeps the community in the address so members are not thrown out of their own community. And three of my own mistakes, caught by running everything rather than only my new checks: I hand-wrote a "not found" page when the site already had a proper one; I misread a success signal as a failure and started refusing requests that had worked perfectly; and I changed behaviour for every web address when only one was actually wrong. The fix now applies only to the address being switched.
- The browser tests' shared list of "how to find things on the page" was mostly fiction, and one entry was quietly reporting success when things had failed. Yesterday's repair fixed the two navigation entries and deliberately stopped there. Checking the rest against the real running site found that nine of them matched nothing at all — they described buttons, lists and pages that do not exist in the shape written down. A test using one of these does not report "this selector is wrong"; it reports that the feature is missing, which is the most expensive kind of wrong answer because it sends someone to investigate a healthy feature. One was worse than useless. The entry for the little pop-up confirmation messages was written loosely enough that it also matched ordinary red error boxes — and the app uses those in hundreds of places. So a test that finished an action and then checked "did a confirmation appear?" could be satisfied by the error message explaining that the action had failed. That is a test that passes when the product is broken, and there were several of them. It has been narrowed so it can only match a real confirmation message. Each entry was checked against the live site rather than reasoned about, and each repair was then confirmed by deliberately removing the thing it looks for and checking that the right test — and only the right test — failed as a result. Two further details worth recording. The list of listings and the "still loading" placeholder are built from identical styling, so anything that recognised one recognised the other; the repair is attached to the results themselves, so it can no longer mistake an empty loading page for a page of results. And the message box was being found by the English words in its placeholder text, so it worked in English and nothing else — the platform has eleven languages. One entry was deleted rather than repaired: it looked for a link to the dashboard, and there is no such link anywhere on the site at any screen size — the dashboard is reached by a button. Nothing was using it. 🔴 A separate fault was found while checking this work, and fixed. The shared test login was reading a setting name that nothing anywhere sets — not the test setup, not any of the six places the build system supplies it. It therefore always fell back to a made-up account that exists on no environment, so every test that signs in this way failed at the sign-in step, having never reached a single check. messages.spec.ts went from 0 of 8 passing to 7 of 8 on that one change alone. The remaining failure is a genuinely separate timing weakness in that test and is not fixed. Nothing user-facing changes: the app changes are eight invisible markers added so tests have something stable to hold on to.

## 1.6.0 - 2026-08-12

### Added

- 🔴 We had never once tested what happens when a lot of people use the platform at the same time — and now we have, which immediately told us something important. A partner organisation reviewing the platform asked for evidence of load testing by name. There was none: not a partial answer, none at all. The platform already measures how fast every real request is in production, but that only says how it copes with today's traffic and nothing about ten or a hundred times it, which is exactly the question a national rollout asks. There is now a tool that pretends to be a crowd of members browsing at once and reports how long each of them waited — reporting the slowest one in twenty and one in a hundred, not the average, because an average hides the person who waited nine seconds behind ninety-nine who waited one. The first run found that the platform serves roughly the same number of requests per second whether five people or forty are using it — the extra people simply wait longer. That is the fingerprint of a queue: requests lining up behind a fixed number of workers instead of being handled side by side. It is a settings question with a measurable answer, which is a far better position than not knowing, and it tells us exactly when the current server needs more workers or more capacity as the platform grows — planning information rather than a problem. It also showed the abuse protection working hard — nothing blocked at five users, nearly half of requests slowed down at forty — which is reassuring, and means the limits need raising in a test environment to measure the platform rather than its own defences. Three deliberate choices. It refuses outright to run against the live site, because 369 real members share one server and load-testing them is an outage you scheduled yourself; overriding that takes a specifically named instruction. It needs no third-party tool at all, which matters for a platform whose whole promise is that anyone can take a copy and run it themselves. And it counts "slow down, you're going too fast" replies separately from genuine errors, because treating them as failures would make correct behaviour look broken, while treating them as successes would overstate how much the platform can really take. 🔴 The figures are honestly labelled as not being capacity figures, twice, in the write-up: on a development machine the software reloads its settings on every single request and reads its code from a slow shared folder, so a request costing two seconds there costs a fraction of that live — proven by including one deliberate control that skips the software entirely and answers in 24 milliseconds. Also written down: nobody has yet agreed how fast is fast enough or how much downtime is acceptable, so the pass marks in the tool are a starting point for that conversation and not a promise.
- Added a guard so the browser tests cannot gain any more checks that pass without checking anything. Two patterns were found yesterday while diagnosing a Safari test: 139 checks that look as though they wait for the page and do not, and 83 that can never fail at all because they end in "or true". Neither is a fault in the product — but both are how a genuine failure hides behind a retry, which cost hours of diagnosis in one day. Fixing all 222 in one pass would be worse than leaving them: done mechanically, an always-passing check becomes a wrongly-failing one, and that breaks the build for everyone. So instead there is now a ceiling: the build records today's counts and fails if either number goes up. The debt can only shrink, and it can be paid down whenever someone is already working in those files rather than as one disruptive sweep. This follows the same pattern the project already uses for untranslated wording and database-column references. Proven rather than assumed: adding one of each pattern makes the build fail; a comment that merely mentions the pattern is correctly ignored (three text-based guards in this project have previously flagged their own documentation); and the legitimate "click this if it happens to be there" form is correctly not counted, because that genuinely should not wait. The check also refuses to pass if it finds no test files at all — a check that reports success having measured nothing is the exact fault it exists to catch. 🔴 One correction: an earlier note said 226 of the first pattern. The precise figure is 139; 226 came from a rougher search that also caught unrelated checks.
- The new accessible site had no error reporting at all — if it had gone live, a crash would have been invisible. Setting up the reporting account was only half of it, and the smaller half. The site had nothing that could report a fault: no library, no setup, nothing. The deployment configuration I wrote yesterday passed it a reporting address, and that address was read by nobody — so it would have looked configured while reporting silently into the void, which is the same class of fault as the bot check that was quietly switched off in production for months. Now: a reporting project created in your existing account, named to match the two you already have; the library wired into the site so faults are actually sent; and privacy matched to the rest of the platform — no IP addresses, no cookies, no form contents, and web addresses stripped of anything typed into them, because a search box on this site carries whatever a member searched for. The constant machine traffic (the health check runs every ten seconds) is excluded so it cannot swamp the account. Verified properly rather than assumed: I sent a real fault through and confirmed it arrived, then marked it resolved so it is not left sitting as an open alarm. One detail worth recording because it would have been easy to miss: the version label had to be written in the exact same shape as the other two apps, because the automatic thirty-minute check that watches for problems after a deployment searches for that exact shape. With it written any other way, the new site would have been invisible during precisely the window where a new fault matters most. That check now watches all three, and the new one is optional so its absence can never break the watch. 🔴 One thing still needed from you: the reporting address is saved on this machine, but it has to be added to the server’s own settings file before a live deployment would report anything. 14 new tests, full site suite 2,082 passing.
- The new accessible site now has a way to be deployed — built, not used, and deliberately switched off until you say otherwise. Measured on the server first rather than guessed: it has 16 GB, uses 6.6 GB idle with 9.3 GB free, and a deployment peaks around 12.3 GB because the main app's build is given a 4 GB allowance and runs at the same time as the other one while both live copies keep serving. So roughly 3 GB is spare at the tightest moment. The new site was then measured directly instead of estimated — 66 MB idle, 71 MB at its busiest across 260 page requests, with its server process at 123 MB. Its allowance is set to 384 MB, not the 512 MB originally planned, because the measurement did not justify it. 🔴 One separate thing worth knowing: the only out-of-memory kill on that machine was the page-prerendering worker hitting its own 3 GB ceiling, not the server running out. It sits at 99.99% of that ceiling permanently. Adding memory to the server would not have prevented it, and it has nothing to do with this work. A serious trap was found and avoided along the way. The new site refuses to start without proper secrets — which is right — but the way that requirement has to be written means the deployment tool reads the whole configuration file before doing anything. On the live server, where those secrets are not set, every future deployment of the existing site would have failed outright. Not the new site failing: everything failing, as a side effect of preparing something nobody had asked to deploy. Found by reproducing it deliberately, then fixed by moving the new site into a separate opt-in file. The strict secret handling is kept, and the existing deployment path is now provably untouched — there is a test asserting exactly that, including that the function returning the two existing port numbers still returns two, because eight places in the deployment script read exactly two and a third value would have been silently discarded. Also added: a small address where the site reports its own name and version, which is the only way to prove a switchover actually happened — without it a web address that was missed keeps serving the old site perfectly happily and nothing notices; a web-server configuration file that falls back to the old site when the new one is not deployed, so a rollback cannot fail its own safety check and abort itself; a read-only listing of every community web address with a probe reporting which site each one is really serving; and a protection refusing to let anyone build the new site from the wrong place — the exact mistake whose obvious "fix" would drag the entire codebase into the main application's image. Proven by breaking it three ways on purpose. 🔴 Two honest limits. Nothing has been deployed and no switchover or rollback has been rehearsed on a real server. And the web-server fallback behaviour that makes rollback cheap is not yet verified against the live server's own configuration — that has to be checked before any switchover, not discovered during a rollback. Readiness score moved from 592 to 631 out of 1000. Verified: full site suite 2,068 passing, deployment script syntax and every configuration file parse, the new deployment contract test passes all 20 checks, and every documentation gate green.
- A task can now say how many hours in total the member is willing to give, not just how long one job takes. Requested by a timebank moving across from the system they use today, which keeps these as two separate numbers on every task. The platform only had one. "Estimated hours" answers how long should I expect this to take, so both sides know what they are agreeing to. The new "Hours available" answers how much am I prepared to give altogether, for a member who is happy to help but does not want to end up committed to an open-ended amount. Leaving it blank means no limit, which is exactly how every existing task already behaves — so nothing needed changing on the thousands of tasks already published, and nobody has to go back and fill anything in. It is deliberately not a tick box marked "unlimited": one blank field has a single meaning, whereas a box plus a number can end up disagreeing with itself. Two mistakes are caught rather than stored. A total lower than the length of a single job is refused, because that task could never be booked even once — a three-hour job with a two-hour total is unbookable the moment it is published. And clearing the field puts the limit back to "no limit" rather than to zero, which is the trap here: an emptied form field arrives as an empty piece of text, and the platform's number handling would quietly turn that into 0, leaving the task looking as though it had been used up. Both are pinned by tests, along with the total appearing where members read the task. Verified: 6 new platform tests, the frontend typechecks, and the database-column check passes against the refreshed schema.
- Safari on a Mac was told to look for a menu Safari does not have. The Install App button never could open Safari's own install prompt, because Safari does not offer one to websites — so it falls back to showing written instructions. Those instructions told the reader to open "a three-dot or three-line menu", which is Chrome's and Edge's menu. Safari has neither, so the button appeared to do nothing useful. It now recognises Safari on a Mac specifically and gives the real steps: the Share button, then "Add to Dock". Getting this right needs care in one place — every major browser puts the word "Safari" in how it identifies itself, Chrome included, so the check for Safari has to come last or it would claim Chrome and Edge as Safari too. An iPad is the mirror image: it identifies itself as a Mac, and the only thing separating them is whether the screen responds to touch. Both traps now have tests, and those tests exercise the detection for real — the existing ones handed the answer in and asserted it came back, which could never have caught this. All eleven languages have the new wording.
- The one thing the new accessible site could not do, it can now do — and that was the last thing holding up replacing the old one. Event staff can record attendance by typing or pasting a signed attendee code, for when a camera or an offline staff device isn't available. The existing accessible site has always had this. The new one couldn't, for a structural reason rather than a missing page: every other action on the site works by asking the platform over the web, but this particular one was written to reach inside the platform directly, so there was simply nothing for the new site to call. That is why the page-by-page comparison sat at 706 of 707 for months, and why the old site could not be retired. There is now a proper platform endpoint for it, and the new site uses it. The comparison is now 707 of 707, with nothing missing. The protections that make this equivalent to the old site, rather than merely similar, are each pinned by a test, because "similar" is what makes a replacement unsafe. Scanning alone changes nothing — a tick box is required, so a link preview or a search engine fetching the address can never mark somebody present. A code that cannot be read and a code belonging to somebody the staff member isn't allowed to see give the same answer, so the form can't be used to work out who is registered. Reversing an attendance record requires a stated reason. And the platform looks up the record's current version itself, because somebody holding a scanned code has no way to know it. One deliberate improvement over the old site: the old one invents a fresh submission key every time, which means the key can never match a previous submission — so a double-tapped scan was never actually protected by it. The new endpoint requires the key to be sent, and the page issues one per form, so a double tap replays the same action instead of recording a second one. Three blocks the old site's page had and the new one was missing were also brought across: the privacy explanation, the "no wallet change" note, and the lost-device warning. All the wording already existed in all eleven languages. Verified: 13 new platform tests and 2 new site tests, and the guard test was checked by deliberately removing the guard to confirm it fails; the full site suite at 2,063 passing; static analysis clean; both generated inventories regenerated with every quality measure still at zero. One existing test had to be narrowed: it checked the whole page for an action the roster form correctly withholds, and was only ever passing because this form was missing.
- The standard terms that ship with the platform are now an actual agreement members are asked to accept — which they never were before. This is the piece that makes the whole terms-acceptance system mean something. The acceptance machinery only ever acts on a document, and the standard terms had only ever existed as words on a page — display text on the terms page and in the translation files, never a record in the platform. So there was nothing to accept, and no member had ever been asked. Measured before the fix: five communities, zero documents. Now every community has the standard terms as a published document requiring agreement, and new communities get it automatically the moment they are created — through both routes a community can be created by, so the two can't drift apart. The wording is not new. Every sentence is the same sentence already shown on the terms page — all eight sections, covering time credits, account responsibilities, community guidelines, prohibited activities, safety, liability, account closure and changes to the terms. A test reads the shipped text and fails if the two ever drift apart, so this can't quietly become a second, different set of terms. Several protections matter here because this is a legal record, not ordinary content. A community that wrote its own terms is never touched — not its wording, not its version number — because members have agreed to a specific version, and replacing that underneath them would invalidate every agreement on file while leaving the records looking perfectly valid. A community that has deliberately marked its terms as not requiring agreement is also left alone; the tool reports it rather than quietly reversing the decision. Running it twice changes nothing. A community's own name is inserted into the text and is escaped, so a name containing markup can't break the document. And the stored text is checked to survive the platform's own content-cleaning step untouched — otherwise a member could be shown something slightly different from what they agreed to. For existing communities this is a deliberate one-off command, not an automatic part of a release, with a preview mode that shows exactly which communities and how many members would be affected before anything is written. Bundling something this visible into an unrelated deployment would give nobody the chance to pick the moment. Verified end to end in a browser: signing in landed on the acceptance page, the terms read correctly with a working contents list, accepting recorded the agreement against the exact version and went straight through to the dashboard, and reading was never blocked at any point. 16 new tests; 85 legal tests and 165 tests across the affected endpoints all pass; static analysis and the database-column check clean.

### Changed

- New members now start in light mode instead of dark. Raised by a community coordinator, in plain terms: dark mode is hard on older eyes, and it was the first thing they saw. A member could always change it, and their choice has always stuck — but having to go and fix the appearance before you can comfortably read anything is a poor first impression, and most people never look for the setting. The platform default is now light. Nobody's existing choice is affected: a saved preference still wins, whether it was made explicitly or by asking the platform to follow the device. The browser tint used before the page has finished loading was moved to match, so the app no longer flashes dark on its way to light.
- About forty documents said the new accessible site must never replace the old one — which is now the plan, so an assistant reading them would refuse the work. The instruction files carry that prohibition as a hard rule, and it was correct when written: the new site was an unproven candidate. Since the decision to switch over, those same lines actively obstruct it. There is now one public page that says what is actually happening — the accessible frontend takeover page — covering the decision and its date, that both existing web address shapes are kept exactly so nothing anyone has bookmarked changes, where the changeover has got to, the seven things still needed from the owner, and a table naming every document that makes a status claim and what to watch for in each. The phase of the changeover is stated in exactly one place, on that page. Everywhere else now points at it instead of repeating a claim that can go out of date. The instruction files were then edited one line at a time rather than swept with a find-and-replace, and each superseded line is flagged as superseded rather than deleted — silently removing a prohibition leaves nobody able to tell whether it was lifted or lost. In place of "the old site is the source of truth" there is now a two-phase rule: while the old site is still running it remains the specification for how pages behave and the page-count comparison is a live alarm; once it is switched off the new site owns page behaviour. In both phases the platform's own API remains the authority on what the data looks like — that never changes. About twenty evidence documents got one short header each instead of roughly 185 line edits. Those lines are dated records with code references; rewriting them would destroy the trail, and some of them use the word "candidate" to describe the old site, which a blanket replacement would have corrupted. The development pause was split rather than flipped. One marker paused two separate pieces of work. Leaving it as "paused" was untrue, because this work is now authorised; flipping it to "lifted" would have falsely reopened the experimental backend, whose live database has had no successful backup since March while it changes its own structure every time it starts. It is now two markers, the old single name is refused outright, and that backup warning is preserved word for word. Three genuinely wrong statements were also corrected. A verification routine told you to change into a folder that no longer exists — a temporary working copy deleted after it was merged, so anyone following those steps would simply have failed. Three documents described a configuration file at the top of the project that is not there and never was in this repository; the only file of that name belongs to the paused backend. And the new site's own release procedure makes lifting its deployment hold conditional on a file that was never copied into this repository, so as written the hold cannot be lifted at all — that is now recorded as something the owner needs to resolve rather than a step somebody will discover at the worst moment. Verified: all documentation checks pass, formatting clean with zero errors across 78 files, the score checker green, and every link in the new page confirmed to resolve.
- The readiness scores in the project's own documents were unchecked by anything, and one of them was measuring the wrong question. Several documents carry a hidden line recording a score out of 1000. They look like a checked figure. Nothing in the repository read them — so a score could contradict its own table, be quietly lowered, or drift away from the evidence it claimed to summarise, and no check anywhere would notice. There is now a checker, and it runs in the build. It insists every score equals the total of its own table, that the table's columns actually add up, that every score names which rubric it belongs to, and that the counts quoted in the document match the generated inventories rather than a remembered number. Its guiding rule is written into it: the generated evidence is the truth and the document follows it — the earlier attempt at this had it backwards and ended up asking a document to contradict its own evidence to satisfy an old pin. I proved the checker works by deliberately breaking it three ways — a score that disagrees with its table, a row inflated so the column stops adding up, and a retired name brought back — and confirming each one fails. Separately, the accessible site's score has been retired rather than adjusted. It was 663 out of 1000, measuring "how closely does this candidate copy the old site?". That cannot answer "is this safe to serve to real people?", because it scored no deployment, no switchover, no way back, and no retirement of the old site. There is now a new scoring document that measures the right question, standing at 592 out of 1000 (59.2%). 🔴 The percentage went down and nothing got worse — 200 points of work that was previously out of scope entered the total for the first time and start near zero, while three previously-measured areas actually improved. Because that is so easy to misread, the new document carries a compulsory line-by-line mapping of the old score onto the new one, so nobody can claim it went backwards or quietly convert one number into the other. The old document keeps every figure and every deduction reason exactly as audited — it is the audit trail — and its hidden line was renamed, so anything still reading the old name now fails loudly instead of reporting a retired number as current. Two other markers were made unambiguous: the ASP.NET score now names its own separate rubric with a warning never to add the two together, and the documentation-quality score was renamed from "score" to "index" because a perfect 1000/1000 for documentation, sitting in the same shape as two product scores, invited exactly the wrong conclusion. One more real fault fixed in the page-counting tool: it finds pages by searching for the literal text of a registration call, so any file that named its router something else would have contributed zero pages with nothing failing — the count would simply have been smaller. That has already cost three working pages weeks of invisibility. It now refuses to run rather than under-report, with two tests covering both ways it can happen. Verified: full site suite 2,065 passing, code style clean, all documentation checks passing, and the new checker green with its three deliberate breakages each confirmed to fail.
- 🔴 Requiring members to agree to the terms is now ON by default, because it is a legal obligation rather than an option. It previously shipped switched off, on the reasoning that turning it on can stop members acting and so deserved a deliberate decision. That reasoning was wrong about which way round the risk sits: a default of "off" meant every new installation silently failed the obligation until somebody remembered to set a server setting — and a setting nobody set is not a decision anybody made. The compliant state is now what you get by doing nothing, and an installation that wants something weaker has to ask for it explicitly. Two things follow from this that are worth being clear about. First, an installation that sets nothing will start requiring agreement as soon as this code is deployed — that is the intent, not a surprise; anywhere that wants a measurement period first now has to opt down to "recording only" on purpose. Second, the behaviour on a mis-typed setting has been reversed to match: it used to fall back to "off" so a typo couldn't start blocking people, and it now falls back to enforcing, because with agreement as the baseline the dangerous typo is the one that quietly switches an obligation off. Either way it is no longer silent — a wrong value is now logged by name. The admin panel display was updated in step, so the two can never disagree about what the platform is actually doing. One important limit, stated plainly: this is necessary but not sufficient. Enforcement only bites where a community actually has a document marked as requiring agreement. There are currently five communities and zero legal documents on this development machine, and nothing in the product creates a default one — so today the setting enforces against an empty list and blocks nobody. Making the legal position real needs the shipped standard terms to exist as a document members are asked to agree to, which is a separate piece of work and a decision about wording that isn't mine to make. Verified: 69 legal tests pass, the five gated endpoints' own suites still pass at 170 tests (they pass because there is nothing to enforce, not because enforcement is inert), static analysis clean, and the admin page confirmed live showing "Enforced on changes".

### Fixed

- Checked the assumption the whole quick-undo plan rested on, and it holds. If we point a community's web address at the new accessible site and then need to undo it, the undo works by putting the old settings back — but that only helps if the web server still accepts its own configuration when the new site is no longer mentioned in it. That had never been checked against the actual server, and it was written down as the one assumption everything else depended on: if it were wrong, an undo would fail at the moment you most needed it. It is now verified in both directions on the real machine, read-only, without touching the live configuration. With the new site configured, the configuration is valid and requests go to it. With it removed — exactly what undoing produces — the configuration is still valid and requests fall back to the platform as before. I also proved which of the two paths is actually chosen in each case, rather than just that the file parses, because "it parses" would have been true even if both paths were being ignored. Readiness for the accessible-site changeover moves from 640 to 651 out of 1000: the deployment path has now been exercised four times on the real server and the undo route is proven, with the remaining marks held back until a real web address is switched over and the undo is rehearsed for real.
- The six broken phone-and-tablet tests flagged yesterday are fixed, and not one of them was a fault in the product. Every single failure was a test looking for something that has never existed on the page. The tests searched for a navigation bar by two names the app has never used, a menu drawer by a label it does not carry, and a "Dashboard" link in the header that is not there. One used an iPad setting the testing tool removed in an upgrade, so it crashed before a browser even opened. One opened a web address that is not the "create a listing" page at all and then reported the form missing — it was on a "not found" page. One looked for form boxes by an internal name that the form library invents fresh on every visit, so it could never match. And the sign-in helper waited for a page this app stopped landing on, which meant four other test files were also being denied a signed-in member — that is now fixed for all of them. The real lesson is that a wrong search term fails identically to a broken feature: all six reported "element not found", which reads exactly like something being missing. Six sat there looking like genuine product faults. 🔴 Worse, three tests were passing while checking nothing at all. The touch-target test measured the first button on the page, which is hidden, so its check never ran — it has been silently green for as long as it has existed. The modal test looked for a Delete button that is not on that page and skipped its entire body. And the landscape check from yesterday was only passing because one of these broken searches matched nothing: the moment the search was repaired the check went red, because it had been written to look at the first navigation bar in the page rather than the first visible one — and the desktop bar is always present, merely hidden on a phone. A dead search term had been holding a broken check upright. All three now check something real: the touch-target test measures every visible button against the accessibility minimum and waits for the listings to actually load first (a deliberately shrunk button went undetected until that wait was added), and the modal test opens the Create menu, confirms it fits the screen, and closes on the Escape key. Everything was proven by deliberately breaking what it checks and confirming the right failure, then putting it back — including putting the original sideways-navigation fault back and watching only the tests at the affected widths go red while the narrower one correctly stayed green. Also covered for the first time: a signed-out visitor's menu button, which is a different button from a signed-in member's and had no test at all. Four small identifying labels were added to the page so the tests can find the header, the bottom bar, the menu tabs and the account button reliably — nothing a member sees changes, and they replace searches that were relying on English wording that changes in ten other languages. 🔴 One honest limit: this was run against the local development stack in Chrome only. The other test files that share the repaired sign-in helper were not individually re-checked, and any remaining failures in them are separate pre-existing problems.
- 🔴 The mobile navigation fault a tester reported twice was fixed a month ago, but nothing tested it at the size where it actually happened. A partner organisation's technical reviewer reported in July that the bar along the bottom of the screen could cover the Submit button on a phone, and that it was worse held sideways. The cause was found and fixed: a signed-in member holding a phone sideways fell into a gap between two screen-size rules and ended up with no menu at all. The fix works, and has been live since 9 August. But the test suite's own landscape check uses a screen 667 pixels wide, and the gap starts at 768 — so it sits below the fault and could never have caught it. A modern phone held sideways is 844 to 932 pixels. The fix was therefore argued rather than proven, which is why the same tester was still carrying it on his list a month later. There are now two checks at 844 and 932 pixels wide, signed in, confirming a member still has a menu and that the Submit button is reachable and not sitting underneath it. I proved these checks work by deliberately putting the original fault back and watching them fail with the right explanation, then removing it again. They pass twelve times out of twelve across two browser engines, including Safari's. The old 667-pixel check is kept, but now says in writing that it does not cover this and points at the ones that do — because a check that looks like coverage and isn't is worse than no check. 🔴 Found while there: six of the nine tests in that file have been failing for unrelated reasons — selectors that match two elements because the header renders a phone and a desktop copy, a device preset that no longer exists in the current test tool, and a sign-in helper that waits for a page this app no longer lands on. Flagged separately rather than fixed here.
- A deploy was refused because a brand-new service had not warmed up yet, and the refusal did not say why. Nothing reached members: the old version kept serving throughout and the switch was never made. 🔴 The new accessible site does work on the server — it built, started, reported itself healthy, and served a real 24 KB page when asked directly. What failed was the check: it asked once, nine seconds after the service started, and a service that new still has to reach the platform, work out which community the request is for, and prepare its page templates for the very first time. A minute later the identical request was perfect. So a working deploy was refused over a cold start. The check now waits up to thirty seconds instead of asking once — long enough for a first request, far too short to hide a genuine fault, so a page that never appears still stops the deploy before anyone is moved onto it. 🔴 And the refusal message was almost useless, which cost real time. It said only "did not render an accessible page" — no status code, no response — because of how the request was made, so it could not tell apart "the platform was unreachable", "a page template is broken" and "the wrong community was asked for". I had to reproduce the request by hand to find out. It now reports the status code and the start of the actual response, plus the command to read the service's own log.
- A real deploy stopped safely and exposed two more line-by-line reading faults — one of them in yesterday's own fix. Nothing reached members: the site kept serving the old version throughout, and the switch was never made. 🔴 The safety check that stopped it was wrong. It refuses any database change that adds a required column to a live table — a sound rule. But it reads one line at a time, and the change it objected to marks the column optional on the next line. Entirely safe, refused anyway. A safety check that cries wolf is one people learn to override, and overriding this one risks breaking the version still serving members, so it now reads whole statements. Verified in three directions: the change it wrongly blocked now passes, and genuinely unsafe changes are still caught whether written on one line or several. 🔴 The instruction to include the new accessible site was silently thrown away — again, one layer deeper. Yesterday it was fixed by moving from a setting that the remote connection discards to an explicit instruction on the command. That instruction survived the connection this time, and was then discarded by the step that puts the deploy into the background, because that step rebuilds its own list of instructions by hand and nobody added it. The deploy log said plainly "not included". Had I not read that log, this would have been reported as a successful rehearsal with the new site never built. 🔴 The same list was also dropping the instruction not to run database changes — and because running them is the default, a deploy told explicitly to skip them ran them anyway. That is the opposite of what was asked, on the setting reserved for emergency recovery. Both fixed, and there is now a check that compares the two lists mechanically, so a third instance cannot slip through: proven by removing the fix again and watching it fail.
- Started paying down the browser-test debt, and found that nothing was checking the browser tests for basic correctness at all. 🔴 Nine decorative checks removed and seven raced checks made reliable, across the federation pages and admin member management. Several were checking the very thing their test was named for and passing regardless — whether a federated listing shows which community it came from, whether a federated member shows their community badge, whether the events page has any filters at all. Those now genuinely verify it. Two were turned into real checks after confirming against the code that the platform truly behaves that way; one was replaced honestly, because whether a member list shows page numbers depends on how many members there are, so the test now checks the list appeared instead of pretending to check pagination. Recorded totals fall from 155 to 148 and 83 to 76. 🔴 The bigger find: the browser test suite was never type-checked. There is a configuration file for it, but no command and no automated step ever used it — so fifteen genuine errors had built up unnoticed, and a mistake of mine yesterday (declaring the same name twice) was committed with every check green. These are not cosmetic: four of them mean a test passes an option that is silently ignored, so the test does not do what it appears to do. I fixed mine, added a check that catches this, and set it to run automatically whenever browser tests change — proven by deliberately reintroducing my own mistake and confirming it now stops. The fifteen existing errors are recorded as tracked debt so the check starts green and cannot grow. 🔴 And the new check nearly shipped broken itself — the way it invoked the type checker silently produced no output on Windows, which would have made it pass while measuring nothing. It caught itself, because I had built in the rule that measuring nothing is never a pass. That rule has now paid for itself twice in two days.
- Corrected the records that had drifted, and closed the last gaps the audit found. 🔴 A headline figure in the readiness document was wrong while every check stayed green. It reported the build-quality subtotal as 512 out of 550; the rows actually add to 519. It had been correct once and was never updated as the route score improved three times. The automated check adds up the table columns and cannot see a number written in a sentence, so the one figure a reader is most likely to quote was the one nothing was watching. 🔴 Two records disagreed about whether the error-reporting key was on the server. It is — added yesterday, with a backup — but the readiness document still said it had to be done. Two enforced documents contradicting each other on the same fact is worse than either staying silent, and this was the one readers are told is canonical. 🔴 The route tally looked like it had lost a route: 707 plus 12 plus 3 comes to 722, but only 721 exist. Investigated rather than assumed: nothing is missing. The site's front page is registered twice on the platform side under two different names, and the accessible site serves both from one page, so one page is legitimately counted twice. The counts measure different things; that is now written down where the next person will look. 🔴 The claim that Safari is always tested was not true. That job does not run on proposed changes at all, and can be skipped entirely if an earlier step fails — the same silent-skip shape it was written to avoid. I have not widened it, because a real Safari run is slow and the service it needs is not set up; but the comment now says plainly what it does and does not cover, so nobody trusts coverage that is not there. Also: the browser-engine tests were running at a window width where several checks quietly skip themselves, and at a different width from the other Safari layer, so the two were not comparable — now pinned. And three files that make up the new deployment path carried no licence header, because the licence check only looks at three file types and never saw them.
- Fixed four more ways the terms-acceptance screen could get in a member's way when the platform itself would not have. 🔴 A community that marked a document "show this, but do not require it" was still blocked on every page. The screen decided from a list built for display, which deliberately ignores that setting, while the platform's own rule honours it — so the two halves disagreed about who was blocked, and the member was stopped over a document their writes would have been accepted for anyway. The platform now states plainly whether any outstanding document is one it will actually refuse over, and all clients follow that. 🔴 Accepting on another device could leave someone stuck in a redirect loop for up to a minute. If you accepted on your phone, or an administrator withdrew the document, the accessible site kept its own minute-old answer and kept sending you back to a page that then sent you forward again, until the browser gave up. It now clears that stale answer whenever the platform says you are clear, however you got there. 🔴 Form submissions across the whole accessible site were being counted three times against one shared allowance. Two page groups were connected without a path, so their submission limit ran on every form post anywhere in the site — cutting the real allowance from twenty submissions per five minutes to about six, and worse for several members sharing one internet connection. Invisible to tests, because that limit is switched off when tests run. 🔴 The signal that lets us measure the impact before switching enforcement on was unreadable by any browser. It was being sent but not permitted, so the browser hid it — meaning the measurement would have missed the app most members use. Both permission lists now agree, and a new check fails if they ever drift apart again, which is a problem that only ever shows up in production. Also corrected a note in the code that still described the old default and claimed the phone app had no acceptance screen; both stopped being true the day they shipped.
- Repaired the tests and safety checks that could not fail — and one of them was hiding a genuine bug that affects volunteers on the door. 🔴 Seven checks on the new attendance feature would have passed even if the feature had been deleted. They only asserted "the answer wasn't success", which a missing page satisfies just as well as a correct refusal. They now assert the exact response, and I proved it by deleting the feature: all thirteen checks fail, where seven used to pass. 🔴 The check that mattered most was hiding a real defect. It accepted three different outcomes, so it could not tell success from refusal. Tightening it revealed that when a check-in scanner retries after a network drop — precisely the situation the safeguard exists for — the platform refuses the retry and reports a conflict. The register ends up correct, but the volunteer holding the phone is told it failed, and may then check the person in again by hand. I have not patched it: the cause sits in shared code that the live check-in screens also use, so a hasty fix could break check-in for real events. It is written down as its own job with the evidence attached, and the test now pins the current wrong behaviour so the fix cannot be forgotten. 🔴 The new safety check I added yesterday could be walked past by ordinary code formatting. It read one line at a time, so any statement a formatter had spread over several lines was invisible to it. Sixteen real problems were hidden that way. The honest consequence is that the recorded figure had to go up, from 139 to 155 — not because anything got worse, but because the counter was undercounting. I listed every one of the sixteen in the record, with the reason, because a limit that quietly rises is worse than no limit. It only shrinks from here. 🔴 And the check could still pass while measuring nothing — its own note claimed otherwise. If a pattern broke, its count fell to zero and the tool announced an improvement. It now refuses. I proved this by deliberately breaking one detector: the tool correctly stops instead of congratulating itself. Also fixed: a browser check that was satisfied by the site header, so it passed on a completely empty listings page.
- Made the cookie promise and the code agree, in all eleven languages — and finished the crash-reporting change that was only half done. 🔴 The cookie policy page was telling members the opposite of what the platform does. The banner had been corrected to say anonymous fault reports are always recorded; the policy page one click away still said error tracking happened "only with analytics consent", and listed it as an example of an analytics cookie. So a member who chose "essential only" could go and read a false statement about their own choice. No automated check could catch it — the wording existed and was translated everywhere, so every translation check passed. Nothing compares wording against behaviour. Now corrected in English and all ten other languages, with fault reporting listed as essential, and a test that fails if it ever drifts back. 🔴 Machine translation nearly made it worse, and this is worth knowing. The first pass reversed the meaning in four languages — German, Polish, Spanish and Dutch all turned "sent whatever your choice" into "sent according to your choice", which says the opposite — and four rendered "performance" as theatrical performance. I rewrote the English into plain short sentences and did it again; all ten now read correctly. Idiomatic English does not survive machine translation, and on a compliance page that is not cosmetic. 🔴 The "we always report crashes" promise was only true for outright crashes. Around a thousand places in the app report a handled error, and every one of them was still silently dropped for anyone who declined analytics — so the promise was untrue for the large majority of faults. Now genuine faults are always reported and everything else (who you are, speed measurements, session replay) still needs your consent, exactly as intended. Because faults now send regardless of choice, I tightened what travels with them: web addresses are stripped of anything typed into them, so a password-reset link or a search term can no longer ride along on a crash report; the same for the trail of recent activity attached to a report. The accessible site had four more of these: a noise filter that was switched on but could never match anything, a crash handler that killed the process before the crash could be sent, a reporting function that was documented as connected but called from nowhere, and a member's network address surviving a routine that promised to remove it. All four fixed, all four now tested.
- Audited every commit from this session and fixed four serious faults it had shipped. Three independent reviewers re-read all 43 commits, each asked to check assumptions rather than results — "what would still be green if this were broken?". CI was fully green throughout and told us nothing about any of these. 🔴 The legal-acceptance rule was not applying to four of the fourteen actions it was supposed to protect. The list of pages that must never be blocked (so the "please accept" screen can load) was matched loosely, so it accidentally also covered acting on behalf of a supported child — creating their listings, transferring their credits, uploading their images, preparing support actions. A member who had not accepted could still do all four, while the system reported itself as enforcing. Fixed, and there is now a test that walks every protected action rather than the single one the old test checked. 🔴 The accessible site ignored the "measure but don't block" setting entirely. Its own note claimed it honoured it; no code read it. So a community choosing to measure the impact for a week before enforcing would still have blocked every accessible-site member for that week. The platform now publishes whether it is really refusing requests, and all three apps obey that one answer instead of each guessing. 🔴 The post-switchover check could not detect the failure it was built for. If a web address quietly reverted to the old accessible site, that produces no response — and "no response" was being treated as "not switched over yet", so the deploy passed. Worse, nothing remembered that the new site was live, so one ordinary deploy without the extra flag would have reverted every accessible address back to the old site at a normal HTTP 200 with nothing failing. Both fixed: the switchover is now recorded, after which silence is a hard failure and a deploy that would drop the new site must say so explicitly. 🔴 A mistyped deploy flag was silently ignored — --with-web-uk instead of --with-webuk deployed without the new site and reported success, which is the same silent-no-op fault the flag was created to replace. It now stops before anything is pushed. Also fixed: a refusal that ran too late (after the push and a several-minute wait), a failure message that could not stop a deploy because it ran in a subshell, the health check sharing a request budget it could exhaust on its own, a drift alarm that would have cried wolf every ten minutes, and a domain-inventory command that reported "nothing to do" when it had actually failed to read anything.
- Rehearsed the new accessible site locally before going anywhere near the server, and it corrected two things I had written down as fact. The production image was run against the local platform. It works: it serves a real community page at 25 KB, and it reports its own identity in exactly the form the deployment check looks for. 🔴 First correction — what happens when its session store is unavailable. I had documented that the site reports "not ready" until its session store connects. It does not. It never starts answering at all: the process runs, logs the connection failure on a loop, and nothing is listening. The safety outcome is the same or better — a deployment in that state can never be declared healthy, so it aborts before any traffic moves — but anyone debugging would have gone looking for a "not ready" response that never appears. Corrected where it was written. 🔴 Second correction — how a community's own web address reaches the platform, which I had recorded as an unresolved risk. I had written that it depended on a particular header surviving an internal call, and that this was unproven. Wrong question. Putting a listening post between the two halves showed the community's address is forwarded as its origin, not as its host — and the platform resolves the community from exactly that, by design, with a deliberate guard against one community's request resolving as another's. So the mechanism was already correct and is now observed rather than assumed. What genuinely remains is a live request on a real community address, which cannot be tested here: no local community has an address set, and I would not invent one in a shared database another session is using. That gets proven during the switchover soak. Readiness 637 to 640 of 1000.
- The one Safari test that needed retrying was not a Safari problem, and the reason it kept failing is that a "wait up to three seconds" was not waiting at all. The listings page test checks that either a heading or some cards are on screen. It asked whether they were visible "with a three second timeout" — but that particular check is an instant snapshot; the timeout is ignored entirely. So instead of allowing three seconds for the page to draw, it looked once, the moment the page frame appeared, and raced the app's first render. It was flaky in both browsers. Chrome failed once and passed on its first retry; Safari failed twice and passed on its second — which is Safari simply being slower to paint, not a Safari fault. Worth stating plainly because "a Safari test failed" invites the wrong conclusion. It now uses a check that genuinely waits for either the heading or the cards. A quietly-swallowed error was removed too: the old form hid the specific complaint you get when a page has two headings, reporting "no heading found" instead of the real problem. 🔴 The same mistake is widespread and worth its own piece of work: 226 checks across the browser test suite are built on that same non-waiting snapshot while looking as though they wait. Worse, 83 assertions can never fail at all — they end in "or true", so they pass whatever the page does. The project's own test helper already warns against exactly that pattern, so it is known; the instances remain. None of this is broken behaviour in the product; it is a large amount of testing that reports success without checking anything.
- Safari testing was switched on but never actually ran — the browser was never downloaded, and the step that was supposed to download it reported success. Safari's engine had never been tested in this project, so a Mac-only fault could not be caught. It was enabled earlier today, and every Safari test then failed with a message about a missing program file, which reads like a broken test rather than a broken setup. The real cause: the build server keeps a copy of the downloaded browsers to save time, and it decided whether that copy was still valid using only the testing tool's version number — not which browsers were in it. Adding Safari didn't change that version, so the server reused a saved copy from when only Chrome was installed, concluded it was up to date, and skipped the download. The install instruction did list Safari. It simply never ran. Fixed so it cannot happen again: the saved copy is now identified by which browsers it contains, so adding or removing one automatically invalidates it. The browser list is now written down once and used both for the download and for naming the saved copy, so the two cannot drift apart. And there is a new check that confirms each browser is genuinely on disk before any test runs, so the next mistake of this kind announces itself in plain words instead of surfacing as a confusing test failure. 🔴 While testing that new check I found it had the same disease as the thing it was checking: given an empty list it reported success having verified nothing. Fixed, and both outcomes proven — an empty list now fails, a real list passes.
- A broker test had a one-in-ten chance of failing, and the cause was that it never gave the member enough credits to complete the exchange. It surfaced as an unexplained failure on the build server with a single word of evidence — "Unable to complete this exchange" — and it passed every time locally, so it looked like the build server being unreliable. It was not. The member accounts these tests invent are given a random starting balance between zero and fifty credits. The test resolves a dispute at five credits, and completing an exchange refuses when the payer cannot cover it — so on a uniform draw from zero to fifty, it fails one time in ten. Measured, not guessed: it failed on the 6th attempt, then the 11th, then after adding an explicit balance it passed twenty times out of twenty. Every other exchange test in that file already set a balance for exactly this reason; this one was the outlier. Two things were fixed along the way so the next one is not a mystery. The failure was undiagnosable because two completely different problems produced a byte-identical response — a genuine crash and a refusal with an unrecognised reason both came out as the same opaque message, and the only description of the real cause went to a log the build server discards. The crash path now records what actually threw, and the refusal path records what it refused with, so the two can be told apart. On the build server the crash detail is also included in the response, which is where it becomes visible; that is strictly limited to the test environment, because an internal error message in a real response is something an attacker reads. 🔴 One honest caveat: the underlying trap is still there. Any test that moves credits using an invented member without setting a balance is the same coin flip. I have not counted how many others do that, and I am not going to guess — but the durable fix is to stop the invented member having a random balance at all, which is a broader change than this one.
- My own push turned main red in two ways, both caught by checks that only run on the build server. Neither was visible locally, because I had run the tests for the things I wrote rather than the checks that guard the whole project. First: a new page heading was flagged as corrupted text in German. The heading is “What has changed”, and the German translation phrased it as a question — “Was hat sich geändert?”. A guard exists to catch mangled characters, and one of its signals is a translated line gaining a question mark the English never had, which is a genuine sign of broken encoding. Here it was a real translation choice, not corruption. Rather than weaken the guard I checked the other nine languages: every one of them renders that heading as a statement, matching the English. German was the odd one out, so it now reads “Was sich geändert hat” — which is both the natural German heading and consistent with everyone else. The generated copy the accessible site actually reads was re-synced too, since it had gone stale the moment the source changed. Second: the new event check-in address was missing from the platform’s published interface description. A check insists every maintained event address appears there, and mine did not, so anyone reading the interface list would not have known the endpoint exists. Now added, with its real responses documented — including the deliberate detail that an unreadable code and a code for someone you are not allowed to see return the same answer, so the endpoint cannot be used to find out who is registered. One thing worth recording about how I did it: my first attempt rewrote the whole interface file and produced a 28,000-line change for a 50-line addition, which nobody could review and which could have hidden anything. I threw that away and inserted the entry as text instead, leaving every other byte untouched.
- 🔴 The platform only found out something had broken if the member had accepted optional cookies. Crash reporting was switched on by analytics consent, so one click on "Essential only" — or simply ignoring the banner — meant every fault that member hit was invisible to us for ever. This is measured, not theoretical: 2 error reports in 14 days across 369 members, and a coordinator who ran into real problems over three days generated no client-side record at all. We were blind to precisely the people careful enough to decline optional cookies, who are also the people most likely to be evaluating the platform. Crash reports are now always sent. What they contain is deliberately narrow: the fault, where it happened in the code, the browser, the operating system and the software version — enough to find a Safari-only fault, which is what prompted this. Everything beyond "it broke" still requires consent, and now genuinely rather than incidentally: the member's own identifier, performance timings, and session replay, which records the screen. Without consent a crash is reported but attached to nobody. The existing safeguard that strips passwords, tokens and email addresses is untouched. The cookie banner said the wrong thing and has been corrected in all eleven languages — it listed "Error tracking" under optional cookies, which is now false, and on a consent banner a false description is a compliance problem rather than a cosmetic one. It now says anonymous fault reports are always recorded, and lists what analytics consent actually adds. Six tests pin the split in both directions. 🔴 Two tests already existed for this and were worthless: named "is disabled when no consent stored" and "is disabled when analytics consent is false", they asserted only that the function did not throw — so they passed identically before and after the behaviour changed, pinning nothing. They have been replaced with tests that actually check.
- Real Safari on real macOS can now be tested, and CI says so plainly when it isn't. The Safari engine check added earlier in this release covers the engine, not the browser — so Safari's own native menus, content blockers, extensions and Lockdown Mode were still untested, and those need genuine macOS, which our build machines are not. There is now a second layer that connects to a remote machine running real Safari. It is deliberately not tied to one supplier: one setting points it at any compatible service, or a self-hosted one, so we are not locked in. It runs the fast, high-value checks only, because that kind of service is billed by the minute and is the wrong place to run 831 tests. 🔴 The part that matters most is what happens when it is not set up. That check runs on every push regardless and states in the build output that real Safari was not covered — because a check that quietly skips while the build goes green is exactly how Safari went untested for the entire life of this project. It never implies coverage it did not provide. Turning it on needs an account and two credentials, which is a decision rather than a code change; the whole procedure, both layers, and what each can and cannot prove are written up in docs/REAL-SAFARI-TESTING.md. That page also lists five classic Safari faults that were tested and ruled out — old-style date parsing, a missing blur prefix, an unsupported animation feature, full-height layouts under the address bar, and cross-domain permissions — so nobody spends time re-checking them.
- I audited my own work from this session and found five faults in it — four of them the exact kind I had spent the session finding in other people's code. Every test passed and every check was green. Green said nothing about any of these. The worst was in my own instructions. The command I had written down for deploying the new accessible site would have deployed without it, succeeded, and reported success — because the deploy runs over a remote connection and under elevated privileges, and neither carries settings across. Nobody would have noticed until a web address pointed at nothing. It now travels as a proper option rather than a setting, so it cannot be silently lost, and the deploy log now states plainly whether the new site was included. Setting it the old way is now refused with an explanation instead of ignored. Second: the two addresses that report whether the site is alive and which version it is were sitting behind two checks that need the platform to be reachable. Those are the addresses you read precisely when something is wrong. One decides whether a switchover actually happened; the other tells the system the site is healthy — so a problem elsewhere could have got the site restarted over a fault that had nothing to do with it. Both now answer before any of that. Third: the tool listing which community web addresses need switching over was broken, and announced it by printing nothing — indistinguishable from a clean result. It was hiding its own error message. With the error visible it immediately exposed two further real faults: it looked for a container that is not running on this server, and the one that is running has no database tool in it. All fixed, and the list now works for the first time: three web addresses to switch over, all currently on the old site. That is a much smaller job than feared, and we had no way of knowing before. Fourth: a security check written loosely enough to pass even if the protection were removed. Tightening it turned up something genuinely reassuring — the platform refuses unauthorised access in two independent places, and the second re-checks who you are from scratch, which also defeats a stale-permissions attack. I proved it by switching the first one off and watching the refusal still happen. Fifth: a comment in my own new checker claimed a protection it did not have — it said an unregistered score would be caught, when it only ever read a fixed list. It now scans the whole project, proven by planting one and watching it fail. Verified: full site suite 2,083 passing, 13 platform tests, every documentation and deployment check green, and each fix demonstrated by deliberately breaking it first.
- 🔴 Safari's browser engine had never once been tested here, and the pipeline was green the whole time. A Mac user reported dropdown menus behaving inconsistently, and there was nothing that could have caught it — for three separate reasons, each enough on its own. There was no desktop-Safari check at all. The one mobile-Safari check sits in a job that only runs when certain optional settings are present; they are not, so that job is skipped and the run still reports success. And every other check installs only Google's browser engine, so even the Safari check had no Safari to run in. Safari's engine is now installed, and a desktop-Safari check runs on every push, deliberately placed in a job that cannot skip, alongside the existing Google-engine one so the two are compared every time. A new menu test opens each header menu three times over, because the complaint was "inconsistent" rather than "broken" and a single successful click would have proved nothing. On the reported fault itself, the honest answer is that it did not reproduce. Safari's engine, at the same version generation the reporter's own device announced, behaved identically to Google's on every menu, both locally and against the live site, with no errors of any kind; one common Safari gap was checked and ruled out rather than assumed. This does not clear Safari, and it would be wrong to say it does — the engine is not the whole browser, so Safari's own native menus, content blockers, extensions and Lockdown Mode remain untested, and a real Mac is needed for those. Writing the test produced three false results before it produced a true one, and each is now recorded in the test itself: the standard desktop-Safari window is narrower than the point where the site switches to its mobile menu, so every test silently skipped while the run went green; the header menus turned out to be panels of links rather than classic menus, so an early version declared two perfectly good menus broken in both engines; and the menu button stays briefly "pressed" after closing and swallows an immediate second click, which made a rapid reopen look like a failure — in Google's engine two runs in three, and never in Safari's. The finished test was run eighteen times across both engines with retries switched off, and passed every time.
- One documentation check could not run on this machine, and when I got it running it turned out to check less than its name suggests. The build that produces the public documentation site needs Python, which is not installed here, so it was the one check with no local equivalent — meaning a documentation change could only be judged after a push, and a push republishes the public site. It now runs in a throwaway container pinned to the exact versions the build server uses, so nothing was installed on the machine. The Phase 7 documentation work passes it. 🔴 But I had told the owner it checks broken links, and it does not. The project configuration deliberately switches link checking off in that tool, so the strict mode has nothing to complain about — proven by adding a link to a page that does not exist and watching it pass. Broken links are caught by a different check that was already passing, which is why nothing slipped through. What the site build uniquely catches is a navigation entry pointing at a missing page — confirmed by breaking one on purpose and watching it fail. Both checks are now described accurately where they run, so nobody over-trusts either, and there is a one-command way to run the site build locally.
- 🔴 A member of a community that has no web address of its own could not sign in, and asking for a new password sent them nothing while telling them to check their email. Reported by a coordinator at a new timebank, and confirmed on the live server. A community can either have its own address or sit at a path underneath its parent's — for example uk.timebank.global/minehead-and-coast-timebank. Start from the parent address on its own, with no community name in it, and the platform decides you are at the parent community. Her account only exists in hers. So the sign-in form looked for her in the wrong community, did not find her, and reported wrong details. The password reset was worse: it looked in the same wrong place, found nobody, sent no email, and still answered "we have sent you a link". She waited for an email that had never been created. Three separate pieces of evidence on the live server agreed: her account exists in one community only, her failed sign-ins are recorded against the parent, and the platform writes a record of every reset it actually issues — there was none for her. This is not a mail problem. Her other emails were all delivered normally, and she is not on any blocked-sender list. Both entry points now also look at the communities beneath the one you arrived at, so a community works whether or not it has its own address. The search is strictly limited to that parent's own family — it can never reach sideways into an unrelated community, and there are tests for both directions. The reply to the member is deliberately unchanged, still the same wording whether or not an account was found: saying "no such account" would let anyone use the form to discover which email addresses are registered. What changed instead is that the failure is now recorded. It always had a log line, written at a level the platform discards everywhere by default — which is precisely why this could happen to a real person for days and leave no trace. That single detail is the reason it took evidence rather than reading the code to find. Where the same address genuinely exists in two sister communities the platform now says so in the log and sends nothing, rather than guessing. Verified: 5 new tests covering both entry points in both directions, the 45 existing sign-in and password tests still pass, and static analysis is clean.
- A critical email alarm had been crying wolf every hour for days, over three demo addresses. The platform's email health check was counting suppressed messages as delivery failures. A suppressed message is the opposite of a failure — it means the block list did its job and the platform deliberately did not send. Three test addresses on recurring digests, on two demo communities, were enough to raise a critical alert every hour, for ever. Worse, the alert reported "100% failure": the percentage was worked out against all mail including the suppressed ones, so on a quiet day three skipped messages out of three read as total collapse. An alarm that is always on is an alarm nobody reads, which is how a real email problem would have been missed. Genuine failures and bounces are now counted and rated on their own, against messages actually attempted. Suppressions are reported separately and, in small numbers, quietly — they still appear in the admin health view but no longer raise an alert. A domain-wide block still does, because that shows up as both a large number and a large share of all mail, and that distinction is what keeps this from becoming a way to hide a real fault. Found while investigating an unrelated report. 4 new tests, the 25 existing email-monitor tests still pass, and the wording is corrected in all eleven languages.
- A visitor's cookie choice on the accessible site was recorded nowhere but their own browser. The existing site keeps a record of every cookie decision — who, when, what they chose — which is the point of asking. The new accessible site only set the cookie in the browser, so for anyone not signed in there was no record at all that they had been asked or what they answered. The platform could always store this for anonymous visitors; the only thing stopping it was that the endpoint sat behind a login. Rather than move that endpoint and change behaviour for everything already using it, there's now a second, public one alongside it, rate-limited because anyone can reach it. Verified against the running platform: an anonymous request writes a proper record with no user attached, while the original login-protected endpoint still refuses as before. Two smaller things fixed on the same page: its title was a hardcoded English word on an otherwise fully translated page (the translation already existed, unused), and it wasn't being given a piece of information the rest of the site had already worked out. The important design point: recording never costs the member their choice. The cookie is set and the page moves on regardless — a slow or failing platform can't turn a consent click into an error. That's guarded twice, and the second guard exists because three existing tests caught it: the first version only handled a failure that arrives late, not one that happens immediately, and in that case the whole page broke — meaning someone clicking "reject analytics" would have got an error page and lost their choice. Exactly the opposite of the intent. Verified: 16 new tests, the full site suite at 2,061 passing, accessibility gate 24 of 24, and the platform's own cookie tests still passing.
- 🔴 The bot protection on the contact form has been switched off in production the whole time, by a code fault rather than a setting. The check reads its secret key from the environment at the moment of each request. Every deployment runs an optimisation step that caches settings and, from that point, stops reading the environment file at all — so the check found nothing, no matter what was correctly configured on the server. And when it finds nothing, it treats that as "not configured" and lets the submission through. So the contact form has had no bot protection in production, silently. Nothing surfaced it: the request succeeded, and the only trace was a diagnostic line at a level the platform discards by default. It also meant the widget added earlier in this release would have appeared on the form and verified nothing. The check's own tests passed throughout — because they set the value through the environment and then read it back the same way, exercising the broken path rather than the real one. Fixed by reading from a proper settings file. Proven on the container rather than assumed: with a key configured, the value now survives the optimisation step, while the old environment read comes back empty — which is exactly the before-and-after that shows what was wrong. A test now fails if the service ever reads the environment directly again. One thing worth recording: the first version of that test searched the file for the offending pattern and matched the comment explaining not to do it — the third time in this release a text-based guard has caught its own documentation, so it now strips comments before checking.
- "Report mode" for the new terms check would have recorded nothing at all, while looking like it was working. Report mode exists for one purpose: to run in production without blocking anybody and produce a record of who would have been blocked and which app they were using. It was writing that record at the lowest importance level — and the platform discards anything below "warning" everywhere by default, production included. So the record was being thrown away. It still set a marker on each response, which is exactly why this would have been easy to miss: the mode would have appeared healthy while producing no evidence, and the decision about whether it is safe to start enforcing rests entirely on that evidence. Found by actually switching the mode on and looking for the log line rather than trusting the tests. The record now goes out at warning level, which is also the honest level for "this request would have been refused". A test pins it, and I confirmed the test genuinely fails if the level is put back — otherwise it would be a test that proves nothing. The setting is now also documented in the example environment file with the warning not to skip report mode.
- Every "we have updated our terms" notification sent members to a dead end, and which dead end depended on whether they clicked the bell or the email. The same notification was building two different links for the same document: the bell used the web app's address for it, the email used the accessible site's. Each one was broken on the other site. Both now use one address, and it is the one that works everywhere: the web app gained a matching route, and the accessible site already redirects the older form. A third case was broken on both sites — when a document has not been given a short name of its own, the platform falls back to its internal name, which is written with underscores. No page on either site matches that, so those links failed everywhere they were sent; underscores are now converted. The address is also now built in one place with a proper guard, so a stray character in a document's short name can no longer produce something odd. A second fault was fixed in the web app's own terms check: the pages showing what changed between two versions were being blocked by the very gate asking the member to accept the changes — because the check only looked at the last part of the address, and "versions" was not on its allowed list. A member facing the acceptance prompt could not read what had changed before agreeing to it, which is the difference between informed consent and just a button. Verified: 6 new tests on the address handling, 6 more on the redirect, the whole frontend typechecks, the separate test-type gate reports no new problems, and all 40 legal tests pass in the container.
- A form submitted from a slightly-out-of-date page on the accessible site would have silently thrown away everything the member typed. Two web addresses on that site permanently redirect to their proper home — the community-prefixed address on a community's own domain, and the older /alpha address renamed in July. Both were sending the kind of redirect that browsers deliberately convert into a plain page request. That is correct for someone following a bookmark or a search result. It is wrong for a form submission: the browser drops the submitted data and asks for the target page instead, so the member lands on a page that looks as though nothing happened, with no error and no explanation. It matters in exactly the situation these redirects exist for — somebody who had the page open before a deployment, or whose offline-capable app cached the old address. The main platform already handles this correctly on the very same redirects, using the method-preserving variant for anything other than a page view. The accessible site now matches it. Two tests cover both addresses in both directions.
- A policy that does not apply yet was being shown as though it had just started applying. Legal documents have a date they come into effect, and that date is very often set in the future — a community publishes updated terms today to take effect next month. The new accessible site was putting that date through a "how long ago was this" formatter. A future date gives a negative answer, which fell into the "just now" case. So terms that do not apply for another month read as though they had come into force moments earlier. That is not an unhelpful date, it is a false statement about when a legal document started binding people. Legal dates now show as a proper date, in the reader's own language, with the exact machine-readable date alongside so nothing is lost. A document not yet in effect now says so in a warning above its text, and its date is labelled "Comes into effect" rather than "Last updated". Three other faults on the same page were fixed while there. Pictures were being stripped out of legal documents on this site only — a diagram explaining how an exchange works is an accessibility measure, and the main platform's own rules allow images; the site was throwing them away. The community's summary of what changed was being discarded even though the platform returns it; it is the single most useful thing on the page when a document has just been updated, and it now appears under "What has changed". And long documents now have a contents list, built on the server from the document's own headings so it works with JavaScript switched off. Verified: 40 new tests plus the rewritten shared test, full site suite 2,009 passing, code style clean, branding guard passed, accessibility gate 24 of 24.
- Two web addresses on the accessible site answered "page not found" when people were being sent to them by email and by the notification bell. /terms and /privacy both returned an error. Meanwhile the platform's own "we have updated our terms" notification has long been sending members a link to exactly /terms. Every one of those already-sent links dead-ended on this site. Both now redirect permanently to the real pages. Two files were removed in the process: unused page templates for terms and privacy that were never reachable, contained hardcoded English legal text of their own — a second, invented set of terms sitting in the codebase — and were missing the licence header every file is supposed to carry.
- A comment warning about a trap in the site-comparison tool triggered that very trap. The tool that measures how closely the two accessible sites match finds pages by searching the code for the text of a page registration. A comment was added explaining this and cautioning against writing one out — and because the comment contained an example, the tool counted the example as a real page, inventing a page called /.... That is not cosmetic: the count of pages the new site has beyond the old one feeds the score used to judge readiness. The tool now ignores anything on a commented-out line, with a test covering commented-out registrations in both styles, so prose can describe the hazard without becoming an example of it.
- The contact form on the new accessible site would have failed for everyone the moment the bot check was switched on in production. The main platform's contact endpoint is the one place that insists on a Cloudflare bot challenge, and the new site rendered no challenge at all — so every message sent would have been rejected. It looked fine only because the check quietly lets everything through while its secret is unset, which it is in development. The challenge is now on the contact form, and only there. It was being handed to five other forms — sign-in, two-factor, forgot password and reset password — which deliberately dropped it back in May in favour of quieter defences, so it sat there unused on all of them; that is now removed, along with a comment that claimed the opposite of what the code did. The bot check's own key is no longer expected in this site's settings either, because it is the main platform that verifies, not this site. A bot check must never be the only way to reach a community, so the page now always offers another route: the community's own email address whenever it has published one, and for signed-in members the report-a-problem form, which has no challenge. Those appear on the success page and, more importantly, on the failed challenge page — where a visitor previously hit a dead end. A visitor with JavaScript switched off is now told plainly that the check needs it and pointed at the email route, because the challenge has no non-JavaScript mode at all; the existing site leaves those visitors with no way to make contact, which contradicts its own HTML-first rule. All 28 new pieces of wording exist in all eleven languages. Verified: 15 new tests for this alone, the full site suite at 1,962 tests passing, code style clean, branding guard passed, accessibility gate 24 of 24, and both translation gates green with no new English introduced.
- The tool that inventories which platform endpoints the new site calls was silently blind to three of them, and had lost its own quality guarantees. It reports, for every endpoint the site uses, whether a test actually proves the request it makes. That report had been at zero unproven for a long time; the 23 endpoints added over the last few days had page-level tests but none that checked the requests themselves, so it had quietly slipped to 21 unproven, 9 of them able to change data. A page test can pass while the real code calls the wrong address, so this is a genuine gap and not bookkeeping. It is back to zero, with 23 requests now pinned exactly — including two that matter on their own terms: the safeguarding reason must travel privately and never appear in a web address, and the public event pages must never send a member's credentials, because doing so would silently switch them onto the member data set, which publishes more fields than the public one. Separately, one helper chose its address by looking it up in a list, which reads perfectly well but is invisible to the inventory tool — it collapsed three real safeguarding endpoints into a single meaningless entry with no matching platform route. It is now three plain functions with a small dispatcher on top, so the page still submits one form and the inventory tells the truth. Every count the tool reports is back to clean. Found by actually uploading one, which nobody had done before — no community in the development database had a logo, so this code path had never once run with real data. Inside Docker the accessible site talks to the main platform on an internal-only hostname, and it was reusing that same address when writing the <img> tag the browser loads, plus the security rule that decides which image sources are permitted. The result: every community logo would have been a broken image, and the security rule would have been permitting a host the browser can't resolve while blocking the one it can. There are genuinely two different addresses needed here — one for the server talking to the platform, one for the browser fetching pictures — so there is now a separate setting for the browser-facing one. It falls back to the existing address when they're the same, so production and the non-Docker development setup need no configuration and behave exactly as before. The safety check that stops a community pointing its header at some third-party website is unchanged and still tested. Verified by resolving the generated image address from outside the container: it returns the actual image.

### Changed

- The "Not affiliated with GOV.UK" line has been removed from the accessible site's header, and the header now matches the existing site exactly. The line was described in the project's own notes as mandatory, so it was checked properly before being touched. Two findings settled it. First, the existing accessible site never had it — not anywhere, in any language — so keeping it meant the two accessible sites disagreed about their own header, and the existing one is the agreed reference. Second, the licence does not ask for it: the GOV.UK Frontend package declares itself MIT, and its own documentation says the code and sample code are MIT while the written documentation is under a separate government licence. MIT requires the licence notice to be kept; neither licence requires a visible statement denying any connection. The real protection was never that sentence — it is not using the crown, the crest, the official logotype, the official header or footer, the government font, or any government copyright wording, and the automated branding guard still refuses every one of those. It is now stronger in one respect: a test insists the removed line stays gone, so it cannot quietly reappear as an unexplained string. The header is now identical to the existing site's: same elements in the same order, confirmed by comparing the two running sites rather than by reading the code. Tenant logos work as they should — an uploaded image, the dark-background version preferred because the header is always dark, sized by shape rather than a hardcoded height, and falling back to the community's name when no logo has been uploaded. Two things surfaced while doing this and are worth knowing: the branding guard caught a phrase in my own explanatory comment, which is the guard working correctly; and the development container only shares the source and public folders, not the scripts folder, so changing a build-check script leaves the container running the old copy and crash-looping until the image is rebuilt. Verified: 5 new header tests, full suite 1,911 passing, code style clean, branding guard passed, accessibility gate 24 of 24, and the live header compared side by side against the existing site.

### Added

- The admin panel now shows whether the terms check is switched on, without anyone needing server access. It appears on the existing Legal Compliance page. It names the current state in words — not switched on, recording only, or enforced — explains in a sentence what that state actually does to members, lists which documents are covered, and says plainly that this cannot be changed from that screen. It is deliberately read-only, with no button, switch or dropdown of any kind, and a test fails if one is ever added. The reason: this setting can stop members using the platform, so it should stay a considered change to a file on the server rather than something a mis-click can do. An unrecognised value is described as "not switched on", matching what the platform actually does with a value it doesn't understand — showing "enforced" there would tell an administrator the opposite of the truth. If the platform is running older code that doesn't report the state, the panel shows nothing rather than guessing. Verified on the running admin page as a real signed-in administrator: it displays "Reporting only", explains what that means, lists the covered documents, and contains zero controls. All 13 new pieces of wording exist in all eleven languages, and the translation gate improved by 10 (it also picked up ten pre-existing gaps in the same file). Plus 9 new tests on the display and 2 on the platform response.
- The phone app now has its acceptance screen, which was the last thing standing between the new terms check and being usable. The app previously had no legal handling at all — across 562 places where it talks to the platform, none knew what to do if the answer was "you need to accept the updated terms". So switching the check on today would have left phone-app users unable to do anything, seeing only a generic error with no explanation. That is why the check ships switched off. Two screens: one listing what needs accepting, and one for reading a document in full. The reason the app could not react at all was a real gap in how it reads errors — the platform sends a short machine-readable code with every refusal ("you need to accept the terms", "you need to finish setting up", and others) and the app was keeping only the human sentence, so it could tell that something was refused but never what. The code is now kept, which fixes this and every future case of the same kind. Reacting to it happens in one place rather than at all 562, because a refusal only some screens knew about would show as a generic error on all the others — exactly the confusion this is meant to remove. The screen deliberately cannot be swiped away, since dismissing it would land the member back on the action that will refuse them again and read as the app being broken; the way out is a sign-out link on the screen itself, because "I do not accept" has to have an answer. Each document is named, marked new or changed, and readable in full in one tap — agreeing to a list of titles is not consent. A failed acceptance is never reported as success. And a failed check is never treated as "nothing to accept", because guessing that would send the member straight back into a refusal they cannot see the cause of; they get a retry instead. The document reader keeps a policy's real structure — headings, paragraphs and bullet points as separate items, with proper punctuation and quote marks — rather than using the app's existing text-flattening helper, which merges everything into one block and leaves &amp; on screen. That is fine for a blog excerpt and not for something a member is being asked to agree to. All wording exists in all seven languages the app supports, reusing the translations already written for the accessible site rather than commissioning a second set — the same sentence must not be translated twice into the same language by two different tools. Verified: 34 new tests, the entire app suite at 1,484 passing across 217 files, typecheck clean, no new code-style errors.
- The accessible site now has its acceptance page, so a member who owes an agreement sees a page explaining it rather than a refusal. The server-side check went in earlier; this is what a member actually sees. It names each document, says whether it is new or changed (calling something "updated" when they have never seen it is simply wrong), shows its version, and links to the full text — a list of titles with a button underneath is not consent. It works with JavaScript switched off: one plain form, one button. Most of the work went into making sure it cannot trap anybody, because a page that stands between a member and the whole platform is dangerous if it goes wrong. Four separate things prevent that. It only ever interrupts a page view — never a form submission, which would silently throw away what was typed. It leaves the entire legal section reachable, including the pages showing what changed, so a member can read before agreeing; that is done by matching the whole section rather than the last part of the address, which is the mistake that had blocked those pages in the web app. Signing out always works — "I do not accept" has to have an answer. And its answer is remembered for only a minute and thrown away the moment someone accepts, so accepting cannot send them straight back. It also fails open: if the platform cannot be reached the member carries on, because the real refusal happens on the server anyway and an unreachable status check must not become an unavoidable wall. A failed acceptance is never reported as success — the member is returned with an explanation, and stays blocked, because telling somebody their agreement was recorded when it may not have been is the one thing this page must never do. Verified in a real browser end to end against the running platform: signing in showed the page, the document link worked from it with its contents list intact, accepting went straight through to the dashboard with no loop, the agreement was recorded in the database against the exact version shown, and returning to the dashboard afterwards was unimpeded. Plus 33 new automated tests, the full site suite at 2,041 passing, code style clean, branding passed, accessibility gate 24 of 24.
- The platform can now actually require members to accept updated terms — until today nothing on the server checked. The only thing enforcing acceptance was the React website, in the browser. Any other way in — the accessible site, the phone app, or anything holding a valid login — simply ignored a pending acceptance and carried on. Since the accessible site is due to take over from the old one, that gap was about to become permanent. The check now lives in the platform itself, where every client has to go through it. It is switched off. That is deliberate and important: switching it on can stop members using the platform, so it is not something that should happen as a side effect of a release. There are four settings — off, report, and two levels of enforcement. Report mode never blocks anybody; it records who would have been blocked and, crucially, which app they were using, so the effect can be measured in real use before anything is enforced. The phone app has no acceptance screen yet, so enforcing today would lock out any of its users with a pending document — report mode is how that gets discovered on purpose rather than by complaint. Several things were built with care. The check lets a blocked member go on doing everything they need in order to unblock themselves: read the documents, accept them, sign out, and load the ordinary page furniture — that last one matters more than it sounds, because the accessible site asks the platform for the member's name and unread counts on every page, including the acceptance page itself, so blocking those would leave the member permanently stuck on a page that cannot load. It lets community admins through so they can always fix their own documents and help someone who is stuck. It fails open: if the platform cannot reach the cache it needs, the request goes through rather than being refused, because a compliance check that takes the whole product down when a dependency hiccups is worse than one extra action slipping past. And it honours a setting communities have been given for years and which nothing ever read — a document can be marked as not requiring acceptance, and until now that choice was silently ignored. Two failure modes got their own tests because they are the ones that would hurt: accepting and immediately retrying the blocked action must work first time (otherwise it is an endless accept-then-blocked loop with no way out), and publishing a new version must immediately start applying to everyone who has already accepted the old one. Verified in the container: 20 new tests, all 72 existing and new legal tests passing, and static analysis clean.
- Members can now read the earlier versions of a legal document, and see exactly what changed between two of them. Neither accessible site had any way to do this, even though the platform has kept full published version history all along and already offers it through its own interface. There are three new pages: a list of every published version newest first with the current one clearly marked, a page for reading one earlier version, and a comparison showing what was added and removed. Care was taken over the things that go wrong with pages like these. An old version says it is an old version before its text, not after — somebody arriving from a search result or an old email would otherwise read terms that no longer apply with nothing to tell them. A version belonging to a different document is refused rather than shown under the wrong heading, since version numbers are shared platform-wide. And the comparison does not rely on colour to say what changed: added and removed text is marked up so screen readers announce it, carries a plus or minus sign visually, and the page has a written key. That last point needed a specific fix — the site's standard cleaning step for published content removes exactly the two pieces of markup that carry that meaning, which would have left colour as the only signal and looked perfectly fine in a browser. The comparison pages use their own narrower cleaning step instead. When a comparison can't be produced — the platform limits how often it will build one — the page says so plainly rather than showing an error. Communities can also now publish their own accessibility statement, which the platform has always supported and both sites were silently ignoring; the standard statement remains what everyone sees until a community publishes one.
- The new accessible site now matches the existing one page for page, bar one — and the last pages built are the most sensitive ones. These are the pages where a supporter can read a supported person's messages, and every safeguard in the original is now in the new site too. Nothing is fetched until the supporter says why. The form asking for a reason is the page, not a box on top of already-loaded messages — a test proves no messages are requested before it is answered. The reason is held in the visitor's session for thirty minutes and sent onward privately; a test proves it never appears in the web address, not even the free-text part. A reason that has expired counts as no reason, and the stale entry is cleared rather than left lying around. One person's reason does not unlock a different person. The free-text note is capped. An unrecognised reason falls back to a known one rather than being stored as-is. And read-only means read-only: no reply box, no actions, and a voice message is named but never playable — the recording itself is not put on the page at all, which a test pins. Page agreement moves from 703 to 706 of 707. The one remaining page is blocked on the main platform, which offers no safe way to do it, so the new site correctly refuses to invent one. Verified: 17 new tests, full suite 1,906 passing, code style clean, branding passed, accessibility gate 24 of 24. One follow-up is written down: now that both sites send the reason privately, the old web-address method can be switched off — and removing it is what makes that exposure impossible rather than merely unused.

### Fixed

- The main app no longer writes safeguarding reasons into the server logs. This is the change that actually closes the exposure the previous entry only made closeable. When a supporter reads a supported person's messages they must state why, and that text can name a person and describe a concern — and the React app was sending it inside the web address, so it was written to access logs, browser history, the header passed on to other sites, and any shared screenshot, every single time. It now travels in the private header instead, and a test pins it: the address must contain no reason at all. One non-obvious hazard was found and handled rather than discovered later. The app avoids repeating identical requests by remembering them by web address only — so the moment the reason moved out of the address, two reads with different reasons would have looked identical, been merged into one request, and one of them would have gone unrecorded. That is exactly the record this feature exists to keep. Those reads now take the client's documented "never share this request" path, which also means a response arriving after the supporter has navigated away is discarded instead of being written to a closed page. Verified: the page's 5 tests pass, the whole frontend typechecks, code style clean, and the separate test-type gate reports no new problems.

### Added

- The reason a supporter gives for reading someone's messages can now be sent privately, instead of only inside a web address. Before a supporter can read a supported person's messages, they must state why, and that reason is written to a permanent record. It is free text, and it can name a person and describe a safeguarding concern. Until now the only way to send it over the network was inside the web address — which means it was written into server logs, browser history, the "where did you come from" header passed to other sites, and any shared screenshot. The existing accessible site sidesteps this by keeping the reason in the visitor's session and handing it straight to internal code, never over the network at all; the project's own notes spell out why. But no other client could do the same, because there was no other way to send it. There is now: a dedicated request header. The old web-address method still works so nothing breaks, and it stays as a fallback rather than the preferred route — retiring it is the point at which the exposure actually goes away. A request body was deliberately not used, because for a read request some proxies and caches discard it, which would silently lose the reason. A blank or spaces-only header is treated as no reason at all and still refuses to return anything. The header was added to both permission lists that govern which headers browsers may send, since missing either breaks only in production. Verified in the container: 15 tests on the message viewer including 4 new ones, 22 tests on the header permission lists, and static analysis clean. 🔴 This change does not by itself stop the exposure — it makes stopping it possible. The main React app still sends the reason in the web address today, so anyone using it is still writing that text into the logs. Switching it over is a small, separate change and is worth doing.
- The new accessible site now has the approval queue, the activity summary, and both ends of the message-access consent loop. Five more safeguarding pages, and the point of each is that nothing happens to somebody without their say-so. The approval queue: when a helper prepares a listing or a transfer for someone they support, nothing happens at all until that person answers — approve, decline, or the helper withdrawing their own request. Declining never requires a reason. An item that was already answered or has expired now says so, rather than reporting a general failure. The activity summary is read-only in the strictest sense — there is no action on the page whatsoever, and a test proves it submits nothing about the person it describes. Someone else's member number never gets past the name check, and "that isn't yours" and "you don't have permission" give the same answer, so the page can't be used to find out whether a person exists. The message-access loop is the careful one: a supporter can ask to see a supported person's messages, and asking grants nothing — it only raises a request that the supported person alone can agree to. Withdrawing is one press, takes effect immediately, asks no reason, and cannot be undone, because turning it back on must always need fresh agreement. A tooling trap is recorded alongside: the tool that measures how closely the two sites match finds pages by looking for a literal piece of text in the code, so a first attempt that named things slightly differently left three real, working pages invisible to it — the sites looked further apart than they were. Verified: 22 new tests, full suite 1,889 passing, code style clean, branding passed, accessibility gate 24 of 24. Page agreement moves from 698 to 703 of 707, and every safeguarding page that could be built without an unanswered question now exists.
- A member using the new accessible site can now see, agree to, refuse or withdraw from a guardian arrangement. When coordinators record that someone is responsible for supporting a member, that member is the subject of the arrangement and the only person who can answer it — and until now the new site had no screen for it at all, on the site most likely to be used by the very people these arrangements are about. Everything works by pressing a button with no JavaScript. The care in the original was carried over rather than approximated. Agreeing to an arrangement grants nothing — it is a record, not a permission — so the controls for what a guardian may actually help with only appear after the member has agreed, because a permission must never be allowed to stand in for the consent. Giving a reason for refusing is offered and never required, since making somebody justify refusing is pressure to agree, and there is a test proving a refusal with no reason is accepted. The page only ever offers answers the system will actually accept, so nobody is presented with a button that then fails. The staff-only exit is refused if someone tries to submit it as a member's answer. And two things are deliberately impossible from this page: granting a "draft only" level that has no screen behind it, and granting message access — which has its own separate consent process, and which a long-dead setting must never be able to switch on. "That arrangement isn't yours" and "you can't do that from here" are reported as different messages, because they mean different things to the person reading them. Two more accessibility faults in the existing site were found and deliberately not copied: its error message cannot be moved to by assistive technology, and it announces itself twice by marking two nested elements as alerts. The repository's own rule caught the first the moment the old markup was brought across. The new site follows the official pattern; the existing site should be corrected to match. Verified: 23 new tests, full suite 1,867 passing, code style clean, branding passed, accessibility gate 24 of 24. Page agreement moves from 695 to 698 of 707. including a member pass that works with JavaScript switched off.** These are the pages where a member shows a code at a local partner venue and staff scan it to record the visit — no money and no discount is involved, only a record that the visit happened. All five are done: the venue directory, the member's own pass, getting a fresh code, the page staff land on when they scan, and the confirmation that records the visit. The pass code is drawn on the server as an image, so it appears even with JavaScript off and without fetching anything from an outside service, which is how the existing site does it and the reason a small code-drawing library was added — a well-established one with no dependencies of its own and no known security problems. Several careful behaviours were carried over rather than reinvented. Landing on the scan page records nothing: preview services routinely fetch links behind the scenes, and a scanned pass must not be used up by that, so the visit is only recorded when staff deliberately confirm it. Getting a new code redirects afterwards, so refreshing the page cannot keep replacing the code. The pass code itself never appears as readable text anywhere on the page — it belongs in the scannable image only, and a test now checks it is absent. And where the existing site gets plain answers back from its own internals, the new site has to translate two of them from error codes, so staff still see "this pass is not valid" or "you cannot record visits" rather than a dead-end error page.
- A real problem was caught while doing that, and it affected the What's on pages shipped just before. Two community settings — publishing events to the open web, and partner venues — are switched off by default on purpose, so a community opts in to each. The new site's list of defaults was missing both, and anything missing from that list was being treated as switched on. The practical effect: a community that had never made a choice could have had its events published on the open web without ever agreeing to it. Both are now correctly off unless a community turns them on, with a test to keep it that way. Worth stating plainly: the strongest evidence the fix is right is that an existing test which encodes the other site's rules started passing again on its own, with no changes to it. A note now records that any future setting added on the main platform has to be mirrored in that list.
- The new accessible site now has the public "What's on" event pages, and logged-out visitors finally get a link that works. Measuring the two accessible sites against each other found the new one had fallen 18 pages behind the existing one — not because anything broke, but because the existing site kept being built during a pause. The first two of those are now done: the public event listing and the event detail page, both of which anyone can read without an account. Six behaviours were copied deliberately rather than approximated, because each one is a decision someone made for a reason. The pages need two community settings switched on, not one, and when either is off they answer "page not found" rather than "forbidden" — a public page that admits it exists but won't let you in is an invitation to go poking. The filter only accepts three values and quietly falls back to "upcoming" for anything else. A corrupted "show me more" link starts a fresh first page instead of showing an error. A missing event and a private one give the same answer, so the page can't be used to discover unpublished events. Times show in the timezone where the event is actually held, and an all-day event shows no time at all rather than reading as midnight. And searching for the literal character "0" survives paging — it is treated as nothing by both languages involved, which is exactly the kind of thing that vanishes silently. The pages read the public event feed, never the member one: the public feed deliberately publishes a narrower set of fields, and quietly using the member feed would risk showing something the public version withholds. A navigation bug was fixed on the way through: logged-out visitors were being shown an "Events" link that only ever bounced them to a sign-in page, because the existing site swaps that link for "What's on" and the new site had never copied the swap. One difference was left in deliberately and it is worth knowing: on the existing site the "What's on" navigation item never marks itself as the current page, because two pieces of code spell its name differently — one with a hyphen, one with an underscore. That is a small accessibility fault, so it was not copied; the new site marks the page correctly, and the existing site should be corrected to match. No translation work was needed — all eleven languages were already in place. Verified: 21 new focused tests, the full suite at 1,808 tests passing, no code-style problems, branding check passed, and the accessibility gate still 24 of 24. Page-for-page agreement between the two sites moves from 688 to 690 of 707.

### Fixed

- The Web UK accessible site shipped a mobile-menu script to every page that had never once run. The file looked for two things on the page — a menu button and a navigation list, each identified by a specific name — and neither name exists anywhere in the project. So it found nothing, stopped immediately, and did nothing, on every single page load, for 2,319 bytes each time. The real danger was not the wasted bytes: the file appeared to implement two behaviours that a reader would reasonably assume were working and tested — moving focus into the menu when it opens, and closing it with the Escape key. Neither has ever happened. Nothing tested the file, which is why nobody noticed. The menu itself has always been driven by the official GOV.UK component, which is why it works correctly. Checked before deleting anything, because the whole point of this site is to behave identically to the existing Blade accessible site: Blade uses byte-identical markup for the same official component, and Blade's own script file contains no keyboard handlers at all. The official GOV.UK bundle contains zero occurrences of "Escape". So Escape-to-close has never worked in either site, and removing this changes nothing a user could observe — it removes dead weight, not a feature. Verified after removal: the isolated accessibility gate passes 24 of 24, the full Web UK suite passes 59 files and 1,787 tests, and a freshly started server serves five scripts instead of six with the menu's accessibility state — its expanded/collapsed reporting, the link it controls, and the fact that none of its 8 collapsed links can be reached by keyboard — all unchanged.
- The security gate went permanently red the moment the .NET backend moved into this repository, and every one of its complaints was mistaken identity. The scanner started reading the .NET project files and failed on three things, none of which are real. It matched a PostgreSQL test helper — a small library whose only job is to start a throwaway database for tests — against the PostgreSQL database server itself, and handed it 26 historical database vulnerabilities going back to 2015. The version numbers make the error obvious once seen: the helper is version 3.10.0, and the real database has never had a version 3. It matched a Serilog logging component against an unrelated Unix utility that happens to be named file; the same mistake was already recorded in this project for a PHP testing library, so this is a known pattern reaching a new language. And it matched five OpenTelemetry packages against a genuine vulnerability that belongs to a different language entirely — it is a flaw in the Go version of OpenTelemetry, where a command is invoked by bare name on BSD and Solaris systems and can therefore be impersonated. Our packages are the .NET version, running on Linux, and contain no such code; the two share a product identifier, and the affected Go version range happens to overlap our version numbers, which is the whole of the resemblance. All three are now suppressed with the reasoning written out, in the same narrowly-scoped style as the existing entries. Checked rather than assumed: .NET's own vulnerability audit — which identifies packages precisely instead of guessing from names — reports no vulnerable packages across all five projects, and the real OpenTelemetry .NET vulnerabilities that do exist were looked up individually and confirmed not to apply, because the versions in use are already past every fix. The OpenTelemetry suppression is deliberately limited to that single mistaken vulnerability so genuine .NET ones still stop a release. One limitation is stated plainly in the file rather than hidden: the database-helper suppression lists the 26 vulnerabilities seen today, so the next PostgreSQL vulnerability published will trip this gate again and the list will need extending. The durable alternative was considered and rejected for now — this project has no working example of it, and one note in the file records it failing before, so it would need proving in a real run rather than assuming.
- A leftover folder from an automated session is now a documented rule rather than a surprise. A previous automated session created a second working copy of the project on disk to do its work in. When that work was merged, the branch was tidied up but the folder was not, leaving tens of gigabytes of orphaned files for the owner to discover. Nothing caught it, because that way of working is not part of the normal process here. The contributor guide now states plainly that second working copies are not used, and that any session creating one is responsible for removing it — with the correct command, since simply deleting the folder leaves the project in a confused state. Three specific traps are recorded alongside, all of which were hit for real: Windows refuses to delete a folder while any program is working inside it, so the removal has to happen from elsewhere; the command that checks whether a branch is safe to delete compares against your own out-of-date copy and will wrongly claim work would be lost; and files copied into the running web container land in the real working copy rather than staying inside the container, which had already left a stray test file behind.
- The two experimental services have been switched off in production. The experimental .NET backend, its database, its message queue and the experimental accessible site were all stopped on 10 August, on the owner's instruction. Their web addresses now return a "service unavailable" response by design, and the addresses themselves are kept. The reasoning was straightforward once the facts were gathered: nobody was using them — not a single request in the logs, only their own background jobs waking up every few minutes — the information inside amounted to 13 test accounts, and nothing in this repository could patch them, because they were released from the separate repository that has since been closed. The version of .NET they run also stops receiving security updates on 10 November. On top of that, the software was offering its interface — including a message-queue administration screen — to every network connection, with only a firewall rule standing between that and the open internet. A dependency was caught during the pre-flight checks that would otherwise have caused an outage: the experimental accessible site was quietly pointed at the experimental backend rather than the main one, so switching off the backend alone would have broken it. Nothing was deleted. All stored information remains on disk, three checked database copies are kept, and a final verified copy was taken immediately before switching anything off. Restarting it all is a single command, written down alongside. The main platform was confirmed untouched and healthy throughout: the sales site, the app, the main backend and the accessible site all responded normally, before and after. The nightly backup was switched off at the same time, since it would otherwise complain every night about a service that is off on purpose, and both addresses were removed from the uptime checker for the same reason.
- The public README described the project as smaller than it is, and got one important fact backwards. Anyone reading it would have understood this as one backend with two clients. It is actually two complete backends and three clients, and the README now says so — while being blunt about which are which. The main Laravel backend, the React app and the Blade accessible site are labelled primary: they are what runs the live service. The ASP.NET backend and the Web UK accessible site are labelled secondary: development-only comparison work, each a full stack in its own right with its own database, its own tests and its own server, sharing no database with the main system. The architecture diagrams in both the README and the architecture guide now draw that second stack inside a dashed boundary, so the separation is something you can see rather than something you have to already know. The factual error: the README said both experimental services were "not deployed". They are both live, on their own web addresses — they simply cannot be updated from this repository, because they were released from a separate repository that has since been closed. That wording had already misled several decisions, so it now says exactly that, along with the three separate safeguards that keep the secondary work from ever affecting a live release.
- Deployment documentation now records the things that were only in people's heads. Three gaps were closed. The two experimental web addresses are now listed alongside the main ones, each marked clearly as having no deployment route from this repository at all — which is deliberate, not an oversight, and the three separate safeguards keeping them out of the main deployment are written down so nobody quietly removes one. Monitoring and backups are now listed in one table: what runs, where it runs, how often, and how it reaches you. That table records something confusing that had already caused a wrong assumption — the alert credentials exist as repository secrets on GitHub, which is why deployment-drift warnings reach the phone, but the production machine has none, so the new nightly backup can currently only write to a log file there. The exact fix is written down. Finally, the notes now record which of the two production halves was actually serving live traffic when last checked, because the previous note had gone stale and a wrong assumption there is how a deployment goes to the wrong place.
- The experimental backend's database is backed up every night again, and this time you will be told if it stops. The old job had failed every single night for five months — 156 attempts, no successes, no warning. The cause was small and total: it tried to contact a server address that was blank, and died before it ever reached the backup step. Nothing checked, nothing complained, so silence looked exactly like everything working. The replacement runs nightly at 02:40 and does four things the old one did not. It checks the backup afterwards — the file must be readable and must contain at least as many tables as the live database, because a file of roughly the right size is not proof of anything. It stops immediately on any failed step rather than carrying on. It raises an alarm on the same channel already used for deployment warnings. And a second job each morning checks the backup is no more than two days old, so "the nightly job quietly stopped running" becomes visible instead of invisible — which is precisely how five months went unnoticed. It only reads: it never starts, stops or writes to anything. Verified on the real server: a genuine backup was taken and checked (265 tables), the freshness check reported correctly, and a deliberately broken run was confirmed to fail loudly rather than report success. Old backups are cleared after 30 days. One thing still needed from the owner: the alarm channel credentials are not yet on the server, so failures currently only reach the log file — the script says so loudly rather than pretending otherwise.
- The experimental .NET backend now runs on a version that will still be receiving security patches after November. The version it used, .NET 8, stops being supported on 10 November 2026, and that backend is a live site on the internet. It has been moved to .NET 10, which is supported until November 2028. .NET 9 was never an option — it went out of support back in May. Verified before committing: the code builds with no errors, and all 3,424 tests pass — 3,386 in the main suite plus 38 messaging tests, with zero failures, the same count as before the move, so nothing was quietly dropped. The container image was also rebuilt and checked: it runs the new version and still starts as a restricted, non-administrator user. Three real problems came up and were fixed rather than papered over. The security library used for logins was too old for the new version and refused to build. The container image would not build at all, because Microsoft changed the underlying operating system for .NET 10 — two commands the old recipe relied on no longer behave the same, and the user identity the image wanted was already taken; that identity was deliberately preserved so any existing stored files keep working. And a genuine published security advisory turned up in a text-cleaning library, surfaced only because the newer tooling checks for known vulnerabilities automatically; it was fixed by a small version bump. Unrelated upgrades were deliberately left alone so a version move did not smuggle in unrelated risk — in particular one testing library has switched to a paid commercial licence, which is a decision for the owner rather than a side effect. Twenty-six warnings about database methods marked for future removal remain, noted as separate work. Nothing has been deployed.
- Two rate limits that looked like they protected logins and file uploads were connected to nothing. The routing configuration declared a "10 logins per minute per address" limit and a "30 uploads per minute" limit. Neither was ever attached to a single route, so neither had any effect — searching the whole project found zero references to either. The danger was not the missing protection; it was the false reassurance. Anyone reading that file, or auditing how logins are defended, would reasonably conclude a 10-per-minute ceiling was in force. Both are now deleted. Login protection is what it always actually was, and this is now written down in the file next to where the dead limits used to be: a database-backed lockout counting failed attempts per email address and per network address, plus a genuine 30-requests-per-minute ceiling on the login route group. Two traps are recorded alongside it so the next reader does not create a new bug while tidying: the real login protection reads the visitor's true address through a helper that understands Cloudflare, whereas the deleted limit used a method that does not — the platform has no proxy-trust configuration, so those two are not interchangeable; and separately the word "auth" is also the name of a live login-required middleware in the same project, which has nothing to do with rate limiting and must not be confused with the deleted limit. The two tests that asserted these dead limits existed have been replaced by a test asserting they stay gone, so the configuration cannot quietly return without a route to attach it to, plus a test checking the opposite direction — that every rate limit a route does name is actually registered. That second test found nothing wrong today; every one of the 26 names in use resolves correctly. One unused entry in the separate table of numeric ceilings was left in place deliberately and labelled: that table is a catalogue of available limits rather than a claim about what is protected, and removing an entry would break any route that later asked for it. Verified by running the routing and rate-limit tests (28 passing) and the login tests (38 passing) in the container, plus static analysis on both changed files.
- Written down: the ASP.NET backend runs on a version of .NET that stops receiving security patches on 10 November 2026. All five ASP.NET projects target .NET 8. It is a long-term-support version, but its support window closes on that date, and api.project-nexus.net is a live site facing the internet. The replacement is .NET 10, supported until November 2028 — .NET 9 is not an option, having already gone out of support in May 2026. The size of the job is now recorded rather than guessed at: five version markers, eight locked Microsoft packages, the PostgreSQL database driver, two container recipes and three places in the automated checks. The 165 existing database migration files do not need regenerating, and the 3,386 existing tests are what make this a manageable change rather than a risky one. One ordering rule is recorded with it, and it matters: shipping the upgrade means restarting the ASP.NET container, and that container applies database changes to the live database every time it starts. There has been no successful backup of that database since 8 March 2026. A verified backup has to exist before any of this is deployed.
- Nobody would have been told if the ASP.NET or Web UK sites went down. Those two services were watched by an automated check that lived in the old separate repository. That repository was archived on 10 August 2026, and archiving a repository switches off its scheduled jobs — so the check stopped, while both services carried on running on their public web addresses. There is now a replacement in this repository that checks all six public addresses every fifteen minutes: the sales site, the React app, the Laravel API, the accessible site, the ASP.NET API and the Web UK site. It messages the same Telegram channel already used for deployment alerts, and only when something changes — when a site first goes down, when a different site goes down, or when everything comes back — because a message every fifteen minutes for the whole length of an outage is how people learn to ignore alerts. Three deliberate safeguards: one failed request is not treated as an outage (each address is retried three times before being called down), the check never reports a site being down as its own failure, and it does fail loudly if it cannot run at all — so "the monitor is broken" can never be mistaken for "everything is fine". Verified against the real live addresses: all six responded, and a deliberately broken configuration correctly reported a dead address and a wrong response code. One caveat worth stating plainly: scheduled jobs only run from the repository's main branch, so this starts running on its timer once the current branch is merged. Until then it can be started by hand from the Actions tab.
- The Web UK accessible site was being built on a version of Node that stopped getting security patches in April. Node 20 reached the end of its supported life on 30 April 2026, and the recipe used to build the Web UK site image still asked for it, so for over three months that image was being assembled from a base with no security updates. It was also inconsistent in a way that hid the problem: the automated checks already installed Node 22 to run the Web UK tests, and this machine runs 22, so the entire 1,787-test suite had been passing on Node 22 all along while the shipped image contained 20. A third answer sat in the project's own package.json, which claimed the minimum was Node 18. All three now say Node 22, the current long-term-support release, patched until April 2027. Verified by rebuilding the production image, starting it, and confirming it serves its health check, then running the full Web UK suite, the code-style check and the branding check. The test meant to catch this had pinned the exact text "node:20-alpine", so it kept passing after that version died; it now checks the version is at least the supported one, which fails if anyone drifts backwards but needs no editing for a future planned upgrade. That change was itself verified by temporarily putting Node 20 back and confirming the check fails. A stale Web UK release document was also corrected: it named Node 20 and a folder path that has not existed since the repositories were merged.
- Pushing a change no longer costs an hour of waiting. A Laravel or React change stopped triggering an unrelated 55-minute .NET test run. The checks that run on every push were taking around 50 minutes, and effectively all of that was one job. The main pipeline finished in 20 minutes; a second pipeline, added when the ASP.NET backend and Web UK frontend moved into this repository, took 59 — and five of its six jobs finished inside four minutes. The sixth ran the ASP.NET test suite: 3,386 tests, strictly one at a time, 54 minutes 38 seconds. Two things were wrong. First, that job was set to wake on app/, routes/, config/, database/ and react-frontend/ — so ordinary Laravel and React work, which cannot affect a .NET test, paid the full cost. Each of the six jobs now runs only when its own project changes, using the same shared path list the rest of the pipeline reads. Second, the job ran the suite as one unsplit command. The repository already contained the script for splitting it — it came across in the move but the new pipeline stopped calling it — so it is called again, now across six parallel runners instead of the four used previously. The script hands each runner its own throwaway database, so they never contend; it also keeps whole test classes together, which matters because most of this suite shares one database fixture. Expected effect: most pushes go back to the main pipeline's ~20 minutes, and pushes that genuinely touch the ASP.NET backend land around 15 rather than 58. To be clear about what has and has not been proven: the pipeline changes are verified as correct configuration, and this exact script and shard layout ran green on Linux in the original ASP.NET repository, but the six-shard timing here has not yet been observed on a real run.
- The experimental backend told administrators their actions had worked when nothing had happened. Any admin action it had not implemented — and it is only two-thirds finished — returned a plain "success" with a normal 200 response, while saving nothing at all. A real administrator could click "suspend member", be told it worked, and it would not have. Every log and every monitor would show a perfectly healthy result. That is worse than an error, because an error tells you something. It also meant any test of the form "did it respond successfully?" passed across the entire admin area without proving a single thing — and the remaining contract work is measured against exactly that kind of evidence, so this could quietly bank false progress at scale. It now answers honestly: not implemented, nothing was changed, use the Laravel backend for this. Verified by compiling the code and running 1,259 admin tests, all passing — nothing depended on the false success.
- The experimental backend changed its own database every time it started. It no longer does. Starting the container applied any pending database changes automatically, in every environment including production. That made an ordinary restart a permanent, irreversible act: putting the old version back does not put the old database back, because these changes only run forwards. Two copies starting at once would race each other on the same changes. And it defeats the whole idea of building a spare copy and proving it before switching over, because the spare alters the shared database the moment it starts. Outside local development it now applies changes only when explicitly told to; if changes are waiting it refuses to start and names them, rather than quietly making them. The note in the code warns against the tempting shortcut of switching the environment to "test mode" to get past this — that also switches off the production security checks and falls back to a signing key derived from a value published in this public repository.
- A safety check accepted signing keys that are published on the internet. Web UK refuses to start in production with placeholder secrets — but it recognised placeholders only by length and by one particular prefix. The two placeholder values actually shipped in this repository are both long enough and use a different prefix, so they passed. Anything started with them would have been signing real cookies and sessions with keys anyone can read, and the safety check would have reported the configuration valid. Both exact values are now rejected by name, along with the usual placeholder prefixes. The new test reads those values out of the file itself rather than copying them, so if the file changes the guard follows automatically instead of silently protecting something no longer there — and it confirms a genuine secret is not rejected merely for starting with similar characters. Also stopped the local production-mode setup from loading that public file's secrets at all.
- A guard for the one thing keeping experimental code out of the live platform. The production image is built from an explicit list of folders, and that is what makes it impossible for the two experimental projects to reach production. Nothing was protecting that arrangement. One well-meant "just copy everything" edit — the sort of thing done to fix a missing-file error — would have shipped both experimental trees into the live image with no check objecting. There is now a check that refuses exactly that, proved by deliberately breaking it four ways: adding a copy-everything line, copying an experimental folder by name, and removing each of the two exclusions. It correctly still allows the legitimate multi-stage copies already in use.
- The local ASP.NET setup used the exact same container and storage names as the dead live system — meaning one routine command could have destroyed a database with no backup. The project name, all four container names, the network and all three storage volumes were byte-for-byte identical to the ones running in production. Docker identifies things by name, so a perfectly ordinary "stop and clean up" command, run in that folder anywhere near the server, would have attached to the live system instead of the local one — and the storage-wipe version of that command would have destroyed the only copy of a database that has never once been backed up. Everything local is now renamed to a clearly-local set of names that cannot collide. Checked first that no local data existed to lose, and confirmed afterwards that both container setups still resolve correctly. The remaining mentions of the old name are folder paths that correctly refer to the dead system, not names that could clash with it.
- The alarm that proves production is running the right code was about to start crying wolf every ten minutes, forever. It compares what production is running against the newest change in the repository. That was fine when the repository only held things production runs. Once this work merges, the repository also holds two experimental projects that production must never receive — so every change to those would make production look permanently out of date, and the alarm would fire every ten minutes about work that must not be deployed. Measured on real history: 9 of the last 14 changes would have triggered a false alarm. It now compares against the newest change that actually affects production, and ignores the rest. This matters more than the noise suggests: that alarm is the only thing proving production runs what the code says, and an alarm nobody believes gets switched off. Also fixed a trap that would have made the whole change do nothing — the check only downloads one commit of history by default, so the new logic would have silently found nothing and behaved exactly as before. Verified against real history, including that ordinary Laravel changes are still correctly treated as needing deployment.
- A single misplaced line hid nearly a third of the platform's API from every comparison tool. The tools that check whether the other backend and the other frontend match Laravel first strip out the comments from the route file. They stripped block comments before line comments — and the route file contains ordinary comments that mention wildcard paths, like /broker/moderation/*. That star-slash inside a normal comment was read as the start of a block comment, so everything up to the next one was deleted. Measured exactly: 808 of 2,681 routes — 30.1% — invisible, and 118,006 characters of real route declarations silently discarded. Nothing ever failed. The tools simply reported on a 70% view of the platform as though it were the whole thing, and every comparison figure produced so far was calculated against that view. Both copies of the fault are fixed, both affected reports regenerated. One immediate effect: five endpoints previously reported as "not present in Laravel" turned out to exist all along — that count is now zero.
- Correction: the two experimental projects are not "deployed nowhere". They are live on the public internet. The guidance document said otherwise and it was wrong, which sent several decisions down the wrong path — mine included. Both were confirmed by direct check: the ASP.NET backend answers as healthy, and the Web UK site serves real pages. They run from the old repository, which means this repository cannot update either of them — not for a feature, not for a bug, not for a security fix. The owner has now declared the old repository and everything it deployed dead, with the web addresses retained and this repository becoming the control panel. Two inherited hazards are now written down so nobody rediscovers them the hard way: the live ASP.NET database has had no successful backup since 8 March — 156 attempts, none succeeded, because the job runs with an empty server address and dies before it starts; and that application applies database changes automatically every time it starts, so an ordinary restart can permanently alter live data with nothing to restore from.
- Guidance that told every AI assistant your own containers belonged to someone else. The instructions listed the ASP.NET and Web UK containers under "never touch — they belong to other projects." They are yours. The effect was that an assistant asked to look into them would politely refuse. Corrected, with the genuine reasons for caution put in place of the false one — and the rule against deploying or restarting them left fully intact.
- Web UK could not reach the platform at all on a developer machine. It was configured to find Laravel on port 8088; this repository serves it on 8090. Nothing listens on 8088, so the first thing anyone tried would fail with "connection refused". Corrected across the code default, both environment files, the container setup, the Docker contract and the test expectations. All 1,779 Web UK tests still pass.
- Two blind spots that let the accessible frontend drift apart unnoticed. Web UK exists to reproduce the Laravel accessible frontend's behaviour — yet changing that frontend triggered no Web UK check whatsoever. During the pause Laravel added 18 accessible pages and Web UK went from one page behind to nineteen, with nothing reporting it for over a week. Separately, 33 route files sat in a folder that no check watched at all. Both now wake the right checks.
- The pre-push check said "all checks passed" when it had checked nothing. A change touching only the ASP.NET or Web UK folders matched none of the areas it knew about, so every check printed "skipped" and it finished by declaring success. It now names what it could not check and says plainly that nothing was verified, rather than showing a tick — and it does the same for any future folder it does not recognise, so this cannot quietly come back. On the current branch it now reports 1,677 ASP.NET files and 618 Web UK files as unchecked instead of staying silent.
- The two per-project instruction files were one command away from being lost. Web UK's 32 KB guide existed only as an untracked file that a routine cleanup would have deleted, and the ASP.NET one had not been brought across at all. Both are now properly kept, the ASP.NET one recovered from the archived repository with its folder paths corrected.
- The package-safety check can now actually block a bad dependency, instead of only mentioning it. It has been running in advisory mode because it needs a GitHub feature that was switched off; the owner switched that on, so the deliberate hold has been lifted as the workflow itself instructed. Checked before flipping it rather than after: the check had already run against this branch — which adds both imported projects' entire dependency lists at once, the largest set it will ever be asked to judge — and reported no vulnerable packages at high severity or above, and no banned packages. So it starts out passing rather than blocking work on day one. Combined with the ten extra file locations added earlier, it now watches every dependency list in the repository instead of six of sixteen.
- The nightly security check has been failing on 522 secrets that do not exist. One faulty detector is now switched off. Every one of those findings came from a single detector looking for keys belonging to Lob, a direct-mail service this platform does not use. It was not a one-off: the same false alarm hit on 9 August, cleared, and came back on 10 August. Pinning the scanner to a fixed version — done last week for exactly this reason — could not help, because the pinned version predates the problem entirely. The cause is outside this project: "verified" means the scanner asked Lob's own service whether a key was real and was told yes, so when that service says yes to everything, everything looks like a leaked key. Proven rather than assumed, by running the scanner over the whole repository twice, identically: 530 verified secrets with the detector on, zero with it off. Also checked that no Lob key or address appears anywhere in the code, that no other detector reported anything at all, and that a mistyped detector name would be rejected rather than silently disabling nothing. Everything else the scanner looks for is untouched, and a note records that this must be reversed if the platform ever starts using Lob. This matters beyond the noise: a security check that goes red on its own teaches people to stop reading it.
- Correction to an earlier note: switching on automatic C# security scanning is not possible yet, and nothing was missed. It was written up as a one-tick setting. In fact the option is not offered, because that setting only lists programming languages GitHub can see on the main line — and the ASP.NET code has not been merged there yet. Confirmed directly: the repository reports eleven languages and C# is not among them, and the change is refused outright when attempted. It becomes available the moment this work is merged, and the note now says so along with the exact steps.
- Documentation that told people to run commands in folders that no longer exist has been sorted out — by fixing what is meant to be followed and labelling what is history. Roughly fifty documents came across still describing the old folder layout. Rewriting them all would have been the wrong move: most are dated records of decisions and audits, and quietly editing a record to match today's layout makes it a false record. So the split is deliberate. Anything someone would actually type or click was fixed: the testing guide's commands, the documentation index's links, the Web UK readme, four production safety notes that pointed at a document never brought across, and the guidance file that agents read on entering the Web UK folder — that last one told them to change into a directory that does not exist and then run eight commands with the wrong folder name, so every step failed. Everything genuinely historical instead got one line at the top saying it predates the move and how to read its paths, with the paths left untouched. Nothing that says "this was deleted" was altered, because that is still true. All documentation checks pass, and both link checkers still resolve every link.
- The ASP.NET container image could not be built at all, and nothing in the pipeline was building it to notice. Its build instructions listed four of the five parts the project is made of, so the very first step failed. This was not caused by the move — the file is byte-for-byte identical to the one in the old repository, and the missing fifth part was added there months ago without anyone updating the list. It went unnoticed because no automated check has ever built this image. One line fixed it, confirmed by building the image successfully. Both container images — ASP.NET and Web UK — are now built automatically whenever their own project changes, so a broken build recipe is caught immediately instead of never. Both were built locally first so these checks start out passing rather than failing. Nothing is published; a successful build here still does not authorise any deployment.
- Package security alerts now cover the parts of the project that had none. The nightly dependency check ran over four areas and silently missed Web UK, which came in with the move and had its own check in its old home. The separate check that reviews packages when they change was watching six files and missed ten, including everything belonging to Web UK and the ASP.NET project — and also, from before the move, the end-to-end tests and the phone app. All are now covered. Verified the Web UK check runs and passes today.
- Two gaps we cannot close from inside the repository are now written down instead of being quietly absent. First: the old ASP.NET repository automatically scanned its C# for security problems. That scanning is switched on here for the JavaScript and Python, but not for C#, so the entire second backend gets none. It cannot be fixed by adding a file — GitHub does not permit the two ways of configuring it to coexist. It is a one-tick setting change, written up with the exact steps. Second: the ASP.NET end-to-end test suite came across, is kept up to date by the package updater, and is run by nothing; making it run needs a live server and database, which is a real piece of work for a project paused since 15 July. Both are recorded with the reason and with what would change the decision.
- The translation comparison reported that the app had no translations at all. It has eleven languages. The same missing-folder fault: it looked for the app's translation files in a location deleted in August, found nothing, and produced a complete, confident report saying zero languages, zero files, zero phrases, and every single Laravel phrase missing. It finished successfully every time. Pointed at the real location it now reports eleven languages on both sides, all eleven matched, none missing. A missing folder is now a loud failure instead of a zero, confirmed by pointing it at one that does not exist. The document explaining how to read this report previously told people the empty result was expected and meant "there is no second app" — that instruction was wrong and has been replaced. The existing test for this tool still passes.
- A report on which backend addresses the Web UK app calls was reading a completely different copy of the project. Its default source location was written into the script as a fixed path to another folder on this machine. That folder happens to exist, so nothing ever failed — the report simply described someone else's code, and had done since 15 July. It now reads this repository, and the record inside the regenerated report names this repository instead of the two dead locations it previously claimed. Its own tests pass, the full Web UK suite of 1,779 tests passes, and two consecutive runs produce identical content apart from the timestamp.
- A report that claimed the React app had zero pages, and the Web UK app made zero backend calls, now tells the truth — and the numbers were far more wrong than the move explains. This report compares what each front end asks for against what each back end offers. Three of its inputs pointed at folders that no longer exist, and instead of complaining it recorded zero and carried on. Zero reads as a finding. It was a missing folder. Fixed, the same report now counts 566 pages in the React app (it was recording none) and 314 backend calls from the Web UK app (also none), of which 13 point at addresses that do not resolve — and those 13 are now listed with the file and line to look at, which is the first time that list has ever been produced. One of the three faults was not caused by the move at all: the count of React pages was taken from a single file that stopped holding the pages when they were split up into separate files some time ago, so it was reporting three out of roughly five hundred and sixty and had been quietly wrong far longer. It now reads the whole folder, and agrees with the separate comparison tool instead of contradicting it. A missing folder is now a loud failure, confirmed by pointing it at one that does not exist. One comparison was retired rather than repaired: it measured the shared React app against a second React app that was deliberately deleted in August, so every page came out as "missing". With one shared front end there is nothing to compare against, and reporting a comparison would be inventing evidence. The two reports that build on this one were re-run and still work.
- Two checking scripts that could never pass again have been removed rather than left to mislead. One checked that documentation was internally consistent; 73 of its checks referred to folder locations from before the move, so it failed every time from a completely clean starting point. The other checked that the ASP.NET project was in a proper "paused and frozen" state — it required the work to be on a particular branch, to have exactly one copy checked out, and to carry eighteen specific saved markers. None of those are true here and none can be made true, because it was describing the old repository's final resting state, not anything about this one. Neither was run by any automated check, so removing them loses no coverage; what they were reaching for is already done by checks that genuinely run, including the link checker that now runs on every relevant change. Both originals remain in the archived copy of the old repository. The six places that told people to run them have been updated, and while those same lines were being edited the wrong folder name in them was corrected too.
- The safety net that stops passwords and keys being committed is back. It had been gone since 9 August. The ASP.NET project brought its own guard that checked every commit for private keys, cloud access keys, server login strings with real addresses in them, and database dumps. The move did not bring that file across, and the command meant to install it pointed at a file that no longer existed — so it failed, and nobody had the protection. That matters more here than it did there, because this repository is public: a key committed by accident is a key published. The checks now live in this repository's own commit guard and run on every commit, needing nothing installed. Two of the original checks were deliberately left out: they looked for the word "password" in almost any form, which in a project like this matches test data, example settings and translated text constantly. A guard that keeps crying wolf teaches people to switch it off — and switching it off would also switch off the test check, which must never be bypassed. That kind of thing is still caught by two separate scanners in the pipeline. Tested seven ways: a fake cloud key, a private key, a server login string, and database dumps under two different names were each correctly blocked; ordinary project code containing the word "password" was correctly allowed; and editing a file after staging it does not sneak anything past, because the check reads what is actually being committed.
- The one ASP.NET container recipe that was still broken now works. An earlier fix corrected the everyday container file but missed its production counterpart, which still looked for the Web UK frontend inside the ASP.NET folder where it lived before the move. Confirmed fixed by asking Docker to resolve both files together. Three pointers in it aimed at a safety document that was never brought across now say so plainly instead of pointing at nothing; the deployment-hold warnings around them are untouched.
- Editing a pipeline setting no longer triggers the entire ASP.NET test suite. The file that decides which checks wake up for which change listed itself in every one of its nine entries. So touching that file — or the ASP.NET pipeline file — marked every area as changed and ran everything, including an hour and a half of machine time for the ASP.NET tests. For the five entries that feed the deployment safety check this self-listing is deliberate and has been left exactly as it was: if the rules themselves change, past results collected under the old rules must not be trusted. For the four entries covering the ASP.NET backend and Web UK frontend it bought nothing, because those are read by one pipeline and no deployment check looks at them. Verified afterwards that the five deployment entries are untouched, the four others are clean, and the deployment checker still loads the file without complaint. In place of it, those checks now run a full sweep once a week, so nothing can quietly rot by never being woken. Weekly rather than nightly on purpose: both those projects have been paused since 15 July, and paying every night for code nobody is changing is waste — worth revisiting the day that work restarts. Two smaller repairs came with it: the weekly sweep now runs in its own lane so an ordinary push cannot cancel it midway, and this pipeline now only runs for changes aimed at the main line, matching every other pipeline (before, a change aimed elsewhere got the expensive half of the pair and not the half that gates it).
- Work-in-progress changes no longer run the three biggest test suites every time you push. A change that is still marked as a draft now skips the PHP test suite, the React test suite, and the ASP.NET test suite — the three heaviest things in the pipeline, together worth around five hours of machine time per push. Everything else still runs, so a draft still gets told immediately if it fails to build, fails a quick check, or breaks a rule. The moment you mark the work as ready, all three run in full, and merging is not possible until they have. Two further safety nets were already in place and are unchanged: the nightly sweep runs everything regardless, and the final release check refuses anything that failed. Checked rather than assumed: the two checks that collect these results already treat "did not run" as a pass and only object to an actual failure, and the separate check that guards deployment is unaffected because it only ever looks at the main branch, where this rule does not apply.
- The check that was supposed to prove the frontends still match the backends was never actually being run — and could not have worked if it were. This was the central question of moving everything into one repository, and nothing was asking it. The comparison tool existed, was complete, and was called from nowhere at all. It also had two faults that would have made it useless: it looked for the Web UK frontend inside the ASP.NET folder, where it sat before the move, and for a second React app that was deliberately deleted back in August because there is now one React frontend serving both backends. On not finding either, it reported "nothing found" and finished successfully. A folder that has vanished now stops the check with a message naming what was missing, which was confirmed by deliberately pointing it at folders that do not exist. Every location it reads is now given to it explicitly instead of being guessed. It runs on every relevant change and publishes its report. Two honest limits: the React app is now counted rather than compared, because with a single shared frontend there is no second one to compare it against, and inventing a comparison would be inventing evidence; and the pipeline will not fail on the comparison's own numbers. Those numbers are a rough automated guess — they currently report several hundred differences in both directions — and Web UK's own documentation describes them as a backlog to work through, not a pass mark. Turning that into a pass mark would be making up a standard nobody agreed. What fails the pipeline is the fault that was actually found: a check quietly measuring nothing. The link checker, which was also failing and also run by nothing, now runs the same way.
- A fresh copy of the repository can now start the ASP.NET and Web UK containers. It could not before. Both projects list a Docker settings file that has to exist before their containers will start, and both those files were left behind in the move — swallowed by the blanket "never commit environment files" rule that exists to stop anyone committing a password. The rule is right; these two files are not secrets. They hold a port number, a local web address, and two placeholder values whose only job is to be long enough to satisfy a length check in local development. They are named individually as exceptions, with the reasoning written next to them, and both now say in plain terms that they must never be used anywhere real. Actual environment files are still refused, which was checked. Separately, the ASP.NET container recipe was still looking for the Web UK frontend inside its own folder, where it used to live before the move — four separate paths, none of which existed. Confirmed by asking Docker to resolve the file: both projects now point at real directories.
- 47 broken links in the moved documentation are fixed, and the checker that finds them now runs automatically. The repository's own link checker was failing. Two kinds of breakage: pages pointing at shared files like the licence and security policy, which are now one folder further away; and pages pointing at each other, since the two projects used to be nested and are now side by side. 40 were repointed by resolving each one against the current layout rather than guessing. Seven were left deliberately unlinked: they name files that were genuinely not brought across in the move, and one of them would have quietly repointed to a same-named file that is a completely different thing. Those now say plainly that the file was not imported. Both projects' documentation now passes: 177 links checked in one, 22 in the other.
- The local pre-flight script for the ASP.NET side could only ever fail. It ran checks against two folders — an admin app deleted before the move, and the Web UK frontend at its old nested path. Neither exists. Anyone following the testing documentation would have hit failures that said nothing about their own work. It now points at the real Web UK location, drops the deleted admin app, adds the lint check that the pipeline runs, and stops with a clear message if it is run outside a full checkout instead of failing obscurely.
- Two contract inventories that vanished in the repository move are back, and were rebuilt rather than copied. The move brought across the ASP.NET backend and Web UK frontend, but a blanket "never commit spreadsheet files" rule — there to stop anyone committing an export of member data — silently swallowed two generated files on the way in: the row-by-row list of every API endpoint the React app calls, and the equivalent list of accessible-frontend routes. Their companion files survived, so the loss was invisible; the documentation still described one of them as the complete list. Copying the old ones back would have made things worse: they carried file paths from two different machines that no longer exist, one of which had never been this repository at all. Both were regenerated from the current tree instead. Doing that required fixing the tool that builds one of them, which had a developer's own folder path written into it as a fallback — so anywhere else it quietly produced an empty result rather than complaining. Every path it records is now written relative to the repository, so the files come out identical on any machine instead of churning in every comparison. The blanket rule is untouched; the two files are named individually as exceptions, and a test confirms other spreadsheet files in the same folders are still refused. All 1,779 Web UK tests pass.
- Dependency security alerts now cover the ASP.NET backend and Web UK frontend, which had none. Both projects arrived with their own dependency-watching configuration, which was not merged into this repository's. Between the move on 9 August and now, nothing was watching their packages — including for published security advisories. Four things are now covered that were not: the .NET packages, the Web UK npm packages, the ASP.NET end-to-end test packages, and the base images both projects build from. That last one needed two separate entries, because the existing root entry only ever looked at the root folder and could not have seen them.
- A deploy script that could have overwritten the whole platform has been deleted. The ASP.NET backend arrived with its own deployment script. Its upload step copied "the current folder" to the server in mirror mode — meaning it also deletes anything on the server that is not in the folder it is copying. In the old separate repository that folder was just the ASP.NET backend. In this combined repository it resolves to the repository root, so running it from the wrong place would have pushed the Laravel backend, the React frontend and the Web UK frontend into the ASP.NET server directory and deleted what was there. Its non-interactive path also skipped the typed YES confirmation. Nothing has replaced it, deliberately: the ASP.NET backend is development-only and has no production deployment. Laravel's own deploy script, at the repository root, is a different file and is untouched. The two other places that mentioned the removed script now say plainly that it is gone.
- The background worker's health check could never fail. It now can. The check asks whether the worker processes are running by searching the list of running programs for their names — but the search matched itself, because the command doing the searching contains those same names. So it always found a match and always reported the worker healthy, even with no workers running at all. This was measured, not guessed: in a container with zero workers, the old check reported healthy and the corrected one correctly reported unhealthy. It matters because of what that check was written for — the note above it records a 2026-06 incident where every worker died at startup and stayed dead for five days while the container kept reporting itself healthy. The check added to catch that had never been capable of catching it. The fix is a standard trick that stops the search matching its own text; it was proved to work in both directions, reporting healthy when workers are present and unhealthy when they are not. The startup grace period was also raised from 90 seconds to 4 minutes, because the new Redis wait can legitimately hold startup for up to two minutes and would otherwise have been reported as a fault.
- The old single-container production setup has been deleted, along with the three deploy scripts and the validation check that kept it alive. Production has run on the blue/green setup since May; the old nexus-php-* containers stopped then and were confirmed gone from the server. The setup file describing them, however, was not inert. A step in every deploy failed if that file was missing, the fallback deploy and rollback scripts began by copying it into place, and a security test read it. Anyone deleting the file on its own would have broken the next deployment immediately. All four are now removed together: the setup file, the three fallback scripts, and the deploy check, which now looks for the blue/green setup file it should have been checking all along. The fallback deploy and rollback commands no longer half-run against a missing file — they stop with a message pointing at the blue/green commands instead. The security test that proved the old containers only listened on the local machine now proves the blue/green ones do, and a new test fails if the deleted file ever reappears, because bringing it back would silently restore all three hidden dependencies. Verified by deliberately recreating the file, which made the new test fail as intended.
- The secret scanner is now pinned to a fixed version, so it can't turn the pipeline red overnight on its own. It was set to follow the scanner project's development branch, which meant every run downloaded whatever they had published most recently. On 9 August one of their builds reported 522 "verified secrets" that do not exist — all from a single detector, with no matching text anywhere in our code — and failed the nightly check on a commit that had passed a few hours earlier on identical code. It cleared itself by mid-morning when they fixed it. A security check that can fail for reasons unconnected to your code is worse than no check, because people learn to ignore it. It is now locked to a specific published version, identified by its exact fingerprint rather than a label, since labels can be moved by the publisher. Upgrading is deliberate: bump the fingerprint and the version together after reading the release notes.
- The other two security tools are now pinned as well, so nothing in the security pipeline can change under us overnight. The vulnerability scanner and the dependency checker were both following development branches, exactly like the secret scanner. Both are now locked to an exact fingerprint. The two were handled differently on purpose: the vulnerability scanner is pinned to its current published version, ten changes behind its development branch. The dependency checker is pinned to the exact code that has been running and passing, not to its newest release — because that release is from 2021 and is five years and fourteen changes behind what we actually use. Pinning to it would have been a large behaviour change dressed up as a safety improvement. No tool in that pipeline now follows a moving branch.
- The background worker now waits for Redis to answer before it starts, instead of crash-looping. Roughly once a week — most recently on 7 August — production logged a two-minute burst of about 600 identical background crashes, at three per second. No user ever saw them: these are background jobs, not page requests. The real cause has now been identified. Redis runs as a separate container that nothing tells the worker to wait for, so when Redis is briefly unreachable — a host reboot, the memory wedge on 23 July, the whole container fleet starting at once — the queue engine dies on "connection refused", the container restarts, and it does that a few times a second for a couple of minutes until Redis answers. Each restart rebuilds Laravel's list of web addresses, and the processes still dying from the previous attempt trip over that file mid-rebuild. That tripping-over is what filled the error log, so it was mistaken for the cause. The worker now checks that Redis is accepting connections before starting, retrying every two seconds for up to two minutes and then starting anyway with a warning rather than hanging. When Redis is already up this adds no delay at all. Both container setups were fixed, and both the "Redis is up" and "Redis is down" paths were tested in a real container.
- Correction to the previous note on this crash: sharing one Laravel cache folder between containers was not the cause. The blue-green setup — the one production actually deploys — stopped sharing that folder on 21 June, and the bursts continued on 23 and 31 July and 1 and 7 August. The 7 August burst carries a build stamp that only the blue-green build produces, so it came from a container where the sharing problem cannot happen. The earlier bursts are stamped with a version number rather than a build, which looks like it identifies an older setup but does not: until 3 August no container stamped itself with a build at all, so every setup fell back to the version number. The shared-folder fix and its build check are still correct and are kept — a shared cache folder is a genuine bug — but they do not close this crash.
- Linked-account and guardian authority now stays with the supported member throughout its lifecycle. Supporters can request help or reduce their access, but only the supported member can grant or expand activity, listing, credit, or message powers. Pending approvals show the exact requested levels on the main, accessible, and mobile interfaces. Prepared co-decide actions are cancelled when a relationship ends or its authority is reduced, and every confirmation channel rechecks the locked live relationship before acting. Guardian refusal or withdrawal clears all powers and message access. The main settings screens now separate staff-recorded guardian arrangements from member-controlled support, show each person's current access from the actual tiers, and require a named confirmation before ending all support on a card. Safeguarding screens now require explicit broker permissions, supporter message lists paginate beyond 20 conversations, transition and consequential proxy-action audit records are transactional, duplicate message-access requests are prevented at database level, and historical safeguarding-assignment conflicts are reconciled without destructive rollback.

### Added

- The experimental ASP.NET backend and shared Web UK accessible frontend now live beside the production Laravel source as isolated development applications. Laravel remains at the repository root and its production blue/green deployment is unchanged. The imported applications have separate build and contract checks, are excluded from Laravel image build contexts, and are not deployed. The former ASP.NET repository history and changelog were deliberately not imported; its exact source commit is recorded for provenance.
- The rule that decides who counts as an administrator is now actually tested — it never was. One small piece of code answers "is this person an admin?" for the whole platform: every admin page and around twenty other places ask it. It had no test of any kind. That matters most for one rule it enforces deliberately: a broker or coordinator is refused administrator access even if an old admin marker is still sitting on their account from a previous role. Nothing was checking that rule held. There are now 38 checks covering it, and each one was proved capable of catching a real break — the rule was deliberately broken three different ways and the tests failed every time, as they should. The same treatment was given to the safeguarding decision object, where the rule being protected is that a safeguarding check which could not be completed must never be read as permission to make contact.
- The two most destructive things the platform can do are now tested for real, against the real database. Deleting a member's personal data on request, and permanently wiping an entire community, were both covered only by tests that never touched a database — one checked a fake stand-in, the other checked that a file could be loaded. That is the exact blind spot that let a piece of this platform write to columns that did not exist for four months, unnoticed, because the code quietly swallows that kind of error and returns something that looks like success. There are now 51 checks that run the genuine operations end to end. The erasure test executes all 1,100 lines of the real deletion, so any table or column that has since been renamed makes it fail loudly instead of leaving personal data behind; it also proves the member's own words are removed from conversations while the other person's replies survive, that two people erased in the same community don't collide, and that a community can never erase someone who belongs to a different one. The community-wipe test proves the job wipes the community it was told to and leaves its neighbour completely untouched, refuses if that community was switched back on or gained a sub-community after the button was pressed, and never lets a failure be silently retried later against a reused community number. Alongside those, data-rights requests and consent are now checked for real: consent given in one community is invisible in another, one community cannot read or advance another's request, and a community's own version of the terms never leaks to its neighbours.
- Nineteen tests that could never fail can now fail — and two of them were already broken. Some tests were written so that if anything at all went wrong, the test quietly marked itself skipped instead of failing. A skip shows a green tick, so those nineteen reported success no matter what broke underneath them. Two were genuinely broken and nobody could have known: the test for adding and removing a federation partner, and the test that adding a duplicate partner is refused. Both invented a web address like test-partner-1234.example.com, which does not exist, and the platform's own safety check — which refuses to store an address it cannot look up, so nobody can point it at an internal machine — correctly rejected it. The failure was then reported as "that database table might not exist", which was never true. Both tests now use an address that needs no lookup, so they run properly everywhere, and two new tests pin the safety check itself: a made-up address and a private one are both refused. The seven presence tests kept their honest reason for skipping — they need Redis — but it is now checked once up front instead of wrapped around the whole test, so a real failure shows as a failure. Correction to an earlier note: a wider sweep suggested thirty-four tests had this problem; on inspection only these nineteen did. The other fifteen guard only their own setup data, and the checks that matter still run.
- And now they have been switched back on: 141 of them. The report below found 184 tests carrying a note like "that database table isn't there" when the table plainly does exist. 141 of those notes were the simple, identical kind, and they are now gone. Every one of the 776 affected tests was run against the real database first, and all passed, so nothing was quietly disabled to reach that number — and the ceiling that stops this creeping back up has been lowered from 281 to 140 to lock the gain in. The point is not the count. A test that skips itself still shows a green tick, so if one of those tables ever really did go missing, the platform would have said nothing; now it fails loudly and someone finds out. Two things were deliberately left: five notes name something genuinely absent, and 34 tests are written so that if the code throws an error the test marks itself skipped instead of failing — meaning those 34 can never fail, whatever breaks. That second group needs each test's behaviour checked by hand and is being tracked separately rather than stripped mechanically.
- We can now tell which switched-off tests are switched off for no reason. Around 280 tests quietly skip themselves with a note like "that database table isn't there". A skipped test still shows a green tick, so this has been frozen at a ceiling to stop it growing — but nobody knew how many were still genuinely needed. A new report answers that by comparing each note against the actual database layout the tests run against. The answer: 184 of them name tables or columns that do exist, so those tests can run and the notes are simply out of date. Only 5 are still real. This does not switch anything on by itself — it produces the list to work through, and it can never fail a build.
- Linked accounts got a round of plain usability care, on every screen that shows them. Every card now says what kind of link it is — family member, guardian, carer or organisation — which was always stored but never shown, so a member with several links can finally tell them apart at a glance. The people with the most at stake, those whose account someone else can help manage, now see exactly what each helper can do, worded from their side ("Your listings: prepare only — you approve each one"), instead of seeing nothing at all. A request you sent now shows how long it has been waiting and has a proper "Cancel request" button rather than only an unlabelled bin icon — and that icon can no longer fire twice if pressed twice. Adding a link now lets you say what kind of relationship it is up front (the server always accepted this; only the accessible site ever offered it). The support-level choices on the safeguarding page now use the same polished control as everywhere else. On the accessible site, the linked-accounts page now links straight to the "Waiting for your approval" queue, and in the phone app a coordinator-recorded arrangement now shows a plain note that the levels are the supported person's to set, instead of switches that looked changeable. All new labels are hand-translated in every language, reusing the accessible site's existing wording so no two screens disagree.
- The accessible site now has the whole message-access loop too — plain HTML, no JavaScript needed for any of it. Everything the main app gained works identically on the accessible (GOV.UK-style) site: the helper's card shows ask / waiting / on-since states for messages; the supported person answers the request on their usual "Waiting for your approval" page; their own settings row says who can view, when they last looked, and offers the one-press stop; and the read-only viewer asks why you're looking first — the reason goes into the same permanent record — before showing conversations with no reply box anywhere on the page. Because web addresses end up in logs and history, the reason is never put in the address: it's held privately for half an hour and then must be given again. People messaging the supported person see the same carefully worded notice as on the main app (it never says whose helper it is), and the person themselves gets the standing reminder with a link to manage it — a notice the accessible site's conversations had been missing entirely, even for coordinator review. Every screen is in all eleven languages, reusing the same hand-checked translations as the main app so the two sites never say different things. Verified live in a browser: ask → purpose form → read-only list and conversation → permanent record written → stop → access refused. Eight new automated checks pin the loop, including that nothing leaks before a reason is given and that the person's unread counts show no trace of a helper's visit.
- The message-viewing consent loop is now on screen, end to end — asked for, approved, visible, and revocable in the app. On Settings → Linked accounts, the helper's card now shows one of three honest states for messages: an "Ask to view their messages" button, "Waiting for their approval" while the person decides, or "On since…" once they agree. The supported person answers the request from the same "Waiting for your approval" card used for everything else, and from the moment they agree their own settings page says so in plain words — who can view their messages, when they last looked (or "never so far"), and a one-press "Stop them viewing my messages" that takes effect instantly. The reading screen itself opens from the helper's card: it first asks why they're looking (recorded permanently), then shows the conversations strictly read-only — no reply box exists on the page at all. Everyone else is told too: anyone messaging a person whose conversations a supporter can view sees a notice, worded so it never reveals whose helper it is or whether it's a coordinator instead, and the person themselves gets a standing reminder in their own conversations with a link to manage it. All of it in all eleven languages. Verified end to end in a live browser: ask → approve → view (with the reason recorded and the database refusing edits to that record) → withdraw → access refused. Two faults were caught during that live walk and fixed before shipping: the conversation list arrived wrapped in a paging envelope the screen didn't expect (the page crashed; the test that should have caught it passed vacuously and now pins the real shape), and "last viewed" was reported in the wrong timezone, making a look from two minutes ago read as an hour old.
- The message viewing itself is now built: strictly read-only, with a permanent record of every look. With the person's consent in place, their helper can open a read-only view of their conversations. Read-only is enforced at every layer: nothing can be sent, nothing gets marked as read (the person's unread counts show no trace a helper was ever there), messages the person deleted for themselves stay deleted, conversations with people on other timebank platforms are excluded entirely, and there is deliberately no sending route to attack. Before anything is shown, the helper must say why they're looking — and that reason goes into a permanent record that cannot be edited or deleted by anyone, including administrators; the database itself refuses. No reason, no access, no record — in that failure order. A safeguarding restriction placed on the pair instantly beats an existing grant. The supported person will see "last viewed" from this record on their settings page — accountability they can check, not just an admin artefact.
- A helper can now ask to see the messages of the person they support — and it only ever happens with that person's own say-so. This reverses an earlier deliberate omission, on the owner's decision, and it is built around consent rather than a switch. When a helper turns the option on, nothing happens except that the supported person is asked — in the app and by an email with one-tap approve and decline. Only their yes switches it on; declining is always fine and never needs a reason. They can withdraw it at any moment with one press, withdrawal takes effect instantly, and turning it on again always requires a fresh yes. Asking twice while a request is open doesn't nag them again. Two protections are permanent: the old "view messages" tick-box that saved-and-did-nothing for years can never quietly activate the real thing — accounts that ticked it long ago get nothing until the person consents today — and coordinators or brokers can never hold this power at all. This piece is the foundation (the permission and consent machinery); the actual read-only viewing screen, the notice shown to people in those conversations, and the permanent record of every viewing are the next pieces of the same build.
- You can now decide what a coordinator-recorded guardian may actually do for you. When a coordinator records that someone supports you, that record on its own grants nothing — and until now there was no way to give that person any real help, because the screen where support levels are set is driven by the helper, and the platform rightly refuses to let a helper grant themselves powers over you. The result was a dead end: for any pair a coordinator had recorded, the support levels were unreachable. Now, once you have agreed to an arrangement, your own Safeguarding settings offer the same two choices as any other linked account — whether that person may help with your listings and with your time credits, each set to nothing, "prepare only, you approve each one", or "do it on their own". It is your decision alone: the guardian cannot set it, cannot change it, and is simply told what you chose. You can take it back at any time, every change is written to the permanent history, and the helper only sees what you granted, marked as yours to change. Available on the main app and the accessible site, in all eleven languages.
- Posting a listing for someone you support now uses the real listing form, not a cut-down version of it. The screen a helper used offered five boxes — title, offer or request, category, hours, description — against a real form with fourteen. There was no way to add a location, a photo, the skills involved, whether it can be done remotely, or any note about accessibility. So a helper acting for someone produced a visibly poorer listing than that person could have posted themselves, which is backwards: someone who needs help posting is the least likely to go back later and fill in what was missing. The screen now renders the very same form used everywhere else, so it has every field, the same checks, and the same writing assistance — and the next time that form gains a field, this screen gains it too, automatically. Sending time credits for someone was thin in a different way: it never showed whose money it was. It now shows the supported person's balance and refuses an amount larger than they have, or larger than the community's single-transfer limit — the same guardrails the person gets on their own screen, checked against their wallet rather than the helper's. Two things needed building behind the scenes to make this honest. Adding a photo, and attaching skills, previously failed for a helper because those actions are restricted to the account's owner — so the helper could fill the whole form in and then watch the photo silently fail. Both now work through a route that carries the helper's authority and confirms the listing really belongs to the person they support. One limit remains and the screen says so plainly rather than dropping it quietly: when a helper is only preparing something for approval, a photo can't be attached yet, because there is no listing to attach it to until the person approves.
- A helper can now actually see the activity they were allowed to see — on every frontend. "View their activity" is the one linked-account permission that is switched on by default — and until now there was no screen behind it, on any frontend. A family could grant it, see it save, and nothing anywhere would ever show the activity. Now a "See their activity" button appears on the person's card in Settings → Linked accounts, opening a read-only summary: hours given and received, connections and groups, and their recent activity. The button only appears when the permission is actually on, the view offers no way to act — seeing and doing are deliberately different levels of trust — and if the permission is withdrawn while the window is open, it says so plainly instead of showing an empty page. Translated in all eleven languages, with tests pinning that the button tracks the permission and that no action buttons can appear in the view. The same view exists on the accessible (GOV.UK) frontend as its own plain-HTML page — no JavaScript needed to read any of it, permission withdrawn mid-visit redirects back with a plain explanation — and in the native mobile app as an expandable section on the same card, translated in that app's seven languages. All three go through the identical server-side permission check, so what each one may show cannot drift apart.
- A member's offers and requests now move with them when they are moved to another community — and no ghosts are left behind. Until now only the account itself moved. The member's listings stayed live in the old community, showing "Unknown" as the author; anyone who tried to message or pay that author got "Recipient not found", and a request sent against such a listing sat stuck forever because its owner could never see it. This is exactly the "scattered stuff" problem reported when members were moved in the past, and it was reproduced end-to-end on a running server before being fixed. What now travels with the member, in the same all-or-nothing operation as the account itself: their offers and requests (each one's category is matched by name into the new community, or marked uncategorised if there is no equivalent), their skills, and their interests. The search index is refreshed for both communities, which also fixes a separate bug where a moved member kept appearing in the old community's member search. Any open exchange requests that involve the member but carry no credits yet are closed, and the other member gets a notification in their own language explaining why. Two situations now stop a move with a clear explanation instead of quietly breaking things. If the member is the only owner of a group, the move asks the administrator to hand the group to someone else first — otherwise the group would become permanently unmanageable. If the member has an exchange that is underway, awaiting confirmation, or in dispute, the move waits until it is finished — cancelling it would destroy real work, and completing it after a move could not pay out correctly. Three related faults found during this work are also fixed, because they can be triggered by members moved before this change existed. Completing an exchange with a vanished counterparty used to take the payer's credits and give them to no one — it now refuses loudly and no credits move. Requesting a listing whose owner has left the community now says exactly that, instead of creating a request nobody can answer. And a volunteer's impact certificate — the kind shared with employers — now keeps verifying after its holder moves community; previously the check silently began returning nothing. Exchange history, messages, feed posts, group memberships, event records, reviews and volunteering hours deliberately stay in the community where they happened: each involves another member or that community's own record, and dragging them along would corrupt the books on both sides. The move dialogs state precisely what moves and what stays, in all eleven languages.

### Added

- A helper can now be given a middle level of trust: "Prepare only — they approve each one". Until now a linked account offered only all-or-nothing: a carer could either do nothing or act alone. The new middle level mirrors Ireland's co-decision-maker arrangement: the helper prepares a listing or a time-credit transfer, and it happens only when the account owner approves it. Approving takes one tap — a card appears on their dashboard and on the Linked accounts page — or one click on a link emailed to them, which works without signing in: the link is single-use, expires after 14 days, and just opening it does nothing (a button must be pressed), so an email scanner can never approve on someone's behalf. Declining is always offered, never needs a reason, and doing nothing is always safe — the request simply expires and both people are told. Every approval is carried out through the member's own code path, recorded as prepared by the helper, and written to the audit log. Available in all eleven languages. The accessible (GOV.UK-style) frontend has the same approval queue at Settings → "Waiting for your approval" — plain forms, no JavaScript needed, approve/decline/withdraw all work identically, in all eleven languages.
- The two "guardian" systems are now one system. For years the platform had two disconnected things called "guardian": a staff-written note that granted nothing, and a member-approved account link that granted real powers — stored separately, with nothing connecting them, and looking identical on screen. Staff-recorded arrangements now live in the same system as everything else. Nothing changes about what anyone sees or can do: the arrangement still grants nothing until the member themselves grants powers; members still see and answer arrangements in the same safeguarding screens (agree, refuse with no reason needed, or withdraw later); coordinators still create and end them from the same broker tab; and a member's "no" is still recorded as their own answer, never as an administrative removal. What changes underneath: one history, one set of screens per audience, no more parallel bookkeeping — and trying to record an arrangement between two members who already have a link is now refused with a clear message instead of silently creating a duplicate. All previous records were carried across; the old records remain untouched as a permanent archive.
- Every change to a linked account now leaves a permanent record. Requesting a link, approving it, changing what a helper may do, and ending the link each write an entry — who acted, in what role, from where, and for permission changes the exact before and after — into a history that the database itself refuses to edit or delete. Until now these links, which grant real power over another member's listings and credits, had less audit history than the staff notes that grant nothing. Re-saving an unchanged setting deliberately records nothing, so the history stays meaningful. Groundwork for folding the two "guardian" systems into one.
- Communities can now record what an act-alone power is based on. When a helper holds act-alone power over someone's listings or credits, a coordinator can record that they have personally seen the formal authority behind it — a decision-making representative court order, a power of attorney, or a registered support arrangement under Ireland's 2015 capacity law. The record deliberately refuses evidence: no document numbers, no dates, no uploads — the form rejects them outright, because the platform must never become a filing cabinet of court orders. What staff write is stored encrypted; marking a record as no longer valid uses a fixed list of reasons; and every change lands in a history that the database itself refuses to edit. This is a record, not a power switch — nothing is granted because of it; act-alone power is always granted by the member themselves. Lives under the broker panel's Support Actions tab, in all eleven languages.
- A coordinator can record an approval given by phone, in person, or on paper. Some supported members will never click a link or open an app — this whole redesign exists for them. When such a member approves a prepared action offline, a coordinator can now record that approval, naming how it was given and who witnessed it. The record is honest about being weaker evidence than the member's own click — it is stored as "recorded offline by staff", never dressed up as the member's own action — and the member is always notified that it happened in their name, so a wrong record comes to light. Only coordinators and administrators can do this; an invalid channel is refused and nothing happens. The screen lives in the broker safeguarding panel under a new "Support Actions" tab, which lists everything awaiting an answer in the community, states the honesty rules before anything is submitted, and offers the witness field as optional.
- Helpers can now prepare a listing or a transfer on screen. On Settings → Linked accounts, a helper with either action level sees "Prepare a listing" and "Prepare a transfer" buttons on the person's card. The form says plainly, before anything is filled in, what submitting will do: at the "prepare only" level, "Nothing happens yet — they will be asked to approve this"; at the "act alone" level, "This will happen immediately… the record shows you did it on their behalf, and they will be told." The submit button matches — "Send for their approval" versus "Do it now" — so nobody is surprised by what a click does. A test fails if a prepare-only helper's form could ever reach the immediate path.

### Changed

- Linked-account permissions now run on a three-tier support model under the hood. Following Ireland's Assisted Decision-Making (Capacity) Act 2015, each capability a supporter holds (see activity, manage listings, send credits) is now one of three levels: assist (see and help), co-decide (prepare, the supported member confirms), or represent (act alone). Every existing family link keeps exactly the power it had — the old on/off switches translate losslessly and no data conversion runs. Two rules are enforced in code with tests that fail if they are ever weakened: a co-decider can never pass a check meant for acting alone, and staff (brokers) are capped at co-decide — they can never be granted act-alone power over a supported member. Nothing changes on screen yet; the member-facing tier choices arrive together with the confirm step, so no option is ever shown before it works. One small visible correction: asking the API to enable the retired "view messages" permission used to store the setting (while granting nothing); it is now simply ignored and old rows that carried it are cleaned to "off" on their next save.
- The word "ward" is retired from every screen. The platform now says "supported member". Wardship was abolished in Ireland by the Assisted Decision-Making (Capacity) Act 2015 (commenced 2023; the last wardship cases must be discharged by October 2027), so the term is legally obsolete — and it describes a person by their dependency rather than as a member. The broker and admin safeguarding dashboards (table columns, the "Consented Wards" statistic, the assignment form labels, the built-in help text) and three API error messages now use "supported member" phrasing, translated in all eleven languages. Database columns and code identifiers are unchanged — this is what people read, not how the system stores it. First step of the approved guardian-module redesign.
- The deploy-time database safety check no longer raises false alarms on ordinary words. Before a release goes live, the server refuses any database change it judges risky while the current version is still serving. It was matching dangerous keywords as bare fragments of text, so a completely harmless change was blocked because the word "changed" appeared inside a list of event names. It now matches whole words only. This does not make the check any less strict: it was re-tested against nine genuinely destructive operations — dropping a column, renaming or truncating a table and the rest — and every one is still caught. Only the false alarms stopped.

### Security

- The security scan no longer hides its own failures behind each other — the gap that let advisories go unnoticed is closed. The scan runs several completely separate security checks in sequence: leaked-secret detection, two different dependency scanners, a PHP dependency check and a vulnerability database check. They have nothing to do with one another. But the system running them stops at the first failure, so whichever check went red first made every later check silently vanish — they showed as "skipped", which reads as nothing to see rather than never looked. This is not theoretical: in one recorded run a single dependency finding wiped out four blocking checks, including the one that looks for passwords and keys accidentally committed to the repository. So a real leaked secret could have gone undetected simply because an unrelated library advisory was published that week — which is exactly how the advisories fixed above sat unseen behind one another, revealing themselves one at a time as each was fixed. Every check now runs regardless of whether an earlier one failed, so one scan reports all the problems at once instead of the first one. This makes the scan stricter, not laxer: nothing was given permission to fail, and a check that runs and finds something still stops the build — only the hiding stopped. Checks are still abandoned if the run is superseded by a newer one, so no time is wasted on results nobody will read, and the reasoning is written into the file so a future check added there inherits it.
- Three newly-published high-severity advisories cleared, restoring the security scan to green. The scan had been failing on every push to the main branch since the early hours of 8 August — not because anything in the platform changed, but because new flaws were published overnight against libraries we already had. Neither is code we wrote; both arrive indirectly. nanoid (a short-ID generator reaching us through the stylesheet build tool and, in the phone app, the navigation library) could be sent into an infinite loop, and it had a proper fix, so every copy in the web frontend and the phone app was moved from 3.3.16 to 3.3.18 — a small patch update that moved nothing else with it. image-size had two flaws with no fixed release published at all, so there was nothing to upgrade to; it is recorded in the scan's ignore file with the reasoning written out and a dated note to revisit. It reaches us only through the phone app's development bundler, which runs on a developer's machine while building and is not part of the app anyone installs, and the only images it reads are our own. Verified by running the same scan locally: all four dependency lists now report zero findings. The security scan checks dependencies twice, with two different tools, and the second check had been invisible: it is skipped whenever the first one fails, so it had been silently sitting behind the failure. Fixing the first check revealed it was flagging the same image-size library under a different reference number. Confirmed there is genuinely nothing to upgrade to — the tool reports every published version as affected, and the only upgrade it offers is a major downgrade of the phone app's React Native framework, from 0.81 back to 0.72, which would break the app without fixing the flaw. Both flags are recorded in that check's own reviewed exception list with the same reasoning and revisit date. All four dependency trees now pass both checks, verified locally before pushing.
- Closed a newly-published high-severity advisory in a small text-matching library used by our build and test tooling. brace-expansion — a helper that expands patterns like file.{js,ts}, pulled in indirectly by the test-coverage tool, the code linter and the performance checker — had a vulnerability published against it (CVE-2026-69152, rated 7.5). No platform code changed and nothing our members use was affected: the library is a development-only dependency and never reaches the shipped website, which was confirmed by checking the production dependency tree separately (it contains no copy of it at all). Every copy across the project was moved to a patched release — 1.1.16 to 1.1.18, 2.1.2 to 2.1.4, 5.0.8 to 5.0.9 — all small patch updates already permitted by the existing version rules, so nothing else moved with them.
- Updated the Markdown library the platform uses for formatted emails, closing six newly-published advisories. Six vulnerabilities in league/commonmark were published on 6 August, four of them rated high: several ways a crafted message could tie the parser up in knots (a denial-of-service), and one that let a disguised link slip past the safety filter that is supposed to block dangerous links. The platform does not call this library directly — it arrives as part of the web framework and is used when rendering Markdown in emails — so no platform code changed. Upgraded from 2.8.2 to 2.9.0, which the framework already permitted; the security audit now reports nothing outstanding.

### Fixed

- When a helper acts on your account, you now hear about it properly — by email, not just a silent bell entry. If someone you allowed to act alone posted a listing in your name or sent time credits from your balance, the only trace was an entry in the notification bell that said "someone who manages your account" did it. No email, no phone notification, no name. Both events now arrive immediately on every channel and name the person — with a note that everything done on your behalf is recorded and a reminder that you can change what they may do at any time. Three more silent moments now speak as well: ending an account link tells the other person (previously they learned by absence); a change to a helper's levels tells the member whose account it is — immediately if the helper's power grew; and when a prepared action is withdrawn or expires unanswered, the member whose approval was pending is told, instead of being left with a prompt pointing at nothing. All in the recipient's own language, in all eleven languages.
- The approvals list no longer shows an empty page when it fails to load, and the activity switch now visibly responds. If the list of things awaiting your approval failed to load, the page looked exactly like "nothing to approve" — dangerous for a member with a real transfer waiting. It now says the load failed and offers a retry. Separately, the "View their activity" switch saved correctly but never moved on screen until a reload; it now responds immediately. And on coordinator-recorded arrangements, the summary now shows all three granted levels instead of omitting the activity one.
- Turning a helper permission "on" from the phone or the accessible site can no longer quietly upgrade it to full authority. The permission for a helper has three levels: off, "prepare only" (the helper sets things up and the member approves each one), and "act alone". The phone app and the accessible website only had on/off switches — and a "prepare only" arrangement showed up on them as an off switch. Flicking that switch "on" didn't restore what was there: it silently replaced "prepare only" with "act alone", handing the helper the exact power the middle level exists to withhold. Three layers now prevent this. The platform itself refuses to upgrade an existing level when an old-style on/off signal arrives — "on" means on, never "maximum". The accessible site now shows the real three-level choice (worded from the right perspective: "Their listings… they approve each one"). And the phone app's switches now read and write the true level, granting only "prepare only" when switched on — choosing "act alone" deliberately remains a web decision for now. Proven end-to-end: a "prepare only" grant was saved and re-saved through the accessible form and stayed exactly "prepare only"; automated tests pin all three layers so none can quietly regress.
- Closed a newly published security advisory in the mobile app's build tooling. A YAML-parsing library used during mobile development and testing (never on members' phones) had a freshly disclosed flaw that could let a crafted file tie up a build machine. Both copies are now on the fixed version.
- Creating a guardian assignment with an unknown email address failed silently. When a coordinator typed an email that matched no member (or paired someone with themselves, or repeated an existing arrangement), the platform refused correctly — but the screen showed nothing at all: no message, the dialog just sat there. The refusal's own explanation ("Supported member not found in this community", and so on) now appears, so staff know exactly what to correct. Found by the owner while testing; a regression test now fails if the message ever goes silent again.
- The mobile app stopped offering the "View messages" switch that never did anything. The web and accessible frontends removed it on 5 August because no code anywhere checks that permission — it saved successfully and did nothing, so a family could believe a carer could read a dependent's messages when no such thing happened. The mobile app kept offering it until now. A test now pins that it cannot quietly return.
- Being asked to let someone manage your account sent no email, and the notification led to the wrong page. Reported by the owner after receiving such a request. When a member asked to be linked to another member's account, the platform wrote a single entry in the bell menu and stopped there — no email and no device notification, because that code wrote the notification record directly instead of going through the dispatcher that also handles email and push. The bell entry then linked to /settings, which opens the Profile tab, leaving the recipient on a page of ten tabs with nothing indicating which one held the request waiting for them. Both events now go through the dispatcher, and both the bell entry and the email button deep-link to the linked-accounts tab. The email is sent immediately rather than waiting for a digest, because being asked to let another person post listings and spend credits on your behalf is not digest material. Approving such a request used to notify nobody at all — the person who asked could only find out by going back to the settings page and noticing the status had changed. They now get a bell entry, an email and a push notification, naming who approved it. Both messages render in the recipient's own language, not the sender's, in all eleven languages. One smaller fault fixed in passing: the bell text inserted the raw relationship code into the sentence, so a German reader saw "…als organization" — an English code word mid-sentence. It now inserts the translated label. Each fix carries a test that fails if the fix is removed, including one asserting a failed approval notifies nobody.
- The "Create Guardian Assignment" form claimed the guardian could read the member's messages. That was never true. The help text under the guardian email field on the broker safeguarding dashboard said "The guardian can view messages sent to and from this member" — but a guardian assignment is a written record for safeguarding staff and grants no access of any kind, and nobody on the platform can read another member's messages (that carer permission was deliberately removed on 2026-08-05 because it would expose the other person in the conversation, who never agreed). The danger ran one way: a broker could believe monitoring was in place and skip a protective step they would otherwise have taken. The text now says what the record actually is — a note for safeguarding staff that gives the guardian no access to the member's messages or account — in all eleven languages.
- Moving a member to another community failed for almost every real member, with a blank "server error". An audit of the super-admin "Move to Different Tenant" feature found it worked in tests but not in practice. The reason: when someone signs in, the platform keeps a record of that sign-in session, and a database safety rule ties those records to the member's current community. Moving the member breaks that tie, so the database refused the move — for anyone who had ever signed in. Test accounts never sign in, which is exactly why every automated test passed while the real thing failed. The move now clears those session records first (they are already cancelled at that moment — moving a member signs them out everywhere, which is deliberate and unchanged), so the move goes through. Three more ways the same button produced a bare "server error" now give a proper answer instead: moving a member into a community that already has an account with the same email address (or username) is refused with a message saying so; a destination community that does not exist or has been retired is refused with a message saying so; and pressing "move" to the community the member is already in says that, instead of erroring. One genuine limitation is now stated rather than crashed into: a member with event records — registrations, invitations, guardian consents — cannot be moved, because those records are deliberately locked to the community they happened in. The screen now says exactly that, and names what is blocking the move in the response for support to see. The screens also told the administrator something untrue. The dialog said the member's "memberships will be updated for the target community" — they are not, and never were. What actually moves with the member: their profile, their sign-in, and their time-credit balance. What stays in the old community: their exchange history, listings, messages, group memberships and event records. The dialogs now say precisely that. The bulk-move screen had a worse version of the same problem: when every single member failed to move, it showed a green "moved (0)" success message — it now shows an error, and a partial move shows a warning with both counts. Every fix carries a test that fails if the fix is removed, including the first test in the codebase that moves a member end-to-end through the real endpoint.
- The new performance monitor could turn an unrelated request into a server error, if logging was misconfigured. The monitor is written so that its own failures never reach the person using the site — it records the problem and gives up quietly. But the act of recording the problem was itself unprotected, so if the log itself was broken, the very code meant to contain the failure became the failure. It then surfaced in whichever request happened to be in flight — in the case that exposed this, someone resolving a disputed exchange got a server error, and that page's own error handling broke the same way, so nothing about the real cause was ever written down. A monitor with no safe way to fail is worse than no monitor, so writing the record is now protected too. That is the end of the line: if the log cannot be written, there is nowhere left to report to, and the request carries on unaffected. Found because it happened: an automated test run had its log setting written in a way that produced an empty value, which made every attempt to write a log fail. That setting has been corrected — two neighbouring settings in the same file were already correct and this one was not — and the monitor no longer depends on it being right.

## 1.5.9 - 2026-08-06

### Changed

- "View as this member" was refused to the super-admin of a timebank, for every member of their own timebank. Reported with a screenshot: pressing it on the admin Users page produced "Impersonate Failed" and nothing else. The cause was the permission gate on that address. There are two gates in the platform — one for ordinary administrators, one for the owner of the whole installation — and this sat behind the second, which is written to deliberately refuse the super-admin of an individual timebank. That is the right rule for the pages it normally guards, which act across every timebank on the installation. It is the wrong rule for signing in as one of your own members, which never leaves your timebank. A third gate now sits between the two: super-admin of something, whether the installation or your own timebank. It deliberately does not require the timebank to have others beneath it, because helping your own member has nothing to do with hierarchy — the timebank-with-branches rule would have excluded every ordinary timebank for a reason that does not apply to them. An ordinary administrator is still refused; only super-admins pass. What the platform already checked, and still does: the member must belong to your timebank, cannot be you, and must sit below you — so a super-admin can view a member as themselves, but not a fellow administrator. Borrowing a colleague's authority is exactly what this must never become. Those ranks are re-checked at the last moment under a database lock, so somebody being promoted mid-action cannot widen it. Every use is written to two logs. One related annoyance fixed at the same time: the Users list offered the option on fellow administrators, where it could only ever fail. It is now shown only where it will work.
- A network administrator can now retire one of their own timebanks, and step into a member's account to help them. Both were things the platform intended to allow and did not, and both were asked for directly. Retiring a timebank. "Delete" here means deactivate — the timebank is switched off and can be switched back on; permanently destroying the data is a separate action that remains with the platform owner alone. Until now a network administrator could switch one of their timebanks back on but not off, which was an oversight rather than a decision. They can now switch off any timebank beneath them, but not the one they themselves operate from — doing that would lock them, and everyone in that timebank, out of the platform. The platform refuses it, and the button is no longer shown there; a short line explains why and who to ask. The existing protections stay: the master timebank can never be switched off, and neither can one that still has active timebanks beneath it, which must be dealt with first. Stepping into a member's account. The platform already allowed a network administrator to do this for members of their own timebanks — it checked properly, allowed their own branch, and refused an unrelated timebank, the platform owner, and themselves. But the button was only ever shown to the platform owner, so nobody else could reach it. The screen was being stricter than the rules it was enforcing, which is the worst combination: the person is allowed to do it, sees nothing, and has nothing to explain the absence. The button now appears for anyone the platform would actually permit, and is still withheld where it would be refused — a platform administrator, an account that is not active, or yourself. Both were checked by hand against a running server as a real network administrator, from both directions: what they can now do, and what they still cannot. Every new test was confirmed to fail if the fix is removed.
- Two large parts of the platform had no documentation at all, and four code comments said things that were not true. A review of the platform against an external tester's list of user journeys was carried out by reading the code rather than the documentation, and the difference mattered: working from the documentation alone, a reviewer concludes that the safeguarding, guardian and consent features do not exist. They do — around thirty database tables' worth, and the strongest-built parts of it use encrypted identities, single-use expiring links and a history table the database itself refuses to let anyone alter after the fact. None of that was written down anywhere. Neither was the permissions model, which is why the same tester asked four separate times whether an administrator sits above or below a broker. Two new pages now describe both: docs/ROLES-AND-PERMISSIONS.md and docs/SAFEGUARDING-AND-CONSENT.md, together with shorter summaries in the architecture map and in the guide the coding assistants read. The permissions page also records, for the first time, how the super-admin panel limits reach: a network's own administrator can only see and act on their own community and the communities beneath it, enforced by checking both ends of any cross-community action, while only the platform owner is unrestricted. That was already built correctly; it simply was not written down, so nobody could confirm it. Four comments in the code were corrected because they asserted the opposite of what the code does. The one that matters most sat above the only tool an administrator has for correcting a member's time-credit balance: both the function and the route said the action was recorded in the audit log, and it is not — there is no audit entry of any kind, and the required reason survives only as a text prefix on the transaction description. Two related defects in the same function are now written down beside it: the adjustment is not marked as an administrative one, so it is indistinguishable from an ordinary member-to-member transfer, and the acting administrator is recorded as the other party to the transaction without their own balance changing, which inflates their apparent totals on the admin dashboard every time it is used. The remaining three: a block of routes was labelled "public" and described as working for signed-out visitors when in fact every route in it requires a login (there is no way for a member of the public to browse offers at all); a function's documentation gave a web address that does not exist; and the description of moving a member between communities understated what happens to their money — because a balance is stored on the member's own record while their transaction history is stored per community, a moved member arrives with a real balance and no history behind it, while the community they left keeps that history. That is now spelled out in three places, including directly above the function that does it, since no test covers it.
- Tests now use the whole development machine instead of one core at a time. The project carried a rule that only one heavy test suite could run at a time, because on the old 16 GB machine a second one really did cause the computer to seize up and report failures that were not real. The development machine is now a 16-core, 96 GB workstation, and that rule was leaving almost all of it idle: test files were being run strictly one after another. They now run concurrently, with the number of parallel workers worked out from the machine itself rather than fixed in the file, so a smaller laptop still behaves sensibly and this workstation uses half its threads. Measured before and after, with the same tests passing in both cases: the interface component suite went from 79 seconds to 12, the groups pages from 72 seconds to 9, and the events front-end step from 67 seconds to 11. The checks that run on GitHub are deliberately left exactly as they were, because those run on much smaller machines and their timings were hard won. NEXUS_VITEST_MAX_FORKS=1 forces the old one-at-a-time behaviour when a test needs to be examined in isolation.
- Full static analysis of the PHP code can now be run locally, which previously was not possible. The advice was to analyse a few files at a time because a full run "hung". The real cause was that the local PHP container was capped at 2 GB of memory while the analyser is asked to use up to 2 GB - the entire allowance - so it was being killed rather than finishing. The local container is now allowed 8 GB, which is under a tenth of the machine's memory, and a complete run finishes in about eight minutes with no errors. The production server's limits are deliberately untouched, because that machine has 16 GB in total and its existing setting is correct for it.
- Documentation that assumed a small, slow machine has been corrected. Six places told contributors to run test suites one at a time or strictly in sequence. A new page, docs/LOCAL-PERFORMANCE.md, records what the machine actually is, the before-and-after measurements, which settings adjust themselves automatically, and - importantly - one thing a faster processor does not fix: the project files are shared into the Docker container over a slow Windows-to-Linux bridge, and reading the PHP source through it takes 4.6 seconds versus 0.045 seconds when the same files sit inside the container. That is roughly a hundred times slower, and it is why giving the static analyser six times as many workers made it only 11% faster. Moving the working files inside that boundary was then investigated properly and rejected, with the reasons recorded so nobody repeats the exercise: it speeds up the static analyser (from 7 minutes 38 seconds to 53 seconds) and, as far as anything measured shows, nothing else. Notably it does not speed up the PHP tests at all - the same 30 test files took 539.8 seconds through the slow bridge and 539.1 seconds without it, which is the same number. Slow PHP tests turn out to be roughly 1.4 seconds spent inside each individual test, with the framework start-up at 94 milliseconds and a database query at 0.29 milliseconds. That cause was then tracked down and fixed - see the next entry.

### Removed

- A leftover performance-tracking file that could not have run has been deleted. app/Middleware/PerformanceMonitoringMiddleware.php called a service class that was removed during the move to Laravel, so any attempt to use it would have stopped the request with a "class not found" error. Nothing did use it: it was not registered anywhere, and no route, configuration file or other piece of code referred to it. Its only test had been skipping itself rather than running ever since the service disappeared, so it was reporting nothing. Request performance is genuinely recorded by a different, live path (app/Http/Middleware/RecordPerformanceSample.php with app/Support/Performance/PerformanceRecorder.php), which feeds the admin performance page and is untouched by this change. The three entries the static analyser held for the deleted file have been dropped from its list of known issues at the same time.

### Security

- Two supporting libraries with newly-published flaws have been updated. Neither is code we wrote — both arrive indirectly, pulled in by other packages. brace-expansion (five copies across the main project and the mobile app) could be made to consume unbounded memory when expanding a crafted pattern, and fast-uri in the mobile app could be tricked into reading the wrong host from a web address containing a backslash. Both were fixed by the library authors in small patch releases, and all five copies now sit on a fixed version. These had been failing the security scan since before this batch of work; they were found by reading that scan's output properly rather than assuming, as had previously been recorded, that its remaining failure was unavoidable noise from the base operating system image.

### Fixed

- A timebank whose position in the family tree was missing would have been billed for every member on the platform. Billing counts a timebank's members including all the timebanks beneath it, which is how a network is charged as one. It works out "beneath it" from a short text field recording where that timebank sits in the tree. If that field was ever empty, the search became "match anything", and the timebank was credited with every active member on the whole installation. Measured on the development copy: the correct answer for a test network is 5 members; with the field empty the same query returned 29, which was every member of every timebank there. That figure is not cosmetic — it decides whether a timebank has outgrown its plan, whether it is put into a grace period, and what it is quoted. So the failure was over-charging a community by however much the rest of the platform happened to weigh, growing quietly as the platform grew. No timebank has an empty position today, and billing is not charging anyone yet, so nobody was affected. It was fixed now because that field is filled in as a second step just after a timebank is created, so an interruption at the wrong moment leaves exactly this state — and the moment to find a billing error is before the invoices, not after. Where the position is missing the platform now counts only that timebank's own members, which under-states rather than over-states, and writes a line to the log saying it happened. The identical flaw was fixed in the access rules earlier this week; this was its last remaining copy anywhere in the code. Three tests now cover it, and all three were confirmed to fail if the fix is removed. Worth recording why that mattered: the first version of those tests passed even with the fix taken out, because in the test database the timebanks holding most of the members happen to have no position of their own, so there was nothing for the faulty search to sweep up. The tests now seed an unrelated timebank that does have one, which is what makes the fault visible.
- "View as this member" (impersonation) never worked, in any of the three places it was offered. Pressing it appeared to do nothing: a new tab opened showing the administrator's own account, or the sign-in page. Nobody was ever signed in as the member. The server hands the browser a short-lived pass that says "let this administrator view this member". That pass is not a sign-in key, and the part of the server that checks sign-in keys only accepts sign-in keys — so every request the new tab made was refused. The browser then quietly recovered the way it recovers from any refusal: by renewing the administrator's own sign-in. That is why the new tab showed the administrator. The step that was meant to trade the pass for a real sign-in as the member had never been built; the function written to do the trade existed but nothing anywhere called it. Two earlier attempts at this fixed how the pass travels between the two tabs, which was never what was wrong. There is now an exchange step: the new tab trades the pass for a genuine sign-in as the member, and only that sign-in is used. The pass can be spent once, expires in five minutes, is bound to one community, and is re-checked at the moment it is spent — so an account promoted to administrator during those five minutes can no longer be entered. Three further problems were fixed alongside it: The two tabs were sharing one set of credentials, because browsers share that storage across every tab on a site. Signing in as the member therefore overwrote the administrator's own sign-in in their original tab. The member session now lives in storage private to its own tab, so neither tab can disturb the other, and the administrator signing out no longer ends the member view. From the super-admin panel the button could not have worked even in principle: the panel exists to browse other communities, but it called the single-community endpoint, which answers "user not found" for anybody outside the current one. It now uses a cross-community endpoint, bounded so that the administrator of a network still only reaches their own branch. There was no way to stop, and no sign you had started. Every viewed page now carries a notice saying whose account you are looking at, with a button that ends the view immediately. Ending it cancels that session on the server without touching the member's own sign-ins on their own devices. A viewing session is deliberately short-lived and is issued with no means of renewing itself — viewing somebody's account should not create a long-lasting key to it. When it lapses the administrator starts again. This went unnoticed because the tests only checked that the wrong people were refused a pass. Nothing checked that the pass could sign anyone in, and it could not. There are now tests that end by presenting the resulting credential to a real endpoint and confirming who it signs in as.
- The super-admin panel link was missing from the sidebar until the page was refreshed. After signing in, the administrator of a community that can have branches beneath it saw no way into the super-admin panel; it appeared only after a full page reload, and then behaved normally for the rest of the session. The sign-in reply carries a deliberately small record of who you are, and the field that says how far your super-admin reach extends is not part of it — that field is worked out by the server and only sent with the fuller profile. The app was keeping the small record for the whole session, and treats a missing reach as "none", so the link was hidden. Reloading the page happened to fetch the fuller profile, which is why the refresh appeared to fix it. Signing in now fetches the full profile straight away, exactly as signing in with a fingerprint or face already did, so the link is there immediately. If that fetch fails the session still opens on the smaller record rather than dropping the user back to the sign-in page.
- The admin Matching Analytics page showed "Failed to load" on any community that has not generated matches yet. Nothing was actually failing. The server answered correctly; the page then threw the answer away and showed an error. The cause is a quirk of the language the server is written in: PHP cannot tell the difference between an empty list and an empty set of labelled counts, so when there are no dismissal reasons, no algorithm versions and no scoring samples to report, all three come back written as empty lists rather than as empty sets. The page checked the answer strictly before displaying it, insisted those three had to be labelled sets, and rejected the entire response over it — including the parts that did have real numbers in them. So a community with real member and listing figures to show saw none of them. The page now accepts an empty list where an empty set of counts is expected, and only there: a list with anything in it is still rejected, as is any count that is not a number. Confirmed against what the live server actually returns for the test community, which is where the exact figures in the new tests come from — asked again on 2026-08-06, and all three of the values in question came back as empty lists exactly as described, so this is the shape a real timebank with no matches yet actually sends. This slipped through because the page's existing tests fed it a hand-written answer in the shape the page wanted rather than the shape the server sends — different names for several figures, and empty sets written the way the page preferred. Two new tests now use the real answer, one at the page level and one on the checking code itself, and both were confirmed to fail before the fix.
- The admin Performance page crashed to an error screen, and the figures it was built to show did not exist anywhere on the platform. It now measures them for real. Opening the page replaced the whole admin area with "Something went wrong". It was asking the server for request timings, slow database queries and memory use at the address of the general event-counter, which answers with a completely different set of figures — so the page reached for a list that was not there and stopped dead. Behind that was the bigger problem: nothing on the platform recorded how long anything took. No table, no code, nothing to point the page at. Half the feature had never been built. It is built now, and deliberately built so the monitor cannot become the slowest thing on the platform: Every request is counted, but not every request is written down. Each one bumps a single hourly tally, which is why the totals and the hourly chart are exact rather than estimated. A full record is kept only for requests that are actually interesting — slow to respond, heavy on memory, making an unusual number of database queries, or repeating the same query over and over. On a healthy site that is a handful of rows a day, not one per visitor. Nothing is recorded until after the reply has been sent. Measuring a request cannot slow it down, because the writing happens once the visitor already has their answer. Repeated-query patterns are spotted automatically. The most common cause of a slow page is asking the database the same question once per item in a list. The recorder notices when one query shape repeats past a threshold and flags the request, so the page can point at it. Slow queries are stored with the exact place in the code that ran them — file, line and function — so there is something to act on rather than just a complaint. No member data is ever stored. Only the shape of a query is kept, with the values stripped out, so these diagnostics tables cannot accumulate names, addresses or anything else about a person. There is a test that fails if that ever changes. It cleans up after itself. Detailed records are deleted after a fortnight and the small hourly tallies after three months, tidied up nightly. The old records are not needed and would grow for ever. It can be switched off entirely, and if it ever is, the page says so rather than showing a page of zeroes, because zeroes read as "your site is perfectly fast" and nobody could have checked that. It also has to survive its own failure: if the recording breaks, the visitor's request still succeeds and the problem is written to the log instead. The page itself shows real figures at last, and three details it was always meant to show but never did — the query count, the peak memory figure and where a slow query came from — were on screen as bare labels with no number after them. Those now carry their values, in all eleven languages. Worth recording why nobody noticed. The page's tests handed it a hand-written set of figures in exactly the shape it wanted — a shape no part of the server has ever produced — and the test file was on the list the automated checks skip, so nothing was watching. It has been taken off that list, the skip list's ceiling has been lowered so it cannot creep back, and there is now a test that compares the page's expected shape against a real server response and fails if either side is renamed without the other. On the server side, seventeen tests cover what gets recorded and, just as importantly, what deliberately does not. One honest limitation: the on/off switch is for the whole platform, not per community. Checking a per-community setting on every single request would itself cost a database query on the busiest path in the system, which is the opposite of the point.
- The network super-admin panel offered a button that would always have been refused, and told the person they were seeing the whole platform when they were not. Walking the new panel by hand for the first time, as the super-admin of a timebank that has timebanks beneath it, turned up two things no test had caught. The dashboard offered a "Federation Controls" button, which is a platform-owner-only area — pressing it could only ever have produced a refusal, and a refused button is worse than an absent one, because the person cannot tell whether they lack permission or the platform is broken. And the heading described the page as a "platform-wide overview", which for a network administrator is simply untrue: they are shown their own timebank and the timebanks beneath it, and nothing else. That wording is exactly how somebody concludes they can see everyone's data. The button is now shown only to the platform owner, and the description now says what a network administrator is actually looking at. Confirmed from both directions on a running server — the platform owner still sees the button and every platform-only area, and a network administrator sees only their own branch.
- The link to the super-admin panel could fail to appear for the administrator of a network. The admin menu works out its contents once and then reuses that answer until something it watches changes. It was not watching the one value that decides whether the super-admin entry is shown. For the platform owner this made no difference, because a different value it was watching changed at the same moment. For the super-admin of a community that has communities beneath it, nothing on the watched list ever changes — they are not a platform owner before their account details load, and they are still not one afterwards — so the menu was never rebuilt and the entry never appeared. The value is now watched, and a note above it records why removing it breaks only the branch case, which is the harder one to notice.
- The automated screen-reader check could not sign in, so it was about to go back to checking nothing. The check was taught to log in as a real member on 2026-08-05, because it had been quietly scanning the login page instead of the member pages. On GitHub it then failed at the first step. The cause was a single setting: that job stored sign-in sessions in memory and threw them away at the end of every request, so the sign-in form's security token never survived long enough to be checked and the sign-in was rejected outright. Reproduced both ways to be sure — with the setting as it was the sign-in is refused and the member is bounced back to the login page, and with it corrected the member reaches their dashboard. The setting is left alone for the other checks in the same job, which want no files written.
- A member could agree to a guardian arrangement and nothing else — they could not refuse it, and could not change their mind. When coordinators record that someone is responsible for supporting a member, that member is the subject of the arrangement. The only button they had was "I agree". There was no way to say no, and no way to withdraw afterwards — undoing it was a staff-only action. There was not even anywhere in the database to store a refusal. A consent that cannot be refused is not consent, it is a button. And a consent that cannot be withdrawn falls short of the ordinary expectation that withdrawing is as easy as agreeing. A member can now agree, refuse, or withdraw agreement they previously gave, and change their mind in either direction afterwards. A reason can be given and is never required — making somebody justify refusing a safeguarding arrangement is pressure to agree, so the screen says explicitly that they do not have to. Their coordinators and the named guardian are told when they refuse or withdraw, each in their own language, because a refusal is a safeguarding signal that must not sit silently in a table. Three further gaps closed at the same time: The guardian could see nothing. They were emailed that they had been made responsible for someone and then had no screen for it — they could not see the arrangement, or whether the member had agreed at all. Half the relationship was invisible. They now have a "People you support" section, which appears only if they actually support someone, and which states that it does not let them act on anyone's behalf. Nothing told a member there was a decision waiting. The only routes in were an email, or knowing to look several clicks deep in settings. There is now a prompt on the dashboard, which shows nothing at all when there is nothing pending. Every change is now recorded in a trail that cannot be rewritten. Who did what, when, in what capacity, and any reason they gave. The database itself refuses attempts to alter or remove those records — that is tested, not assumed. Worth being straight about one limit: the records are protected against the application and ordinary database edits, not against someone with full database access who empties the table outright.
- The same screen now exists on the accessible version of the site, which had none of it. When the guardian-arrangement screen was first built it was built only in the main app. The accessible site — the plain-HTML version intended for people who need the most accessible experience — was left with nothing: no way to see an arrangement made about you, and no way to agree, refuse or withdraw. That was the wrong way round, because the people most likely to be under such an arrangement are the people most likely to be using that version. It is now there in full, and it works entirely without JavaScript: every action is an ordinary form with a button. It shows who has been recorded as responsible for you, when, any note from your coordinators, your current answer, and any reason you gave. It offers the same three answers, with the same optional reason and the same explicit statement that you do not have to give one. Guardians see the people they support and whether each has agreed. It is linked from the settings hub — an unlinked page is an undiscoverable one — and it has been added to the list of pages the screen-reader check scans, where it passes with no problems found. Both versions call exactly the same underlying code, so the rules about what answers are possible, the record of who did what, and the notifications to coordinators cannot drift apart between them.
- A carer could be told they were allowed to read a vulnerable person's messages, and it was never true. On the linked-accounts screen a family saw four identical on/off switches: view activity, manage their listings, send and receive time credits, and view their messages. The first three now work. The fourth never did — nothing in the platform ever checked it. It saved, it showed as on, and no part of the system paid it any attention. That is worse than the feature being absent, because a family could reasonably act on it. In a safeguarding feature it is the most serious kind of error, and it appeared in both the main app and the accessible version. The clinching detail: the database column's own description lists three permissions. The fourth reached both screens and never reached the design. The switch is gone from both, and both now say plainly that carers cannot read messages, rather than the option quietly disappearing — a family who had switched it on needs to know it never did anything. The same permission can no longer be sent from the accessible version at all. It has not simply been deferred: letting a carer read a dependent's conversations exposes the other person in that conversation, who never agreed to it. The platform's existing answer for oversight is to notify the people involved, and until that notice exists for carers this must not be offered. The test that used to demand the switch exist now demands it does not.
- A member could be told someone had been made responsible for them, follow the link in the email, and find nothing. When coordinators record that one member is a guardian for another, both people are emailed, and the ward's email links to their safeguarding settings. That page never showed the arrangement. Worse, nothing anywhere could record the ward's agreement to it — so the figure on the admin dashboard counting how many people had consented was permanently zero. The behind-the-scenes half of this was built earlier in this release. What was missed is that no screen ever called it, which is the same fault it was meant to repair: the original bug was a function nothing called, and it had been replaced by an address nothing called. A member still could not see or agree to anything. There is now a "Guardian arrangements" section in safeguarding settings. A member sees who has been recorded as responsible for them, when, any note the coordinator added, and can give their agreement — which is what finally records it. Only they can: a guardian trying to agree on their behalf is refused, and there is a test for that boundary specifically, because an agreement signed by the wrong person is worse than none. The section states plainly that this record does not allow anyone to create listings, use the member's time credits, or read their messages, because it genuinely does not.
- Two unrelated features were both called "guardian", with nothing to tell them apart. A member can link a family account and choose "guardian" as the relationship. Coordinators separately record "guardian arrangements". These are different things in different places with different consequences — a link a member sets up can grant real abilities, while a coordinator's record grants none at all — and they are so unconnected that no single file in the codebase touches both. Nothing on screen explained which was which. Both screens now say what they are and where the other one lives. New wording added and translated into all eleven languages. Three separate translation faults were caught and fixed before this shipped, all in the same short label. Machine translation dropped it entirely in every language on the first attempt, because it contained a date placeholder. Reworded without the placeholder, it was then skipped in every language by a safeguard that treats single words as computer values rather than text. Forced through, it came back in Japanese, Spanish, Polish and Portuguese meaning audio recording. The English was changed to something unambiguous instead of overriding the safeguard, which is the point: that safeguard was right and the fix was to stop giving it an ambiguous word.
- The accessibility check that guards the screen-reader frontend was scanning the login page instead of the pages it claimed to cover. The accessible (GOV.UK-style) frontend has a browser accessibility scan that runs on every relevant change and is marked as blocking. Twenty-three pages were listed. Six of them — the activity feed, offers, messages, events, volunteering and the knowledge base — are only visible once you have signed in, and the scan never signed in. Asking for a page you are not allowed to see sends you to the login page, and the browser follows that quietly. The scan then checked four things: that the page has a main content area, a heading, a "skip to content" link, and the phase banner. The login page has all four. So each of those six pages passed by scanning the login page a second time, and the result was reported as a clean pass. Two more gaps sat alongside it. Anything the scanner rated "moderate" was thrown away before the result was judged — and heading order, page landmarks and form labelling, which are exactly what somebody navigating by screen reader relies on, are routinely rated moderate. And the pages that carry the core exchange journey — the dashboard, an exchange, the wallet and the member's own profile — were not in the list at all, which is the journey the tester specifically asked about. All three are fixed. The scan now signs in as a real member, covers ten signed-in pages including the exchange journey, and fails on moderate as well as serious problems. Two safeguards keep it honest rather than merely passing: signing in is now a prerequisite step, so a broken login fails the whole run instead of letting every page quietly fall back to the login screen; and each signed-in page now asserts that it was not redirected, because having a heading and a main area is not proof of which page you are on. Both safeguards were tested by deliberately breaking them — with a wrong password the run stops and twenty-seven checks are reported as not run, and with an expired session the individual page fails and names itself. Under the old version both of those situations passed. The genuinely good news: with all ten signed-in pages actually being scanned for the first time, there are no violations at any level, including the "minor" ones that still do not fail the build. The accessible frontend really is clean. It simply had not been checked.
- A second accessibility check was scanning an error page four times and calling it four pages. Separate from the one above, this check covers the main React app. It listed four addresses — a community's home page, its sign-in page, About and Help — and served them from a built copy of the app with no server behind it. Measured properly, three things were wrong at once, and each on its own made the result meaningless. The tool serving those files answers every address with the same single file, so all four were byte-for-byte identical. With no server to talk to, the app cannot load the community's settings, so what it actually displayed was its "Unable to connect" screen — for all four. About, Help and the sign-in page were never examined and could not have been. And the check was written so that three separate things could go wrong silently: the scanner's own error messages were written into the file meant to hold its results, its failure signal was explicitly discarded, and the small piece of code that counted the problems ended with an instruction to report zero if anything at all went wrong. A scanner that crashed outright was recorded as a clean pass. Scanned honestly, that error screen had one serious problem and no page landmark at all — meaning somebody using a screen reader had nothing to navigate to, on the one screen that appears when everything else has failed. The problem was the "Try again" button: white text on the standard brand colour measures 4.46 against a required minimum of 4.5. A fraction under, and a real failure. Both are now fixed on that screen, and the check has been rewritten to examine exactly what a server-less copy of the app can honestly show — the app's shell and that offline screen — using the same browser-based tooling as the rest of the suite, with failures that actually fail. Pages with real data in them are covered by a different check that runs the whole platform in containers and signs in as both an ordinary member and an administrator; that one was already sound, and it is the one to trust for real pages. A wider issue was found on the way and is not fixed, deliberately. The colour used for text on brand-coloured buttons is fixed as white, while the brand colour itself can be changed by each community and each member. Nothing checks that the resulting combination is readable. The offline screen now uses a fixed, checked colour instead, but everywhere else that pairing is still unverified. That needs its own piece of work, because changing it affects how the whole platform looks. Two smaller notes, recorded so nobody assumes otherwise. The performance report that runs on front-end changes also produces an accessibility score, but it only ever loads that same server-less copy — so its score describes the offline screen, not the app, and it is advisory rather than blocking. And pa11y, an accessibility tool, is listed as a dependency of the project but is not used anywhere at all.
- Reporting is now findable in one place. A partner organisation's technical reviewer reported that they could not find the reporting well enough to judge it. The figures were separately wrong and have been fixed, but the finding itself was about finding it: reporting was spread across five different panels, and seven analytics screens had no menu entry at all and existed only for someone who already knew the web address. The one report whose download genuinely matched its screen was filed under a different panel entirely. Everything an administrator can report on is now listed under one heading, and each entry only appears if the community actually has that feature switched on — a link to a disabled area is a dead end, and there is a test that checks exactly that. Five of those newly listed entries were briefly missing their menu labels in all eleven languages, which would have shown administrators the internal name of the screen instead of its title. The wording already existed elsewhere in the same files — every language already had a proper translation of each one for use in the trail of links across the top of the page — so the labels were taken from there rather than machine-translated, and no new wording was invented in any language.
- A guardian arrangement recorded no consent, because nothing could write it. When staff record that one member is responsible for another, the record has a place to note the second member's consent — and nothing in the platform could fill it in. The function that would have done so was written but never called from anywhere, so the figure on the admin dashboard counting how many people had consented was permanently zero, no matter what happened. The member on the receiving end also had no way to see the arrangement at all: they were emailed about it, and the link in that email led to a page that did not show it. Both halves are now fixed. A member can see who has been made responsible for them, and can give their consent, which is what finally populates that record. Consent can only be given by that member — a guardian attempting to consent on their behalf is refused, and there is a test for that boundary specifically, because a consent record signed by the wrong person is worse than none.
- Marking a "delete my data" request as completed did not delete anything. When a member asks to be erased, the platform records the request for a coordinator to action — deliberately, because deciding a data request is a human and legal judgement rather than something to automate, and there is already a daily alarm that flags requests left unattended past the legal deadline. What was missing is that the coordinator's own action did nothing: marking the request "completed" wrote the word "completed", stamped who did it and when, and left every piece of the member's data exactly where it was. That is worse than an unprocessed request. It manufactures a false compliance record — the paperwork says the erasure happened — and it silences the overdue alarm, because the request no longer looks open. Completing an erasure request now performs the erasure. The code to do it already existed and was already written to be called this way, including a safeguard that refuses to record the request as completed if any part of the erasure failed, leaving it for a coordinator to retry rather than reporting a false success. It was simply never connected. Two claims from the original review did not survive checking, and are recorded here so they are not repeated: the absence of automatic processing is intentional and documented, not an oversight; and members are told their request has been submitted, not that their account has been deleted, so the wording was accurate all along.
- New members were never told they were waiting for approval — and that is the normal case, not a rare one. Most communities require a coordinator to approve new members, and that is what a freshly set-up community does by default. Yet the sign-up screen only ever said "check your email". The member then verified their address, tried to sign in, and got a single line of red text with no explanation of who approves them, how long it takes, or what to do. What makes this one frustrating is that the screens were already built. The sign-up page has a proper "waiting for approval" panel, and the app was already written to look for that information — the sign-up process simply never sent it, so the panel was unreachable. The verify-your-email page had the same panel, and it too could never appear, because it depended on a community setting that is deliberately kept private and so was never available to it. All three points now explain the situation properly: the sign-up confirmation, the page after verifying an email address, and the sign-in screen, which now offers a way to contact the community. New wording added and translated into all eleven languages.
- There was no way to decline a membership application. A community could approve someone, suspend them, ban them, or delete them outright — and nothing else. In practice an application was declined by leaving it pending forever, which is invisible to the applicant and leaves no reason recorded anywhere. There was nowhere on the member record to put a reason even if someone had wanted to. Coordinators can now decline an application with a reason, which is required. The reason is stored on the record itself rather than buried in a log, along with who declined it and when, and the applicant is notified in their own language — an application quietly absorbed is indistinguishable from one ignored. It is reversible: approving someone clears the earlier decision, so a community can change its mind without making the person apply again. Declining is restricted to applications; removing an existing member is still a suspension or a ban, which are different decisions with different consequences. This also fixed a real fault found on the way. The member record's list of allowed states did not include "declined", yet the sign-up code already tried to set exactly that when two people race for the last use of an invite code. Because this database rejects out-of-range values rather than quietly accepting them, that path failed outright — so instead of the intended "invite code no longer valid" message, the person got a server error and the half-created account was left pending. Adding the missing state fixes both.
- New members ticked the terms box and the platform kept no record of which terms they had agreed to. The tick was checked — registration was refused without it — and then discarded. Nothing was written anywhere. A properly built table for this already existed, recording the exact version of each document a member accepted along with the date, their address and their browser, and it even had "registration" as one of its listed ways of accepting — a value that had never once been used. The function to write those records existed too, and already defaulted to "registration". It had simply never been called from the signup process. The consequence: the only record a member ever got was created later, the first time they signed in, when a separate prompt asked them to accept. So between signing up and first signing in there was no evidence of what anyone had agreed to, and for anyone who signed up and never signed in, there was none at all. Registration now records the exact version at the moment of signing up. Both the main app and the accessible version go through the same code, so both are covered. Two deliberate limits. If a community has not set up any terms documents, nothing is recorded, because there is no version to point at — and signing up still works, which is covered by a test. And accounts created by signing in with Google or similar are not given a registration record, because that route never shows a terms box; recording agreement there would be inventing consent the person never gave. Those members are still correctly captured by the prompt at first sign-in, and there is now a note in the code explaining why this must not be "tidied up" later.
- A completed exchange could not be corrected, so a mistake moved real credits permanently. Once an exchange was marked complete, that was final: there was no way to undo or amend it. The only tool an administrator had was the single-member balance adjustment, applied by hand twice — once to each person — with no link back to the exchange that was wrong and, until earlier in this release, no record of who did it or why. For a platform whose entire purpose is an accurate record of hours given and received, that was the most serious gap found in the review. A broker or administrator can now reverse a completed exchange, restoring both members' balances in a single step, with a mandatory explanation recorded in the exchange's own history — which both members can already see — and in the community's audit log. It is built to match the one place on the platform that already did this correctly, the marketplace refund, point for point. The original record is never altered or deleted; the correction is its own separate entry, mirroring the first, so the history of what happened stays intact and the correction is visible beside it. The amount is read back from the original entry at the moment of reversal rather than taken from whoever is asking, so a reversal cannot move a different sum from the one that was moved — there is a test proving that even if the recorded hours are altered afterwards, the reversal still returns exactly what actually changed hands. The two member records are always locked in the same order, so two corrections happening at once cannot jam against each other. And a second reversal of the same exchange is impossible: it is blocked both by a check in the code and by a rule in the database itself, because a check alone can be beaten by two requests arriving together. One deliberate decision: if the person who received the credits has already spent them, the correction still goes through and their balance is allowed to go below zero. Refusing would leave the record permanently wrong, so the shortfall is instead shown honestly as a debt. This matches how refunds already behave elsewhere on the platform. This reverses; it does not re-post at a corrected figure. Amending a wrong number of hours means reversing and then recording the exchange again correctly. Building a second way to move credits, purely to save that step, would have meant a second thing to get wrong.
- Carers and guardians were shown permissions the platform never actually honoured. A member can link another person to their account as family, a carer, a guardian or an organisation, and grant them named abilities: view my activity, manage my listings, send and receive time credits for me, view my messages. Only the first of those four was ever checked anywhere. The other three were offered as switches in both the main app and the accessible version, with those exact labels, and nothing in the platform consulted them — there was not even a way for a carer to attempt any of it. A family could have been told a carer was able to spend a dependent's credits when they could not. It failed in the safe direction, so nobody gained an ability they should not have had, but people were being misinformed about a safeguarding arrangement. Two of the three are now real. A carer with permission can post a listing for the person they support, and can send time credits from that person's balance. Both go through exactly the same code as the member's own equivalent action, so the carer's route carries the same spending cap, the same over-spend protection, the same safeguarding contact checks and the same duplicate-submission protection — a second, weaker path for someone else's money would have been the wrong way to build this. The safeguarding contact check is repeated at the moment of use rather than only when the link was first approved, so a restriction that lands later takes effect immediately, and a link the dependent has not yet approved grants nothing at all. Crucially, the platform now records who actually did it. A listing posted by a carer still belongs to the person it was posted for, and credits still come from that person's balance — but a new field on both records names the carer who acted. Without it, a carer's action would have been indistinguishable from the dependent's own, which for a feature that lets one person spend a vulnerable person's credits is not acceptable. Every such action is also written to the community's audit log, and the dependent is notified in their own language that something was done in their name, because a proxy action the owner never learns about is not consent. The third permission, viewing the dependent's messages, is deliberately still switched off pending the notice work — the people messaging that member never agreed to a carer reading their conversations, and the platform's own answer to this elsewhere is to tell them. That is being built separately rather than quietly shipped.
- The only tool for correcting a member's time-credit balance kept no record of who used it or why. Both the function itself and the route that reaches it stated in writing that the action was recorded in the audit log. Nothing was written anywhere. The reason the administrator is required to type survived only as a text prefix on the transaction description, in a field nothing can search. Every use is now recorded properly — who did it, which member, the reason, and the balance before and after — and deliberately written in the same single step as the balance change, so an adjustment can never happen without its record. If the record cannot be written, the adjustment is undone. Two further faults in the same place. The adjustment was not marked as an administrative one, so it was indistinguishable from an ordinary transfer between two members. And the acting administrator was recorded as the other party to the transaction while their own balance was never changed — which meant every single adjustment quietly inflated that administrator's apparent totals on the admin dashboard, because those totals are calculated by adding up transactions per person. Credits created or removed by administration now correctly have no second party, matching how the rest of the platform already records this. The existing test had been asserting the faulty behaviour rather than catching it; it now checks the correct behaviour, and two further tests cover the audit record and the transaction type.
- A disputed exchange could never be resolved by anyone, and the credits were stuck. When two members each confirm a different number of hours and the gap is too large to average, the exchange is marked as disputed. Nothing could then happen to it — not by either member, not by a broker, not by an administrator. The credits had not yet moved and never would. This was the worst kind of dead end because the platform openly advertised that something should be done: the broker dashboard counted disputed exchanges as needing attention, and a separate monitoring check raised an alarm as they aged, so the warning could be triggered but never cleared. Most of the machinery already existed and was simply unreachable — the code that completes an exchange already handled the disputed case and even had a dedicated "dispute resolved" email written for it, but the only way in was through a check that excluded disputed exchanges. A broker can now settle a dispute by setting the final number of hours, with a mandatory note explaining the decision. The figure is held to the same limits a member's own confirmation would be, so an arbitrator cannot enter a number the platform would have refused from a participant, and the decision is written to the exchange's own history, which both members can already see. A broker who is one of the two parties is blocked from deciding their own case. Found while doing this: the existing code for a broker to cancel an exchange they are not part of tested a permission flag that does not exist on the platform — there is no such column and no such property — so the check was always false and a genuine broker would have been turned away. It now uses the platform's proper permission test. That path had no way in either, so nobody had hit it.
- Turning a phone sideways left signed-in members with no way to navigate the app. The bar of buttons along the bottom of the screen was set to disappear once the screen was wider than 768 pixels, on the assumption that anything wider is a desktop and would show the full menu across the top instead. But the top menu only appears at 1024 pixels and above, and the hamburger button that opens the slide-out menu was deliberately built for signed-out visitors only. A phone held sideways is around 900 pixels wide, which falls straight into that gap: the bottom bar had gone, the top menu had not arrived, and there was no hamburger — leaving nothing but the small account dropdown, which does not list the feed, listings, messages, exchanges, events or groups. Everything on the platform was still reachable by typing an address, and nothing else. The bottom bar now stays until the top menu takes over, so exactly one of the two is always present at every screen width. The gap is closed by construction rather than by picking a new number: the two conditions are now exact opposites of each other. Reported by Timebanking UK during testing. The separate phone app was unaffected because it is locked to upright, which is why this was never noticed internally.
- Two of the main admin reports were broken, and the tests were checking made-up data so nobody found out. The "Hours" report has three views. Opening the one that lists hours per member crashed outright, because the server sends that list wrapped in a container with a page count and the page was treating the container itself as the list. On top of that, roughly twenty figures, chart labels and table columns across the Hours and Member reports were permanently blank or zero — the page was asking for information under names the server has never used, such as asking for "month" where the server says "period", "unique givers" where it says "unique providers", and a month-by-month retention grid the server does not produce at all. Each one silently produced nothing rather than an error. Why it survived: the automated tests for both pages supplied invented data in exactly the same wrong shape the pages were reading, so the tests agreed with the bug and reported success. The Member report's tests were weaker still — several of them ended in checks that can never fail, such as asserting that the page exists. Both sets of tests now use the shapes the server genuinely returns, copied from it, and check real values, so this class of fault fails immediately instead of passing quietly. Fixing the test file's typing also removed ten long-standing type warnings, and the project's warning ceiling has been lowered to match so they cannot come back. Also fixed while in there: the member list showed only the first fifty members with no way to reach the rest, despite the page count already being available; and the retention table, engagement figures, contributor leaderboard and inactive-member list now show the figures the server actually computes rather than placeholders. Six new labels were added and translated into all eleven languages.
- Report downloads did not match what was on the screen. On the Hours report, the download button always fetched the "by category" breakdown no matter which of the three views was open — so someone looking at hours per member received category totals in a file named after the view they had been reading. The two missing download types have been added, and they now call the same code that draws the screen, so the two cannot drift apart again. On the Member report the download sent no filters at all, so all six views downloaded the same complete all-time member list under six different names; four of those views have no matching download on the server, so the button is now switched off for them rather than handing over an unrelated file, and the file that remains is named after what it actually contains. A separate date fault affected every report and every download: asking for a range ending on the 30th of the month excluded the whole of the 30th, because a date with no time attached is treated as the first instant of that day. A one-month report was quietly returning one day short, on screen and in the file. Fixed in both places that build these date filters.
- A rejected listing told the member it had failed but never which field was wrong. The server already reports precisely which fields are at fault and why, but the form discarded that and showed a single generic message. Field-level messages now appear against the fields themselves, and anything the server reports that is not tied to a specific field still goes to the pop-up message, so nothing can be swallowed. Reported by Timebanking UK during testing.
- Permanently deleting a community was recorded in the audit log without saying what had happened. Wiping a community is the most destructive thing the platform can do — it is irreversible and removes every trace of that community's data and members. It is supposed to leave a permanent audit entry saying so. The entry was being written, with the full description, who did it, when, and the before-and-after figures. But the field that says which kind of action this was came out blank, so the entry did not appear under "Tenant Purged" anywhere, could not be filtered for, and did not appear in any count by action type. In practice the record existed but could not be found. The cause: the list of permitted action names stored in the database had never been given "tenant purged", even though the rest of the code had been written expecting it. The database is deliberately configured in a forgiving mode, so instead of rejecting the unknown name it quietly stored a blank and reported success. Nothing failed, nothing was logged, and no part of the system could tell. This had already happened for real. A community was permanently deleted on 5 July 2026 — 4,821 records and 32 members — and its audit entry has been sitting there unlabelled ever since. That entry is now corrected as part of the fix; it was the only affected entry out of 2,129. Two further action names were missing for the same reason, both concerning the granting and removal of the highest level of administrator access. Neither had been used yet, so nothing was lost, but they would have failed identically. Three things now prevent a repeat. The code checks the action name before saving rather than trusting the save to complain, because the save never did. If a name is ever unrecognised again, the entry is still saved — under a visible "Unrecognised Action" label rather than a blank, so the record and everything else in it survives — and the problem is written to the error log and reported back to the caller. And a test now holds the code's list of action names and the database's list against each other, so they cannot drift apart again unnoticed; permanently deleting a community is also now covered by a test that checks the audit entry is correctly labelled.
- The PHP tests were running in the wrong mode on developer machines, and were 3.4 times slower as a result. Every test spent about 1.1 extra seconds re-checking every translation file on disk. The code already had a shortcut to skip that during tests, and a second shortcut to load only three languages instead of eleven, but neither was ever switching on locally. The reason: the test setup announced "this is a test run" in one place, while the Docker container announced "this is a development machine" in another place that the framework reads first, so the container always won. Setting it in the place the framework actually checks first fixes it. Measured on the same test files before and after: a group of six went from 145 seconds to 41, and a group of thirty from 540 seconds to 160. The results were identical in both runs - same test count, same assertions, same pre-existing failures - and the two areas most affected by the language shortcut were checked explicitly, with all 102 internationalisation tests and all 53 passkey tests passing. Two honest caveats. This does not make the automated checks on GitHub any faster - they were never affected, because they set the mode correctly already, which is precisely why the problem stayed invisible for so long. And roughly half a second per test remains, which is Laravel starting a complete application - 47 components and 4,077 web addresses - once for every single test. Reducing that further would be a real design change, and has not been attempted.
- The slowest stretch of the automated checks was split into more parallel groups. The PHP tests ran in six parallel groups whose times ranged from 15 to 29 minutes — the slowest group set the pace for every push. They now run in ten groups, aiming for roughly 12–15 minutes each. The split method is unchanged (it balances by file count, deliberately, for stability), so the exact times need a few runs to settle and will be re-measured rather than assumed.
- The full test suite now runs automatically every night. The checks that run on each push deliberately test only what changed — that keeps pushes fast, but it means a problem spanning two areas can hide if only one of them changes. (That is exactly how the mobile-app agenda bug stayed invisible for weeks.) A complete run of every check now happens nightly at 3:30am on GitHub's machines, so anything the shortcuts miss is caught within a day instead of whenever someone next forces a full run. The nightly run and the ordinary push runs can no longer cancel each other.
- Changing a shared data contract now wakes every app that depends on it. The files describing the agreed shape of event data (contracts/) are read by the server, the website, and the mobile app — but changing them previously triggered checks for none of the three. All three now run whenever a contract file changes, which would have caught the mobile agenda bug on the day it was introduced.
- Deploy verification is now smart about what "fully checked" means. The safety net added earlier today demanded that every check ran on the exact version being deployed — safe, but it forced a redundant half-hour full re-run before almost every deploy, because ordinary pushes correctly skip checks for areas that didn't change. The verifier now accepts a check that passed on an earlier version when none of the files that check watches have changed since — the same rule the checks themselves use to decide when to skip, applied consistently. A check that failed on the newest code it ran against still always blocks, a skipped check still never counts as passed, and the list of which files each check watches now lives in one shared place (.github/ci-paths.yml) so the deploy verifier and the push-time checks can never disagree. Combined with the new nightly full run, deploy verification normally completes in seconds instead of forcing a fresh 40-minute run.
- Deploying now waits for the checks, and refuses if they did not all run. The deploy script pushed to GitHub and immediately told the server to go live. But pushing is the thing that starts the automated checks, so the deploy and the checks began at the same moment and the deploy always finished first — nothing ever read the result. Code could go live and the checks could fail twenty minutes later, with no connection between the two. There was a second, quieter problem. The checks skip whole sections when they judge an area untouched, and a skipped section counts as a pass on the overall result. So a commit could carry a green tick while its PHP tests, its React tests, the container build, the end-to-end run and the accessibility audit had never run on it at all. That was the normal state, not an edge case. Deploying now stops after pushing and asks GitHub one question: has every required check actually run, and passed, on exactly this version of the code? A skipped check counts as "not checked". If the answer is no, it starts a full check and waits; if that does not pass, nothing is deployed and the reason is printed. If it cannot reach GitHub or is not signed in, it refuses rather than assuming the best. ALLOW_UNVERIFIED_DEPLOY=1 overrides it for a genuine emergency and says loudly that it has. Nothing was removed or made faster: everything that ran before still runs. The change is that the deploy is now connected to the result. Requesting a full check run also genuinely runs everything now — the container build and translation-drift checks previously stayed skipped even when everything was explicitly asked for.
- The old recurring-events engine has been retired and removed. The platform carried two engines for months; the newer one is now the only one. This removes the split that caused editing a single occurrence to fail, and it retires a generator with real defects: it ignored the weekdays an organiser selected (a "every Monday and Thursday" series simply repeated weekly from whatever day it started), it treated the first occurrence as one interval after the date you chose rather than on it, and it could not record that an individual occurrence had been changed. Two deliberate consequences. A series can now run to 366 occurrences instead of 52 — the old ceiling was a limitation of the removed generator. And the rollout switch no longer restores the old behaviour: it now governs only the optional extras (rolling recurrence, revisions, blueprints) and what the API advertises. Turning it off does not bring the old engine back, because there is nothing to bring back. Existing series were converted first, and protection against a double-submitted series — which previously only guarded the old path — was carried over to the new one rather than deleted with it. Also corrected as part of this: the platform's own health check treated "new engine on, automatic extension off" as a misconfiguration, which is now the normal resting state — so every installation would have reported itself unhealthy the moment the engine was switched on. The genuinely broken combination (automatic extension on with the engine off) is still reported.
- A tool to finish the recurring-events engine migration. The platform has carried two recurrence engines for some time: the older one that every existing repeating event uses, and a newer one that was built to replace it but never switched on. Being stuck between the two is what broke editing a single occurrence, because the database was enforcing the new engine's rules on the old engine's data. events:migrate-recurrence-to-v2 converts existing series onto the newer engine properly — translating each series' repeat pattern using the engine's own logic rather than a reimplementation, and giving every occurrence the calendar identity the newer engine expects. It is deliberately cautious. A dry run reports exactly what would change and writes nothing. It never creates, deletes or reshapes occurrences, so registrations and attendance stay attached to the events they are already on. It can be run twice safely. And it refuses a series rather than forcing it whenever conversion would be unsafe — an unsupported repeat pattern, two occurrences sharing a start time, or an occurrence whose identity is referenced by records that cannot be rewritten. After conversion, editing one occurrence of a series records that it diverged from the rest — something the older engine could not track at all.
- Editing one occurrence of a repeating event failed for everyone. Saving a single occurrence also tried to record per-occurrence "override" bookkeeping, but the database only accepts that bookkeeping from the newer recurrence engine — and every occurrence any community has ever created came from the older engine, which is the default. So the save was rejected outright. The content change itself was always valid; only the bookkeeping was impossible, so it is now skipped for older-engine occurrences, which have no concept of it. Every recurring occurrence on the platform was affected. This also corrects an earlier note in this changelog: the published-series restriction described previously is real, but it was not what blocked these saves.
- The new Platform switches screen returned a server error on every change. The endpoint called a helper that does not exist on the base controller, so the first click failed. Its test only proved that a community administrator is refused — the working path was never once executed. It is now covered end to end: reading the switches, saving a mode, saving a toggle, reverting to the server setting, and rejecting a bad value with a clear message rather than a crash.
- Moved Platform switches out of Growth & Discovery into Platform operations, beside Module configuration, where the per-community switches it governs already live. It had been filed under SEO only because that was where the one other owner-level screen happened to sit.

### Added

- Deploys are now watched for half an hour after they go live. The deploy process tested the new version before switching traffic to it, but nothing watched the minutes after — a problem that appeared ten minutes post-switch was only noticed when someone complained. The deploy command now waits for the switch, then watches production error levels against the newly deployed version for 30 minutes, using the per-deploy error tagging added earlier. If errors spike well above normal it raises the alarm and prints the one-command rollback — it never rolls back on its own, and if it cannot check (for example, a missing monitoring key) it says "unverified" rather than pretending health. Verified end to end against the live platform, which surfaced one counting bug before release: logged error messages (as opposed to crashes) were silently excluded from the count, so five real errors read as zero. The watch now counts exactly what the error dashboard calls an error.
- A quick local check to run before pushing. node scripts/preflight.mjs looks at what actually changed and runs only the relevant fast checks — catching type errors, broken tests you just touched, missing changelog updates and licence headers locally in a few minutes, instead of twenty-plus minutes later on GitHub. It is deliberately honest: a check that could not run (for example, because Docker is off) is reported as "not checked", never quietly counted as a pass. It uses the same changed-area rules as the GitHub pipeline, which remains the authority — the heavy suites stay there. On its very first run it caught a real mistake (an unrefreshed in-app changelog copy) before it reached GitHub.
- Platform switches you can set yourself. Platform-wide rollout gates — the attendance-credit engine, the newer recurring-events engine, rolling recurrence, recurrence blueprints, timed waitlist offers and optional analytics — used to live only in server environment variables, so raising one needed someone with server access. There is now a Platform switches screen for the platform owner. A switch there sets the ceiling; each community still controls its own settings underneath, so turning something on centrally never enables it for anybody by itself, and reverting a switch hands the decision back to the server configuration. Only that fixed list of switches can be changed, and a tenant administrator cannot reach the screen at all. Worth knowing: the other capabilities listed on a community's Event settings page — ticketing, agenda, offline check-in, broadcasts, registration forms, invitations, safety evidence and federation delivery — are not switches. They report whether the feature is installed, which depends on the database, so they stay read-only.

### Fixed

- The buttons in our emails were invisible in Outlook. A member of a partner timebank received a "New exchange request" email and replied that there was no link to accept it. The link was in the email — but the button was coloured with a gradient, and Outlook throws gradient backgrounds away. That left white lettering on a white card: the button was there, occupying space, and unreadable. The same applied to the coloured banner at the top of the email, and to the button in every other email the platform sends — exchanges, events, volunteering, matches, newsletters and the shared email template. It had been that way since those emails were written. Every coloured panel and button in our emails now carries a plain colour as well as the gradient, written as two separate instructions so a mail app that rejects one still applies the other. Buttons are additionally built the way email buttons are supposed to be built — as a coloured table cell around the link, which also fixes desktop Outlook. Under every email button there is now the plain web address as text, so even if a mail app strips the styling completely, the recipient still has something to click or copy. A check now refuses this pattern in the code itself. There is no way to notice it at runtime: the email we send is perfectly valid, the send succeeds, and the damage happens inside the recipient's mail app.
- "1.00 hour(s) hour(s)" in exchange emails. The exchange request and exchange completed emails printed the word "hour(s)" twice. The English wording was being added by the code as well as by the translation, which also meant the English word was appearing in the middle of German, French and Japanese emails. The code now supplies only the number and lets each language supply its own wording.
- Server errors now name the exact deploy that produced them. Errors reported to the monitoring service carried only the platform version (e.g. 1.5.8), which spans many deploys — so "which deploy introduced this error?" was unanswerable, and errors from background workers carried no identifier at all. The error reporter now uses the same build stamp as the X-Build response header, derived the same way, so an error's release always matches the deployed code that threw it. The website side already did this correctly; the server now matches. Takes effect from the next deploy.
- The mobile app would have refused to read event agendas. The events work added a new event_status field to the shared event-agenda contract. The server and the website were both updated to know about it; the native mobile client was not. That client checks every response against an exact list of expected fields and rejects anything unfamiliar outright, so it would have thrown a contract error rather than showing the agenda. The client now recognises the field — treated as optional, and with the status values read as plain text, so a future lifecycle value cannot break it the same way. Unfamiliar fields are still rejected, which is the point of the check. No released build was affected: the native app is in testing and has no users. It went unnoticed because the mobile checks only run when the mobile folder or the CI configuration changes, and none of the events work touched either — so a green tick on those commits never covered this.
- The event analytics tab said "could not be loaded" for almost every event. The request was succeeding; the page was throwing the answer away. One field — the per-channel message delivery breakdown — is an empty map for any event that hasn't sent notifications yet, and PHP has only one array type, so an empty map was sent as an empty list rather than an empty object. The page validates that response field-by-field and rejected the whole thing. It is now always sent as an object. Both test suites missed it because neither could see it: the backend test asserted individual values while its own passing response contained the malformed field, and the frontend tests used hand-written fixtures where an empty map is unambiguous. The new test checks the actual JSON type.
- Per-channel delivery counts were inflated. In the same breakdown, a channel that first appeared partway through the results was seeded with the running totals accumulated for earlier channels instead of starting at zero, so second and subsequent channels reported everything counted before them as their own.
- Event check-in and safety pages were completely broken in production. Both send a request header that neither of the two CORS allow-lists included, so the browser refused the request before it left the page — and reported it as "unable to connect", which looks like an outage rather than a configuration gap. Both headers are now allowed in both places, and a test pins every custom header the frontend sends against both lists so this cannot recur.
- Saving a repeating event failed with the unhelpful message "Invalid status". Changing the repeat pattern of a series that has already been published is refused on purpose: regenerating its dates would discard occurrences people have registered for. That rule is right, but the message told the organiser nothing at all. It now explains the rule and what to do instead — edit the single occurrence, or cancel the series and start a new one.
- The event Federation tab showed a raw internal label (manage.federation.health.not_configured) instead of readable text, because none of the five sharing-status labels had ever been translated. All five now read properly in all eleven languages: "No partners set up", "Sharing normally", "Sending to partners", "Deliveries failing" and "Withdrawn from partners".
- Event settings looked broken because Save appeared dead. Every change to community policy needs a reason recorded against it, which is good governance — but the Save button simply greyed out with no explanation, as did every Restore button on the page. The page now says which of the two things is missing ("nothing to save yet" or "add a reason"), marks the reason field as required, and shows a notice at the top when there are unsaved changes.
- Cleared the events audit backlog — the five findings previously left for a decision are now all fixed. Cancelling an event now cancels its unsent announcements. Registrations, the waitlist and reminders were all being cancelled; broadcasts were simply missed, so an announcement scheduled before the cancellation still went out afterwards telling attendees to turn up. Drafts and scheduled broadcasts now die with the event, their queued deliveries are cancelled, and the reason is written to the broadcast's own audit history so an operator can see why. Anything already mid-send is left alone and reported rather than being allowed to block the cancellation. A cancelled event's agenda no longer reads as though it is going ahead. Sessions keep their own "scheduled" status by design — the programme is preserved as a record — but nothing told the reader the event itself was off, so every session looked live. The parent event's status now travels with the agenda, and both the React workspace and the accessible running-order page show a clear cancellation notice. A retried "create recurring series" request no longer duplicates the whole series. On the default recurrence engine each occurrence's identity was derived from its own new database row, so the uniqueness constraint could never catch a repeat submission: a double-click or a client retry silently produced a second complete set of occurrences that nothing downstream could tell apart. An identical series from the same member within a short window is now recognised as a retry and the original is returned. Genuinely different series are unaffected, and the window is configurable (or can be switched off). Broken event emails will now raise an alarm. Every existing health check watched the legacy reminder tables and their email category — but under the default configuration those are never written, because event mail flows through the newer outbox pipeline instead. Email delivery for events could therefore fail for a tenant without a single warning. The audit now watches the live pipeline directly: deliveries stuck in the queue, deliveries abandoned after retries, and outbox entries nothing ever picked up. Offline check-ins can now prove the code was really scanned. A device is given each attendee's credential fingerprint so it can verify a scan with no signal, but sync accepted that same fingerprint back as the evidence — so a device holding the list could record a check-in for someone it never met. It granted no permission the operator lacked, but it did mean the offline record couldn't be trusted to mean "this person was there". Devices may now send the code they actually scanned, which the server verifies itself, and a new setting refuses anything less once every device supports it. Existing devices keep working unchanged.
- Deeper events audit — subsystems the first review never reached (registration/waitlist/ticketing, recurrence, offline check-in, broadcasts, safety, agenda, federation), plus notification locale, background jobs, query plans and index coverage. Four fixes landed: Guests now occupy venue capacity. max_guests_per_registration caps how many guests one member may bring; it says nothing about how many people the room holds — and because a registration is always counted as exactly one capacity unit, guests were invisible to the capacity check entirely. A two-person event would accept a member, then ten guests from that member, then ten more from the next: twenty-two people against a stated capacity of two, with the capacity gate never firing once. For a community or council venue that number is frequently a fire limit, so guests are now counted against it like anybody else, with a clear "this event has reached capacity" response rather than the generic validation error. Withdrawn guests give their seat back, and events with no capacity set are unaffected. Wallet and XP ledger lines render in the recipient's language. The attendance-reward description was translated at the moment of the check-in scan, so it used the organiser's locale — a Spanish member checked in by an English-speaking volunteer got an English line permanently written into their wallet history. Unlike an email this cannot be re-sent or re-rendered, so it needed the recipient-locale wrap the project already requires for notifications. The reversal description had the same defect and the same fix. Four indexes for hot paths that were scanning far more than they needed. The monthly treasury cap total runs on every mint and filtered a column (completed_at) that no index covered, so it summed the tenant's entire completed-claim history on each check-in. The admin claims ledger's default unfiltered view could not use its status-prefixed index for sorting and fell back to sorting the whole ledger. The per-member monthly visit count filtered visited_on while both visit indexes were built on visited_at. And the anonymous public listing — the crawler-facing one — had no index matching its actual filter-and-sort shape. Also batched a per-venue staff count that was issuing one query per venue directly beside two already-batched aggregates. Verified along the way and deliberately not changed: the admin feature toggle does correctly invalidate the tenant bootstrap cache (a stale payload during testing turned out to be an artifact of writing the database directly); the partner-venue engagement summary's lifetime totals are intentional rather than a broken time filter, and are now documented as a known scale ceiling rather than silently altered. This pass also confirmed a long list of subsystems as sound under adversarial reading — cancellation correctly closes reminders and pushes a federation retraction, check-in and registration both refuse cancelled and unpublished events, offline check-in honours device revocation and cannot double-apply, recurrence v2 cannot duplicate occurrences or lose per-occurrence overrides, guardian-consent tokens are single-use, and every events notification path already renders in the recipient's language. Remaining findings that need a product decision rather than a patch (scheduled broadcasts surviving event cancellation, agenda sessions not reflecting a cancelled parent event, and duplicate occurrence sets if a create-recurring request is retried on the legacy engine) are recorded for the owner rather than changed unilaterally.
- Re-audit hardening across the events module and the Coventry features — four review passes (money paths, security, frontend wiring, end-to-end journeys) plus a live-stack E2E, then fixes for everything that survived verification. The four that mattered most: Money is now atomic. The treasury mint and reclaim each wrote the balance and the ledger row as two separate statements; a failure between them (deadlock, connection blip) left the balance permanently changed with no transactions row, the claim marked failed — which is retryable — and the retry then paid again: a silent double-credit with a single ledger entry. Both wallet writes now commit atomically with their ledger row, and the claim's completion commits in the same transaction as the money movement (so a claim can also no longer strand at pending after a successful mint). A regression test injects a failure after the ledger insert executes and proves the balance rolls back and the retry pays exactly once. Drafts no longer leak into the member events list. The list query never filtered publication_status, so every member saw full cards — title, image, date, organiser identity — of other members' Draft and PendingReview events; only the detail click 404'd. Members now see published events plus their own unpublished ones; tenant admins keep full visibility for moderation. Public pages now tell the truth about cancelled and hybrid events. Cancelling an event only writes operational_status, which the public projection never read — a cancelled event kept advertising itself as a normal upcoming event to anonymous visitors, sign-up button and all. And the create form only ever writes allow_remote_attendance, never the raw is_online column the public pages branched on — so every hybrid or online event created through the standard form showed as in-person-only publicly (including in search engines' structured data). The shared public projection now carries operational_status and computes attendance_mode with the member contract's exact semantics; both React pages and both accessible What's On pages render Cancelled/Postponed tags, hybrid events show venue and online marker, schema.org output uses EventCancelled/EventPostponed/MixedEventAttendanceMode, and the register call-to-action disappears on cancelled events. The kill switches now match how incidents actually run. The platform mode (EVENTS_ATTENDANCE_CREDIT_MODE) gates admin retries too — previously a retry could mint after an operator had switched minting off. And the audit ledger plus the reversal endpoint deliberately keep working with the tenant flag off, because disabling the flag is the first response to a bad batch of rewards — previously that same action locked admins out of inspecting and correcting the damage. Retry (which mints) stays behind both gates. Also fixed: CSV formula injection in the partner-venue visits export (member-controlled names now pass through the codebase's standard CsvExportSanitizer, as every other export already did); the monthly-cap input in Event Settings could not accept any value below 1 credit (keystroke coercion wiped the field on the leading "0"); staff re-checking a member in after an undo now see "reward already granted earlier" instead of an indistinguishable success toast (the credit outcome now rides the transition payload on both check-in paths, with the response schema updated in lockstep — it is .strict() and would otherwise have rejected the new field); the accessible venue pass gained the "get a new code" rotate action the React pass already had; challenge titles/descriptions get explicit length validation instead of relying on non-strict MySQL truncation; the accessible What's On pagination link no longer drops a literal "0" search term (PHP array_filter falsy trap); its check-in error states use the notification-banner pattern instead of a misused error summary; and the navigation registry's multi-feature gates are now carried through to the Navbar's secondary filter so dual-gated items can never leak through a future refactor. The monthly cap's calendar-month clock (app timezone) and the retry-mints-the-frozen-claim-amount rule are now documented decisions in code.
- Public events get their front door, and the three Coventry modules get honest labels. Signed-out visitors now see a "What's on" item in the main navigation (desktop and mobile) whenever a community has public events switched on — the navigation registry gained anonOnly and multi-feature gating to express "shown only to visitors, needs both events and public_events", with a parity test covering the new mechanism on both surfaces. A signed-in member who follows a shared /whats-on link is handed through to the full community events page instead of being shown a sign-in prompt, and the sign-in buttons on both public pages now return the visitor to the page they were reading after logging in. The three new module cards (public_events, event_attendance_credits, partner_venues) carry a Beta badge — the same honesty mechanism caring_community and courses use — and the attendance-credits card's Configure button now leads to Event Settings, where its monthly cap and claims ledger live. Spec hygiene: /v2/public/events and /v2/public/events/{id} are now documented in openapi.json, and — the part that keeps this fixed — the events OpenAPI coverage test's path matcher now includes public/events, so the spec ⇄ routes check enforces the public surface in both directions. It previously matched events… and admin/events… but not public/events…, which is exactly how the public endpoints shipped undocumented with a green gate; the regex fix was verified by watching the test fail before the spec entries were added.

### Fixed

- main briefly went red on the React navigation-registry suite after the venues nav item was auth-gated without updating the test's pinned list of authenticated-only destinations. The pin now includes venues, and the same commit adds the anonOnly/multi-feature parity coverage above. Root cause: a registry policy change landed without running its dedicated policy suite. Prevention: the registry's pinned-policy tests are now part of the standard per-workstream battery alongside layout suites.
- The accessible (GOV.UK) frontend catches up with the Coventry features: partner venues, a public What's On, and radius parity. Members on the accessible frontend get a venue directory, their visit history, and a venue pass whose QR is rendered server-side as inline SVG — no JavaScript required, in keeping with this frontend's HTML-first rule — encoding the same check-in URL as the React pass, so venue staff scan one canonical flow regardless of which frontend a member uses. Venue staff get a no-JS confirm page: the scan lands on a GET that deliberately records nothing (link-preview crawlers prefetch URLs), and the visit is recorded only by the explicit confirm POST, through the same service rules as everywhere else (staff authorization, one visit per member per day, XP and challenge progress). What's On (/{tenant}/accessible/whats-on) is the accessible frontend's first logged-out events surface: anonymous visitors browse published community events with search and upcoming/past filters, and each event page shows the venue-accessibility answers (step-free, hearing loop, quiet space…) a visitor needs before deciding to attend. It serves exactly the public projection the /v2/public/events API serves — the field allowlist was extracted to a shared PublicEventProjection class precisely so two copies of a privacy boundary cannot drift; organisers appear by first name only, and drafts, private-group events and unknown ids all return an identical 404. The service navigation now shows What's On to signed-out visitors and Partner venues to signed-in members, feature-gated per tenant. Radius parity: the accessible frontend's "near me" filters now offer the 100 km option (previously defined in translations but unreachable — the whitelist stopped at 50) and default to the member's saved match-preference radius, mirroring the React useSavedRadiusKm behaviour; an explicit choice in the URL always wins. All new strings ship in all 11 locales, including two per-module translation files, with the machine pass corrected where it produced an out-of-vocabulary Italian "Cosa succede?" (now "In programma") and untranslated Dutch/German labels.
- Partner venues are now reachable and fully manageable — previously the entire feature was invisible unless you typed the URL. Members get a "Partner venues" item in the main navigation (desktop and mobile, gated on the tenant flag) and a "My venue pass" button on the Wallet page; admins get a Partner Venues sidebar entry. The admin page catches up with its own API: a status filter (active/paused/archived), a per-venue engagement report (total visits, unique members, last-30-days) that the backend always returned but the page never rendered, CSV export with venue and date-range filters, and staff are now added by searching members by name instead of typing a raw numeric user ID. GDPR: erasing an account now revokes the member's venue pass. The pass is a standing bearer credential — venue staff can record a visit from the QR alone, no login — so like passkeys and API tokens it must not survive Article 17 erasure; previously it did. Visit history rows deliberately survive (PII resolves through the anonymised member record, the same posture as messages), and a regression test pins both halves. Also added: rate limits on every admin venue endpoint (the CSV export streams up to 20,000 rows and previously had none), HTTP-layer tests for the whole admin surface (validation, 403s, tenant isolation, CSV content), and OpenAPI documentation for all fourteen partner-venue operations.
- Challenges can now be created and managed from the admin panel — previously members could see and claim them, but no admin could create one without a database console. New Challenges page under Engagement (sidebar entry, Gamification Hub tile, /admin/gamification/challenges) with the full lifecycle: create, edit, activate/deactivate, delete — the delete confirmation warns that member progress (including completed-but-unclaimed rewards) is erased with it, because the progress table cascades on delete; deactivation is the reversible alternative. The action-type choices come from the server, not the form. Challenge progress only advances through the engagement junction, and only three actions are wired through it (partner venue visits, verified event attendance, event RSVPs) — so the form offers exactly those, and the API rejects anything else. Offering any other XP action would create a challenge stuck at zero forever while looking like a working feature. Event RSVPs now advance challenges. The "going" RSVP on both frontends previously awarded XP directly and never touched challenge progress; both now route through EngagementService, so an admin-created "Attend three events" challenge actually moves. XP is unchanged (same amount, same reference-based idempotency — the existing idempotency tests pin this). The stale ChallengeFactory — which invented columns and vocabularies that exist nowhere — now matches the real schema and draws from the same constants the service validates. Eight new backend tests cover the CRUD surface, authorization, tenant isolation, the unsupported-action rejection, the delete cascade, and the RSVP→challenge wiring.
- Attendance rewards are now fully operable from the admin UI — and the ledger gained a retry, a reversal, and a monthly budget. Until now the reward engine was sound but admin-blind: the endpoint that sets a per-event amount had no consumer anywhere in the product, so switching the module on did nothing an admin could see. Events admin now has an "Attendance reward" action on each event (amount, ceiling, per-status claim totals, and a clear notice when platform minting is off), members see an "Earn X time credits for attending" chip on event pages — on both the React and accessible frontends — and Event Settings gained an Attendance rewards section. A failed reward is no longer a dead end. Previously, if the wallet write failed during check-in, the failed claim permanently blocked that member's reward: the ledger's unique key made every later check-in report it as already paid. A later check-in now resumes a failed claim through the same money path, and admins get a tenant-wide claims ledger (GET /v2/admin/events/attendance-claims, /admin/events/attendance-rewards in the UI) with Retry for failed mints and Reverse for completed ones. A reversal records a child claim (parent_claim_id, claim type attendance_reward_reversal), reclaims the credits (member → community, transaction type event_attendance_reversal — kept ≤30 chars because transactions.transaction_type is varchar(30) and this database truncates rather than rejects), and requires a written reason. If the reclaim itself fails, the original claim is restored to completed — the ledger never claims money moved when it did not. One reversal per reward, enforced by both a conditional state transition and the child claim's unique subject key. Monthly mint cap: Event Settings now takes an optional monthly ceiling on treasury minting (attendance_credit_monthly_cap). A reward that would overshoot is recorded as a failed claim (monthly_cap_reached) without ever blocking the check-in itself, becomes payable again when the month rolls over or the cap is raised, and a reversal frees its budget. Admin retries deliberately do not bypass the cap. Twelve new backend tests cover the cap, the resume path, retry, reversal, the failed-reclaim rollback, and the HTTP endpoints; the three new endpoints are documented in openapi.json (whose events coverage test now enforces them).
- Attendance rewards ("skill gifting"): a community can now grant time credits for a verified event check-in. The claim ledger for this was built some time ago and left deliberately unreachable — EventCreditService returned disabled for every input, and EventAttendanceService threw outright if it ever returned anything else, because the funding model had not been decided. It has now been decided and implemented: the reward is minted against the community (sender_id IS NULL, transaction type event_attendance_reward — the same shape as starting_balance, community_fund and admin_grant), so no member and no organiser is debited and hosting an event is never a personal cost. Three independent switches must all be on, so no single misconfiguration can start moving credits: the EVENTS_ATTENDANCE_CREDIT_MODE=treasury env mode, the tenant's event_attendance_credits flag (default off), and a per-event amount. Any unrecognised mode still fails closed and logs at critical, exactly as before. Setting the amount is a tenant-admin action (PUT /v2/admin/events/{id}/attendance-reward) rather than a field on the organiser's own form — the community is paying, so deciding an event pays out is the community's call, and that admin action is the payer-consent step the service's contract required. The reviewed semantics, all covered by tests: paid once per member per event, guaranteed by the ledger's unique key rather than by a prior read, so a re-check-in cannot double-pay; triggered by verified check-in only, never by an RSVP, which is unverified; a flat per-event amount rather than one derived from attendance duration, because duration-derived amounts invite gaming the check-out time; clamped to a community ceiling rather than rejected, so a stale over-ceiling amount left on an old event cannot mint more than was agreed; an organiser cannot reward themselves, mirroring volunteering's self-verification block; and a mint failure never costs the member their check-in — the claim records failed for an admin to retry and the attendance stands. The interlock in EventAttendanceService was relaxed to an explicit allow-set, not removed: it still aborts on any status the reviewed writer would not produce, so a future unreviewed writer cannot slip a success-shaped value past it. A test pins every returned status against that allow-set, because adding an outcome without adding it there would break check-in. The time-credit ticket gateway stays closed and untouched — a reward for attending is not the same thing as pricing a ticket in credits. Two further guarantees worth naming. The tenant flag is read for the event's tenant rather than the ambient request context, because a queue worker or console command can be pointed anywhere — there is a test that settles an event while a different tenant is the ambient context. And with the mode off, behaviour is byte-identical to before: the existing attendance, offline check-in, idempotency and wallet suites all pass unchanged, which is the strongest evidence that enabling this for one community cannot affect any other. Fast-follows deliberately not in this change: a member-facing "earn X credits for attending" badge, an admin UI control for the amount (the endpoint exists and reports configured amount, ceiling, mode and per-status claim totals), and a per-tenant monthly mint cap with alerting.
- Public events advertising: communities can now put their published events on the open web at /whats-on. Events previously required an account even to browse, so a community had nowhere to point people who had not joined yet. A new public_events tenant flag — default off, and effective only alongside events — adds a read-only listing and detail page for anonymous visitors, with schema.org/Event JSON-LD for rich results. Registration still requires signing in; nothing public is a write path. The public payload is an allowlist, built field by field rather than by stripping keys off the member DTO. That direction matters: a denylist would silently start publishing whatever field someone adds to the shared serializer next. RSVP state, attendee lists and counts, capacity, online joining links, organiser contact details and safety/agenda internals are all absent, and the tests assert their absence precisely because no positive assertion would ever catch a future leak. Individual organisers are published by first name only — the member listing shows full names, but that audience is already inside the community, and a resident should not get their surname on the open web for offering to host a craft session. Venue accessibility is published deliberately: it is what a disabled visitor needs in order to decide whether to come, and withholding it until sign-up defeats the point. Audience rules are shared with the authenticated listing rather than reimplemented. The visibility predicate was extracted into one method now used by the member listing, the public listing and the public detail lookup, so the three cannot drift; a null viewer reaches only ungrouped events and events in active public-visibility groups, because the owner and membership branches are viewer-scoped. Public discovery additionally filters to published events. An event that would not appear in the public list also cannot be opened by guessing its id — private-group, draft and archived events all return 404 rather than 403, so the endpoint cannot be used to probe for their existence. The page lives at /whats-on, not /events, for a load-bearing reason: TenantShell chooses the route registry by path, not by auth state, so declaring a public route at /events would have handed the lighter public registry to signed-in members too and replaced their real events page. A distinct URL also gives an organisation something clean to link to. A route-gate test pins this, along with both feature gates. Sitemap and prerender exposure are deliberately not included: SitemapService::getEventUrls() and the prerender auth-required route list are two independent, intentional gates, and opening them is a separate decision. Covered by 11 backend tests (feature gate, anonymous access, field-absence, organiser-name policy, draft/archived/private-group exclusion with positive controls that the lifecycle columns really persisted, tenant isolation, and two proving the member API is unchanged) and 12 frontend tests.
- Partner venues: a member pass QR that venue staff scan to record engagement at local premises. A community can now keep a directory of partner venues — a café, a shop, a leisure centre — and record when a member is recognised at one. Each member gets a membership pass whose QR encodes a frontend URL, so staff scan it with any phone camera, land on a page that asks them to confirm, and one tap records the visit. Recording it advances XP and any admin-defined challenge keyed to the venue_visit action, and tenant admins get a per-venue engagement rollup plus a CSV export of the visit log. This records engagement only. The platform issues no coupon, prices no discount, and moves no credits or money here; a venue's offer_summary is descriptive text about whatever that venue chooses to offer on its own terms. Discount mechanics remain entirely inside the marketplace / merchant-coupon modules, which are untouched and stay off by default. The feature is a new partner_venues tenant flag, default off, with no dependency on marketplace, merchant_coupons or caring_community. The design deliberately reuses the volunteering QR check-in pattern already proven in production rather than the marketplace coupon machinery: a DB-stored 32-byte token, a QR pointing at a frontend landing page, and a deliberate human tap (never an on-load action, because link scanners and chat clients prefetch URLs — a prefetch of the scan URL therefore cannot disclose who holds the pass, since member details are only returned in the response to that tap). Staff authorisation reuses the existing typed org_members pivot under a third org_type of partner_venue, following the club precedent, so a venue can have several staff accounts on shift; the shared role enum was not altered. Three new tables carry tenant_id throughout. Members can rotate their own pass token, which invalidates the old QR. Two properties are enforced at the database level rather than in application logic. A unique key on (tenant_id, venue_id, user_id, visited_on) makes a second scan on the same day a friendly no-op instead of an error or a double count — that key is simultaneously the idempotency guarantee and the anti-gaming ceiling. And staff cannot record their own visit, mirroring volunteering's self-verification block, so the ledger stays a staff attestation rather than self-report. This also makes admin-defined challenges functional for the first time. ChallengeService::updateProgress() had existed with zero production callers, so a challenge could be created and its reward claimed but its progress never advanced from any real member action. A new EngagementService is now the single junction between an action and both reward systems (XP and challenge progress), and is the first caller of that method; both halves are fault-isolated so recording engagement can never fail the underlying visit. New action keys venue_visit and event_attendance_verified were added to both XP tables. Covered by 19 backend tests (authorisation matrix including cross-tenant token rejection, same-day idempotency proving a single row and no second XP award, the XP-plus-challenge-progress wiring, challenge completion reporting, paused-venue and self-scan refusals, staff-of-several-venues disambiguation, feature-gate 403) and 12 frontend tests, plus route-gate assertions that the pass and scan-landing routes can never resolve unauthenticated.

### Fixed

- The Spanish and Arabic "give feedback" links on the accessible frontend were dead, because a translator translated the mailto: URL scheme. Spanish rendered it as enviar correo a: and Arabic as the transliteration ميلتو:, so in both languages the footer feedback link pointed at a scheme no browser understands and simply did nothing. Both now use mailto: again. The checker that should have caught this had a hole, and that is the more important fix. hasSuspiciousCorruption() exempted anything isUrlOrEmail() recognised, and its bare-email pattern (^[^\s@]+@[^\s@]+\.[^\s@]+$) matched the Arabic corruption outright — the mangled value contains no spaces, so ميلتو:feedback@project-nexus.ie?subject=… read as "an email address" and skipped every corruption check. Spanish was only caught incidentally, by an unrelated question-mark heuristic, because it happened to contain spaces. There is now an explicit check that a translated value keeps the URI scheme its English source had, evaluated before the URL exemption, plus a bare-email pattern that rejects : and ? so a broken URL can no longer masquerade as an email. Verified by re-breaking the value and confirming the gate fails, then confirming it passes once fixed — and the scheme list is a known-scheme allowlist rather than "any word before a colon", which on the first attempt flagged ordinary labels like Error: and Rating: :value of 5. A sweep of every lang/ namespace (PHP and JSON, all ten locales) found no other mangled scheme: 10 locale values carry a real URI scheme and all are now correct. And the check is now wired into CI. scripts/check-govuk-alpha-translations.mjs existed but nothing invoked it, so its findings had been sitting unread — the Spanish breakage was being reported to no one. It runs as a blocking step in the Translation Drift Detection job. Same lesson as the token-integrity gate: an unenforced check is not a check.
- Partner venue visits now appear in a member's data export, and the pass token deliberately does not. Venue visits are movement data — where a member went and when — recorded by venue staff rather than entered by the member, which is precisely why they belong in a subject access request. The membership pass token is excluded on purpose: it is a live bearer credential, so writing it into a downloadable archive would turn a leaked export into a usable pass. Members can see and rotate the token in the app instead. Two tests cover it, including one asserting the token appears nowhere in the archive JSON.
- Three build- and routing-level gates caught real defects in the partner-venue and public-events work. Recorded rather than folded silently into the feature commits, because each is a distinct class of mistake worth seeing. venues and whats-on were added as top-level routes without being added to RESERVED_PATHS. That list is what stops a tenant slug being mistaken for an app route, so until this was fixed a community whose slug happened to be venues would have had its pages resolve to the venue directory instead. tenant-routing.test.ts derives the expected set from the router itself, which is exactly why it caught it. The two public events pages hoisted getFormattingLocale() into a local variable and passed it to toLocaleString. The locale-formatting contract requires the call at the formatting site, and it is a blocking prebuild step, so this failed the production build while tsc --noEmit was perfectly happy — a reminder that typechecking is not the build. The admin venues page also surfaced raw server error strings in toasts and used raw database values (venue.status, row.role) as translation fallbacks. Both are barred for admin UI; every status and role the API can return already has a key, so the fallbacks were unreachable anyway. Finally, the new GET/PUT /api/v2/admin/events/{id}/attendance-reward endpoints are now documented in openapi.json. An event route that exists but is undocumented fails EventOpenApiCoverageTest, which asserts the live route table and the published spec match in both directions — so the spec cannot drift from reality in either direction.
- Every "near me" filter opened at a hardcoded 25 km, ignoring the search distance the member had already saved. match_preferences.max_distance_km has existed for some time — editable on the Matches preferences page, clamped to the tenant ceiling server-side — but nothing outside that one page ever read it. So a member who set their radius to 50 km still got 25 km on Listings, Events, Volunteering and Members, and had to change it again on every page, every visit. A new useSavedRadiusKm hook is the shared reader. It fetches the preference once per session (memoised at module scope, because several filters can mount on one page and this must not become N identical requests), and snaps a saved value to the nearest offered option so a preference of 30 km opens the dropdown on 25 rather than showing a blank selection. Changing the radius in any filter now writes it back, so the next page opens on the same distance. Clearing filters returns to the member's saved radius rather than the platform fallback — clearing a filter should not discard a preference. Two wiring points, because Members hand-rolls its distance control instead of using the shared ProximityFilter: fixing the component covers Listings, Events and Volunteering together, and Members needed the hook separately (it adopts the saved value only if the member has not already changed it on that page). The caring-community copy of ProximityFilter is deliberately left alone — its only consumer sits behind a module that is off by default. One defect found while testing rather than after shipping: the write-back was documented as fire-and-forget but only guarded a rejected promise, so a synchronous throw from the API client propagated into whatever UI flow had changed the radius — it broke the mobile filter-apply flow outright. Now guarded both ways, with a regression test that makes the client throw synchronously.
- A safeguarding refusal shown after a blocked send attempt vanished about five seconds later, taking the member's only explanation with it. When MessageService::send refuses a direct message on safeguarding grounds, ConversationPage replaces the composer with a panel naming the reason (vetting required, coordinator-mediated contact) and what to do next. Two background 5-second timers then overwrote that panel: the message poll, and the blocked-policy recheck. Both read the preflight safeguarding meta, so on any conversation where the preflight says "allow" they cleared both the panel and the composer gate — the member saw the refusal for a few seconds, then it disappeared and the composer came back with no indication anything had happened. Retrying just produced the same refusal again. The two answers can legitimately disagree, and the send-time one is the authoritative one. MessageService::send re-evaluates the gate a second time inside the write transaction, against locked tenant-scoped rows (MessageService.php:636, documented there as the "Definitive write check"); the preflight state in getConversation() — the safeguarding meta every GET returns, the poll included — comes from the unlocked read. A background "allow" is therefore weaker evidence than the denial the member's own send attempt just returned, so it no longer overturns it. Scoped deliberately narrowly. Only an explicit allow arriving on a background timer is held back, and only against a source: 'send' notice. A background "unavailable" still applies, so fail-closed behaviour is intact. Preflight restrictions still auto-clear on the recheck interval, which is what that interval exists for — a recipient can withdraw a contact preference while someone else has the conversation open. And the member is never stranded: the panel's own "Check again" button, returning focus to the tab, and a visibility change are all treated as authoritative re-asks and still unlock the composer immediately. Found as a latent second instance of the poll-overwrites-the-gate family fixed below, and deliberately split out because it is a product decision about safeguarding semantics rather than a test-mechanics repair. It was confirmed as a real behaviour rather than only a test artefact: with the panel on screen, one 5-second poll erased it. Two regression tests cover both halves of the rule — the denial survives both background timers firing, and an explicit recheck still clears it. The first fails without the fix. Verified deterministic with the poll interval forced to 50 ms across three consecutive runs; that margin is what surfaced a one-commit window where the provenance of the notice was not yet recorded when a poll landed, now closed at the point the notice is set.
- A single failed background message poll could lock a member out of their own composer and show them a safeguarding warning. ConversationPage polls for newer messages every 5 seconds, and that handler applied the response's meta.conversation.safeguarding to the composer gate before checking whether the request had succeeded. The API client resolves rather than throws, so a 503, a maintenance window, an expired session or a failed token refresh arrives as { success: false } with no meta at all — and the safeguarding evaluation reads absent policy information as fail-closed "unavailable". The composer was therefore replaced by the "policy could not be evaluated" panel on a transient network blip, recovering only when the 5-second recheck next succeeded. The poll now moves the gate only when the response actually carried a conversation payload, matching the sibling refreshSafeguardingPolicy which already checked this. Nothing is loosened. MessagesController::show() returns meta.conversation on every response including this poll, so a genuine revocation still arrives and still locks the composer; the authoritative preflight paths (loadConversation, plus refreshSafeguardingPolicy on window focus, visibility change and its own interval) remain fail-closed; and send-time enforcement was always server-side, so an open composer is not itself an authorisation. Only responses carrying zero policy information are now ignored. This was found while root-causing the CI-only ConversationPage.test.tsx flake that had reded main twice and was being masked by --retry=1. The test failure was never a timing lottery: three tests mocked api.get with URL branching that never modelled the polling URL, so the poll answered with a payload contradicting the state under test, and a second 5-second interval disagreed on the same deadline — so the gate oscillated rather than failing once, which is precisely why a retry never rescued it. Those mocks are now a single total helper driven by one source of truth, mirroring the real endpoint. Verified deterministic with the poll interval forced to 50 ms (~100 polls per test), and a new regression test pins the component fix, which it fails without.
- Claiming the same challenge paid out differently depending on which frontend you used; both paths now award through one shared routine. The accessible frontend awarded XP, the challenges.badge_reward badge and a notification bell; the React path (POST /api/v2/gamification/challenges/{id}/claim) awarded XP only, ignoring badge_reward entirely and sending no bell. So a member who finished a challenge with a badge attached got the badge on one frontend and nothing but XP on the other — and which one they got depended on where they happened to click claim. React was confirmed as the incomplete path rather than assumed to be. badge_reward is a real varchar(50) column on challenges, and ChallengeService is the only place a challenge badge is awarded anywhere in the codebase — every other awardBadgeByKey() caller belongs to a different subsystem (badge collections, course completion, leaderboard seasons, two admin grant endpoints), so nothing else was quietly granting these badges and unifying the two paths cannot double-award. The two challenge bells are also distinct and fire at different moments: complete_claim ("claim your reward") when progress completes, complete_earned when the reward is claimed. Adding the claim bell to the React path therefore does not duplicate the completion bell. ChallengeService::awardChallengeReward() — previously private, which is precisely why the React path grew its own reward logic — is now public and is the single award routine both paths call. It returns what the challenge is configured to award, so the API response reports reward.badge alongside reward.xp instead of pretending XP is the whole payout. Two smaller divergences at the same site went with it: the React path left user_challenge_progress.claimed_at NULL where the accessible path stamped it, so the two frontends were writing different ledger rows for the same event; and the tenant + existence check is now single-sourced through a new ChallengeService::getModelById(), which both paths use. That accessor exists because the reward needs the model — resolving a challenge into an array is how badge_reward got dropped silently in the first place, since a caller only copies the fields it knows about. The XP ledger description is unified on Challenge: {title} (the React path previously wrote Completed challenge: {title}). This completes a decision deliberately deferred: the earlier claim() repair aligned the two paths' claim semantics (progress row required, completion required, conditional UPDATE against double-pay) and kept awardChallengeReward() only so badge_reward would not become dead code, leaving the rewards themselves for a separate call. This is that call. Regression cover is real-database, in tests/Laravel/Feature/ChallengeRewardParityTest.php: four tests comparing a normalised snapshot of everything a claim writes — XP ledger rows, badge keys, bell counts by type, the resulting total, and the claim ledger — between a member who claims via HTTP and one who claims via the service, for a challenge with a badge and for one without, plus the API's reported reward and a both-paths double-claim refusal. The badge key is taken from the live definition list and asserted to resolve, because awardBadgeByKey() silently no-ops on an unknown key: a hardcoded key that did not exist in a given environment would make both paths award no badge, and two empty snapshots compare equal, so the test would have passed vacuously on exactly the bug it exists to catch. Run against the pre-change code, three of the four fail — on the missing badge, on the missing notification (caught by the no-badge case, where XP alone was identical), and on the API reporting null for the badge.
- The two remaining ChallengeService methods were written against an imagined schema; repaired against the real columns rather than deleted. create() and getAll() are the same class of defect as the claim() fix below, in the same service — and they survived that pass because neither has a caller. Between them they referenced four columns the challenges table does not have: status, category, starts_at and ends_at. The real columns are is_active, challenge_type, start_date and end_date. The two methods failed in opposite and equally unhelpful ways. getAll() filtered on status and category, so either filter threw SQLSTATE[42S22] Unknown column on the count() before a row was read — and unlike most of this codebase that method has no catch-all, so the first caller to pass one would have taken an uncaught 500. create() was the quieter half: none of its four phantom keys appear in Challenge::$fillable, so Eloquent discarded all four silently instead of failing, and a caller setting a category would have watched the write succeed and the value evaporate. Under the non-strict session sql_mode this app runs (config/database.php sets strict => false) that failure mode leaves no trace at all. create() was also broken independently of any phantom column, which is the part that would have bitten first: action_type and end_date are both NOT NULL with no default, and both defaulted to null here, so every call threw 1048 Column 'action_type' cannot be null. Both are required payload keys now and are validated up front, so a bad payload fails naming the field instead of surfacing an integrity-constraint error from inside the driver. challenge_type is checked against its enum for the same reason the event- and tandem-status fixes needed it — under this sql_mode an out-of-enum literal truncates to '' and persists as a challenge nothing can ever match. starts_at/ends_at survive as input aliases only, so a caller written against the old shape keeps working and its dates actually land in start_date/end_date. getAll()'s status key is likewise kept as an explicit alias onto is_active rather than dropped, because a filter that silently matches everything is the same trap in a new place. Its tenantId argument was decorative — the result depended entirely on ambient TenantContext — and is now an explicit predicate alongside the global scope, matching getById() and claim(). Repairing was chosen over deleting: both methods are public API with existing coverage in two test files, they are the obvious admin-CRUD pair for a table that has read and claim endpoints but no management endpoints, and the next person to wire up challenge administration is precisely the person the trap was set for. npm run check:db-columns cannot see either defect, by design — Eloquent mass-assignment and where() are deliberately outside its scope, since where() can legitimately name a joined table's column. Its tracked count is unchanged at 17. This was found by reading the CREATE TABLE in database/schema/mysql-schema.sql against the code, and confirmed against the live nexus_test table, which matches the dump exactly here. Regression cover is real-database, in tests/Laravel/Feature/SchemaWriteRegressionTest.php (FIX 10): eight tests covering the write of real columns only, the refusals for each NOT NULL column and the enum, the status→is_active and challenge_type filters, and tenant scoping. The strongest of them asserts that a row created by create() is then visible to getChallengesWithProgress() and getActiveChallenges() — a row with the old null-ish dates would have inserted and been permanently unreachable. The pre-change code was run first to confirm the tests watch something real: getAll() threw Unknown column 'status' and Unknown column 'category', and create() threw 1048, so neither method could execute at all.
- The React locale translator's --google path could silently eat {{placeholder}} tokens; fixed, and a stray literal {{count}} already showing to members in six languages removed. translate-i18n-gaps.mjs masked each {{token}} as <nexus0/> before sending text to the engine and restored it with a regex requiring the literal word "nexus". Google Translate translates the tag name — it returns <nexo0/> for Spanish and Portuguese and <lien0/> for French — so the restore missed and the placeholder was lost or left on screen as a literal tag. The identical bug cost 207 values in lang/govuk_alpha.php before it was fixed in the sibling PHP translator; this file still carried it, so the next --google run over the React locales would have reproduced it. The three protections from the sibling script are ported across. The mask is now <x0/> — a single meaningless letter gives an engine nothing to translate. Restore matches by index with a tolerant tag name (/<\s*[A-Za-zÀ-ɏ]{1,10}\s*(\d+)\s*\/?\s*>/g), treating the digits as the token's identity and the name as decoration, and falling back to the matched text when the index is unknown. Most importantly, the placeholder multiset of every result is now compared against the English before it is written, at the merge point in main() so that Google, DeepL and OpenAI are all covered by one gate rather than each backend policing itself: on mismatch the English is kept and the value is reported. A missing translation is recoverable, whereas a dropped {{count}} renders as literal text in a member's face. DeepL's ignore_tags list was renamed to match the new mask. Verified by round-tripping all 4,031 placeholder-bearing values in locales/en/*.json (zero failures), by replaying the observed failure shapes — <nexo0/>, <lien0/>, <Nexo0 />, spaced and slashless variants — through the new restore, and end-to-end against a stubbed engine that both renames the tag and drops a placeholder, confirming the renamed tag still restores and the dropped one is refused with the English kept. Auditing the existing locales for damage from past runs found no mask artefacts in any of the 552,368 values across the eleven languages. It did surface a different placeholder defect: kb.json's feedback.yes / feedback.no carried a trailing ({{count}}) in German, Spanish, French, Irish, Italian and Portuguese, while English and the other four locales carry the bare word. The article page appends the count itself in JSX and passes no interpolation values, and i18next's skipOnVariables default leaves an unfilled placeholder in place — so those six languages were rendering "Ja ({{count}}) (3)" on every knowledge-base article. The suffix is removed, and the French and Irish values, which were still the English "Yes"/"No", are translated.
- A quarantined admin test suite was failing for a recorded reason that was not the real one; fixed and returned to the blocking gate (quarantine 55 → 54). GdprConsentTypes.test.tsx was listed as a "delete-confirm dialog race", pointing at the known HeroUI slot="close" hazard — but that hazard only manifests in a real browser, never in jsdom, so it could not have been the cause here. Both delete tests located the confirm button by textContent === 'confirm', while the page passes confirmLabel={t('enterprise.gdpr_delete')} and the test setup loads the committed English locale files, so the button actually reads "Delete". The lookup returned undefined and an if (confirmBtn) guard turned that into a silent no-click: the test failed having exercised nothing, and no amount of work on the dialog would have moved it. ConfirmModal's own tests passed the whole time because they never override confirmLabel. ConfirmModal's action buttons now carry stable data-testid values (confirm-modal-confirm / confirm-modal-cancel) and the tests target those rather than a translated label — the same i18n-independent approach used for the earlier assertion cluster, and available now to the ~40 other admin pages that use this modal. One of the two tests also called waitFor(() => document.querySelector(...)), which resolves on any returned value including null and so never actually waited. Verified 12/12 with --retry=0 so the suite is not merely retry-rescued, alongside ConfirmModal's own two suites (23/23) and four consumer suites (41/41) to confirm the shared-component change is additive. This also means the two frontend tests added with the consent-type fix below are now genuinely enforced by CI; while the suite was quarantined they were excluded from the shard job.
- All three live schema defects fixed, and every one turned out to be the code being wrong rather than the schema. No table was created and no column was added; in each case the working store already existed and the broken code was writing somewhere imaginary alongside it. The gate's tracked list shrank from 22 entries to 17. Creating a GDPR consent type now works, and the consent-types page is no longer permanently empty. consent_types is a platform-global catalogue: slug is its unique key, tenant_consent_overrides and consent_version_history are foreign-keyed onto it, and per-tenant customisation is what the override table is for — GdprService has always resolved the effective version by COALESCEing a tenant override over the global row. Adding the missing tenant_id would have broken the unique key, both foreign keys, and duplicated the override table. So the column came out of the code instead. The blast radius was wider than the one flagged INSERT: the list endpoint filtered on ct.tenant_id too, threw, and returned [] from its catch, so the admin page had been showing "No consent types" since March rather than showing an error; update and delete filtered on it and returned 500; and the separate GDPR consents list used it in a subquery and was also permanently empty. The INSERT additionally omitted current_text, which is NOT NULL with no default and would have failed the insert on its own. Because the catalogue is shared, creating, editing or deleting a consent type is now restricted to a platform super admin — those three writes change GDPR definitions for every community on the installation, and a delete cascades away every other tenant's overrides and version history for that type. Reading the catalogue stays open to any tenant admin, with the consent counts scoped to their own community, and the admin page hides the three controls rather than offering buttons that would 403. Claiming a challenge on the accessible frontend now works. The claim ledger is user_challenge_progress.reward_claimed, which the React path has always used; ChallengeService::claim() was an orphan reimplementation against a challenge_claims table that has never existed in any schema. Creating that table would have been the wrong fix twice over — the method also never awarded anything, so it would have converted a visible failure into a silent one. It is reimplemented on the real ledger with the same semantics as GamificationV2Controller::claimChallenge(): the member must have a progress row, it must be completed, and the flip to claimed is a conditional UPDATE so a double submit cannot pay out twice. A second phantom reference at the same site is also gone — the method filtered on challenges.status, a column the table does not have, which would have kept it throwing even after the ledger was corrected. Member category preferences were already being saved. The flagged match_preference_categories sync was vestigial: the canonical match_preferences.categories JSON column — the one the matching engine actually reads — is written and read correctly, and the side-table block sat below it swallowing its own failure at debug level. The dead blocks are removed rather than backed by a new table. The dead MatchingService::recordInteraction duplicate went with them: it wrote match_history.score/.distance (really match_score/distance_km), omitted tenant_id entirely, and had no callers, since every live interaction goes through MatchLearningService::recordInteraction. Two more things surfaced on the way and are fixed rather than left. AdminEnterpriseController called Log::error() in two catch blocks without ever importing the facade, which is live in production, not merely latent: PHP raises Error: Class "App\Http\Controllers\Api\Log" not found, and because an Error is not an Exception it escapes the enclosing catch (\Exception $e) entirely — so the handler meant to return a structured 500 instead produced an unhandled fatal and lost the log line that would have explained it. One of the two sites is the GDPR export failure path, which has nothing to do with the three defects above and would have failed this way on any export error. PHPStan had known about both since they were introduced and was carrying them in the baseline as class.notFound with count: 2, which is why nothing ever surfaced them; the import is added and that baseline entry is removed. And the consent-types page's catch was dead, because the API client resolves rather than throws; a failed load rendered as an empty list, which is the same shape as the bug being fixed, so it now checks res.success and surfaces the error. The list endpoint likewise stops returning [] on failure. Regression cover is real-database, in tests/Laravel/Feature/SchemaWriteRegressionTest.php (FIX 7–9): the consent-type insert, the catalogue listing with tenant-scoped counts, a tenant admin being refused, the claim flip, claim idempotency, refusal before completion, and category round-tripping. All eight were run against the pre-change code to confirm they actually watch something: the six consent-type and claim tests fail without the fix (the three claim tests on Unknown column 'status', before the phantom claims table is even reached), while the two category tests pass either way — that change removes dead code rather than altering behaviour, so they lock in the existing contract instead of proving a repair. Two existing ChallengeService unit tests had mocked challenge_claims — enshrining the phantom table and keeping the broken path green — and were rewritten against the real ledger.
- All 22 flagged database references triaged: none is schema drift, three were live, and one was the checker's own fault. All three live defects are fixed in this same release — see the entry above; this entry records how they were found and ruled on. Each was tested against the live database through information_schema rather than only the committed dump, because "the code is wrong" and "the dump has gone stale" need opposite responses. Every one is absent from the live database too — there is zero dump drift here. The query was proved to discriminate first, with positive controls (users.email, group_content_flags.resolved_at, match_history.match_score, post_hashtags.hashtag_id, user_skills.skill_name) all returning present; a check that answers "absent" to everything is indistinguishable from a broken one. The live database does carry 727 tables against the dump's 723, so drift exists — just not in any of these. Three were reachable in production (all three now fixed — see the entry above): Creating a GDPR consent type had never worked. POST /v2/admin/enterprise/gdpr/consent-types inserted a tenant_id column into consent_types, which does not have one — consent types are global, so the per-tenant assumption was wrong rather than the column name being a typo. The insert threw, was caught, and returned HTTP 500 to every tenant admin, every time. Claiming a challenge on the accessible frontend had never worked. ChallengeService::claim() both read and wrote challenge_claims, a table in neither the dump nor the live database, so it always threw; the caller reported to Sentry and redirected with status=challenge-claim-failed. And the previously-found match_preference_categories. This one was over-called here: fixing it established that the canonical match_preferences.categories JSON column was already being written and read correctly, so category preferences were not in fact being discarded — the side-table block was vestigial code swallowing its own failure, not a live data-loss bug. It is counted as live because the reference was real and reachable, not because members lost preferences. Sixteen are unreachable — real mismatches on code paths with no callers and no route, several of them mutually confused (DeliverableService writes deliverable_comments.body while DeliverableController writes content, and neither column exists). They are still wrong and still recorded, but nothing hits them. Establishing that needed care: a first pass matching bare method names credited DeliverableService::create() with 267 callers, because every ->create( in the codebase matched. Qualifying by class collapsed most to zero — AdminListingsService::approve looked live until the controller turned out to inject ListingModerationService, a different class. One was my checker being wrong. PasswordResetController writes users.password_changed_at — but guards it with SHOW COLUMNS FROM users LIKE 'password_changed_at' first, so the write never runs without the column. That is a deliberate optional-column shim, and flagging it would have pushed someone to delete a compatibility guard. The gate now recognises runtime existence checks and drops it. Getting that exemption right took two attempts, and the first was worse than the bug. Treating any guard anywhere in a function as excusing every write in it silenced 505 of 8,442 checks at a stroke. Requiring the guard to name the specific identifier recovered most, and a second flaw remained: a Schema::hasTable() guard proves the table exists, not the column, so allowing it to excuse a column mismatch lost 335 more checks. Column mismatches now require a guard naming the column. Coverage settled at 8,283 checks with 159 genuinely guarded writes skipped — and all three mutation tests were re-run afterwards, because an exemption is exactly the kind of change that can quietly turn a gate into decoration.

### Added

- A blocking check that PHP cannot write a column which does not exist. Following a service found writing three non-existent columns for four months, every place in app/ where both the table and the column are written as literals — 8,442 of them across 1,733 files — is now verified against the committed schema dump on every push. It runs in the Migration Safety Gate, which is in the release gate's dependency list, so a failure genuinely reds the build. It parses the committed dump rather than connecting to a database. A gate that needs a database can pass vacuously on a runner whose env config points somewhere empty, and "0 problems found" then means "found nothing to look at" — this way a green result means the same thing on CI and on a laptop with Docker stopped. Two design choices are deliberate. Precision over recall: the first draft scanned a fixed window after ->update( and swept up keys from return arrays as if they were columns — 93 hits, almost all noise. It now walks the argument list with bracket matching and reads keys only where a column name is the only thing a key can be. where(), orderBy() and select() are out of scope entirely, because those can legitimately name a joined table's column and one false positive is how a gate gets switched off. And the baseline is enforced in both directions: an entry that no longer occurs also fails, so a fix cannot land without removing its entry, and the list can only shrink. The gate was mutation-tested rather than assumed: it catches the three real group_content_flags columns when pointed at the pre-fix file, reports zero on the fixed one, fails when a bad column is injected, fails when a still-present entry is removed from the baseline, and fails when a phantom entry is added. The scan found 21 candidates, of which four are verified. HashtagService::syncTags writes post_hashtags.tag where the column is hashtag_id; MatchingService::recordInteraction writes match_history.score and .distance where they are match_score and distance_km — and a migration written in March says in its own header that it added the columns "that MatchingService writes to", so the fix landed in the schema and never in the code. All three are dead: syncTags has no callers, and the live interaction path is MatchLearningService::recordInteraction, which uses the correct columns. The fourth is live: MatchingService::savePreferences, reached from the accessible frontend, syncs to a match_preference_categories table that exists in no migration and no dump, inside its own try/catch, so a member's category preferences are silently never saved. The remaining 17 surfaced when the scan was widened from the fifteen services of one 2026-03-20 commit to all of app/. They are recorded as untriaged and explicitly marked not to be quoted as defects: the pattern match is sound, but nobody has yet confirmed whether the code is wrong or the committed dump has drifted from the live database, and that distinction has bitten this project before.
- Why catch (\Throwable) is the reason any of this was invisible, written down. The idiom is pervasive here — 2,882 occurrences across 540 files, 350 of them returning a falsy default from the catch — and it is not a smell to be stamped out. But it converts a schema error into a success-shaped value: three methods returned null, false and [], all of which read as "nothing to do" rather than "broken", and the tests covering them asserted the swallowed value. The agent guide now says plainly that when auditing a service whose methods each swallow Throwable you must assume nothing works until proven, and that a new catch-all must not turn a contract error into a success. Since the idiom cannot be removed, the static check above is the compensating control.

### Changed

- The nine admin strings PHP actually reads are now translated, and the 38,743-value dump around them is gone. The admin translation namespace existed twice: once as the React admin locales, and once as a 3,981-key PHP copy of them per language. The PHP copy accounted for 93% of the entire untranslated-value debt — 38,743 of 40,191 values — and almost none of it was reachable. Nine keys are: four Gmail-API status messages in the mailer, and five member-statistics labels in the CRM export. admin_nav and admin_dashboard had zero call sites in the whole codebase. So the PHP copy shrinks to exactly those nine keys in all eleven languages, hand-translated rather than machine-filled, and the two dead namespaces are deleted outright (22 files). The untranslated ratchet falls from 40,191 to 1,448 — a 96% reduction — and what remains is small enough that every value in it is real work rather than noise. One thing the debt ledger had wrong, and it mattered: those nine keys were never being served by the PHP files at all. The __() helper asks the JSON translator first and only falls back to Laravel's .php loader, so the live values come from lang/<locale>/admin.json — where all nine were still verbatim English in every one of the ten non-English languages. Hand-translating only the PHP side, as planned, would have translated a path nobody reads and left the visible strings in English. Both sides are now translated and both are held by tests, including one asserting that no non-English locale's live value is byte-identical to English — a regression the untranslated ratchet structurally cannot see, because it only scans .php files.
- The remaining untranslated debt is down to 249 values, and the allowlist that got it there now has to prove itself. After the admin shrink, 1,448 values were still byte-identical to English, of which 186 were distinct. Most were not work at all: 864 occurrences are strings with nothing in them but placeholders (:community alone accounts for 680), and the rest are language endonyms — which are written the same in every language by definition — plus units, brand names and a feedback mailto: link. Those go on the invariant allowlist globally. Single-word borrowings that are genuinely the same word in one language but not the others go on it per language. The judgement in that second group is exactly where an allowlist rots into a suppression list, so it is no longer a matter of trust. Each per-language entry had to pass a mechanical check: that language never renders the same English value differently anywhere else in lang/. A counter-example means a translator did not, in fact, leave it alone. This is what separates the entries that are there from the ones that are not — Status is invariant in Dutch, where all thirty occurrences are identical, but German, Polish and Portuguese also produce Stand, Stan and Estado, so all three stay counted. Irish gets no entries at all: it borrows far less than the continental languages, and every one of its remaining values is genuine work. The check now runs inside the gate and fails the build, so a future entry that contradicts the lang files cannot be added — and because a bad entry lowers the count, it is checked before the baseline can be written, or the suppression would be permanent the moment it was introduced. What remains is 249 values that are all real translation work: the Irish plural forms, and about sixty single words that the language in question demonstrably does translate.

### Fixed

- A group ban check that reported "not banned" for everyone is gone, and the flag writes next to it were dead for the same reason. GroupModerationService::isUserBanned() queried a group_bans table that has never existed — no Laravel migration, no legacy SQL migration, no entry in the schema dump — inside a catch (\Throwable) that logged a warning and returned false. It had no callers, so no live control was being bypassed, but it would have handed its first caller a silent "allowed" on every call. It is removed rather than implemented: platform-wide group bans were never a feature. The real, enforced ban is per-group — group_members.status = 'banned', checked by GroupService at join and on membership resolution — and standing up a second, platform-wide ban concept is a design decision with its own admin surface and appeal path, not a cleanup. The same defect was in three more methods, in a form that hides better. They query group_content_flags, which does exist, but wrote columns that do not: updated_at (the table has none), plus moderated_at and action_taken where the real columns are resolved_at and moderation_action. Every insert and update therefore threw and was swallowed by the same catch-all, so flagContent() always returned null, moderateContent() always returned false, and getModerationHistory() always returned [] — content flagging had never worked at all. The column names are corrected against the live schema, and moderateContent() now scopes both its SELECT and its UPDATE by tenant_id, which it did not (it matched on id alone, across tenants). Guarding the class of bug rather than the instances: two tests now assert that every table the service queries has a CREATE TABLE in the schema dump, and that every column its write paths touch exists on group_content_flags. Both read the committed dump rather than a live database, so they cannot pass vacuously in a shard with different environment config. The parser was mutation-checked against the live table to confirm it returns all thirteen real columns and rejects the three phantom ones. Two things the docs asserted that the code does not. docs/modules/groups.md listed group_bans as a real table in the schema table and described the ban check as a control; both are corrected, along with a test-coverage line claiming a "ban check" test that did not exist. More consequentially, the guide presented GroupModerationService as the groups moderation path. It is unreachable — zero callers anywhere in app/ — and the live admin endpoint GET /v2/admin/groups/moderation reads the platform-wide reports table instead, touching neither the service nor group_content_flags. A reader following the guide would have instrumented the wrong table. Left alone deliberately: the vestigial group_user_bans table, which survives in the schema dump without a user_id column and so cannot record which user is banned; nothing reads or writes it, and dropping a table is the owner's call.
- Cross-cooperative hour transfers, hour gifts and federation peer registration no longer refuse members in English. Forty-eight refusal messages across three services were hardcoded English strings thrown as exception messages. The controllers on both the member and admin sides pass $e->getMessage() straight into the error response, so every one of them reached the user verbatim — and each sat next to a translated fallback in the UI, which meant the interface looked localised while never being localised. "Insufficient banked hours", "You cannot gift hours to yourself", "Destination cooperative must be different from source" and forty-five others now come from lang/en/api.php and are translated into all ten other languages. All three services are now held by the localisation regression test, so the English cannot come back. The test was run against the pre-change code first to confirm it actually fails there: four failures across the three services, none afterwards. The plan for this work described eight of those strings wrongly, and translating them would have broken federation. They looked identical to the rest — string literals assigned to an error key in a service — but five of them are the error field of the JSON response the inbound transfer endpoint returns to a peer install, not to a person, and three others only ever reach a log line. One of the five, signature_invalid, is what the controller compares against to answer 401 instead of 422. A remote deployment we do not control reads these. They are now named constants with a comment explaining why they are not text, the controller compares against the constant rather than a repeated literal (a typo there would have silently downgraded an invalid signature to 422), and a test pins each published value. That keeps the sweep's "no literal assigned to error" rule strictly enforceable without an exemption list — which is the kind of list that grows until it hides a real defect. Two translation repairs worth noting, both in the same class of error: Google capitalised a sentence-initial peer_slug / base_url / shared_secret in five places, but those are literal request field names an integrator has to type, not words; and Irish rendered "hyphens" as Ceachtanna ("lessons"). Machine translation of technical strings needs the field names checked afterwards. The remaining tail is 414 prose exception throws across app/Services/, unchanged in character: the largest are in the login and SSO flows, which reach the user through a different surface and need their own analysis before being touched.
- Stories, municipal surveys, member support levels and paid push campaigns stop refusing in English too. Another 51 hardcoded refusal messages, on the same path: thrown by the service, passed through $e->getMessage(), rendered to the user verbatim. 28 new keys, and four messages wired to story_not_found / story_expired / paid_push_campaign_not_found / member_premium_tier_not_found keys that already existed unused rather than duplicating them. All four services are now under the regression test — which brings it to nine services — and were proven failing before the change, one failure per service. Two messages put a raw database id in front of a user — Tier 7 not found, User 42 not found. Those are the only places the English wording changed; the id is a log detail, not something a member can act on, and the translated messages name the thing rather than its primary key. Everywhere else the original wording is preserved exactly, including phrasings a copy editor would want to fix (title is required). That was a deliberate reversal: the improved wording had been written first, and it broke ten assertions in the services' own test suites, which match on the literal English text. Rewording user-facing strings is a separate decision from making them translatable, and mixing the two turns a localisation change into a behaviour change that has to be argued route by route. The messages now come from lang/en/api.php, so the wording can be improved later in one place, in every language at once. Two throws are deliberately left in English: the Stripe webhook path's recordEvent invariants. Only Stripe's delivery log and ours ever see them. Adding lang keys would have put two more values into eleven locale files that nobody will read — the exact shape of debt the admin-namespace shrink removed 38,743 of. They are private named constants, so the "no hardcoded English refusal" rule stays enforceable without an exemption list.
- The OAuth token mint returned 500 in every test, which is why the platform's sharpest authenticated surface had no test at all. CorsHelper::handlePreflight() read $_SERVER['REQUEST_METHOD'] directly. PHP-FPM always sets it, so production was never affected — but Laravel's test HTTP kernel dispatches a Request object without writing the superglobal, so the read raised "Undefined array key REQUEST_METHOD" and every controller calling that helper answered 500 under test only. The one controller that does is the v1 federation OAuth token mint, which is deliberately outside the federation authenticator because it has to be reachable to exchange credentials for a token. Any attempt to write a feature test for it died inside CORS handling before reaching the endpoint, so nobody had. It now reads the method from the Request when there is one and falls back to the superglobal otherwise. Production behaviour is unchanged; the audit below is what the fix bought. Root Cause: a static helper reached for a superglobal instead of the framework request, so its behaviour depended on how PHP was invoked rather than on the request. Prevention: the regression test asserts preflight is still detected with the superglobal absent, and the twelve federation audit tests now exercise the endpoint end to end — the same assertions returned 500 before this change and the correct status after. A second copy of the class carries the same line and is tracked separately rather than folded into this change; it was described here as unused, which turned out to be wrong — see the entry below.
- The second copy of CorsHelper was not unused — it is the one on the hot path, and the fix above had reached only the quieter of the two. App\Core\CorsHelper and App\Helpers\CorsHelper have been byte-similar duplicates since the 2026-03-20 src/ inlining refactor split them, and the App\Helpers one is live in two places: EnsureCorsHeaders, prepended as the outermost middleware, calls its isOriginAllowed() on every API response that Laravel's HandleCors did not already stamp; and AppServiceProvider calls its getAllowedOrigins() at boot to merge tenant custom domains into config('cors.allowed_origins'). The App\Core copy — the one that got fixed — has exactly one call site, the legacy_v1 federation preflight. So the REQUEST_METHOD read is now fixed in both, and the regression test covers both rather than one. The copies had also drifted in the opposite direction, which is the more consequential half of this. The CORS subdomain hardening of 2026-04-12 — which replaced "accept any subdomain of an allowed host" with an explicit CORS_ALLOWED_SUBDOMAINS allowlist that also rejects nested labels such as a.b.project-nexus.ie — was applied to App\Core\CorsHelper only. It never reached the copy that actually serves the request path, so the hardening does not currently apply to it. That is deliberately left unchanged here: it alters origin-acceptance behaviour on the outermost middleware for every API response, and it belongs to its own reviewed change rather than to a duplication cleanup. It is recorded in the class docblock so it cannot be lost again. Worth stating for whoever consolidates these: the two are not interchangeable. App\Helpers::getAllowedOrigins() merges tenant custom domains from the database and App\Core's does not, and AppServiceProvider depends on the merging behaviour — so collapsing onto the App\Core implementation without reconciling that first would silently stop tenant custom domains passing CORS. Root Cause: a duplicated class, where each fix landed on whichever copy the author had open — twice, in opposite directions — so neither copy was ever the correct one. Prevention: the preflight regression test is now data-driven over both copies, and a third test discovers CorsHelper copies from disk and fails if one exists that the provider does not cover, so a future duplicate cannot inherit the trap silently. Each fix was verified by reverting it and re-running: the App\Helpers assertions fail with Undefined array key "REQUEST_METHOD" and pass once restored.

### Security

- The overall panel's entry guard was still turning away the very people it had just been opened to. Hiding the platform-only sections from the panel's menu was not enough on its own. The guard on the panel door still required a platform-wide administrator, so a community administrator would have clicked the newly added "Super Admin Panel" link and been bounced straight back to the ordinary admin screen. A link that goes nowhere is worse than no link. The door now admits both kinds of administrator, exactly matching who the server will serve. And the platform-only screens inside — platform income, pricing, the federation controls, the provisioning queue — now refuse politely on their own account, rather than relying on being hidden from the menu. A hidden menu item is a convention, not a lock: a bookmark or a pasted address still opens the page, which then fires request after request that gets refused, and looks broken rather than closed. A community administrator arriving that way is now returned to their own dashboard. To be clear about what was ever at risk: nothing. The server has been the authority throughout, it is split into the two tiers, and twenty-four tests prove a community administrator is refused every platform-wide action. This was about refusing cleanly instead of failing messily. Ten further tests cover the two guards, including that a community administrator genuinely gets in, that the flag alone is not enough without the server's say-so, and that an administrator whose sign-in details predate the new field still keeps full access.
- The overall administration panel is now split in two, so a community with communities beneath it can have its own — seeing only its own branch. This was always the intent, and most of the machinery for it was already built and correct: the tree, the branch matching, and the confinement on the important listings. What was missing was any separation between powers that belong to a single branch and powers that belong to the whole platform. Everything sat behind one door marked "platform only", which is why a community administrator got the ordinary admin panel with extras and no way into an overall panel at all. The endpoints are now sorted into two groups. The first — the community list, the member list, the tree, the summary figures, the activity log, and the actions that create, rename, move and manage communities and their members — confine themselves to the caller's own branch, so a branch administrator may use them. The second stays platform-only: platform income and pricing, the switches that control connections to other installations, platform-wide rollout settings, and granting platform-wide administrator rights. That split is not a matter of taste. Two concrete reasons decided it. The billing actions take the community they act on straight from the request and only check that the caller is an administrator — so letting a branch administrator near them would have let one branch change another branch's billing. And granting platform-wide administrator rights is the way out of your own branch entirely, so it can never be a branch-level power. Deleting and permanently purging a whole community also stay platform-only: they are irreversible, and building a network needs creating and moving, not destroying. Twenty-four tests hold the boundary, and they are the point of the whole exercise: a branch administrator's community list, member list, tree and activity log each contain their own branch and nothing from a sibling branch; guessing a sibling's identifier gets refused; they cannot change another branch's billing, cannot grant platform-wide rights, cannot purge a community, and cannot sign in as another member. A platform administrator still sees every branch and still reaches everything. An ordinary administrator reaches neither. Nothing is switched on for anyone yet — the panel's own screens still need to hide the platform-only sections before a branch administrator is given the door key.
- A platform-wide administrator whose account happened to sit on the wrong community was being treated as a local one. Two parts of the system disagreed about what the platform-administrator flag meant. The gate guarding those screens let such a person straight through, platform-wide — but the code that decides how much they can see only granted full reach to accounts on the very first community, or to the top-level owner account. Anyone else with that flag was quietly confined to their own community's branch. Both now use the same definition, written out side by side so a future change to one is obvious in the other. This also removes a risk introduced by the fix below: without it, a genuine platform administrator whose community had no recorded position would have been locked out of the panel entirely.
- A community administrator whose community had no position recorded in the hierarchy would have been able to see the entire platform. The platform supports a community that has communities beneath it having its own administrator, who should see only their own branch. That boundary is enforced by comparing the start of a text field that records each community's position in the tree — and that field is allowed to be empty. An empty value does not mean "no access". It means everything. Anything at all "starts with" nothing, and the equivalent database filter matches every row. Measured rather than assumed: the filter an empty value produces matched every community in the database. Worse, the six places that apply that filter were written so that an empty value skipped the filter altogether — handing back the whole platform rather than nothing. Two of them had a second version of the same fault: an empty result from the first lookup also skipped filtering. In the one part of the system where the safe failure is "show nothing", every path failed the other way. This was not exploitable, for a single reason: the gate on those screens currently refuses community administrators outright, so nobody could reach the code. It has been fixed now precisely because that gate is about to be opened up — a change that would otherwise have turned a sleeping fault into a genuine cross-community data leak. Fixed in four places, all now failing safe, with fifteen tests covering it: access is refused outright rather than granted with an empty position; the individual permission check refuses instead of allowing; the database filter matches nothing instead of everything; and the six filter sites now share one function that has to be told explicitly to allow. The same guards were added to a second, currently unused copy of this logic so that connecting it later cannot reopen the hole. Also verified: a legitimate branch administrator still sees their own branch and still cannot see a sibling branch, and a similar-looking position (such as /900/ against /9001/) does not match. One thing deliberately not done: the column was not made mandatory, despite an earlier note of mine recommending it. Creating a community inserts the record first and fills in its position immediately afterwards, because the position is built from the record's own identifier — so making it mandatory would break creating communities altogether. That two-step write is also how an empty position could arise in the first place, which is why the safe-failure code above is the real defence rather than a nicety.
- Two security advisories were published against Guzzle, the library the platform uses to make outbound web requests, and we were on an affected version. Both were published on 2026-08-03 and affect every release below 7.15.2; we were on 7.15.1. The more serious one (rated high) is that a web address written in an unusual but still valid form can slip past checks that decide whether a host is allowed to be contacted. The second (rated medium) is that a cookie set for one address can stay attached to requests to addresses beneath it when it should not. The library is now on 7.15.2, which is the version that fixes both. It was the only package that moved. On how exposed we actually were: no code in app/ uses Guzzle directly — every outbound request goes through Laravel's own HTTP wrapper, which sits on top of it — and the platform does not ask Guzzle to make host-allowed-or-not decisions, which is the specific thing the first advisory undermines. So the likely real-world exposure was low. That is a reason to be calm about it, not a reason to leave it: the same library carries our requests to payment and identity providers and to federated partners, and "we probably do not use the vulnerable path" is a weaker guarantee than being on the fixed version. The automated dependency check that flagged this is the one CI job that was still failing, and it now passes.
- The CORS subdomain hardening written on 2026-04-12 was live on production for the first time on 2026-07-30 — for three and a half months it had been applied only to the copy of the class nothing on the request path calls. The hardening replaced "accept any subdomain of an allowed host" with an explicit CORS_ALLOWED_SUBDOMAINS label allowlist that also rejects nested labels, and it landed on App\Core\CorsHelper, whose one caller is the legacy_v1 federation preflight. The copy that EnsureCorsHeaders — the outermost middleware, on every API response — actually calls is App\Helpers\CorsHelper, and it kept the permissive rule. This was not a theoretical gap. Probing production before the change, api.project-nexus.ie returned Access-Control-Allow-Origin: https://evil.project-nexus.ie for an Origin of that name, and did the same for https://a.b.project-nexus.ie — alongside Access-Control-Allow-Credentials: true, which is what makes a reflected origin worth having. config/cors.php matches exactly and has no origin patterns, so Laravel's own HandleCors could not have produced those headers; the reflection could only have come from the unhardened copy, which is also how the hot path was confirmed rather than assumed. The behaviour change was enumerated against production data before being made, because it applies to every API response. Neither CORS_ALLOWED_SUBDOMAINS nor CORS_ALLOWED_ORIGINS is set there, so the built-in default (app,api,staging,admin,super-admin,project-nexus) governs. Of the hosts that sit under a configured apex domain, exactly two live ones lose the grant they had: accessible.project-nexus.ie and accessible-uk.timebank.global. Both are the accessible (GOV.UK) frontend, server-rendered by the PHP app, and their only fetch() targets a same-origin Laravel route — they never made a cross-origin request, so nothing they do stops working. Everything else that was reaching the API cross-origin is granted by exact match: the six tenant custom domains in tenants.domain, and the static origin list. One tenant needed checking rather than assuming, and it changes a failure mode. Tenant 11 is served at uk.timebank.global, runs the React app, and therefore does call the API cross-origin — and uk is not an allowlisted label, so the permissive wildcard was what granted it. It survives because it is a row in tenants.domain, which isOriginAllowed() matches exactly. The consequence is that its CORS now depends on that database-and-cache lookup, where previously the wildcard was a fallback: during a simultaneous Redis and database outage that tenant would see a browser CORS error instead of a readable 5xx. timebanks.us and pairc-goodman.com already depended on that step, since no configured apex host covers them. Adding https://uk.timebank.global to ALLOWED_ORIGINS in production's .env would remove the dependency; that is an owner change and has not been made. The matching rules now live in exactly one place, App\Support\CorsOriginMatcher, which both copies delegate to. Porting the logic a second time would have reproduced the thing that caused the bug. The two classes still exist, because they are not interchangeable — App\Helpers::getAllowedOrigins() merges tenant custom domains from the database and AppServiceProvider depends on that, while App\Core's returns only the configured list — but nothing security-relevant is duplicated between them any more. This supersedes the previous entry's statement that the divergence was deliberately left unchanged. Two smaller findings came out of single-sourcing it: a malformed Origin header reached str_ends_with() with parse_url()'s false return and raised a TypeError rather than being rejected, since both copies guarded only null; and setting CORS_ALLOWED_SUBDOMAINS replaces the defaults rather than adding to them, so any deployment that sets it must list every label it needs including app. Both are now covered by tests. Root Cause: the fix for a security defect was written into a duplicated class, and landed on the copy with one caller instead of the copy on the outermost middleware — so the hardening existed, was reviewed, was tested, and did nothing. Prevention: the rules are single-sourced, and both helpers' test classes plus the new matcher's assert rejection of unlisted labels and nested labels — coverage neither copy had, which is why nobody noticed the hardening was inert. The assertions were checked for discriminating power by running the new inputs through the old permissive rule: seven of nine change verdict, and the two that do not are the allowlisted labels that must keep passing. Not deployed — this is an origin-acceptance change on every API response and the switch is the owner's.
- legacy_v1, the platform's partner API, is now audited route by route — and is still switched off, waiting on a deliberate decision to enable it. All fifteen /api/v1/federation/* routes are classified by caller, credential, required scope, and what they read or mutate; three of them move value or create content. Twelve tests cover it. The route list is now derived from the router rather than written out by hand. The existing kill-switch test lists twelve of the fifteen routes literally, which means it proves nothing about a sixteenth route added tomorrow — the exact failure the kill switch was built to prevent. The audit reads the route collection instead and fails if the surface changes size, if any route is missing its protocol gate, or if any route answers anything but 503 while the switch is off. Switching legacy_v1 on is also proven to open legacy_v1 and nothing else. The central question was what a minted token can actually do, since the mint is the one route deliberately outside the federation authenticator. Answers, all proven rather than read: a token cannot widen its own scope (the mint intersects the request with the key's stored permissions, and a request matching nothing is refused); a token is bound to its key's tenant; a tampered payload does not validate — which is what makes the first two load-bearing, because the middleware reads tenant and scopes from the token in preference to the live database row; a wrong secret and an unknown client id are indistinguishable, so the mint is not an oracle for which client ids exist; and a revoked or expired key cannot mint. One property is worth stating rather than discovering later: narrowing a key's permissions does not affect tokens already issued. Scopes travel in the token, and the per-request database check confirms only that the key is still active. The exposure window is therefore the token lifetime — one hour by default, twenty-four at most — and revoking the key, rather than narrowing it, is the immediate control. That is ordinary bearer-token behaviour; it is now a known number instead of an assumption. No security findings in the fifteen routes themselves. One recorded non-finding: the grant_type check is an exact string comparison sitting behind Laravel's global input trimming, so surrounding whitespace is accepted. That is normalisation, and it is now asserted, so removing the trimming middleware cannot silently change this endpoint. Nothing was enabled. Turning an external federation protocol on is a security-relevant production change and belongs to the owner, on the same footing as a deploy. One follow-up: five of the token tests initially passed locally and failed in the pipeline, because the JWT signing secret comes from an environment variable that a developer's .env usually sets and the test environment does not — so the mint answered "failed to generate token". The test now pins a fixed, non-secret signing key of its own, which is what it should have done from the start: these tests are about scope, tenant binding and signature verification, not about deployment configuration. Root Cause: a test depended on ambient environment configuration rather than establishing its own. Prevention: the secret is a constant in the test, verified by running the suite with the environment variable cleared — five failures before the change, none after.

### Fixed

- Eight more admin test suites are back under the gate, and the assertions they used could never have worked. These were the group the previous pass named as next: tests asserting the literal English that a t() call produces, in an environment where no translation resources load. The subtler half of the problem is that i18next's missing-key output is the same string for every key, so an assertion matching on it could not have distinguished view_profile from delete_data even in principle — it was not a translation that had drifted, it was an assertion with no discriminating power. Each is rewritten to assert a data-* attribute carrying the raw value the component was actually given: user status and role, breach severity and data categories, audit action, consent type, GDPR request type, error-log action, permission category. That is stable whether or not translations load, and it says what the test means. One was doubly stale. GdprConsents renders the API's human-readable consent_type_name and falls back to a translated "unknown" — and the fixture never set a name, so the raw consent type the test looked for was never on screen under any conditions. All eight were verified together with retries disabled — 82 of 82 — so none is passing by retry rescue. Known-broken suites drop from 64 to 55 of 1,283. Four suites in the same directory stay quarantined, and deliberately so: they are not this cluster. One is a delete-confirmation dialog race, one asserts a computed percentage, one an error-message spy, and one looks up checkboxes by a derived label. Recording which failure belongs to which cause is the point of the list; lumping them together is how a quarantine file stops being a work queue.

### Documentation

- The contributor documentation was telling people to do the exact thing that created the 99,139-value translation debt. A review of every maintained document against the last month's work found five that had fallen behind, and one of them was actively harmful: both docs/I18N.md and the agent guide instructed contributors to add a new English key "to every other locale file". That satisfies the structural parity gate — which compares key sets and cannot see a wrong value — and is precisely how 62.3% of non-English PHP values ended up as byte-identical English while CI stayed green. Both now say to translate the key, name the two translation helpers, and explain why parity alone proves nothing. Also missing from the i18n page: the blocking untranslated-value ratchet, the invariant allowlist and the rule an allowlist entry has to survive, and the fact that __() reads a namespace's .json file before its .php one — so a .php namespace can be entirely dead while the live JSON beside it is entirely English. And a name-collision hazard worth stating outright, because it nearly caused a wrong deletion during this review: admin_nav exists as a live React namespace used by 39 components and existed as a dead PHP file of the same name in a different tree.
- The testing and CI pages described a pipeline that no longer exists. Neither mentioned the eight-shard full Vitest suite, which has been blocking since 2026-07-28 and is what actually gates a release. CI.md still asserted that the only blocking frontend checks were type-checking, lint, contracts and the build; TUTORIAL.md told readers CI reported frontend tests as non-blocking evidence "until that runner issue is resolved". A green pipeline now proves 1,228 of 1,283 suites, and the 55 that are skipped, along with the shrink-only rules governing that list, are documented rather than implicit. TESTING.md asks, in its own closing section, to be updated "when a green check no longer proves what this page says it proves". That is the obligation this closes. Both pages also now record the two structural traps that made an earlier gate enforce nothing — a job-level continue-on-error swallowing its own blocking steps, and a job missing from the release gate's dependency list — and the two environment differences that let a test pass locally and fail on a CI shard. The federation manual needed no changes: it was brought up to date on 2026-07-29 and already states that every external protocol is switched off and that the v1 partner API has no replacement.
- The public Features page said external federation had real partners exchanging data daily. It has none, and has been switched off for three days short of a fortnight. The entry read "Live with external timebanking platforms — partnerships established, messages flowing", with a note asserting "Real partnerships exist and exchange data daily." External partner federation has been off platform-wide since the 2026-07-27 deploy; every external endpoint answers 503 before a credential is examined, no partner is connected, and twelve weeks of retained access logs contain zero external callers. The same false claim was mirrored in all ten non-English translations, and it sat under a subheading promising honest labelling. The root cause was that the label vocabulary could not express the truth. All three maturity levels asserted production use — GA meant "used in production", Beta meant "working in production today". A capability that is finished, tested, deliberately switched off and used by nobody had no honest label available, so it was marked Beta and the prose drifted to match the label rather than the reality. There is now a fourth level, Built, not enabled, in a neutral colour because nothing is wrong with a completed feature that is waiting for demand. The maturity legend explains it, and it is translated into all eleven languages. Federation is also now presented as the two different things it actually is. Connecting communities hosted on one installation is in everyday use and stays unmarked; connecting to other installations and other platforms is the dormant part. Those had been blended into a single list where four live internal features and two dormant external ones all carried the same Beta chip. The new copy states plainly that the groundwork is done and audited, that other operators have expressed interest, that nothing further is being built until a concrete integration is on the table — and invites anyone who wants to federate to get in touch.
- A newsletter template told members the Komunitin federation was live and their data was travelling between communities. "The full Komunitin federation spec is live — members, listings, events, and reputation now travel with you across connected communities", seeded in both the PHP and React translation trees and in all eleven languages. Nothing travels anywhere: the protocol is switched off and no install is connected. It now says the protocol support is built and tested, ready for the day there is another platform to connect to.
- The admin integration page told administrators every endpoint on it was live, and published two that do not exist. Of the fourteen endpoints it lists, only the two OpenAPI documents are reachable as shipped — the rest sit behind the Partner API switch (itself off, and additionally a per-community setting that starts off) or the external federation switch. The page also invited admins to "share this URL with integration partners". Worse, two of the published Partner API operations — PUT and DELETE on a webhook subscription by id — have no route at all, so a partner building against them received a 404 rather than a 503. Those two entries are removed from IntegrationShowcaseService with a note not to add an endpoint without a route behind it.
- The OpenAPI document described itself as the complete API and recommended itself for building federated integrations. It contains 843 paths and none of the partner-facing federation protocols: not one of the Komunitin, Credit Commons, Native V1 or Partner API routes, nor the aggregates, inbound hour-transfer or external webhook endpoints. Exactly one external federation endpoint appears in it, and its response list omitted 503 — the only status a partner can actually receive today. The description now states what the document covers and what it does not, and points to the federation manual; the one external endpoint documents its 503.
- The README's Quick Start listed two URLs that do not work after following it. The sales site is behind an opt-in Compose profile that none of the documented commands start, and the accessible UI URL used a community slug that does not exist on a fresh install — the seeder creates only the Master Tenant, which has no slug, so the documented address returns a hard 404. Both now say what extra step they need.
- A documented deploy command in the agent guide could not run, in five places. It was written ssh -i "$PROD_SSH_KEY" -o RequestTTY=force "$PROD_SSH_USER@$PROD_SSH_HOST", but deploy.env defines only PROD_SSH_HOST and PROD_SSH_KEY, and PROD_SSH_HOST is already a full user@host string. The command expanded to @azureuser@host and ssh rejected it; a sixth variant double-prefixed the user a different way. scripts/deploy.sh had it right all along. The same guide claimed Husky pre-commit and pre-push hooks run lint and tests locally. There is no .husky/ directory at all, exactly one hook is installed — the staged-PHP-test gate — and scripts/pre-push-checks.sh is a script nothing invokes. It also said an i18n check "runs in pre-push", which nothing does. CI is the only safety net, and the guide now says that plainly, because believing otherwise is how a push gets treated as pre-verified.
- RouteServiceProvider's own docblock said the opposite of what the file does. It stated "there is NO /api prefix" — left over from the pre-Laravel router — while line 182 applies ->prefix('api'). That wrong comment had already propagated into two published documents as a login URL that does not exist. The docblock now explains the real arrangement, including why a handful of live routes sit under /api/... with no version segment.
- Documentation that named symbols which do not exist. The deepest sweep found a class no gate in the project can see: guides citing JobVacancyService::createVacancy(), FeedActivityService::deleteActivity and ::ensureActivity, an event class GroupChatroomMessageSent, a React component, an endpoint, a test file and a config path — none of which are in the repository. A reader greps for one of these, finds nothing, and stops trusting the whole document. Each was corrected to the real symbol or the claim removed, and all 1,813 remaining Class::method references, source paths, test paths and route pairs across docs/modules/ were then verified to resolve. Every one now does, or is explicitly documented as absent. Two of them turned out to be about the code rather than the prose, and neither is what it first looked like. GroupModerationService::isUserBanned() queries a group_bans table that has never existed in any migration or in the schema dump — but the method has no callers, so nothing is being silently bypassed; it is a dead path that would trap the next caller, and it is tracked separately. And the volunteering guide described an insufficient_balance guard on auto-payment that the code deliberately removed: the org balance is a reconciliation figure rather than a spending limit, so approved hours are always minted, and the old guard was itself the bug because it left approved hours permanently unpaid. The code is right there; the documentation had not caught up.
- How this was done, and what it says about doing it again. Every claim was checked against the code, and every finding was then attacked by an independent reviewer before anything was edited — which mattered, because three plausible-sounding findings were wrong, including one repeated from a stale local plan and one about a namespace that exists in two trees with the same name and different lifecycles. The more useful lesson ran the other way. The findings held up well: six of six spot-checked independently were real. The fixes did not — an independent recheck of the first 84 corrections found 35 second-order defects in the corrections themselves: a stale value left in a sibling file, a new sentence contradicting an old one nobody removed, off-by-one counts, and identifiers that were invented while fixing something else. Three further rounds each found real defects in the previous round's work, converging 35 → 20 → 14 → the symbol sweep. Correcting documentation is not lower-risk than changing code, and it needs the same verification discipline.

## 1.5.8 - 2026-07-29

### Added

- Every test suite now runs in the pipeline, across eight parallel shards. The blocking test steps covered roughly 150 of the project's 1,283 suites, so about 88% of the suite could break and still produce a green build — the second of the two structural reasons breakage here is only ever discovered in large batches. A new job splits the whole suite across eight shards, each assigned by a hash of the file path so that adding one test file reshuffles only itself rather than moving every other file to a different shard and invalidating the timings. Suites already failing for reasons that predate this job are listed in a quarantine file and skipped, so the job can cover everything else immediately rather than waiting until all 1,283 are green. That list may only shrink, and a quarantined path that no longer exists fails the run rather than sitting there indefinitely — a renamed test would otherwise quietly stay excluded forever. A visibility step runs the quarantined suites too, without gating on them, so a suite that gets fixed is noticed instead of remaining excluded because nothing ever runs it. The job is deliberately non-blocking at first. Its first duty is to measure: the quarantine list was seeded from a local census covering 335 suites, of which 39 failed, and the rest of the suite — plus anything that fails only on a Linux runner — has to be catalogued before the job can gate anything. Shards do not cancel each other on failure, so one bad shard cannot hide the other seven's results, and each shard names every file as it starts so a shard that exhausts its time budget identifies the suite that hung. The comment above the job records the three changes that must happen together when it is switched to blocking, including adding it to the release gate's dependency list — a job missing from that list reads as enforced while being unable to fail the build. The failures found so far are overwhelmingly stale queries and assertions left behind as the application changed underneath the tests, which is accumulated rot rather than new breakage. The runner also fixes a latent inconsistency: the pipeline's inline test invocations never raised Node's heap limit the way every package script does, so they silently ran with a quarter of the intended memory.
- Fixed an infinite render loop in a test that looked like a component-library bug and was neither. A batch of admin smoke tests aborted with React's "maximum update depth exceeded", thrown from deep inside the component library's collection machinery — which read as a genuine defect in a live admin page. It was not: the page's own test suite passes completely. The cause was in the test's own setup, where the notification helper was rebuilt from scratch every time a component asked for it. Handing a component a brand-new object on every render invalidates anything that depends on its identity, so an effect re-ran, set state, re-rendered, received another new object, and looped until React gave up. The library frames appeared only because a dropdown re-registered itself on each pass. Creating that helper once fixes it, and the file now passes completely and returns to the gate. The reasoning is recorded in the file, because the symptom points firmly at the wrong culprit — as it did here for a while. I checked whether this was widespread before assuming: twelve other quarantined files build mocks the same way, but none of them fails with the loop signature, so the pattern is only dangerous when a component puts the object in a dependency list. Worth knowing rather than fixing blindly.
- Translation setup no longer wipes itself in tests, removing a class of "passes on my machine" failures. The test environment deliberately loads the committed English translation files up front so components render the real strings users see. But the application's own translation setup re-initialised the library unconditionally when imported, replacing those loaded strings with a network loader that cannot fetch anything in a test environment — and it is pulled in indirectly by the API client and two core contexts, so it affected most page tests. Every lookup then fell through the missing-key path, whose output differs between an interactive developer session and the pipeline. That is why the same assertion could pass locally and fail in CI, or the reverse. The setup now leaves an already-initialised instance alone. In a browser nothing initialises it first, so production behaviour is unchanged. This fixes no failing test on its own — it was pursued as a likely shared cause for a group of failures and turned out not to be one — but it removes a genuine source of environment-dependent results, which had already sent one investigation down the wrong path. Verified against roughly 3,600 tests across three independent selections with no change in outcome.
- Forty admin test suites are back under the gate, from one root cause. The largest group of known-broken suites — 42 of them, failing because they could not find elements by their test markers — turned out to share a single defect rather than being 42 separate problems. Each stubs the whole group of shared admin building blocks, while the page under test loads the one it needs by its own path; the test runner keys replacements per module, so the replacement never took effect, the real component rendered, and the marker the test looked for was never there. Pointing each replacement at the paths the pages actually load fixed 40 outright: 489 of 491 tests across those files now pass, and the suites blocking a release rise from 1,179 to 1,219 of 1,283. The known-broken list drops from 104 to 64. One trap is recorded in every file, because it bites immediately: the shared replacement must be declared as a function, not assigned to a const. Replacement registrations are lifted above the rest of the file, so a const is still uninitialised when they run. The remaining two are fixed for this defect but held back by a second one — each asserts literal on-screen text that the component produces through translation, and no translations load in the test environment. That is the same environment-dependence found in the glossary component, and it is the next group to work through. Two subtleties worth recording. Where a page loads several components from one file, the replacement must be bound to that file, not to the component's own name — one page takes its status badge from the data-table module, so binding the badge's own path would have done nothing. And where a replacement is bound to a file whose other exports the page also needs, it must pass the real module through, or those exports become undefined; two files were deliberately left partly unbound for exactly this reason rather than break a confirmation dialog.
- The automated mock check was comparing text instead of resolving modules, and was blind to most of the defect it exists to find. It decided whether a test targeted one of the watched component groups by comparing the written import string. But a test can name the same group several ways — a path relative to its own folder is the identical module as the project-wide shortcut — and the tool that runs the tests resolves them to the same thing. So a test naming the group relatively was skipped outright. This was not cosmetic: of the 42 admin suites failing for this exact reason, only 14 were visible to the check, because the other 28 spell the group relatively. It now compares the resolved module, falling back to the written string only when a path cannot be resolved, so nothing is silently dropped. A regression test covers the relative spelling.
- Fixed an abbreviation component that could render a meaningless abbreviation, and a test whose result depended on which machine ran it. The admin glossary component wraps a term in an <abbr> whose tooltip text comes from the translation files. Given a term it doesn't recognise it still emitted the element with an empty tooltip — worse than emitting nothing, because assistive technology announces an abbreviation and then offers no expansion for it. Unknown terms now render their content plainly. Real call sites are constrained to known terms, so this only bites if a term is removed from the dictionary while something still references it. Its test suite was failing for a subtler reason: no translation resources load in the test environment, so the component produced an empty tooltip locally, while the pipeline fell back to showing the raw translation key. The same assertion therefore passed or failed depending on where it ran. The test now pins the lookup so it means the same thing everywhere, checking the part that is actually this component's responsibility — that it looks the definition up under the right key — rather than whether a particular translation is present. Both suites are green and back under the gate. A correction to yesterday's note in this file: the "file never loads at all" failure described previously was not a real category. Three suites appeared to fail that way, and all three load and run correctly on re-examination; the errors were a transient local build-cache artifact, not a defect in those files. One of the three had never been on the known-broken list at all. The genuine remaining failure in that group is an infinite render loop in a component library collection, which is a real and separate problem.
- Repaired a course-creation test file that was absent from the suite entirely, and removed tests for a component that no longer exists. The course-creation file contained a genuine syntax error — an await inside a non-async setup block — which meant the whole file failed to compile and all thirteen of its tests were silently missing rather than reported as failing. Fixing that exposed five real failures underneath, all the same weakness: the tests located the Save button by taking "the first button that isn't disabled" and clicking whatever that happened to be, so the save was never triggered. They now find it by its accessible name. Two of them also wrapped their assertions in a conditional, meaning a missing button would have passed silently. The file is now fully passing and back under the gate. Separately, tests and a stand-in for a legal-document version form were removed: that component was deleted months ago and replaced by the full-page editor (which has its own tests), but the references were left behind pointing at a file that no longer exists. An unresolvable import fails the entire file at compile time, so those references were taking every other test in their files down with them. Both files have a second, unrelated loading problem and remain on the known-broken list — the dead references were simply the first error, not the only one. Worth noting: the type-checking gate independently flagged both the await misuse and the missing import. They were part of the recorded backlog rather than new, so the gate held its baseline instead of failing — but this is the class of defect that no longer accumulates unseen.
- Fixed the last blocking test step that had no retry, after it failed the build twice in one day on timing alone. Two suites — one for message threads, one for team chatrooms — failed in that step while passing locally on the first attempt and passing in the full-suite job, which does retry. In both cases the test queried the page while loading placeholders were still on screen. The step now retries a failing test once, matching the full-suite job; a genuinely broken test still fails both attempts and still fails the build, so only the timing lottery is removed. The reasoning is recorded next to the step, since the file that carries the setting cannot hold comments.
- Grouped the 106 known-broken suites by cause, which turned up a blind spot in the check meant to catch this exact defect. Fixing them one file at a time would have been 106 unrelated edits; grouped by failure signature they collapse into a handful of causes, the largest by far being 42 suites that cannot find an element by its test marker. Those markers are overwhelmingly a few shared admin building blocks — a statistics card, a data table, a page heading, an empty-state panel — accounting for several hundred individual failures between them. The cause is the same defect the automated mock check was built to find: the test replaces a whole group of admin components at once, but the page loads the one it needs by its own individual path, so the replacement never takes effect and the real component renders without the marker the test is looking for. The check only ever inspected two groups of components and not the admin one, so the single biggest instance of the defect it exists to catch was invisible to it. It now inspects the admin group too, which adds 22 findings across 14 files to the recorded total — measurement, not new breakage. Worth recording why the obvious shortcut is wrong: none of those markers has ever existed on the real components, which the project history confirms. Adding them to make the tests pass would put test-only attributes into production components and preserve the deeper problem, which is that these tests currently assert against stand-ins rather than real behaviour. The fix is per-suite — point the replacement at the path the page actually loads, or rewrite the query against what the real component renders. The check's own tests were updated to derive the number of inspected component groups instead of hard-coding it, so adding another group in future cannot break them the way it broke them this time.
- Seventeen suites came off the known-broken list and back under the gate, taking it from 123 to 106 and the suites that block a release from 1,160 to 1,177. The step that runs the known-broken suites for visibility — added precisely so a suite that quietly starts working again gets noticed rather than sitting excluded forever — reported these passing on its first outing. Each was then re-checked locally with retries switched off, so none was included merely because a retry rescued it. The cap moved down to match; it can only ever move down.
- The full-suite test job now gates the build. It spent its trial period unable to fail anything, on purpose, while the list of already-broken suites was catalogued. It was switched on after two consecutive runs had all eight shards green with zero genuine failures and no test needing its retry — so the pass was real, not retry-assisted. 1,160 of the 1,283 suites now block a release, up from roughly 150. The remaining 123 are the recorded known-broken list, capped and shrinkable only. Three changes had to land together, because any one alone produces something that looks enforced and enforces nothing: removing the marking that let the job fail harmlessly, adding it to the release gate's dependency list (that gate decides from its dependencies, so a job missing from the list can conclude anything without consequence), and the cap on the skip list. The reasoning is recorded above the job so a future reader does not undo one part of it. Two safeguards remain deliberately in place. A shard failure does not cancel the other seven, so one bad shard cannot hide the rest. And the step that runs the known-broken suites for visibility is still allowed to fail on its own terms — otherwise the 106 suites expected to fail there would have brought the whole job down the moment it started gating.
- Groundwork for making the full-suite job actually gate the build: one retry per shard, and a cap on the skip list. Two things stood between the job and being able to fail a build, and neither was the job itself. The first was flakiness. Across three trial runs, two suites failed once and passed on a re-run, so no run had all eight shards green on the first attempt. Turned blocking at that rate the job would fail builds semi-randomly and train everyone to ignore the pipeline — the exact habit this work exists to reverse. Each shard now retries a failing test once. That is deliberately retry rather than skip: adding a flaky suite to the skip list would buy a green build by throwing away the coverage, whereas one retry separates the two cases honestly — an intermittent test passes on its second attempt, while a genuinely broken one fails both and still fails the shard. The trade-off is written into the source: retries make intermittent failures quieter, not fixed, so a suite that keeps needing one is a bug to fix rather than a permanent arrangement. The second was that the skip list is the obvious way to fake a green shard — one line turns a failing shard green. It is now capped at its current size and can only be lowered. One detail worth recording, because it is the same trap the job's own instructions warn about: that cap is enforced in the main frontend job, not inside the full-suite job. The latter is currently marked to not fail the build, and that marking applies to everything inside it — so a check placed there would appear enforced while being incapable of failing anything.
- All eight shards of the full-suite job now pass, so every one of the 1,283 test suites is either running in the pipeline or explicitly listed as known-broken. Fixing the shard that hung revealed it had only six genuine failures behind the hang; with those recorded the quarantine list settles at 123 of 1,283, and 1,160 suites run on every frontend change — up from roughly 150. The job stays non-blocking for a few more runs to confirm the result is stable rather than a single lucky pass, and the run in which seven shards passed and one failed also confirmed that a failing shard cannot redden the build while it is in this state.
- First results from the full-suite job, and two fixes it paid for immediately. The initial run catalogued 78 further failing suites, taking the quarantine list from 39 to 117 of 1,283. The most useful finding is where they were: most of the failures are ones the local census did not see, because they fail only on a Linux runner. Running the census in the pipeline rather than on a developer machine was therefore the right call — a local sweep would have declared the suite far healthier than it is. The one shard that neither passed nor failed was hanging, and the cause was the runner's own fault rather than a bad test. It was forcing every file in a shard through a single process — a setting borrowed from the fourteen-file smoke step, where it fixes an unrelated channel hang. At roughly 150 files per shard that setting causes a different hang: browser-environment state and memory accumulate across files until the run stalls, which is why the shared test setup already forces a garbage collection and clears the page between files. The suite's own configuration is tuned for exactly this workload and gives each file a clean environment, so the runner now defers to it. The suite named as the culprit passes on its own in twelve seconds, which is what pointed at the harness rather than the test.
- Cleared the first batch of the remaining tests whose stand-in components silently did nothing, taking the recorded count from 129 to 115 and the files affected from 31 to 28. All three marketplace suites replaced form controls on the shared component group while the pages import each of those controls by its own individual path, so the replacements were never installed and the real components have always rendered. Each block was commented as protecting the test from components that can loop endlessly in the test environment — a protection it never actually provided, since the stand-ins were never in place. The suites pass with the real components, so the loop being guarded against does not occur, and the comments were describing a safety that did not exist. The replacements were therefore deleted rather than pointed at the paths that would have made them live: switching fourteen stand-ins on for the first time would change what these tests actually exercise, which is the failure mode the previous pass hit and had to undo. Each file records which treatment it received and why. Verified by the 38 tests in those suites and the 817-check component contract gate. Worth noting because it is the point of the exercise: the new test-file type gate caught its first real change immediately — deleting the dead code removed a type error along with it, and the gate refused to pass until that improvement was recorded, rather than letting the baseline quietly overstate the remaining debt.
- The focused smoke-test list is no longer duplicated in two places. The same fourteen paths were written out inline in both the smoke step and the coverage step, free to drift apart with nothing to catch it. Both now read one file.
- Test files are now type-checked, after being checked by nothing at all. The project's TypeScript configuration deliberately excluded every test file and the shared test harness, and the linter ignored the same paths — so renaming a prop or changing what a hook returns was a compile error in application code but completely invisible in the tests that exercise it. 1,951 type errors had accumulated across 650 of the 1,281 test files, none of them reported anywhere. This is one of the two structural reasons test breakage here is only ever discovered in large, painful batches; the other, that the automated pipeline runs only about one test file in seven, is addressed separately. A second configuration checks the same source tree with the test exclusions lifted, and a new gate holds the error count to a baseline that can only shrink. The existing debt is therefore recorded rather than fixed in one heroic pass, while a new error fails the build on the commit that introduces it. The gate also fails when a baselined error is fixed without the baseline being regenerated — otherwise the record slowly becomes fiction and re-breaking that file would pass unnoticed. Its output distinguishes the two cases, so an improvement reads as "lock the win in", not as a failure. The baseline records errors per file and per error code, which is the detail that decides whether a gate like this survives. Line numbers churn on every unrelated edit above an error, so a line-based record would cry wolf until people stopped believing it; a bare per-file total would let a brand-new error class slip into an already-erroring file as long as one old error was fixed in the same edit. Errors may move freely within a file, but one more of a given kind, or any kind not already recorded there, fails. Two decisions worth recording. The gate reads the compiler's structured diagnostics rather than parsing its printed text, so nothing depends on compiler message formatting. And the test configuration deliberately does not declare an explicit list of global type packages: doing so replaces automatic inclusion, which silently dropped the Google Maps type definitions and produced 29 phantom errors in four application files that the normal type-check passes cleanly. Test files must be checked in the same type environment as the application, or the baseline records artefacts of its own configuration. Test globals are supplied by a single reference file instead, which carries that reasoning as a comment. The gate refuses to run blind: if fewer than 1,000 test files reach the compiler, it fails rather than reporting a clean pass — an include rule that stopped matching the test suite would otherwise disarm the whole check with a green build. One genuine harness error was fixed in passing (a forced garbage-collection call in the shared setup file was typed as unknown and therefore not callable).
- The Partner API now has its own kill switch, sitting beside the federation one. An audit of the federation kill switch turned up a second external system it never covered: the Partner API (AG60), ten endpoints under /partner/v1 that let approved third parties read members, listings and wallet balances, credit wallets, and subscribe to webhooks using their own bearer tokens — plus outbound webhook delivery. Switching external partner federation off did nothing to any of it, so "external access off" was not true. It gets a separate switch in Super Admin → Federation rather than being folded into the federation master, so each label keeps meaning exactly what it says: one governs federation protocols with other installations, the other governs third-party API access. Tests assert the two are independent in both directions. The gate wraps the whole /partner/v1 block rather than sitting inside the partner auth middleware, because the OAuth token and revoke endpoints deliberately run without it — gating only the authenticated routes would have left the token mint open. Outbound partner webhook delivery is gated too. Blocked callers get HTTP 503 with Retry-After, matching the federation gate, and existing tokens and webhook subscriptions are preserved and resume on re-enable. It ships disabled, which matches the existing posture: the partner_api tenant feature already defaults to off, so the API is opt-in.
- The Partner Timebanks panel now says when external federation is switched off, instead of presenting it as live. A panel-wide notice appears above every page while the kill switch is off, and the two sidebar sections that carry traffic to other installations — "External connections" and "Access & security" — are marked. Previously those pages gated only on the tenant's federation feature flag, so with external federation disabled an operator still saw external partners, protocol configuration, API keys and webhooks as fully working, with nothing to explain why actions silently failed. The pages stay reachable rather than being hidden: an operator needs them to inspect and reconfigure before switching federation back on. The notice states explicitly that federation inside this installation is unaffected — without that, someone reading only "federation disabled" may conclude same-install partnerships are broken and switch the gate back on to "fix" it. The status is read from the tenant-scoped federation settings endpoint rather than the platform one, because this panel is reachable by tenant super admins who are excluded from platform-super-admin routes. A failed read renders nothing rather than a false alarm.
- A kill switch for external partner federation, so protocol traffic with other platforms can be switched off for safety review — one protocol at a time. Super Admin → Federation gains an "External Partner Federation" panel with a master switch plus an individual switch for each of the seven external protocols (Nexus native, Komunitin, Credit Commons, the legacy v1 API, partner webhooks, cross-platform hour transfers and aggregate reporting). It ships with external federation off, so each protocol is re-enabled deliberately as its audit passes. This was not simply a new toggle. The existing "Federation" switch did not do what it appeared to do: turning it off left all 17 Credit Commons endpoints (including relayed and three-phase transactions), 16 of the 17 Komunitin endpoints, the whole legacy v1 API — including the endpoint that mints access tokens — the inbound hour-transfer endpoint and the public aggregates endpoint all answering external callers exactly as before. Outbound pushes to partners were similarly ungated. Fifty-nine external routes are now gated, verified by a test that enumerates each one individually. The switch is deliberately a separate axis from the existing controls: it governs traffic with other installations only, and federation between communities inside this installation — including sub-communities and the Partner Timebanks panel — keeps working when it is off. The panel says so on screen, and a regression test asserts it, because the failure mode to guard against is a future operator "fixing" the disabled switch out of fear it had broken something internal. Blocked callers receive HTTP 503 with Retry-After, not 403 — their credentials are fine and the capability is temporarily withdrawn, and many federation clients treat a sustained 403 as permanent revocation. The response deliberately does not name the protocol, so an unauthenticated caller cannot enumerate which protocols this installation supports. Where the internal controls fail open on a database fault, so a brief outage cannot sever working same-install federation, the external switch fails closed: a missing configuration row, a missing column, an unrecognised protocol or any database error all resolve to "blocked". The panel also shows, per protocol, how many outbound pushes were blocked in the last 24 hours, so it is visible whether anything is still trying to reach partners. Blocked inbound attempts are recorded to the application log only — deliberately, since three of these endpoints are unauthenticated and writing an audit row per rejected request would turn the kill switch into a way to make the platform write unbounded rows. Two further subtleties worth recording. Blocked outbound calls are not counted as partner failures, because doing so would trip the circuit breaker and leave partners unreachable for five minutes after the switch was turned back on — re-enabling takes effect immediately, and a test proves it. And FEDERATION_ENABLED, FEDERATION_API_VERSION and FEATURE_FEDERATION have been removed from the example environment file: they were documented as if they were switches but no code has ever read them, which is a plausible way to believe federation was off while it was on.

### Changed

- Notification text, safeguarding wording, and the event-management screens are now translated too. Notifications are the messages members read most often and were entirely English in every language; those, the safeguarding vocabulary, membership dues, and the ten event-management files are now translated — about 2,900 more values. One family of files was being skipped silently and is not any more. Eleven translation files still open with an older array syntax, which the rewriting step did not recognise; rather than guess where the data began it refused to touch them, which was correct but meant those namespaces would have been passed over without anyone noticing. Both syntaxes are now handled, and the files come back in the modern form.
- The accessible frontend is now translated into nine languages instead of being English throughout. All 24 of its translation files — the whole HTML-first accessible experience: wallet, volunteering, listings, events, groups, jobs, messages, goals, search, saved items, the marketplace and courses, the feed, member and organisation pages, federation, and the shared page furniture — held byte-identical English in every language but English. Around 32,400 values are now translated. This track exists specifically for people who need a plainer, more accessible interface; serving them English regardless of the language they picked was the part of it that did not work. A defect in the translation tooling was found and fixed by the safety check rather than by anyone noticing broken text on screen. Placeholders are hidden behind a marker before being sent for translation, and the marker was initially a word — which the translation service duly translated: nexus came back as nexo in Spanish and Portuguese, and as lien in French. Those values could then not be reassembled, and 207 of them were refused and kept as English, exactly as intended. The marker is now a single meaningless letter, and reassembly identifies markers by position rather than by name, so a translated marker can no longer break anything. The 207 refused values were re-translated afterwards; one value platform-wide remains English by this rule, a multi-line AI prompt.
- Every message the API sends is now translated into nine languages instead of being English. All 1,977 server messages in the API's translation file — validation failures, refusals, "not found", rate limiting, wallet and volunteering errors, everything the server writes back during a request — were byte-identical English in every language except English. 17,777 values are now translated across Arabic, German, Spanish, French, Italian, Japanese, Dutch, Polish and Portuguese. This is the file behind roughly 4,900 places in the code that write a message to a member or an administrator, so it is the single largest piece of user-facing text the platform has. Placeholders were the real hazard, not the words. A message like :field must be :max characters or fewer. breaks visibly if a translation service moves, drops, or duplicates one of those markers — the member reads a literal ":max" mid-sentence. Every marker is therefore hidden before translation, restored after, and then counted: if the set of markers coming back does not match the English exactly, the English is kept and the value reported instead. That happened 17 times out of 17,777, each one a case where the service inserted a word inside a marker or ran two together. English that a reader can still understand is a far better outcome than a sentence with a broken placeholder in it. Every rewritten file is re-read by PHP itself and compared against what was meant to be written, so a formatting mistake cannot pass as valid-but-different. All ten languages are included, Irish among them. Irish was initially left out on the strength of a note saying free machine translation of it was too poor to ship. That note was wrong: the existing tooling does treat Irish specially, but only because the paid translation service it prefers has no Irish at all — the free one does, and its Irish is of a piece with the other nine. About 5,900 Irish values are translated here alongside the rest. On quality: this is machine translation. It is a large improvement on guaranteed English, and it is not finished work. It gets ordinary sentences right and gets domain terms wrong in predictable ways — "rate limit" has come back in several languages meaning a price or a speed rather than a request rate, and "broker" sometimes as an estate agent. Japanese and Arabic in particular would benefit from a native reader. The measurement added in this release makes that reviewable rather than invisible.
- Untranslated text in the backend's translation files is now measured and can no longer get worse. The existing check on those files compares which keys exist in each language, so a file passed it while every line in it was a word-for-word copy of the English. That was not a corner case: 62.3% of all non-English values — 99,139 of them — were byte-identical English, and the check was green. Copied English is invisible to a key-based check by design; the key is there, and only its value is wrong. A new check counts values instead, and holds the count as a ceiling per file. Adding English fails it. Removing English passes and says how much was removed. It is deliberately a ceiling rather than a pass/fail line: a debt of ninety-nine thousand lines cannot be repaid in the same change that starts measuring it, and a check that fails the day it is written gets switched off instead of fixed. Backfilling those values follows in this release. Reading the files is done by asking the language they are written in — one process for all 462 of them, about a second — rather than by pattern-matching their text, which is how a value slips through unexamined. Where a value is genuinely the same in another language (a currency code, a product name, a borrowed technical term) it is listed by its text, so one entry covers everywhere that text appears rather than becoming a per-line list of exceptions nobody reads.
- The app now tells the server which language it is being read in. Every API request carries the language selected in the app, so anything the server writes during that request comes back in it. Previously the app sent nothing of its own and the server had to fall back to the browser's language, which is often not the language the visitor chose — this was the one gap left open when API response language was fixed earlier in this release. It changes most for people who are not signed in: registration, password reset, email verification, and the public pages have no saved preference for the server to read, so the browser's language was the only signal available. A signed-in member's saved preference still wins; this only replaces the guess underneath it, and an explicit language in the address still overrides both. Only the eleven languages the platform actually has are sent, and regional variants are reduced to the language (pt-BR becomes pt) rather than sent for the server to discard. File uploads assemble their request separately and are covered too, because an upload can be refused and its refusal is read like any other.
- Admin screens now show the server's own words when an action is refused, in the reader's language. Twenty-six places across the admin panel read the server's message from a field the server never fills — or from a catch block that can never run — so the message was silently dropped and replaced with a generic local one. On six of those, nothing checked whether the request had succeeded at all: saving the civic-digest cadence, editing an isolated-node item, sending an emergency alert, and loading the emergency-alert and survey lists all reported success, or showed an empty list, when the server had actually refused. Those are now reported. Two mistakes were repeated across the file: reading message on a failed response, which only ever carries error; and expecting the API client to throw on a rejected request, which it does not — it returns a failed result. Both read as correct code, and both meant the same thing in practice: the reason the server gave was thrown away. A related dead end was cleaned up on the sub-regions form. Per-field validation was being pulled out of an err.response.data.errors shape belonging to a different HTTP library — inside a catch that never fires — so field-level messages never appeared and every rejection produced one flat toast. It now reads the errors the response actually carries.
- The check for untranslated admin text is now blocking, and can see through a type cast. Admin screens keep a lot of their wording in TypeScript objects and in server messages, where a JSX-based linter cannot see it, so a separate check covers those paths. It only ran when somebody remembered to type the command. It also had a blind spot that hid this entire class of defect: it identified a server message by the text of what it was read from, so (res as { message?: string }).message did not look like a server message to it. A cast is exactly what a developer writes when the property is not on the declared type — which is the case most worth flagging — so the check was blind precisely where it mattered. It now reads through casts, parentheses, and non-null assertions. Suppressing a line now also accepts the reason in the comment block directly above it, rather than only on the line itself. A one-line reason has to be terse, and the reason is the entire value of a suppression.
- Translated URLs and other must-stay-literal text are now caught automatically. A check already existed for this and had found a real defect — the Irish admin copy had translated the route /partner/v1 into /comhpháirtí/v1, sending Irish-speaking administrators to an address that does not exist — but it only ran if someone remembered to type the command locally. It is now a blocking check on every relevant change. The existing translation check compares which keys exist in each language, so it cannot see this class of problem: the key is present and looks translated, and only its value is wrong. Since translations are often filled in by machine, and machines translate anything that resembles a word, this will recur. The check now also runs when the checking scripts themselves are edited, which the previous file-matching rule missed.

### Fixed

- API responses now follow the language the member chose, instead of their browser's language. Anything the server writes during a request — validation messages, refusals, service errors — was rendered in whatever language the browser asked for, ignoring the language the member had actually selected in the app. Someone who set the platform to French but browses with an English browser received English. The locale is resolved from four things in order: an explicit override, the member's saved language, the browser's Accept-Language header, then the platform default. The second of those never applied. Locale resolution runs early enough to cover every API route, which also means it runs before the request's login token has been checked — so at that moment there is no known member to read a preference from, and resolution quietly fell through to the browser. Three of the four tiers worked, which is why this held up under casual inspection. It also survived testing. The standard way to write an authenticated test happens to make the current member available earlier than a real request does, so the saved preference appeared to be honoured in tests and only failed in production. The new test deliberately signs in the way the app itself does, and additionally asserts that its own request was authenticated at all — an unauthenticated request would have passed the language check for the wrong reason and proved nothing. The language is now applied again the moment the member is identified, which is the earliest point it can be known. An explicit override still wins. The Content-Language header on the response is now read back from what was actually used, rather than from the earlier guess, so it no longer reports a language the body was not rendered in. One related gap is recorded but deliberately not addressed here: the frontend sends no language of its own with API calls, so requests made before signing in still depend entirely on the browser's header.
- Sub-region errors in the Caring Community admin now appear in the admin's own language. Naming a sub-region with no usable characters, or reusing a web address already taken by another sub-region, produced English text — "Invalid sub-region slug." and "Sub-region slug already exists for this tenant." — for every administrator regardless of the language they had chosen. Both now use translation keys, translated into all ten other languages. This is the same shape of defect as the federation one below, and it was found by looking for the shape rather than by a report: a message written in English deep in the service layer, handed outward unchanged by the controller, and displayed by an admin screen that already had a translated fallback string sitting next to it — a fallback that could never run, because a message is always supplied. The regression test that guarded the federation service has been generalised to cover every service whose admin-facing refusals have been cleaned, so none of them can quietly reacquire an English literal. Its scope is a deliberate list rather than a scan of every service: a larger tail of untranslated exception text still exists elsewhere, and a check that failed on the day it was written would have been switched off rather than fixed.
- Federation partnership errors now appear in the admin's own language. Every refusal from the partnership lifecycle — requesting, approving, counter-proposing, rejecting, suspending, reactivating, ending, and changing permissions — was hardcoded English in the service layer. Forty-four of them. A French, German or Irish administrator got English text such as "Target tenant is not accepting federation requests" no matter what language they had chosen. What made this hard to spot is that the admin screens looked correct. Each one already had a translated string sitting beside the server message as a fallback, so the code read as though it were covered. It was not: the server always supplies a message, so the fallback never ran. Every one of those translated strings was unreachable for real rejections. The path in between has no translation step anywhere — the service returns the text, the controller passes it through, and the API client copies it into the field the toast displays — so whatever the service writes is what the admin reads. All forty-four now use translation keys, translated into all ten other languages. Refusals coming from the federation availability gate are handled slightly differently: that gate returns both an English diagnostic and a machine-readable level code, and the message is now chosen from the level. The English diagnostic deliberately stays English, because it is what operators read in logs and error reports, where a message that changes language with whoever triggered it is worse than useless. Admins get their own language; operators keep stable text. A regression test guards all of it, and refuses any future hardcoded error string in that service, any translation key that does not exist, and any interpolated message whose placeholder does not match the key it is passed.
- Turning external federation off no longer causes a retry storm. Four listeners that push to partners — reviews, messages, transactions and accepted connections — classify a failed push as retryable and throw so the queue retries it. A push refused by the new kill switch is reported with status code 0, which that rule read as a transient fault, so switching federation off made every queued push throw and retry until its attempts were exhausted, raising an alert each time. A deliberate operator action should be quiet. Blocked pushes are now terminal, while genuine connection failures and 5xx responses still retry; both halves are asserted.
- Repaired the test suite's external-access posture, which the kill switch broke. Both switches ship disabled, and roughly 25 existing suites assert what the external surfaces do when reachable — protocol endpoints, push listeners, partner auth, rate limiting. With the production default in place they were asserting against HTTP 503 instead of the behaviour under test. The base test case now seeds both switches enabled, mirroring pre-switch behaviour so those assertions keep their meaning, and the tests that exercise the switches disable them explicitly. It seeds federation_enabled and clears the emergency lockdown as well as the external columns: the external switch is nested under both, so seeding only the child left the posture dependent on whatever an earlier test in the same process last committed to that singleton row — an order-dependent failure that surfaced only when suites ran together.
- Custom pages built in the page builder were rendering with no styling at all, and now render correctly. Every custom page lost its entire stylesheet before reaching the browser — the baseline rules that give the page its background, text colour and image sizing, the page's own styling from the builder, and the light/dark theme overrides. Pages fell back to whatever the surrounding app happened to apply, so anything laid out or coloured in the builder appeared plain. The cause was a sequencing mistake in the sanitiser that cleans builder content before display. It handed the finished page, stylesheet included, to the HTML security library, and then looked for the stylesheet in what came back — but that library strips stylesheet blocks as a matter of course, even when explicitly told they are permitted. The stylesheet was therefore always gone by the time the code went looking for it, and the page was published without it. The fix separates the stylesheet from the page body before that step rather than after. Nothing was loosened to achieve this. Styling safety was never that library's responsibility here: a dedicated policy confines every rule to the custom page container, discards rules that try to target the surrounding application, and strips attempts to break out of the container even when marked as high priority. That policy is unchanged and was re-verified in a real browser — a page trying to hide the whole site with a global rule still cannot, while its own legitimate styling now applies. Regression coverage already existed for all of this and had been failing; it passes now, and the eight failing checks are what led to the bug being found.
- Repaired five core test suites that had been silently failing, and found out why tests here rot. Measuring the suite properly turned up two structural causes rather than bad luck. First, tsconfig.json excludes every test file from type checking, so renaming a prop or changing what a hook returns is a compile error in application code but invisible in the tests that exercise it — 1,957 such type errors have accumulated across 653 of the 1,282 test files, none of them reported anywhere. Second, the automated pipeline only ever runs about one test file in seven, so runtime breakage is silent too. Both safety nets are off, which is why breakage is only ever discovered in large, painful batches. The five repaired here — the two authentication context suites, the tenant context suite, and the useApi and useMenus hook suites — all failed for the same underlying reason: the application now shows localized messages where it used to pass the server's raw error text straight through, and the tests still asserted the old English wording. One of them also mocked the translation module without providing its t function at all, which crashed nine tests outright. Rather than paste the new wording in, each assertion now resolves its expected text through the same translation key the code uses, so rewording a message in a locale file cannot fail these tests again — only a real behaviour change can. Two tenant assertions were pointed at the stable error code the provider now exposes for the consuming screen to localize, which is the actual contract. 242 checks across the affected areas pass, and all five suites were added to the blocking pipeline step so they cannot quietly rot a second time. No application code changed.
- Repaired the tests whose stand-in components were silently doing nothing in a way that changed what the test checked. The automated check added in 1.5.7 recorded 302 of these dead replacements, graded by how much damage each could do. This pass clears the whole top grade — all 114 of them, across 27 test files — and takes the total from 302 to 129 across 31 files, with the ratchet lowered so it cannot drift back. Rather less than half that reduction is the top grade itself; the rest follows automatically, because a replaced module is never executed and so stops dragging its own imports into the picture. Three distinct faults sat behind the same symptom. Eleven suites believed they were holding the realtime, presence, tenant and toast layers still while the real ones loaded underneath them, so any assertion about live updates was checking nothing. Two of those also replaced a map component on the group import path while the page loads that map by its own individual path — so the real mapping library was loading in tests that appeared to have stubbed it out. The remaining sixteen did the same with dialogs, tab strips, dropdown menus and tooltips. Where a test's expectations had quietly grown up around the real component, the real component won: five suites had their misleading replacement deleted rather than switched on, because their checks rely on genuine accessibility roles and on a real dialog marking the page behind it hidden — behaviour no stand-in reproduces. Switching those on would have turned a dead replacement into a live and wrong one, which is worse; one of them was caught doing exactly that, breaking a passing test, and was reverted. Each repair carries a note recording which of the two treatments it got and why. All 327 tests across the 27 repaired suites pass, which is the point: these were silent gaps in what the tests covered, not visible failures, and nothing in the application changed. To stop the repairs decaying, all 25 repaired suites that were not already covered have been added to the blocking pipeline step that re-runs them, taking it from 72 suites to 97 and from around 800 checks to 1,046.
- Fixed a test that only failed when the machine was busy, and had been quietly hiding a second fault. The prerender admin suite checks that using browser back/forward moves the visible tab, by telling the page the address changed and then waiting for the tab to catch up. The waiting was the bug: the page's address listener sits outside the part of the framework the test harness controls, so the resulting redraw was merely scheduled, not applied, and the check raced it against a one-second budget. Alone that always won. Running alongside ninety-odd other suites competing for the same processor cores, it lost — and because it passed in isolation, the fragility read as an unrelated infrastructure quirk. The test now applies the redraw before looking, so it is deterministic rather than usually-fast. This also turned out to be the reason a stand-in for the realtime layer had appeared to break the same test earlier in this work: the stand-in only shifted the timing enough to lose the same race. With the race gone, that replacement is back in place, which is what clears the last two top-grade findings and lets the suite rejoin the blocking pipeline step it had been held out of. One root cause, two symptoms that looked unrelated.
- Fixed an error that failed an entire test run while every check in it passed. The federation messages page scrolls a thread into view from inside a timer, so the scroll lands after the test that opened the thread has already finished. The browser stand-in used for tests has never implemented that scroll method, so it surfaced as an uncaught error, which the test runner reports as a failed run even though all eight of the suite's checks passed. It now carries the same one-line shim two other suites already use. This had been latent for as long as the suite existed and only became visible when the suite was added to a step whose result is actually enforced — until then, nothing was reading the exit code.

### Security

- Audited the three externally-reachable federation endpoints that require no login, and closed two gaps in what was proven about them. These are the first of the seven protocols switched off pending review, chosen first because they are the only ones an anonymous caller can reach at all. A correction worth recording: only one of the three is genuinely anonymous. The other two authenticate inside the request handler rather than at the boundary — one by a shared-secret signature, one by an API key or signature — which is why they looked unauthenticated from the routing table alone. The genuinely public one, which returns an aggregate activity report for a community, holds up well. A community must opt in explicitly or the endpoint returns "not found" — and it returns exactly the same response whether the community does not exist or has opted out, so a name cannot be probed. Member and partner-organisation totals come back as ranges rather than exact numbers, and any activity category with fewer than five contributors is dropped entirely rather than reported, so a small group cannot be picked out of it. Responses are signed so a consumer can detect tampering, every query is recorded with its origin and pruned later, and requests are limited both per caller and across a whole network address, so hopping between communities cannot multiply the allowance. Two things were true but untested, which is what the audit was for. First, the date range accepted from an anonymous caller: non-dates now provably fall back to a default window, a twenty-six-year request is provably clamped to a year, and a backwards range is provably corrected instead of quietly returning nothing — which would have read as "this community has no activity". Second, and more important: the endpoint that credits hours into a member's wallet is protected by a signature and nothing else, and while that rejection was tested deep in the service layer, nothing pinned the response the outside world actually sees. A forged signature must now return 401, name the reason, and leave the balance untouched. No blocking findings; all three are candidates for switching back on. Nothing was switched on as part of this work — that is a deliberate production decision, and the recommended order is recorded with the audit.

### Documentation

- Renamed the federation protocol switches so they say who is on the other end and which way data flows. The old names actively misled — including the platform's own author, who reasonably read them as "one of these is for partners on another server, the other is for communities sharing this one". Both were for other servers. The real difference between them is direction, and no label mentioned it. Two names did the damage. "Nexus native" reads as our own internal thing when it means traffic with a different NEXUS installation — the most external thing here; a product's own name inside a protocol name will always read as "ours, inside". And "Legacy v1 API", described as the "older v1 federation API", reads as retired. Nothing replaced it: it is the only way a partner reads your members, listings, messages and reviews in this platform's own format. That wording was persuasive enough that it nearly got the surface deleted. Every switch is now named for its counterpart and its direction — "they send to us", "they read from us", "both directions", "they notify us" — and the descriptions say plainly what crosses the boundary. The public totals switch now also states that it is the only protocol answering without credentials, that counts are rounded to ranges, and that groups under five people are omitted. All of this is translated into the other ten languages, which until now still said "legacy" in their own words. The federation manual gains an "inside versus outside" section built around the only two questions that matter for any endpoint: is it for people already here or for another installation, and does data flow in or out. It names the three traps directly — that one route prefix holds both internal and external surfaces so the path tells you nothing, that v1 did not become v2 because they serve different audiences, and that "native" does not mean internal. The README now distinguishes the two kinds of federation and warns self-hosters that external protocols ship switched off deliberately, so nobody spends an afternoon debugging a working kill switch. Matching notes sit in the route file for anyone reading the source.
- Fixed an Irish translation that had translated a URL. The Partner API description rendered the literal route /partner/v1 as /comhpháirtí/v1, which is not an address that exists. Anyone following the Irish admin copy would have been sent to a path that does not resolve.
- The federation manual now says, up front, that every external protocol is switched off. All of the partner federation protocols are built and complete but deliberately disabled, because none is connected to a live partner yet. The manual described how to call them without mentioning that, so anyone following its examples received a bare "service unavailable" and no explanation — with nothing to distinguish a deliberate platform setting from wrong credentials or a broken integration. It now opens with a per-protocol status table, states that a disabled endpoint answers 503 whatever credentials are presented, and notes the two things people get wrong about the switch: federation between communities inside one installation is unaffected and keeps working, and the Partner API has its own separate switch that is not turned on or off with the others. The entry describing the version 1 partner API was also misleading. It read as though that surface were superseded, when nothing has replaced it: it remains the only partner-facing read API in the platform's own native format. The newer families do different jobs — one accepts inbound pushes, two speak other platforms' formats, and the member-facing routes are for the platform's own logged-in users. The entry now says so, and records which of its endpoints sit outside the federation authenticator and why the token endpoint necessarily does. The admin-facing API documentation page needed no change: it already inherits a banner announcing the disabled state.

## 1.5.7 - 2026-07-26

### Added

- The search box and Filters button on the phone filter bar now meet the 44px touch-target minimum. Both were 40px, four pixels under the size the platform commits to elsewhere — and because they live in the shared bar that now serves twelve pages, the one exception was spreading. They are now 44px, matching the filter chips, bottom sheets and overlay buttons alongside them; the bar grows by four pixels. The small removable filter chips under the bar deliberately stay at 28px: they wrap onto extra rows, so sizing them up would roughly double the bar's height once a few filters are applied and push results off the screen, and every filter can also be removed inside the filter sheet or cleared in one tap. At 28px they still meet the WCAG AA minimum; 44px is the stricter standard this platform holds itself to for primary controls.
- A new automated check catches tests whose mocks silently do nothing. When a test replaces a whole group of components at once but the code under test loads one of those components by its own individual path, the replacement never takes effect — the real component runs instead, and the test either crashes for want of a provider, looks for markup only the stand-in produced, or checks an accessibility role the real component does not use. Six test suites were found broken this way, having gone unnoticed because the automated pipeline only ran about 50 of the project's 1,282 test files, and neither the type checker nor the linter inspects test files at all. npm run audit:dead-mocks now reads every test file and the modules it loads, works out which replacements are dead and which file the author should have named instead, and records the current findings as a baseline that can only shrink. Two blocking pipeline steps enforce it, along with a re-run of the six repaired suites so they cannot quietly rot. The check also fails if it stops finding what it is meant to inspect, so a directory rename cannot disarm it with a green build. Measurement and enforcement only — no test or component behaviour was changed.

### Security

- Patched two newly published dependency advisories. The postcss build tool (path traversal via source-map auto-loading) is bumped to a fixed release in the web frontend, and the brace-expansion glob helper (denial of service) is bumped to its fixed 5.0.8 release everywhere a fixed release exists. The remaining 1.x/2.x copies of brace-expansion — pinned by upstream developer tooling with no fixed release published for those lines yet — are documented and suppressed in the vulnerability-scan ignore file with a dated revisit note; they are build-time-only code that never ships to production or handles untrusted input. The blocking npm-audit step in the security workflow now runs through a small gate script (scripts/npm-audit-gate.mjs) that honours a reviewed, dated exception list (.npm-audit-exceptions.json) — the npm-audit counterpart of the existing Trivy ignore file — so an advisory whose only fix is a breaking major of the on-hold mobile app no longer leaves the pipeline permanently red; every other high/critical advisory still blocks, and exceptions are printed in the CI log for quarterly review. The documentation-hygiene check, which flags stray task output committed to the repo root, now exempts that exception file by name — its filename contains "audit", which the check otherwise reads as a scratch audit report.

### Fixed

- An events test that passed in the pipeline failed on Windows machines, and the pipeline's test-splitting script reported the wrong answer when asked about it. One structural check on the event-sessions service asserts across two lines of source, so it only matches files saved with Unix line endings — which is how the pipeline checks the code out, but not how it lands in a Windows working copy. The check now normalises line endings before comparing, so it holds the same guarantee on either platform. Separately, the script that divides the PHP test suite into six parallel groups keyed each file's group on its path including the path separator, so running it on Windows printed a completely different division than the one the pipeline executes — making a test look as though it were assigned to no group at all, which reads alarmingly like a check that never runs. Paths are now normalised before grouping, so the local answer matches the pipeline's. No change to which tests the pipeline runs: every one of the 1,548 test files was, and still is, assigned to exactly one group.
- Search no longer runs a query you backed out of. The search box used one value for both what you were typing and what the results were actually for. If you opened the search field, typed something and then dismissed it without searching, the abandoned text stayed in the box as though it were the applied search — and if you then removed a filter chip or pressed Clear all, the page searched for that abandoned text while the address bar still showed the previous query. What you are typing and what has been searched for are now separate: the box, the result count, the "no results" message, saved searches and Try Again all reflect the search that actually ran, and reopening the search field starts from that search rather than from whatever was last abandoned.
- Focused form fields no longer show two misaligned focus rings. Clicking or tabbing into any HeroUI-based field (search boxes, text inputs, selects) drew two nested purple outlines that didn't line up — the component's own ring on the field shell plus a second global outline forced onto the inner input, most visible in the new listings filter sheet's category search. The global keyboard-focus fallback (which guarantees a visible indicator on plain HTML elements) was written outside any CSS layer, so it overpowered both the component library's "the shell draws the ring" styling and custom Tailwind focus rings; it also force-changed the focused element's corner rounding. It now sits in the base layer where component and utility styles can override it, the radius override is gone, and decorated inputs (fields with icons, like the login form) — whose inner input is deliberately unstyled — now draw one correctly-shaped ring on the field shell itself, which previously had no indicator of its own. One aligned ring everywhere; keyboard-focus visibility (WCAG 2.4.7) is preserved for every element class.
- The composer no longer shows a spurious "couldn't load groups" error on communities that have Groups turned off. The group-audience selector fetched the groups list without checking whether the community has the Groups feature enabled, so on communities with it off every composer open surfaced a red error toast. It now respects the feature flag (and treats a feature-disabled response quietly), matching the server.
- Reduced non-actionable error noise in monitoring. Routine real-time websocket reconnects, expected message-send refusals (e.g. blocked or safeguarding-restricted recipients), and a benign Android in-app WebView teardown message are no longer reported as errors — so genuine failures are easier to see. No user-facing behaviour changes.
- Platform administrators and cross-community members can donate and subscribe again. Making a donation or starting a member-premium subscription resolved the paying user with a community-scoped lookup, so anyone whose home community differed from the one they were acting in (platform admins, cross-community and federated members) hit a "user not found" error and could never reach the payment step. The acting user is now resolved by their global account id, while every donation and subscription record stays community-scoped; the saved Stripe customer is now persisted correctly for these users too.
- Sending a newsletter from the admin panel no longer fails partway through. On production (which preloads compiled code) the newsletter sender referenced a delay setting that only existed when the file was loaded the old way, so triggering a send over the web crashed with a fatal "undefined constant" and left the batch half-sent. The delay is now a fixed value that is always available.
- Listing and other detail pages no longer error on accessible custom domains. On a community's own accessible (GOV.UK-style) domain, opening a listing/event/exchange detail page returned a 500 because the host-derived community slug was appended after the page id and the two were read in the wrong order. The slug is now injected ahead of the id so pages load correctly; the standard /{community}/accessible/... URLs were unaffected.
- Time-credit transfer limits are now enforced consistently. A community's configured maximum transfer amount was shown to the transfer form but never enforced on the server — which used a fixed platform ceiling — so a direct API call could exceed a community's stricter cap (and a cap set above the ceiling was advertised but silently reduced). The limit is now resolved in one place (the community setting, clamped to the platform safety ceiling) and used by both the enforcement path and the value the UI reads, so what's shown always matches what's applied.
- Reusing a transfer's idempotency key for a different transfer no longer silently drops it. If a client reused one Idempotency-Key for two genuinely different transfers, the second returned the first transfer's result as success while moving no credits to the intended recipient. The key is now bound to the transfer's recipient and amount, so a different transfer is processed as the distinct transfer it is, while an accidental double-submit of the same transfer is still collapsed to a single debit.
- Operational alarms no longer double-report themselves as failed scheduled commands. The four daily/weekly monitors that intentionally exit non-zero when they detect a problem — SLO breach, stuck Stripe webhook, overdue GDPR data-subject request, and missing backup — were scheduled in the foreground, so Laravel's scheduler read their deliberate "attention needed" exit as the command itself crashing and reported a second, context-free error to Sentry on top of the real alert. In production the overdue-GDPR pager fired this every day for as long as a genuine backlog existed, adding noise that trains operators to ignore error alerts. All four monitors now run in the background, so only their genuine alert is reported while their manual-run exit code is unchanged; a regression test locks the background scheduling in.
- Toast notifications no longer crash the current page after a deploy. The toast viewport loaded through a plain lazy import with no stale-chunk recovery, so when a toast (an error or success message) first appeared on a tab that had been left open across a deploy, its re-hashed script file could 404 and replace the current view with the generic error screen. It now uses the app's shared stale-chunk recovery — a one-time reload to the fresh build — and a new catch-all handler extends the same recovery to every other lazily-loaded screen that wasn't already wrapped.
- Story video upload and camera capture work again. Two separate faults were fixed. First, the July security-hardening pass added a Content-Security-Policy media-src rule without blob:, which blocked video previews, recorded-clip playback, and the live camera feed in the story composer (image previews were unaffected because they use data: URLs); blob: is now allowed for media in both the SPA and PHP policies, with a regression test locking it in. Second, the live feed never attached to its preview — a black screen — because the video element mounts a moment after the camera starts; the stream now attaches as soon as the preview exists, front/back camera flip works, and recording uses whichever format the device supports (iPhones record MP4, not WebM) instead of failing to start on iOS.
- Taking a profile photo with the phone camera works again. The avatar picker's accept list had been narrowed to specific formats, hiding the camera option on phones. Restoring the image/* wildcard brings the camera back on many devices, but newer Android versions open the system photo picker, which has no camera option at all — so the profile-photo button (settings and onboarding) now opens an explicit menu: "Take photo" launches the camera directly, "Choose from library" opens the picker. Unsupported formats are still rejected by the existing client- and server-side validation.
- Signup welcome credits no longer award exchange badges. New members were receiving "First Exchange", "First Earn", and "First Spend" (and progress towards higher earn/spend/transaction badges, plus credit-leaderboard rankings) purely from their signup credits, because the gamification engine counted the starting-balance grant — and the admin-approval welcome bonus, which was historically written as a self-transfer — as real transactions. Every transaction-based badge check, the badge-progress meter, and the credits-earned/credits-spent leaderboards now count only genuine member-to-member exchanges, excluding starting balances, admin wallet grants, legacy welcome-bonus rows, and self-transactions; the admin welcome bonus is now recorded as a proper system grant. A new gamification:revoke-grant-badges maintenance command (dry-run by default, --apply to write) revokes historic wrongly-awarded badges, removes their bonus XP and feed cards, and recalculates member levels — badges backed by at least one real exchange are kept.

### Changed

- Exchanges, the blog and the marketplace complete the phone rollout. All ten directory pages now share the pattern: no desktop hero on phones, the page name in the top app bar, and one slim pinned bar with a Filters button showing how many filters are applied. These three each have a single filter dimension, so their sheets apply a tap straight away with no confirm step, and exchanges has no search pill because the page has no search. "Browse Listings" on exchanges and "Sell Something" on the marketplace stay reachable on a phone, and the blog's post count moves under the bar. Tablet and desktop are unchanged. The marketplace's promoted-listings block is deliberately still shown on phones, since it is paid placement.
- The resources library, volunteering and search now open straight into results on phones too. Same treatment: no desktop hero or filter card on phones, the page name in the top app bar, and one slim pinned bar with search, a Filters button showing the applied count, and a bottom sheet of tap-once chips that take effect when you confirm them. Volunteering had sixteen sections that wrapped into a wall of pills on a phone, so its section picker also becomes a sheet showing one control with the current section's name. Its plain-language explanation of how volunteered hours become time credits is kept as a collapsed line you can tap open, rather than dropped — it is the only place an anonymous visitor can find that out. Upload Resource, Log Hours, Browse Organisations, Register organisation and the organisation dashboard links all remain reachable on a phone. Volunteering's remote/in-person filter is currently phone-only, and clears itself if you widen the window to desktop so it cannot stay applied with no control to clear it. Tablet and desktop are unchanged.
- Events, members, groups and job vacancies now open straight into results on phones. The same treatment the feed and listings pages already had reaches four more directory pages: the desktop hero and filter card are not rendered on phones, the page name moves into the top app bar, and one slim pinned bar holds search, a Filters button showing how many filters are applied, and the page's view switch where it has one — sliding away as you scroll down and returning on the first upward scroll. Tapping Filters opens a bottom sheet with every filter as tap-once chips; on pages with several filters the changes only take effect when you confirm them, so the results behind the sheet stop churning while you experiment, and applied filters sit under the bar as removable chips with a one-tap "Clear all". Events saves the most: it was spending more than a full screen — around 800-900 pixels — on a hero, a second calendar-view card and two filter cards before the first event. Groups keeps it simple, applying its single scope filter immediately with no confirm step. Features that previously lived only in the hero have been moved rather than dropped: the events calendar-subscription feed and Create Event, the Create Group button, and the job vacancies links to My Applications, Job Alerts and Post Vacancy are all still reachable on a phone. Tablet and desktop are unchanged on all four pages.
- The phone filter bar and filter sheet are now one shared platform component. The sticky filter bar, the tap-once chip groups, the sheet shell and the draft-filter machine introduced for the feed and listings pages have been lifted into shared building blocks so the same pattern can be rolled out to the remaining directory pages (events, members, groups, jobs, resources, volunteering, search, exchanges, blog, marketplace) without ten divergent copies. The feed and listings pages now render from those shared blocks with no change to what they look like or how they behave on any screen size. The generic wording ("Filters", "Clear all", "Show N results", "Remove filter…") now lives in one place and is translated once for all eleven languages instead of being duplicated per page, and pages that have better wording of their own keep it — listings still says "Show 23 listings". One visible fix falls out of the move: a result count of one now reads "Show 1 result" rather than the ungrammatical "Show 1 results", in every language that distinguishes singular from plural.
- Browsing listings on phones now opens straight into results with one slim filter bar. The listings page's large hero and filter card disappear on phones — "Listings" moves into the top app bar and a single pinned bar holds search, a Filters button, and the grid/list/map switch, sliding away on scroll down and returning on the first upward scroll (matching the feed). Tapping Filters opens the platform's bottom sheet with every filter as tap-once chips — offers/requests, sort with the ranking label, category (with a searchable full list for communities with many categories), duration, remote/in-person, posted date, and distance — and changes preview live in a "Show N listings" button before anything is applied, so experimenting with filters no longer churns the results behind the sheet. Applied filters appear as removable chips under the bar with a one-tap "Clear all", so it's always visible what's narrowing the results. Tablet and desktop keep the existing full filter card unchanged. Bottom sheets platform-wide gained an optional pinned action bar for this pattern, plus a visual polish that reaches every sheet (comments, share, GIF picker, feed filters and more): the title and close button now share one compact row instead of stacking centred, the header divider and footer run edge to edge, and filter chips align left instead of centring raggedly when they wrap.
- The first tab on the phone bottom bar is now "Feed" with a newspaper icon. It was labelled "Home" with a house icon while already opening the community feed, so the label didn't describe where it went; it now matches the feed entry used everywhere else in the navigation.
- "Feed" is no longer mistranslated as animal feed in the other ten languages. Machine translation had rendered the standalone "Feed" label in the feeding an animal sense across 115 of 140 strings — Japanese showed 飼料 ("livestock fodder") and 餌 ("bait"), Dutch "Voer", German "Futter"/"Füttern", Italian "Foraggio"/"Nutrire", Portuguese "Alimentação", Spanish "alimentar", Polish "Karmić", Arabic "يٌطعم", French "Nourrir"/"Alimentation", and Irish "Soláthar" ("supply"). This affected the Feed page's own title, the Groups feed tab, the mobile bottom bar, and seven admin breadcrumb and module labels. Each language now uses one consistent, correct term throughout — "Feed" for German, Spanish, Italian, Dutch and Portuguese (the native usage in those locales), "Fil d'actualité" (French), "Aktualności" (Polish), "Fotha" (Irish), "フィード" (Japanese) and "الخلاصة" (Arabic) — replacing the four to five competing variants some languages had accumulated. Twenty-four longer Arabic strings that used التغذية ("nutrition") for the social feed were swept to the same term, including "Feed Moderation", which had read as "temperance in nutrition"; genuine uses — feedback loops, RSS feeds, and data-pipeline feeds — were left untouched.
- The Template button has been removed from the composer. The "Template" picker in the create window's header (both the desktop dialog and the mobile full-screen composer) is gone, so the Listing/Post/Event/Goal/Poll tabs now start from a clean form. Per-tab draft persistence is unaffected.
- The floating composer now opens on the Listing tab with the full listing form. On both phones and desktop, the general "create" entry points on the feed — the "What's on your mind" prompt, the mobile pen button, and the primary create button (relabelled from "New post" to "Create") — open the compose window on Listing instead of Post; the photo and poll shortcuts still open their own tabs. The composer's listing form has been rebuilt as the same polished form used on the listings page: offer/request intent cards, AI "Help me write", the optional experience/equipment/accessibility details, in-person/remote delivery choice, searchable category picker, skill tags, and the larger photo dropzone — with the composer's draft persistence and template picker retained. Both surfaces now share one form component, so future improvements land in both automatically. The unused "UN Sustainable Development Goals" picker (never displayed anywhere in the platform) has been removed from the listing and event composers.
- The feed page now opens straight into content on phones. The page's hero card disappears entirely on phones — "Community Feed" moves into the top app bar next to the community logo — and one slim bar below the app bar holds the For You/Recent toggle, the ranking label, and a Filters button. The bar stays pinned while browsing, slides away when scrolling down and returns on the first upward scroll, so reading posts uses the whole screen. Tapping Filters opens the platform's standard bottom sheet with every feed filter (and the Offers/Requests sub-filters when Listings is chosen); the button shows the active filter's name when one is applied. This replaces the interim horizontally scrolling filter rail; tablet and desktop keep the full hero card and wrapped filter pills unchanged. Pages can now publish their title into the app bar via a small shared hook, ready to reuse on other mobile screens.
- Conversation threads are now full-screen on phones with a single slim header. Opening a message thread hides the site header (like the bottom tab bar already did), giving the conversation the whole screen behind one app bar: back, avatar with presence, and the name with a compact green verified check — the "ID Verified" chip no longer wraps mid-word, and the trust label moves to the status line ("Online · ID Verified"). Search-in-conversation, auto-translate, and view-profile fold into the ⋮ bottom sheet on phones (desktop and tablet keep the visible buttons and site header). The safeguarding review notice compacts to a one-line tappable pill on phones that opens the full wording, with its own X to dismiss; the dismissible full banner remains from tablet up. Safeguarding vetting blocks (DBS Enhanced and other police checks) are unchanged — the full-width restriction panel still replaces the composer. Opening a thread no longer scrolls the page itself (which could drag the thread header off-screen on long messages): messages scroll only inside their own container, and the page body is locked while a thread is open on phones.
- Transaction-style tables now stack into cards on phones. Wallet regional-points and hour-transfer history, loyalty redemptions, data-export history, seller coupons, and ad/push campaign lists no longer force sideways scrolling on phones: each row becomes a rounded card whose values lead with their column label, and columns previously hidden for width (like the loyalty item column) reappear inside the card. The collapse is an opt-in mode of the shared table (mobileCards), so dense comparison grids keep the existing horizontal scroll; desktop rendering and the screen-reader table semantics are unchanged.
- Long-press and pull-to-refresh now give haptic feedback on Android. Holding a message bubble, feed post, or bookmark button pulses when the action sheet is about to open, and releasing a pull-to-refresh past its threshold ticks — matching native app feedback. iPhones don't expose web vibration, so the feedback simply doesn't fire there; nothing else changes.
- Bottom sheets no longer scroll the page behind them. Reaching the end of a menu, picker, dialog, or drawer sheet on a phone now stops the gesture at the sheet's edge instead of rubber-banding the page underneath (overscroll containment on every responsive sheet surface).
- Logged-out visitors on phones now see both Log In and Sign Up in the header. The Log In button was hidden below 480px wide, leaving portrait phones with only Sign Up; both guest actions now stay visible at every width, with the brand logo collapsing first so the header never overflows — verified down to 320px.
- Every horizontal tab strip is now touch-scrollable and shows its overflow. The shared tabs component's scroll affordance — hidden scrollbar with edge chevrons when more tabs exist, horizontal touch panning, and keeping the selected tab in view — is now the default for all ~100 tab strips across the platform instead of an opt-in used by two pages; vertical tab lists and strips that manage their own overflow are unaffected, and individual strips can still opt out.
- Searching Listings and Members on a phone now opens a full-screen search experience. Tapping the search box slides up a native-style overlay with the keyboard open, your recent searches for that page (stored on the device, clearable), and live filtering behind it — the Instagram/Facebook search pattern; desktop keeps the inline filter field. The overlay is a reusable component ready to roll out to the other directory pages.
- Editing your bio on a phone now opens a full-screen editor. Tapping the bio field in Settings opens an Instagram-style full-screen editor with Cancel and Save, instead of typing into a small inline box; desktop keeps the inline field. Numeric create-form fields (listing hours, event capacity and recurrence count) now request the matching numeric keypad.
- Writing a comment on any detail page now opens a native composer sheet on phones. Tapping the "Write a comment…" pill on post, listing, event, blog, job, goal, ideation, volunteering, poll, resource, review, and group-discussion pages slides up the full comment thread as a bottom sheet with the composer pinned above the keyboard and focused — matching the feed's comment sheet — while the thread also stays visible in place on the page; desktop keeps the inline composer.
- Tap-to-open info popovers now present as bottom sheets on phones. Every surface built on the shared popover — the feed ranking-algorithm explainer, match dismiss reasons, hybrid pricing and community-delivery explanations, goal reminder settings, and the theme picker — slides up from the bottom edge with a grabber and dimmed page instead of rendering as a small anchored bubble; tablet and desktop keep the anchored popover, and individual popovers can opt out where anchoring is essential.
- Confirmation dialogs now present as bottom action sheets on phones. Delete/confirm prompts across the platform (every useConfirm-driven destructive action, plus group, event-staff, and admin confirmation dialogs) attach to the bottom edge with rounded top corners and safe-area padding on phones, matching the native sheet treatment ordinary dialogs already received; desktop keeps the centered dialog. The confirm/cancel flow itself is unchanged.
- The Android keyboard no longer covers the page — content resizes instead. The viewport now declares interactive-widget=resizes-content, so when the soft keyboard (and its suggestion strip) opens on Android Chrome, the page and its fixed bottom composers shrink to the remaining space instead of being overlaid; iOS continues to use the visual-viewport compensation, which self-corrects so the two mechanisms never double-apply.
- The AI chat composer now stays visible above the soft keyboard. The chat layout subtracts the keyboard inset on iOS (Android is handled by the viewport change), keeps the latest message in view while typing, and labels the keyboard's Enter key Send.
- Mobile keyboards now get proper hints from text fields. Search boxes across the platform tell Android and iOS keyboards to show a Search key and suppress autocorrect, auto-capitalisation, and the prediction strip; the message and comment composers label the Enter key Send; and the wallet transfer and donation amount fields request the decimal keypad.
- Feed comments now open as a native bottom sheet on phones. Tapping Comment (or the comment count) slides up a Facebook-style sheet with the full thread and a composer pinned to the bottom edge; the sheet has a drag handle, shows the live comment count in its title, and mention suggestions open upward so the keyboard never hides them. Post detail pages keep comments expanded in place, and desktop keeps the inline expansion.
- The feed share menu is now a native bottom action sheet on phones. Repost, Quote Post, Copy Link, external share, and Send via Message present as comfortable touch rows with descriptions under the shared sheet chrome (drag handle, title, safe-area padding) instead of a converted dropdown; desktop keeps the anchored menu.
- Conversation threads now behave like a native messenger on phones. The bottom tab bar steps aside inside a thread so the composer owns the bottom edge; the layout tracks the on-screen keyboard through the visual viewport so the composer stays visible above it on iOS and Android; Today/Yesterday/date separators group the message stream; and a jump-to-latest button with a new-message count appears when scrolled up into history — real-time messages no longer yank the view down while reading older messages.
- The GIF picker opens as a full-height bottom sheet on phones. Search results are no longer crammed into a small popover above the keyboard; tablet and desktop keep the anchored popover.
- The reaction picker is friendlier to touch. Long-press opens it faster, the emoji targets meet the 44px touch minimum on touch screens, and the pill clamps inside the screen instead of clipping at the viewport edge on narrow phones.
- The new touch info-popovers announce an accessible name and the message action sheet's reaction buttons meet the 44px touch-target minimum. The tap-to-open explanation popovers added for touch users now carry a screen-reader heading, and the mobile message reaction buttons use the audited minimum touch size, keeping the platform's accessibility and touch-target contracts satisfied.
- Information previously hidden in hover tooltips is now reachable on touch devices. A platform-wide audit classified every end-user tooltip; the six that were the sole carrier of real information are fixed: the ranking-algorithm chip, community-delivery explainer, hybrid time-plus-cash pricing explanations, and the AI applicant-ranking reason now open on tap as popovers, while the members CommunityRank score shows its label inline on phones and federation profiles display disabled-action reasons (opt-in or transaction restrictions) as visible helper text on phones. Redundant or decorative tooltips that duplicate visible text or accessible labels were left unchanged.
- Page navigation now animates like a native app on phones. Moving deeper (a list to a detail page) slides the new page in from the right, while going back slides in from the left, using the Material shared-axis motion. The animation is phone-only, skips the initial page load, and is fully disabled for users who prefer reduced motion; custom swipe-back is deliberately not added because Android and iOS already provide system edge-swipe back gestures.
- Date pickers now open as bottom-sheet calendars on phones. Event, goal, poll, and volunteering creation flows present their calendar in the same grabber-handled bottom sheet as menus and selects, centred and touch-friendly, while desktop keeps the anchored popover calendar.
- Select pickers now open as bottom sheets on phones. Every dropdown selector built on the shared select — contact subjects, category and skill pickers, form selects across the platform — slides up from the bottom edge with a grabber, dimmed page, and comfortable touch-height options, while tablet and desktop keep the anchored picker. The bottom navigation moves out of the way while a picker is open.
- The bottom navigation now hides while scrolling down and returns when scrolling up. The mobile tab bar shares the top header's scroll behaviour, giving content the full screen while reading and restoring navigation the moment it's needed — matching native app chrome behaviour.
- The mobile message composer now gives the text box the full row while typing. Focusing the input folds the attachment and GIF buttons into a single chevron — matching native messaging apps — so the field no longer shrinks to a cramped box between oversized icons; the chevron restores the tools, and leaving the empty field brings them back automatically. Desktop keeps the full toolbar.
- Message bubbles now open a native-style bottom action sheet on phones. Long-pressing a message (or tapping its single options button) presents reactions, copy, edit, and delete in one thumb-reach sheet with a drag handle, replacing the two cramped floating menus beside each bubble; desktop keeps its hover controls. Copied messages confirm with a toast, and the copy action is translated across all ten maintained locales.
- Dropdown menus now present as bottom action sheets on phones. Every menu built on the shared dropdown — feed post options, share menus, conversation actions, the language switcher, and other "⋮" menus — slides up from the bottom edge with a grabber, full-screen dimming, and comfortable touch-height rows, while tablet and desktop keep the anchored popover presentation. The bottom navigation moves out of the way while a menu sheet is open.
- Multiline text boxes now grow with their content, so the message composer no longer hides what you type. The shared HeroUI v3 textarea wrapper implements the auto-grow behaviour its minRows/maxRows props always promised: the field expands line by line up to its configured maximum, then scrolls internally. The mobile conversation composer starts as a single rounded line and grows to six lines like a native messaging app, with its attachment, voice, and send controls anchored to the bottom edge as it grows; the same fix applies to every compose, review, transfer, broker, and admin form that sets those props.
- The Android native release gate now uses the Expo SDK 54 patch versions required by Expo Doctor. Expo 54.0.36 and Expo Updates 29.0.19 replace the immediately preceding patches, keeping the SDK dependency contract current while preserving the passing mobile type-check and 1,427-test suite.
- The React frontend lockfile again installs cleanly with npm ci. A cleanup had removed the rxjs@7.8.2 entry still required by the i18n tooling's interactive CLI, which failed every CI dependency install; the entry is restored via a lockfile-only regeneration with no dependency version changes.
- The root and mobile npm lockfiles now use patched brace-expansion releases across every installed major line. Versions 1.1.16, 2.1.2, and 5.0.7 close the newly published exponential-expansion denial-of-service advisory without changing the parent Expo, retired web-wrapper, ESLint, or build-tool packages.
- Guzzle is updated to 7.15.1 following newly published upstream security advisories. The patched HTTP client preserves host-only cookie scope, bounds cookie processing, prevents redirect-generated referrers from disclosing URI fragments, and retains the proxy-authorization isolation introduced in 7.14.2; the accompanying promises, PSR-7, and Symfony deprecation-contract lockfile updates remain within existing dependency constraints.
- Production now serves WebP and AVIF artwork as images instead of the React HTML shell. The single-container and blue/green nginx static-asset rules include modern image formats, restoring the three partner-page images for the Hour Timebank tenant and preventing the same zero-dimension placeholder failure elsewhere.
- Laravel CI contract tests no longer fail from translated response fields or leaked test clocks. Pilot launch-readiness coverage now asserts the localized summary_code/summary_params contract, while the CRM timeline contract test disables its date window so unrelated frozen-clock state cannot hide the signup fixture.
- The ordinary React website now behaves like one coherent mobile web app. Authenticated navigation remains in the safe-area-aware bottom tab bar, the More menu opens as a bottom drawer, the desktop footer is removed from phone layouts while its required attribution stays in the mobile menu, and the compact app header no longer leaves a desktop-sized gap above content. The More drawer now keeps its install-app promotion with the secondary actions at the bottom and presents Credits, Messages, and Alerts as equal-width, unclipped account cards. The quick-create control now opens an opaque, internally scrollable action sheet with full-height, readable create cards and no bottom-navigation bleed-through. The shared HeroUI v3 modal wrapper now presents ordinary dialogs as edge-attached bottom sheets on phones, while side drawers automatically become handled bottom drawers and marketplace or talent filters use scrollable mobile sheets instead of expanding into page content. Cookie consent, unsaved-preference controls, notifications, and other fixed surfaces now clear or temporarily replace the bottom navigation, all text-entry controls use the iOS no-zoom font baseline, and the tab bar moves away while the software keyboard or an overlay is active; tablet and desktop layouts retain their existing modal, drawer, filter, and footer presentation.
- Super-admin billing pages now clearly identify their development status. The billing controls and revenue dashboard display a persistent translated warning that the React replacement and Stripe-connected workflows are still being validated and that displayed figures must not be treated as financial records.
- Super-admin tenant hierarchy controls now have one clear, reliable workflow. The shared HeroUI v3 switch uses the documented clickable compound structure, edit-time Hub capability changes route to the authoritative Hub Settings control, disabling a Hub warns that tenant-super-admin privileges will be revoked, and Parent/maximum-depth guidance now explains the separate move workflow and absolute hierarchy-depth semantics.
- Super-admin tenant editing no longer exposes obsolete legal overrides. The inert Privacy Override and Terms Override tab has been removed; tenant administrators continue to manage published, versioned legal documents through the authoritative Legal Documents administration surface.
- Tenant feature and module controls now have one authoritative administration surface. The misleading Features tab has been removed from the super-admin tenant create/edit form, including its stale core-module switches and raw-default display logic; tenant capabilities remain managed through the tenant-scoped Module Configuration page, while new tenants inherit the canonical platform defaults.
- Polls created from the Feed modal now save successfully. The feed endpoint uses the saved poll's numeric ID when building its response and recording feed activity, preventing the post-save server error that previously returned HTTP 500.
- Terminated federation partnerships now display their real status. The shared admin status badge recognises every partnership lifecycle state instead of falling back to "Unknown", with translated labels across all maintained React locales.
- Poll option fields now retain focus in both poll creation surfaces. Typing in either the standalone Polls page or the Feed composer no longer remounts the active option input after each character, so complete option text can be entered normally.
- Security CI no longer treats npm Audit API availability or vendored mobile build templates as OWASP Dependency-Check failures. Blocking production-dependency audits explicitly cover the root, React, E2E, and mobile lockfiles, while Dependency-Check retains installed package/CVE analysis with its redundant network-dependent Node Audit Analyzer disabled. Mobile remains lockfile-audited instead of materializing third-party CocoaPods/Gem templates and native binaries for broad CPE matching, its transitive shell-quote dependency is updated to the patched 1.9.0 release, and the unpatched Metro-only image-size advisory is documented as a time-bounded build-tool risk pending an Expo/Metro update.
- Frontend container builds now respect the repository boundary of the admin translation gates. The frontend prebuild continues linting both admin surfaces without reaching outside its Docker build context, while the full JSON integrity, indirect UI literal, API literal, and functional-token checks remain enforced by the root i18n pipeline.
- The accessible frontend's 3,609 same-as-English translation findings are resolved and permanently gated. Gaelic, German, French, Italian, Portuguese, Spanish, Dutch, Polish, Japanese, and Arabic now translate genuine interface copy, narrowly allow only reviewed locale-specific cognates and invariant formats, and omit three unused backend status-code entries. Contextual review of the machine-assisted first pass corrected false friends, damaged Laravel plural branches, lost interpolation and boundary whitespace, inconsistent product terminology, invisible characters, and older encoding corruption; native-speaker editorial review remains recommended for final wording quality. The permanent GOV.UK catalog gate now rejects missing or extra keys, placeholder and boundary-format drift, per-branch plural damage, suspicious encoding or control characters, and unreviewed English fallbacks across all 4,101 maintained source entries.
- The React administration surface is now translation-complete and guarded end to end. Hardcoded interface copy, raw backend error prose, generated help/glossary/editor labels, module and API documentation metadata, built-in AI/gamification/Caring Community definitions, audit descriptions, health checks, and other server-supplied display defaults now resolve through stable translation keys or semantic codes while preserving tenant-authored content. Coverage now follows admin and super-admin dependency imports, scans admin API controllers and display-metadata services, and blocks literal UI/server-message regressions. All ten non-English React locales were synchronized, with interpolation, markup, technical identifiers, routes, example data, email/phone placeholders, and machine-translation corruption audited and repaired. Permanent catalog gates now reject invalid or duplicate JSON, generated stubs, and damaged i18next/Laravel interpolation, route, schema, protocol, backend-format, and brand tokens across every locale.
- Documentation health is now enforced as a release invariant across the maintained repository. Every docs/ page and scoped mobile, accessible-frontend, E2E, and test guide is indexed and review-dated; material module, security, privacy, CI, deployment, monitoring, API, and mobile drift was checked against current source and corrected, while a tracked local audit dump was removed and the oversized HeroUI Native task log was replaced with a concise maintained parity matrix. CI and the publishing workflow now use pinned Markdownlint, MkDocs Material, and Redocly versions, build the site strictly, and block on navigation, scoped-index, freshness, archive, link, OpenAPI, and generated-changelog integrity.
- The canonical OpenAPI contract now matches the hardened message-media and CSP-reporting routes. It documents authenticated no-store attachment and voice delivery, the one-step multipart voice-send endpoint, both browser CSP report media types, and their real response contracts; the obsolete two-step voice-upload route is no longer published, and a focused Laravel-route parity gate prevents these security-sensitive operations from drifting again.

## 1.5.6 - 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_participants → pending_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 listed → none) 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.

## 1.5.5 - 2026-07-08

### Added

- Volunteering module: completed the deferred cross-surface features from the module audit. (1) Admins can now open submitted expense receipts through a tenant-scoped, admin-gated download endpoint (previously the link pointed at a private storage path that 404'd). (2) A new Shift Swaps admin screen (Volunteering → Shift Swaps) resolves swap requests that require admin approval — such requests previously sat in admin_pending forever with no UI to approve or reject them. (3) Scanning a shift check-in QR now opens a proper check-in page that verifies the volunteer and offers checkout; the QR is generated on-device (via the qrcode package) instead of being sent to a third-party image service. (4) Approved volunteer hours now appear in the community feed as their own card type (a dedicated volunteer_hours feed source keyed to vol_logs — they were previously written to the feed but silently dropped as orphans). (5) Federation is now two-way for opportunities: inbound opportunities mirrored from partners appear in the opportunities listing, and deleting/closing a shared opportunity retracts it from partners (with matching receiver-side handling that deactivates the mirror). New user-facing and admin strings were added across all supported locales, with backend regression tests for the receipt download, org-wallet money paths, feed listener, and federation retraction.
- Fresh installs now seed a usable master tenant and first-run platform admin. php artisan migrate --seed now creates tenant 1 as the neutral Master Tenant and a god-mode platform administrator, with local development credentials documented as admin@project-nexus.local / ChangeMe123! and overridable via NEXUS_BOOTSTRAP_ADMIN_EMAIL / NEXUS_BOOTSTRAP_ADMIN_PASSWORD. The default seeder no longer creates the private hOUR Timebank tenant or demo tenant-2 content, E2E fixture defaults use generic project-nexus.local addresses, public API examples now use the seeded master tenant header, and god-mode auth lookup now recognises is_god / role=god during cross-tenant login.
- Added featured-image upload and preview to the admin blog builder. Content > Blog now lets admins upload a JPEG, PNG, GIF, or WebP featured image through the shared tenant-scoped upload pipeline, stores a portable /storage/... image path in the existing featured_image field, keeps the manual URL fallback, shows an inline preview through the configured API asset host, and provides a one-click remove action. New admin UI strings were added across all supported locales, with focused Vitest coverage for the upload-and-preview path.
- Added the full GrapesJS webpage builder to admin custom Pages. Content > Pages now has the same four authoring modes as newsletters (Plain text, Rich text, HTML, Design), but Design uses the webpage-focused GrapesJS stack (grapesjs-preset-webpage, blocks-basic, forms, tabs, tooltip, custom-code) instead of the newsletter MJML builder. Pages now store content_format and design_json so the published HTML remains in content while visual designs reopen losslessly; public custom pages preserve builder CSS and render builder pages full-width instead of forcing them into the rich-text card layout. The builder uses the existing HeroUI/Tailwind admin chrome and NEXUS upload/image-library flow, exports script-free HTML, and deliberately avoids Joomla scripts/packages.
- Hardened the custom Pages GrapesJS builder for production use. Public builder CSS is now parser-scoped to the custom-page container and unsafe/global CSS, app-shell escape declarations, scripts, handlers, and public inline styles are stripped so tenant-authored pages cannot restyle the React shell or attribution chrome. The admin save path now synchronously flushes the current GrapesJS canvas before submitting, so an immediate Save after editing cannot send stale HTML or design_json. The Pages API validates plausible GrapesJS project JSON, rejects invalid, malformed, wrong-shape, or oversized designs with translated errors, clears design_json when leaving Design mode, and has targeted frontend/backend regression coverage for the save, edit, sanitisation, invalid-project fallback, and validation paths.
- Added Laravel route-file matching and a Laravel-only smoke manifest to the React backend-prep guardrails. The API-call inventory now checks Laravel routes/*.php when openapi.json does not cover a React call, records the matched Laravel method/path/source in the local matrix, and keeps ASP.NET status as not_checked. A new npm --prefix react-frontend run smoke:laravel-manifest command writes a local Laravel-mode smoke checklist under .local-docs-archive/react-laravel-smoke/latest/, and check:dual-backend-prep regenerates it with the other prep artifacts. This is preparation tooling only: it does not add real frontend adapters, does not run ASP.NET, does not certify ASP.NET readiness, and does not change Laravel production runtime behaviour.
- Added local module certification worksheets for future ASP.NET backend parity. npm --prefix react-frontend run certification:worksheets now turns the Laravel React API-call matrix into local module worksheets under .local-docs-archive/react-api-certification/latest/, and check:dual-backend-prep regenerates them after guardrail and inventory checks. The worksheets group P0/P1/P2 rows by module, show Laravel OpenAPI match status, keep every ASP.NET row as not_checked, and list the proof required before any future compatibility claim; they are local handoff material only and do not change frontend runtime behaviour.
- Added Laravel React dual-backend preparation guardrails and OpenAPI matching. The local preparation command npm --prefix react-frontend run check:dual-backend-prep now verifies Laravel remains the default backend, blocks obvious ASP.NET-specific conditionals in production page/component files, runs inventory fixture tests, and regenerates the local matrix. The API-call inventory now matches React calls against the Laravel openapi.json where possible and records ASP.NET status as not_checked for every row, preserving the rule that ASP.NET remains development-only until separately audited and smoke-tested.
- Expanded the Laravel React API-call inventory into a module/prioritised contract matrix. The inventory tool now has a fixture-backed Node test (npm --prefix react-frontend run test:api-inventory) and records module grouping, P0/P1/P2 priority, auth/tenant hints, upload/download markers, upload field names, response type hints, dynamic path markers, raw-fetch markers, and first source locations. The regenerated local matrix still makes no ASP.NET readiness claim; it is a safer work-queue seed for making the development ASP.NET backend conform to the production Laravel React frontend contract later.
- Added a safe Laravel React API-call inventory tool for future ASP.NET contract work. npm --prefix react-frontend run inventory:api-calls now scans the production Laravel React source without changing runtime behaviour and writes a local JSON/Markdown matrix under .local-docs-archive/react-api-inventory/latest/. The dual-backend documentation and frontend agent guide now clarify that this is preparation only: Laravel remains the production/default backend, ASP.NET is still development-only and not assumed ready, and generated inventory output is input for making ASP.NET match the Laravel React API contract later.
- Started the safe React dual-backend switch scaffolding with Laravel protected as the default. The production React frontend now has explicit local backend-target scripts (dev:laravel, dev:dotnet, build:laravel, build:dotnet) plus a tiny tested backendTarget config helper that defaults missing or invalid values back to laravel; no existing API calls, auth flow, tenant bootstrap, upload flow, Pusher flow, deployment script, or Laravel production default was changed. Documentation was tightened across the public roadmap and agent/frontend guidance to state the current operating model clearly: the Laravel backend and Laravel React frontend are in production, the ASP.NET backend is development-only, ASP.NET must conform to the Laravel React API contract, and the React frontend should move to its own repository only after backend contract parity is mature enough.
- Documented the React dual-backend portability guardrails before implementation. Added docs/REACT-DUAL-BACKEND.md and linked it from the public docs index to define the safe path for making the Laravel React frontend switchable between Laravel and ASP.NET while keeping Laravel as the protected default contract. The roadmap requires environment-only backend targeting, ASP.NET conformance to Laravel API contracts wherever possible, tiny adapter boundaries for unavoidable differences such as realtime transport, and Laravel-mode verification before any future ASP.NET portability change is accepted. A local implementation plan was also recorded under .local-docs-archive/plans/ for agent handoff; no runtime code, backend routes, frontend behaviour, or deployment configuration changed.
- The newsletter builder now has an image library — browse and reuse images you've already uploaded instead of re-uploading each time. A new tenant-scoped GET /v2/upload/list endpoint (UploadController::index → ImageUploadService::listImages) returns the tenant's previously-uploaded images (newest first, images only), and a new Image library toolbar button opens a HeroUI gallery modal (AssetLibraryModal) to browse them or upload a new one. Picking an image applies it to the current target — hero background / image src / a fresh mj-image — through the same absolute, email-safe URL pipeline. New newsletter_builder.library_* strings across all 11 locales (drift 0/0). Regression coverage: UploadControllerTest asserts GET /v2/upload/list is registered + auth-gated and returns the tenant's images newest-first while excluding non-images.
- Third-party licence attribution + a dependency-licence audit gate (open-source compliance & sellability groundwork). Added THIRD_PARTY_NOTICES.md (curated attribution for the bundled open-source components — GrapesJS and MJML for the newsletter builder, plus React, HeroUI, Tailwind CSS, Laravel, etc.), a generated THIRD_PARTY_LICENSES.md full production inventory, and scripts/check-licenses.mjs (npm run check:licenses) which audits the npm + Composer production trees and fails if a new strong-copyleft (GPL/AGPL/SSPL) dependency is introduced. NOTICE and README now point to the third-party notices. The audit confirmed both trees are permissive (npm ~362 pkgs all MIT/Apache/ISC/BSD/0BSD; Composer 130 pkgs MIT-dominant) with documented licence elections (nette/*→BSD-3, james-heinrich/getid3→MPL-2.0, DOMPurify→Apache-2.0) and flagged the single transitive strong-copyleft dependency — joomla/string (GPL-2.0-or-later, pulled only by wamania/php-stemmer) — as a tracked KNOWN_EXCEPTIONS entry to remove before any fully-proprietary distribution. Purely repo/compliance tooling — no runtime or user-facing behaviour change.
- A platform super admin in god mode can now permanently delete a tenant — safely, and for the first time. The super-admin panel already had a "Delete" action, but it was misleading and inert: it called deleteTenant(id) with no hard-delete flag, so it merely deactivated the tenant (identical to the adjacent "Deactivate" item); the backend's real hard-delete was never reached from the UI, was neutralised by a frontend/backend query-param mismatch (?hard=1 vs hard_delete), and even if invoked only cleared 3 of ~600 tenant-scoped tables, orphaning everything else and leaving Stripe still billing a deleted customer. Tenant deletion is now a deliberate two-stage lifecycle: Deactivate (is_active = 0) stays the default, reversible action, and a new god-only "Permanently delete" button (in the tenant detail Danger Zone, hidden from non-god users, disabled until the tenant is deactivated) runs a complete, audited purge. The engine (TenantPurgeService, the inverse of TenantProvisioningService) discovers every base table with a tenant_id column from INFORMATION_SCHEMA (so it can't rot as tables are added) and deletes the tenant's rows in bounded chunks; it deletes the tenant's own members while reassigning platform super-admins to the parent community (never deleting them); and it cleans up external state best-effort — cancels Stripe subscriptions + detaches the customer (StripeSubscriptionService::cancelAllForCustomer), removes the tenant's documents from the shared Meilisearch indices by tenant_id filter (SearchService::purgeTenant — never dropping a shared index), clears Redis tenant keys (RedisCache::clearTenant), and deletes the on-disk httpdocs/uploads/tenants/{slug}/ directory. Custom domain / Plesk vhost / DNS teardown remains a surfaced manual follow-up (reported in the UI and command output). Hard guards make an accidental purge impossible: never the Master tenant (id 1), only a deactivated tenant, only one with no child tenants, and the UI requires typing the tenant slug to confirm against a live dry-run preview of exactly what will be removed (row counts, member count, manual follow-ups). The real work runs as a queued job (PurgeTenantJob, $tries = 1) so a multi-million-row purge can't time out the request, and everything is audited (tenant_purged). The same engine is exposed as php artisan tenant:purge {id} [--dry-run] [--force] — --dry-run doubles as a "how much data does this tenant hold" report. New endpoints: GET /v2/admin/super/tenants/{id}/purge-preview and POST /v2/admin/super/tenants/{id}/purge (both requireGod); the old "Delete" list item was removed (it duplicated Deactivate) and the ?hard=1 param path deleted. New api.super_purge_* strings across all 11 locales; new admin.super.purge_* UI strings, translated across all 11 locales (drift 0/0). Regression coverage: TenantPurgeTest (purge removes the tenant + its data + members, preserves platform super-admins, refuses Master / active / has-children, dry-run counts without deleting) plus updated adminApi tests for the new purge endpoints.
- The newsletter module now sends genuinely designed emails — a MailChimp-grade authoring upgrade, delivered in two phases. Previously the only editor was a Lexical rich-text box whose DOM round-trip silently dropped or escaped any HTML it didn't understand (tables, inline styles, <style>, MSO comments, buttons), so a pasted designed email arrived in inboxes as literal escaped source. The content field is now a multi-mode editor (NewsletterContentEditor, shared by the newsletter and template forms) with a segmented switcher — Plain text · Rich text · HTML source · Design — a contextual hint per mode, and a collapsible live desktop/mobile preview rendered through the real send pipeline (new POST /v2/admin/newsletters/preview), shown inside a device frame (browser chrome / phone shell) so it reads as a real email, always in a sandboxed script-free iframe with a DOMPurify pass and an approximate client-only fallback if the endpoint is down. HTML mode is a lazy-loaded CodeMirror 6 editor that stores markup verbatim (structurally impossible to mangle) with an Insert-image button that uploads to our own domain (POST /v2/upload). Design mode is a full GrapesJS + MJML drag-and-drop visual builder (NewsletterBuilder) whose blocks are MJML components — so the exported HTML is inbox-safe/table-based by construction; the editable design is serialized to a new design_json column (reopens losslessly) while the compiled HTML is what gets sent, and its asset manager reuses the same image host. Switching from a designed format back to Rich text (which Lexical would strip) is gated by a confirm dialog. Authors can start from a gallery of six professionally-designed, email-safe starter templates (Announcement, Community Digest, Event Invite, Welcome, Re-engagement, Simple Letter) picked from a thumbnailed modal with live mini-previews, seeded per-tenant (idempotent migration). Backend rendering (NewsletterService::renderEmail()) became a format dispatcher on a new content_format column (plaintext|richtext|html|builder, default richtext so every existing newsletter renders exactly as before): richtext keeps the branded shell; html/builder are injected verbatim (a complete <html> document isn't re-wrapped, a fragment gets a minimal skeleton) with a compliance backstop that injects an unsubscribe footer + List-Unsubscribe when the author didn't include one; plaintext is escaped and also sent as a real text/plain alternative part (proper html→text via soundasleep/html2text, threaded through the Postmark, Gmail and SMTP transports — the SMTP path was HTML-only and now sends multipart/alternative). Every format runs through a CSS inliner (pelago/emogrifier, fail-open) so styles survive Outlook/Gmail, with @media/dark-mode preserved, and admin-authored HTML is cleaned by a new email-safe sanitizer (EmailHtmlSanitizer — strips <script>/<iframe>/<form>/on*/javascript: while preserving tables, inline styles, <style> and MSO conditionals; the generic HtmlSanitizer would have destroyed the email markup), gated on format with a 512 KB cap. New merge token {{unsubscribe_url}} (bare URL for href="") complements the existing {{unsubscribe_link}} (full <a>). New deps: @uiw/react-codemirror, @codemirror/lang-html, grapesjs, grapesjs-mjml (all lazy-loaded so they cost nothing until their mode is opened), plus Composer pelago/emogrifier + soundasleep/html2text. Every new UI string is translated across all 11 locales (the ~45-key newsletter_content_editor block); new api.* keys for the format/size validation across all 11. Regression coverage: NewsletterRenderFormatsTest, EmailHtmlSanitizerTest, EmailCssInlinerTest (PHP) + preview/content_format cases in the controller feature test; contentFormat, NewsletterContentEditor, TemplateGalleryModal, NewsletterBuilder (vitest), with the newsletter/template form suites updated for the new editor.

### Security

- Volunteering module: closed within-tenant authorization and safeguarding gaps. Recurring shift pattern endpoints (create/update/deactivate/delete future shifts) now require the caller to manage the owning opportunity via VolunteerService::userCanManageOpportunityById() — previously any authenticated tenant member could alter patterns and delete another opportunity's future shifts. Shift swaps now reject swapping across different opportunities (which bypassed per-opportunity application approval). Group-reservation addMember now validates the added user belongs to the tenant and tenant-scopes the roster join (previously accepted arbitrary user ids and could leak another tenant's names/avatars). Guardian-consent requests no longer return the consent token in the API response — it is delivered only to the guardian by email, so a minor can no longer self-approve their own safeguarding gate.

### Fixed

- Login and registration no longer download the full application route registry or app-only runtime providers. Auth-entry routes now load through a small AuthRoutes chunk, while the full AppRoutes registry and realtime/menu/presence/podcast provider stack are imported only after navigation into the non-auth application shell. Startup SEO metadata now imports TenantContext directly so the @/contexts barrel cannot pull realtime/presence/podcast code back into the entry chunk, and AuthContext now lazy-loads the WebAuthn helper only when a passkey login action actually runs. The bundle-budget gate now blocks the auth pages from drifting back into the full route registry, blocks TenantShell from statically importing app-only provider code, and blocks these startup leaks from returning.
- Feed reaction popups now load only on interaction. The pure reaction emoji/label config now lives outside ReactionPicker, and the hover/long-press reaction menu, tooltip, and motion code lazy-load only when the picker opens, keeping ordinary feed-card rendering focused on visible core content.
- Feed profile hover cards now load only after hover intent. Feed cards now render a tiny deferred wrapper around author links and lazy-load the full profile popover, connection actions, presence indicator, and hover-card API path only when a desktop user actually hovers an author. The first hover still opens the popover after the lazy chunk arrives, and touch devices keep the plain tap-to-profile behaviour.
- The main Tailwind build no longer scans test-only React files. The production app stylesheet now explicitly excludes src/test and every __tests__ folder from Tailwind's source scan, with a bundle-budget guard so test fixtures cannot leak one-off utility classes into the shipped CSS.
- Early error telemetry no longer pulls Sentry into startup. Production logger errors and i18n missing-key reports now route through the consent-gated post-paint telemetry queue instead of dynamically importing the Sentry wrapper immediately, and the bundle-budget guard blocks those startup files from reintroducing direct Sentry imports.
- Core timebanking, volunteering, and organisation audit fixes. Volunteer user-review eligibility now constrains both application aliases to the active tenant, admin volunteer-hours audit responses include vol_org_transactions payment reconciliation again, organisation wallet payouts follow the canonical auto-credit reconciliation model even when the org balance goes negative, accessible organisation wallet/dashboard pages no longer expose the stale auto-pay toggle, wallet transfer errors/system counterparty labels now use API translation keys across all supported locales, and the React matches/reviews routes are feature-gated. Focused backend, accessible frontend, schema-contract, and React route-gate regression tests were added.
- Admin legal document versions: restored the "Create New Version" button label. The versions page now looks up the button text through the existing legal_versions.create_new_version key in the split admin_enterprise namespace, so users no longer see enterprise.create_new_version.
- Partner Time Banks federation production hardening. External partner time-credit transfers now use client/content idempotency before debit, carry partner idempotency evidence into the canonical transaction ledger, preserve opaque remote member IDs, surface pending reconciliation states safely, and include richer stale-transfer reconciliation audit context. Federation read/settings endpoints and direct external member/listing filters now honour operation gates and partner allow flags; internal cross-tenant message writes are atomic; Partner API user/listing exports follow federation consent and visibility; public volunteering/organisation aggregates apply small-N suppression; external partner admin defaults fail closed; and related backend/admin errors now use translations. External partner API response logging now handles list/numeric payloads without dropping successful member/listing results, federation member/listing/event React controls now use HeroUI Switch/semantic Button patterns instead of clickable chips/gradient actions, and mixed internal/external member/listing responses now expose explicit source counts plus internal cursor/load-more scope so first-page external enrichment cannot be mistaken for fully paginated partner data. Regression coverage was added for external transfer replay, allow-flag enforcement, Partner API privacy, aggregate suppression, translation context partitioning, external source metadata contracts, pagination-scope contracts, and React transfer/admin flows.
- Volunteering credit conservation: auto-approved hours are always paid. The auto-pay path now debits the organisation wallet unconditionally (allowing a negative balance, as a reconciliation figure) and mints whole credits to the volunteer, mirroring the manual verify path. Previously, when auto-pay was on and the org wallet was short, the hours were committed approved but paid nothing and could never be paid later — silently stranding the volunteer's credits.
- Volunteering gamification and rewards now fire on every approval path. Volunteer badges and personal-dashboard hour totals now query vol_logs on the real approved status (they filtered on a non-existent verified status, so badges were never awarded and dashboards always read 0 hours). Admin-panel hours approval and the auto-approve path now dispatch VolLogStatusChanged, so XP, badges, and feed activity are awarded consistently, not only on member self-verify.
- Volunteering admin screens no longer crash or render blank on real data. Admin expenses and hours-audit tables now coerce MySQL DECIMAL columns (which serialize as JSON strings) to numbers before formatting/summing, fixing .toFixed crashes and string-concatenated totals. The training admin page now reads the actual API field names (user_name, completed_at, expires_at, certificate_reference) so volunteer name, dates, and certificate render, and the backend now returns real status-count stats across all records under pagination.
- Volunteering correctness and i18n fixes. Editing an opportunity no longer wipes its latitude/longitude (the controller now forwards them). myShifts/myHours now forward cursor/per_page so entries beyond the first page are reachable. Waitlist spot-offer notifications are now delivered instantly by email and push (previously they reached nobody). The new-opportunity admin email now reads the poster from created_by. RecurringShiftService now returns translated, correctly-shaped error envelopes instead of hardcoded English strings; the hours-paid email no longer duplicates "time credits"; the applications empty-state now shows a translated status label instead of the raw enum. Accessible-frontend create-opportunity now sends valid federated_visibility values (listed/none), and the organisation "View job openings" link is gated behind the job_vacancies feature.
- Pre-render volunteering and organisations freshness now follows the live React/API contract. Tenant prerender and sitemap planning now treat /organisations as part of the volunteering feature, source organisation URLs from vol_organizations, include active/open volunteer opportunities only when their organisation is approved/active, clear versioned tenant sitemap caches on content changes, purge stale snapshot sidecars for deleted/hidden dynamic routes, enqueue recache jobs for valid routes even when no prior snapshot exists, and restrict prerender admin read/monitoring endpoints to platform super-admins. Regression coverage pins sitemap generation, route ownership, stale inventory detection, queue reset, sidecar cleanup, and platform-only admin access.
- Location and map route chunks now avoid the shared location barrel. Map, autocomplete, listing-detail, profile, settings, events, groups, members, volunteering, marketplace, compose, and admin analytics callers now import only the direct location component they render, and the bundle-budget gate blocks non-test source from reintroducing @/components/location barrel imports that could couple Google Maps/location exports into unrelated page chunks.
- Register no longer loads Google Maps on first paint. The register page now renders a plain location input initially and lazy-loads the richer place autocomplete only after the user interacts with that field, keeping the initial auth/register network trace at zero Maps requests while preserving autocomplete when it is actually needed.
- The newsletter design studio now lazy-loads the GrapesJS builder. The full-screen design route renders its chrome and loads newsletter metadata before fetching the heavy MJML/GrapesJS builder chunk, uses direct UI primitive imports, and the bundle-budget gate blocks the static builder import from returning.
- Newsletter admin routes now use a split translation namespace. The newsletter dashboard, campaigns, subscribers, segments, templates, diagnostics, send-time tools, preview panes, and newsletter-specific editors now load the smaller admin_newsletters locale file instead of fetching the full admin translation monolith, with bundle-budget guardrails preventing those surfaces from drifting back to admin.json.
- Advanced admin routes now use a split translation namespace. SEO, redirects, 404 tracking, AI/email/feed algorithm settings, match diagnostics, email deliverability, and prerender tooling now load the smaller admin_advanced namespace instead of the full admin translation monolith, with bundle-budget guardrails checking both the source usage and per-locale namespace files.
- Configuration and system admin routes now use split translation namespaces. Module configuration, translation/infrastructure settings, activity logs, cron tooling, onboarding/image/native-app/registration/SSO/retention/verification/WebP system screens now load admin_config or admin_system instead of the full admin translation monolith, and the bundle-budget gate verifies those namespaces across all shipped locales.
- Federation and enterprise admin routes now use split translation namespaces. Federation activity, API keys/docs, agreements, partners, analytics, settings, webhooks, GDPR/compliance, roles, logs, legal docs, system monitoring, and enterprise health screens now load admin_federation or admin_enterprise instead of the full admin translation monolith, with bundle-budget guardrails covering both modules.
- Super-admin and volunteering admin routes now use split translation namespaces. Super admin tenants/users/audit/federation/pilot-inquiry screens and volunteering approvals, config, consents, expenses, giving days, hours audit, organizations, projects, safeguarding, training, and refund screens now load admin_super or admin_volunteering instead of the full admin translation monolith, with bundle-budget guardrails covering both modules.
- Split admin locale groups are no longer duplicated in the monolithic admin namespace. The migrated newsletter, advanced, configuration, system, federation, enterprise, super-admin, and volunteering translation groups were pruned from every locale's admin.json, cutting the English admin monolith by roughly 395 KB and adding a bundle-budget guard so split groups stay out of the fallback admin payload.
- More admin modules now avoid the monolithic admin locale payload. Timebanking, groups, gamification, reports, billing, analytics, matching, moderation, CRM, AI, marketplace, content, resources, blog, jobs, users, listings, caring-community, advertising, agents, API partners, categories, community, deliverability, diagnostics, events, goals, help, ideation, impact, legal, national, performance, podcasts, polls, premium, provisioning, regional analytics, safeguarding, support, and shared editor/legal admin screens now use module-level admin namespaces, with their migrated groups pruned from admin.json; the English monolithic admin locale is now roughly 2 KB instead of the original roughly 903 KB.
- Global locale startup no longer preloads broker/admin panel payloads. The 77 KB broker namespace was removed from the universal i18n startup list, while the broker and partner panel shells now load only their own small shell namespace instead of prefetching the 903 KB admin namespace before a specific embedded admin page needs it. The bundle-budget gate now blocks admin/broker namespaces from returning to global startup and blocks broker/partner shell preloads of the admin monolith.
- PWA install no longer precaches heavyweight lazy chunks. The generated service worker now excludes newsletter builder, asset-library, HTML editor, map, PDF/chart, markdown/editor, contextual-help, and large PNG fallback bundles from the precache, cutting the generated precache from roughly 18.6 MB to roughly 10.0 MB while keeping those hashed assets browser-cached on demand. The bundle-budget gate now enforces a 12 MiB service-worker precache ceiling so route-only bundles cannot quietly return to first-visit install cost.
- Frontend CI installs recover after the startup-budget dependency update. The React lockfile now records the rxjs dependency shape expected by npm 10 npm ci, including HeroUI CLI's optional peer entry, the Lighthouse workflow runs on Node 22 to match the frontend engine requirement, and the React CI job has a bounded timeout so a stuck worker cannot leave main pending indefinitely.
- Platform audit — Priority 1 fixes for feed pagination, notification locale, and hot-path performance. The feed's "load more" no longer sticks in a permanent loading state when a fresh load (new-posts banner, filter/mode switch, compose success, or pull-to-refresh) interrupts an in-flight append: the loading flag now resets by abort-controller identity rather than abortedness, so infinite scroll, the load-more button, and pagination skeletons recover instead of freezing until remount. Admin-initiated member notifications (credit grants, balance adjustments, event approvals, group approve/reject/remove, report resolve/dismiss, review hide/remove, deliverable assign/reassign — bell + device push) now render in the recipient's preferred_language via LocaleContext::withLocale, and the credit-grant/balance-adjust hour unit is pluralised through trans_choice (new wallet_admin.hours_unit key across all locales) instead of a hardcoded English ternary. Personalised marketplace browse no longer rebuilds the collaborative-filtering interaction matrix once per saved listing (now memoised per request and seeded from the 25 most-recent saves) and batches per-owner reciprocity lookups into one query, and the notification bell dropdown hydrates all grouped actors in a single users query instead of one query per collapsed group. Root Cause: the feed's finally guard skipped the reset for the deliberately-aborted append; admin notification blocks rendered __() under the acting admin's request locale; and the ranking/notification-grouping loops issued per-iteration queries. Prevention: loading flags reset by controller identity, all admin member-notification sends are wrapped in LocaleContext, and the batched-query paths are covered by the existing ListingRanking/CollaborativeFiltering and notification-grouping tests.
- Saved-collection detail and the message composer handle failure and non-mouse input correctly. CollectionDetailPage now shows an error state with a retry action instead of rendering a blank page when the collection fails to load, and the message-composer attachment-remove button is now always visible (previously hover-only, invisible on touch) with a larger 24px target so it works for touch and keyboard users, not just mouse hover. Root Cause: the collection loader used try/finally with no catch, leaving data null so neither the loading, empty, nor content branch rendered; the remove button relied on group-hover opacity and a 16px hit area. Prevention: the loader now catches failures into an explicit error state, and the control is always-visible with an enlarged target.
- Federation protocol privacy and ledger follow-up. Credit Commons completion now uses one balance/hash/ledger path for both POST commits and PATCH-to-completed transitions, records the canonical transactions ledger row exactly once, and respects settings-backed federation consent for account discovery, debits, credits, relay credits, and local payees. Komunitin account discovery/detail and transfer settlement now use the same federation_user_settings gates instead of legacy users.federation_optin, V1 member/listing federation APIs now honour profile/search/item visibility, and newly-created/imported users now seed as local-only until they explicitly opt into Federation. The accessible Federation onboarding parity test now exercises CSRF correctly, and the React Federation hub/connections polish pass removes the heavy gradient treatment from primary Federation navigation/action surfaces.
- Federation internal module audit fixes. Internal Federation now consistently honours member profile visibility and item-level federated visibility across React API and accessible frontend browse/detail paths, clamps member travel radius, clears notification/review sharing flags on opt-out, stores message and transaction text unescaped, and localises recipient notifications plus backend activity/audit labels with the recipient language. Admin Federation settings now write the real tenant feature gates instead of legacy config-only values; partnership rows, analytics, balances, and exports count same-platform messages/transactions from the canonical internal ledgers without double-counting sender/receiver copies. Data Management exports the tenant-scoped internal Federation records as well as partnerships/external partners, clearly labels the limited import scope, and the missing admin locale keys for that page are now present across supported locales. Regression coverage was added for private profiles, listing/event visibility, message-copy visibility, and admin partnership statistics.
- Federation follow-up audit fixes tightened setup access and member privacy. Super-admin-only Federation setup endpoints now enforce that boundary server-side for API keys, data import/export/purge, aggregate consent secrets, webhooks, external protocols, Credit Commons config, and settings mutation; member activity feeds are opt-in gated and scoped to the current member; partner listing stats count only visible federated listings from opted-in owners; group browsing respects the group owner's Federation privacy; accessible Federation transfers are idempotent and count received transactions correctly; quick opt-in keeps location sharing off by default; and failed optimistic message read updates roll back in React.
- Marketplace checkout now completes the audited buyer paths instead of dropping critical order details. Accepted offers now expose a buyer payment action, listing checkout requires an explicit shipping or pickup choice when fulfilment options exist, selected shipping costs are sent to the order API and reflected in the Stripe total, coupon discounts update the displayed checkout total, pickup reservation failures stop payment-intent creation, and sellers now have a discoverable shipping-options management route from marketplace navigation. The order API validates shipping_cost, and SQL marketplace pagination now uses sort-aware cursors for price/popular ordering so equal-price listings are not skipped or duplicated across pages. Regression coverage pins accepted-offer checkout, shipping totals, coupon totals, pickup reservation failures, shipping manager discovery, shipping-cost order payloads, and price cursor pagination.
- Marketplace checkout and seller onboarding now use registered, tested upload/shipping contracts. Buyer shipping selection can fetch a seller's active shipping options through the public /v2/marketplace/sellers/{id}/shipping-options route, merchant onboarding avatar/cover uploads now go through a dedicated /v2/merchant-onboarding/image endpoint instead of the text-only seller profile update path, and the marketplace form/filter UI no longer emits the audited React/a11y warnings. Regression coverage pins the buyer shipping route, onboarding image upload, and React upload endpoint wiring.
- Production login and registration no longer do heavy unauthenticated startup work. React i18n now preloads only the startup namespaces needed by the shell/login flow and lets route components lazy-load their own translation files, removing the large all-namespace locale waterfall from unauthenticated page loads. The register route is lazy-loaded out of the main app bundle, Google Places setup is deferred until a Google-backed location field is focused or edited, and auth pages skip public menu fetching because they do not render the navbar/footer.
- Image-heavy listing, marketplace, and branding surfaces now request cached thumbnails instead of full-size local uploads. A public Laravel thumbnail endpoint safely resizes same-origin /uploads and /storage media on demand with long-lived cache headers, listing cards/detail heroes, feed media, message context cards, group media grids, marketplace galleries, order dashboards, collections, map-search cards, profile listing cards, search and federation listing/event cards, story highlight previews, podcast mini-player artwork, author avatars, tenant/partner logos, course/ideation/caring covers, and shared avatar images all request bounded derivatives for local uploads. Built-in footer/partner-page artwork now ships WebP alternatives instead of forcing multi-megabyte PNGs on modern browsers. Shared avatars default to lazy/async loading, realtime startup code is deferred until it is actually needed, Sentry now loads through the consent-gated wrapper instead of a startup SDK import, and marketplace browse/search now hydrates only the primary card image plus an image count instead of eager-loading multiple images per listing.
- Marketplace avatars now use the cached thumbnail pipeline by default. Local uploaded avatar URLs now resolve through /api/v2/media/thumbnail at a bounded 96x96 size, while external/federated avatar URLs remain untouched. Marketplace detail, buyer orders, seller orders, and seller profile pages now use the shared avatar resolver instead of direct raw avatar_url image sources, closing the live trace class where listing pages could download multi-megabyte avatar originals.
- Partner-page artwork now avoids heavyweight original image fallbacks. The public partner page now points its banner, NEXUS logo, and West Cork partner artwork at the existing WebP assets even on the fallback img src, removing ambiguity around large PNG/JPG originals. The unused PNG/JPG originals for partner banners, powered-by badges, and the NEXUS logo were removed from public/images, and the bundle-budget guard now fails if those heavyweight originals or references quietly return.
- Telemetry context updates no longer pull Sentry into returning-user startup. User, tenant, and breadcrumb context set during auth/tenant bootstrap is now queued inside the lightweight telemetry wrapper and flushed only after the post-paint/idle Sentry initialization has completed, so analytics-consented sessions do not load the Sentry SDK just because bootstrap finished before the idle window.
- Routine startup telemetry no longer requests the Sentry wrapper chunk before idle. Auth user context, tenant context, API timing breadcrumbs, API stale-client messages, and service-worker reload breadcrumbs now pass through a tiny consent-gated telemetry queue that waits until after first paint/idle before importing the local Sentry facade. Crash boundaries, support reports, and missing-translation/error reports still load telemetry on demand, but ordinary login/register/bootstrap traffic no longer spends an early network request on the Sentry wrapper even for returning analytics-consented users. The bundle-budget guard now blocks AuthContext, TenantContext, and the API client from reintroducing direct Sentry-wrapper imports on those startup paths.
- The production service worker no longer precaches every route bundle on install. The PWA precache now contains only the push handler, manifest, favicon/Open Graph image, and app icons instead of hundreds of JS/CSS route chunks, preventing a first-install background download of the full application. The bundle-budget guard now caps generated precache payloads at 256 KiB so route-only code stays demand-loaded.
- Auth startup now preloads fewer translation namespaces. The global i18n bootstrap no longer fetches legal, public, or settings locale files on every app load; those route namespaces lazy-load when their pages or modals mount, while login/register keep only the auth-shell namespaces needed for first render.
- Repeat visits now keep build-scoped translation caches warm for longer. Production i18n localStorage entries and the service-worker locale runtime cache now live for 30 days instead of expiring after an hour/day; the cache keys and URLs remain build-versioned, and stale build prefixes are purged, so new deployments still invalidate translations while repeat visits avoid unnecessary locale JSON refetches.
- Optional analytics no longer load on auth-entry pages. Returning users who previously granted analytics consent no longer fetch Sentry or Ahrefs while sitting on login, register, password reset, email verification, identity verification, or OAuth callback routes; the consent provider starts optional telemetry only after navigation into a non-auth app route, while crash capture still lazy-loads the lightweight Sentry wrapper only if an actual root error occurs.
- Consented Sentry startup waits until after first paint/idle. Non-authenticated route visits that already have analytics consent no longer fetch even the local Sentry facade chunk during the initial render; the consent provider now schedules that import after the post-paint idle window, while auth-entry routes still skip optional analytics entirely and crash capture remains lazy/on-demand.
- Prerendered public pages no longer preload route-only app chunks. The static prerender pipelines now strip non-core Vite modulepreload hints from generated public HTML snapshots, preserving only vendor-react and vendor-i18n; public pages still hydrate through the normal import graph, but first paint no longer has to compete with eager vendor-heroui, route, map, chart, or editor preload fetches. The bundle-budget gate now scans every generated HTML file so heavy prerender preloads cannot silently return.
- Authenticated layout chrome now lazy-loads rare global surfaces. The shared app layout now loads the podcast mini-player, caring-community emergency banner, Swiss FADP consent banner, and floating support reporter only behind their existing feature/auth gates, keeping ordinary app routes from bundling those conditional controls into the common layout chunk.
- The cookie-consent banner no longer competes with first auth paint. The consent provider still mounts immediately and optional telemetry remains blocked until consent, but the visible banner chunk now waits until after the initial render/idle window before loading its modal, disclosure, switch, and icon dependencies. Local production-preview traces show login/register first-second startup at 15 total requests, 6 JS requests, zero Maps, zero Stripe, zero Sentry, zero admin locale requests, and zero cookie-banner chunk requests.
- Authenticated cookie-consent restore no longer competes with first paint. The provider still saves explicit consent changes to the server immediately, but the cross-device GET /cookie-consent restore for already-authenticated users now waits until after the first render/idle window. A focused regression test verifies the restore request is not issued on mount and only runs after the deferred idle window, keeping login/register and first app-route rendering free of this non-critical API request.
- Production service-worker registration no longer competes with auth startup. The controllerchange safety listener still attaches immediately so already-controlled tabs can reload safely after an update, but the actual /sw.js registration and periodic update polling now wait until after first paint/idle. The bundle-budget guard now fails if main.tsx regresses to immediate service-worker registration, preventing the PWA update check from adding another early request during login/register/bootstrap.
- Map and Google Places code now load only when needed. Shared location cards, marketplace listing detail maps, marketplace map search, generic entity map views, and admin community geography analytics lazy-load the Google/OpenStreetMap implementation only after maps are enabled and coordinates are present, so profile, event, group, listing/detail, marketplace, and admin analytics routes no longer fetch map-provider code just because they include a possible map branch. The Google Places autocomplete branch also moved behind a focused/edited-field lazy import, keeping create/register/detail workflows from loading @vis.gl/react-google-maps before the user actually uses Google-backed location search. The bundle-budget guard now covers the location component folder and blocks LocationMapCard, PlaceAutocompleteInput, or direct production LocationMap imports from reintroducing static map/provider imports.
- Authenticated layout chrome now avoids the shared UI barrel. Navbar, mobile drawer/tab bar, notification flyout, quick-create, theme picker, footer, tenant logo, and shared navigation menu items now import only the UI primitives they render instead of the full @/components/ui barrel. The bundle-budget guard now covers layout, branding, and navigation runtime folders so common app chrome cannot accidentally pull unrelated UI modules into repeated route chunks.
- The React route registry no longer rides in the first app chunk. TenantShell now loads the tenant route registry from a dedicated route chunk, preserving slug-stripped tenant routing while moving the hundreds of route dynamic-import references out of App.tsx. The bundle-budget guard now blocks the route registry from drifting back into the startup file.
- Route-island Tailwind utilities now load with their lazy apps. The main stylesheet no longer scans src/admin, src/broker, src/caring, src/partners, or src/super-admin; each lazy app entry imports its own focused Tailwind sheet instead, moving admin, broker, caring-community, partner-timebank, and super-admin utility CSS out of login/register/member-facing startup. The bundle-budget guard now verifies that the main Tailwind source excludes those route islands and that each lazy app keeps its CSS split wired in.
- Shared HeroUI wrappers now avoid the package-root barrel. The local UI compatibility layer imports HeroUI primitives from focused @heroui/react/* subpaths instead of the root export wherever the installed package exposes one, leaving only the package-root-only disclosure hook on the root import. The bundle-budget guard now blocks root HeroUI imports from returning to production source, and wrapper tests mock the same subpath modules used by runtime code.
- Auth pages no longer download the all-HeroUI vendor chunk. Vite now keeps React Aria/Stately/Types/Internationalized internals in a focused vendor-react-aria chunk while letting @heroui component packages split with the route/UI surfaces that use them. The built login static JS graph dropped from about 474 KiB gzip to 369 KiB gzip, and register from about 481 KiB gzip to 377 KiB gzip. The bundle-budget gate now computes the built LoginPage/RegisterPage static JS graphs and fails above 420 KiB gzip so the old all-HeroUI chunk cannot return.
- The compose modal now loads only the active creation workflow. The shared compose shell no longer imports post, listing, poll, event, and goal tab implementations synchronously; each tab is now its own lazy boundary, so opening one compose workflow does not also download every other compose form. The bundle-budget guard now blocks both the shared UI barrel and synchronous tab imports from returning to ComposeHub.
- Feed visits no longer download composer and desktop-sidebar code up front. The feed page now lazy-loads ComposeHub only when creating/editing content and lazy-loads the desktop sidebar behind the existing desktop media-query gate, so mobile and read-only feed visits stay focused on the actual feed list instead of fetching composer forms and sidebar widgets. The bundle-budget guard now blocks those static imports from returning to FeedPage.
- Feed cards defer comment threads and analytics until opened. FeedCard now lazy-loads the threaded comments UI only when a card's comments are expanded and lazy-loads the post analytics modal only when the analytics action is selected, keeping ordinary feed scrolling focused on visible card content. Bundle-budget checks prevent those optional surfaces from returning to the first feed-card chunk.
- Feed reaction summaries no longer preload the reactor-details modal. The visible reaction count row now keeps only the lightweight summary in the feed card and lazy-loads the tabbed reactor-details modal, user lookup, avatars, and loading state only after a user opens reaction details. Bundle-budget checks prevent the modal/API path from returning to ReactionSummary.
- Feed bookmark controls no longer preload collection-management UI. The ordinary save/unsave button now keeps the long-press collection picker in its own lazy chunk and the feed social controls use focused UI primitive imports, so scrolling the feed does not download bookmark collection forms before the user asks for them. Bundle-budget checks prevent the picker or broad UI barrel from returning to the feed-card path.
- Toast rendering no longer rides the global startup chunk. The toast context now keeps only the lightweight state/actions on the app shell and lazy-loads the animated live-region viewport, motion shim, and toast icons only when a toast is actually shown.
- Production performance hardening now covers marketplace checkout, donation checkout, maps, editor CSS, contact security checks, and more image-heavy pages. Marketplace, donation, Verein dues, and optional identity-verification payment flows lazy-load Stripe only after a payment intent exists, the marketplace barrel no longer re-exports Stripe or map modules into ordinary browse/detail chunks, GrapesJS builder CSS moved out of the public app shell and into the lazy admin builder chunks, Contact now defers Cloudflare Turnstile until the form is used instead of loading the challenge script on first paint, Apache no longer overwrites the media-thumbnail endpoint's immutable cache headers, shared image uploads now create a persistent 640px WebP/JPEG derivative, and blog/event/group/explore/marketplace detail surfaces request bounded thumbnails instead of raw local uploads.
- Hot-path database indexes now cover the remaining audited listing, marketplace, inbox, and notification scans. A guarded Laravel migration adds additive composite indexes for public marketplace browsing by newest, price, popularity, and promoted ordering; message inbox sender/receiver latest-message scans plus unread sender counts; notification cursor lists with unread/type filters; and public listings pagination with moderation-aware filtering. The message inbox query now scans sent and received messages as separate indexed branches before grouping conversations, avoiding the old OR/CASE access path that could collapse into a broad tenant scan. Public event lists now apply the same active/null status visibility rule as nearby events, keeping draft/cancelled/completed rows off the hot public list path and aligning the query with the existing tenant/status/start-time index. A new read-only php artisan performance:audit-hot-paths --strict command runs representative EXPLAIN checks for feed, listings, marketplace, SQL search fallback, messages, events, and notifications so the production DB pass can be repeated after deploy without ad hoc SQL. The migration checks tables, columns, and existing index names before altering schema so older or partially migrated environments can run it safely.
- Search and member-directory hot paths now have deeper DB performance coverage. The read-only hot-path audit now checks unified search fallbacks for users/events/groups, autocomplete suggestions, trending terms, saved-search lists, and the member-directory fallback instead of treating "search" as listings-only. SQL event search and suggestions now apply the same active/null status visibility rule as public event browsing, and a guarded follow-up migration adds indexes for event search by tenant/start time, saved-search listing by user/created date, and privacy-aware member-directory scans. The audit now distinguishes tiny local table scans with viable candidate indexes from true larger-table scan risks, so local Docker evidence stays readable while production EXPLAIN runs can still flag load-bearing regressions.
- Auth startup, public routes, and admin navigation now stay leaner under the performance budget. Returning analytics-consented users no longer start the Sentry SDK before first paint; the app shell now keeps a local crash boundary in main.tsx, schedules the Sentry wrapper from a post-mount React effect after first paint/idle, and otherwise lazy-loads telemetry only when a breadcrumb/error is actually needed. The budget gate blocks static Sentry imports or direct Sentry startup from returning to main.tsx. Login/register now use a separate auth layout and lazy-load the full navbar/footer/podcast/session shell only when a non-auth route needs it, while auth, identity, public, about, help, legal, listings, blog, explore, marketplace, feed, volunteering, matches, group-exchange, skills, activity, bookmarks, resources, knowledge-base, federation, onboarding, ideation, caring-community, clubs, organisations, advertising, settings, hashtag, endorsement, jobs, goals, polls, groups, exchanges, leaderboard, achievements, dashboard, messages, wallet, profile, search, notifications, members, events, and landing/listing components import UI pieces directly instead of pulling the broad shared UI barrel back into their chunk graphs, cutting the built main entry from roughly 233 KiB gzip to roughly 178 KiB gzip. Auth SSO/OAuth buttons, password-strength feedback, suspense/error fallback screens, cookie consent, premium gating, glass-card primitives, and the globally mounted toast provider now also import only their rendered UI primitives instead of the broad barrel; the crash-only support-report form is lazy-loaded from the error boundary instead of riding the normal app startup path. The register page also lazy-loads the Google-backed location autocomplete instead of statically importing the location/maps barrel on the auth startup path. The auth language switcher, source repository tooltip, legal acceptance modal, login page, and confirm-dialog surface now load on demand, and the Rollup vendor split keeps React's JSX runtime out of the HeroUI vendor chunk, so the HTML shell no longer modulepreloads vendor-heroui and the built main entry is roughly 96 KiB gzip. Admin breadcrumbs, headers, sidebars, page headers, stat cards, data tables, bulk-action toolbars, icon pickers, visibility-rule controls, and the contextual-help drawer now use direct UI primitive imports and/or the small admin_nav namespace, and the super-admin entry no longer preloads admin.json, so admin shells and dashboard-style pages do not fetch the monolithic admin locale just to render navigation/loading/help/table/action/icon-picker/visibility labels. Admin page headers also defer the 76 KB contextual-help registry until after render and lazy-load the help drawer only when opened, and the admin header defers its non-critical support-request count lookup until after paint/idle so every admin route does not compete with first render for that badge request. The bundle-budget gate now fails if the main JS entry regresses above 200 KiB gzip, if the HTML shell modulepreloads HeroUI/charts/maps/Sentry chunks, if startup/auth files reintroduce the broad UI barrel / location barrel / monolithic admin namespace / startup Sentry import, if shared admin chrome reintroduces the help registry/drawer, full UI barrel, or monolithic namespace on the initial/open-help/table/action/icon-picker/visibility-control path, or if performance-critical public and high-traffic route surfaces reintroduce the broad UI barrel.
- Auth-entry routes now avoid the remaining post-idle startup leaks. Routine telemetry queued on login/register/password/verification/OAuth entry routes no longer flushes Sentry just because a returning user previously allowed analytics, tenant SSO/OAuth buttons render without the shared HeroUI button wrapper, auth pages import PageMeta and the page-title hook directly instead of the SEO/hooks barrels, and the login page defers SimpleWebAuthn/passkey autofill code until the user focuses the email field or starts passkey login. Bundle-budget checks now block static WebAuthn imports in LoginPage, auth-startup hooks/SEO-barrel imports, and missing auth-route telemetry suppression.
- Added frontend loading regression budgets for the performance audit. Lighthouse CI now has explicit script/stylesheet/image/total resource budgets, a Playwright performance spec checks that login/register do not eagerly load Maps, Stripe, realtime, telemetry, or a large locale waterfall, browse pages avoid checkout/map bundles and oversized local image responses, and npm --prefix react-frontend run check:bundle-budget fails if the built main JS/CSS gzip payloads creep above the audited thresholds. npm --prefix react-frontend run analyze:bundle now emits a Rollup visualizer report for bundle investigations. Tenant bootstrap, public tenant lists, platform stats, category lists, and marketplace category templates now get public ETag/cache headers plus tenant-aware in-memory SPA caching, while role-aware menu responses stay uncached at the HTTP layer. The PHP Docker runtimes now install the webp command-line tools alongside GD WebP support so legacy and batch conversion paths have the expected converter available, and php artisan media:warm-thumbnails can safely dry-run or pre-generate the same cached derivatives used by /api/v2/media/thumbnail for existing uploads.
- Production route performance audits are now repeatable after deployment. npm run audit:performance:routes runs a Playwright-based route sweep for login, register, listings, marketplace detail, feed, profile, search, messages, events, groups, and admin dashboard against PERF_AUDIT_BASE_URL/PERF_AUDIT_TENANT_SLUG, optionally using PERF_AUDIT_EMAIL/PERF_AUDIT_PASSWORD and admin credentials for protected workflows. It records request counts, transfer sizes, navigation timings, oversized local images, unexpected Maps/Stripe/Sentry/realtime loads, media-thumbnail cache headers, each route's largest transferred resources, and locale-waterfall warnings under .local-docs-archive/performance-traces/latest/; it explicitly flags too many locale JSON responses, admin locale payloads on non-admin routes, and oversized locale files so the original language-file regression is checked in every post-deploy trace. The audit also auto-discovers the first marketplace detail link from the marketplace browse page when PERF_AUDIT_MARKETPLACE_DETAIL_PATH is absent, warns if no real detail URL can be found, labels localhost/Vite runs as dev-mode evidence rather than production proof, and fails fast when the target host is unreachable so the post-deploy production trace requirement is a deterministic command instead of a manual checklist.
- CDN/cache-header verification is now a repeatable post-deploy command. npm run audit:performance:cache probes the app HTML shell, service worker, manifest, and a real media-thumbnail URL against CACHE_AUDIT_BASE_URL, records Cache-Control, content type, validators, and CDN cache headers under .local-docs-archive/performance-traces/latest/, reuses the first thumbnail URL observed by audit:performance:routes when CACHE_AUDIT_THUMBNAIL_URL is not set, and fails/warns when the HTML shell is cacheable without revalidation, service-worker caching is too loose, or thumbnails lack long-lived immutable caching plus ETag/Last-Modified evidence.
- Volunteering group reservations now consume shift capacity everywhere. Shift listings, applications, direct shift signup, admin approval, waitlist joins/promotions, and group reservation creation now share reservation-aware capacity accounting; group reservation creation locks the shift row before checking slots and inserting, direct signup re-checks opportunity/organisation visibility, failed expense submissions delete uploaded receipts, and the React volunteering/organisation pages hide disabled matching, expenses, QR check-in, and review surfaces instead of leaving dead UI.
- Jobs module audit fixes closed pagination, visibility, i18n, and test-runner gaps. Public job browsing now uses structured cursor pagination that respects featured-first ordering plus deadline/salary sort keys, recommendations share the same open/deadline/moderation visibility filter as public listings, jobs AI/calendar/audit/offer strings now render through PHP translations, the timebank offer credit ledger description is translated, and the jobs page/component Vitest coverage no longer hangs on recursive/asynchronous mocks.
- Volunteering shifts now enforce approval, capacity, and visibility rules consistently. Opportunity creation/update, group signup, waitlist, expenses, check-in, matching, and organisation application flows now share stricter approved-organisation/opportunity checks, translated validation messages, and frontend guardrails so pending or unavailable volunteering records cannot leak into public or applicant workflows.
- Volunteering React workflows now expose the end-to-end module actions. Browse and organisation-profile apply flows recover into guardian consent, opportunity details can sign up, switch, cancel, or waitlist shifts, opportunity creation sends remote/geocoded data, expenses upload receipts, group leaders can reserve shift slots, organisation profiles accept reviews, and organisation application declines support policy-aware decision notes.
- Volunteering organisation profiles no longer expose pending or suspended organisations. The public React/API and accessible frontend organisation detail paths now use the same approved/active visibility rule as the directory, while still returning the full public stats contract (opportunity_count, volunteer_count, total_hours, review_count, average_rating) for approved profiles. New organisation registration now returns users to their owner-scoped managed-organisations area on web and mobile instead of opening a public pending profile. The organisation detail page and accessible jobs placeholder no longer pass vol_organizations.id into the jobs module's separate organizations.id filter, avoiding unrelated job matches. Volunteer hour logging now refuses non-approved organisations, and the documented admin volunteering opportunities endpoint is registered. Regression coverage pins public API, service, accessible, React, and mobile behavior. Root Cause: the public detail path called the older British-spelling service method, which was tenant-scoped but not status-scoped or stats-complete, and client registration flows had come to rely on that leak. Prevention: both organisation detail service methods now hide non-approved organisations by default, and tests cover pending/suspended HTTP detail, accessible detail/jobs, registration redirects, and suspended-org hour logging.
- Main-branch CI now passes npm 10 lockfile validation, PHP 8.2 config bootstrap, and the current OWASP scan. The React frontend lockfile now includes the nested rxjs@7.8.2 peer entry required by HeroUI/i18next tooling under GitHub Actions npm 10 resolver, the Docker/test database SSL option now uses a cache-serializable PHP 8.2-compatible PDO option map, the test translation preload limiter now reads through cached Laravel config, the OWASP-only extract-zip@2.0.1 finding is suppressed as dev/CI-only Puppeteer browser-download tooling with no patched npm release, and prerender/page/wallet regression tests were updated for tenant-prefixed inventory routes, full custom-page fillable fields, and collision-free CI fixture users.
- Blog featured-image uploads now preview and render reliably across dev and production hosts. The blog builder no longer saves the environment-specific absolute URL returned by the upload endpoint, which could persist localhost or a development API hostname into posts.featured_image; uploaded blog images are saved as /storage/... paths and resolved at display time. The React asset resolver now honors VITE_API_URL as well as VITE_API_BASE, re-roots stale localhost /storage URLs, and the Vite dev/preview server proxies /storage alongside /uploads. The public Blog API also re-roots stale localhost storage URLs so posts created during the broken window can still render.
- Custom Pages built with GrapesJS now respect light and dark mode. The NEXUS starter blocks no longer export hard-coded light backgrounds/text colours; they use the platform theme tokens (--background, --foreground, --surface-elevated, --border-default, --accent-color) with fallbacks. Public custom-page rendering now injects a scoped builder baseline so builder pages inherit the active React/HeroUI/Tailwind theme, and legacy saved .nexus-page-* starter CSS is tokenized at render time so pages created before this fix are not stuck in a light palette. The scoped NEXUS starter theme override is emitted after saved builder CSS, so old pale hero gradients cannot win over dark mode. The builder preview iframe now receives explicit light/dark token definitions instead of always rendering on a white canvas. Regression coverage pins tokenized starter CSS, legacy public rendering, and themed preview output.
- Fresh install Super Admin now boots into a usable tenant-1 platform root. The default seeder now gives tenant 1 the required hierarchy root path (/1/) as well as hub/subtenant permissions, and the bootstrap regression now logs in with the seeded god-mode admin, verifies the Super Admin dashboard, tenant list, hierarchy endpoint, and creates a child tenant under the master tenant. True god-mode users now receive platform-global Super Admin access even when their current login token belongs to a non-hub tenant such as tenant 2; ordinary tenant superadmins still need master/hub scope. The React tenant bootstrap also clears tenant-bound auth tokens when URL/bootstrap resolution moves the app from one tenant to another, preventing stale tenant-2 sessions from producing misleading Super Admin 403s on tenant 1.
- React error fallback no longer crashes outside the toast provider. The app-level error boundary now sits inside ToastProvider, and the fallback can suppress the provider-dependent report-problem action when needed. This prevents an original route/chunk failure from being hidden behind a secondary useToast must be used within a ToastProvider crash.
- Custom Pages built with GrapesJS now respect light and dark mode. The NEXUS starter blocks no longer export hard-coded light backgrounds/text colours; they use the platform theme tokens (--background, --foreground, --surface-elevated, --border-default, --accent-color) with fallbacks. Public custom-page rendering now injects a scoped builder baseline so builder pages inherit the active React/HeroUI/Tailwind theme, and legacy saved .nexus-page-* starter CSS is tokenized at render time so pages created before this fix are not stuck in a light palette. The scoped NEXUS starter theme override is emitted after saved builder CSS, so old pale hero gradients cannot win over dark mode. The builder preview iframe now receives explicit light/dark token definitions instead of always rendering on a white canvas. Regression coverage pins tokenized starter CSS, legacy public rendering, and themed preview output.
- Local Laravel API boot no longer stalls the Vite login flow on Docker Desktop. The custom translation cache now stores merged PHP + JSON translation groups, avoiding a full per-locale PHP translation preload during every Laravel bootstrap. This cuts local Docker API startup/request overhead that made the React Vite login screen feel stuck.
- Docker fresh installs can load the committed Laravel schema dump without a MySQL SSL mismatch. The local/dev/test database config now disables MariaDB client SSL verification for Laravel's schema-loader path, matching the non-SSL Docker DB service used by php artisan migrate --seed.
- Local development documentation is now Docker-first. Removed stale non-Docker local-server guidance and its hardcoded startup helper, standardized local API references on Docker PHP at 127.0.0.1:8090, and updated frontend/mobile/accessibility examples so fresh installs target the Docker stack.
- Fresh prerender audit closed the remaining dynamic-route and all-tenant route-safety gaps. Manual enqueue, webhook/service invalidation, and unexpected-snapshot cleanup now all fail closed for tenant-owned dynamic routes: public detail pages must match a known sitemap shape and belong to the target tenant before they can be recached, while private/tool routes such as /listings/create and /profile/settings are rejected instead of slipping through broad prefixes. Explicit all-tenant route jobs are now limited to the always-public route floor, so feature-gated routes such as /jobs cannot bypass the tenant-aware planner and render across tenants with that module disabled. The cleanup sweep now removes wrong-tenant dynamic snapshots such as stale /blog/{slug} files, not just wrong-tenant CMS pages. The host queue worker now validates the exact five-key job export contract before eval, and the Inventory UI translates staleness chips consistently instead of showing raw API enum values.
- Custom Pages admin list now stays lightweight for full GrapesJS pages. GET /v2/admin/pages no longer returns the full content, content_format, or potentially multi-megabyte design_json editor payload for every row; the list screen keeps using summary metadata, while the edit screen still loads the full page through GET /v2/admin/pages/{id}. A controller regression test pins the slim list contract so large designed pages cannot make Content > Pages sluggish again.
- Fresh custom Pages builder audit pass closed the remaining edit/save race and UI polish gaps. Switching away from Design mode now flushes the live GrapesJS canvas before any destructive-mode warning, cancelled switches keep the fresh builder state instead of falling back to stale parent props, and mode tabs stay inert while saving. The Design canvas/palette/inspector now show a read-only saving layer so direct GrapesJS edits cannot mutate the page underneath an in-flight save. Public custom-page breadcrumbs now route through the tenant-aware home path, and shared builder chrome comments/tests were generalized from newsletter-specific wording to the shared page/newsletter builder surface.
- Fresh custom Pages full-stack audit fixed schema bootstrap drift and the remaining outer-form save race. The committed MySQL schema dump now records the Pages builder migration in laravel_migrations, matching the columns already present in the dump so schema-loaded environments do not think the migration is still pending. The Content > Pages form now disables title, slug, meta, status, navigation settings, preview/back/cancel controls, and menu fields during an in-flight save, matching the editor lock so authors cannot change page metadata under the request.
- Custom Pages HTML rendering now preserves safe inline styling from pasted or builder-authored HTML. Public custom pages still strip scripts, event handlers, unsafe URL schemes, app-shell/global CSS, fixed/sticky overlay declarations, zero-inset escapes, and z-index, but safe inline declarations such as colours, spacing, sizing, and border radii now survive the public sanitizer. This fixes a production-readiness gap where HTML pasted into Content > Pages could save correctly but render visually flattened on the public page. The unsafe CSS checks were also made deterministic by removing stateful global regex flags, with repeated-call regression coverage.
- Hardened the custom Pages GrapesJS builder completion path. Creating a page now respects an explicitly entered slug while preserving title-derived and duplicate-suffix behaviour, public builder HTML no longer renders inside a duplicate builder wrapper, and the shared image-library/modal + HTML insert-image flows now use the builder asset API with explicit page/newsletter labels. Page Design uploads now register absolute uploaded image URLs with GrapesJS whether they come from the toolbar, the GrapesJS asset manager, or the image library, HTML-mode custom pages render through the scoped custom-page HTML path so pasted designed HTML keeps its layout/CSS, and regression tests cover manual slugs, wrapper scoping, toolbar uploads, selected-image replacement, asset-manager drops, and library upload/select behaviour.
- The prerender engine no longer fans tenant-owned detail routes, including custom CMS pages, across every tenant. Explicit prerender jobs such as /page/foo now require a tenant_slug/tenant id at the API, service, and host-script layers, preventing a page that exists only on one community from being rendered as 404 snapshots under all other tenants. Auto-recache and sitemap-drift detection now normalize cached app-domain paths back to tenant-local routes before enqueueing, so /tenant-a/page/foo is recached as tenant A's /page/foo instead of being misattributed by shared host. The unexpected-snapshot purge now validates /page/{slug} against the owning tenant's published CMS pages, so stale cross-tenant custom-page snapshots can be cleaned up while legitimate tenant pages are preserved. Parent-domain sub-tenant host resolution was also aligned with the sitemap planner. The admin panel now includes plain-language operator workflows, a tenant safety inspector that explains why each saved snapshot exists, and a preview-first live purge flow with typed confirmation; the live-delete button now stays disabled until the matching preview, typed confirmation, and all-tenant acknowledgement are complete. The API enforces the same confirmation for live all-tenant purges, and summary counts now ignore the synthetic inventory-truncation marker instead of treating it as a snapshot. The audit history now uses plain-language action/outcome labels with formatted details instead of raw codes and dense JSON, and the large admin module has been split into focused operator-guide, tenant-safety, purge, and formatting components. Health/metrics observability now reports cache read/write status, tenant-aware route-planner readiness, queue age, stuck jobs, scheduler heartbeats, and the last successful/failed prerender pass with operator-readable labels. The sitemap explorer now parses the actual prerender:plan-routes JSON shape, so operators see tenant dynamic routes from the same plan the worker uses. Regression coverage covers global CMS-page enqueue rejection, tenant-scoped CMS/blog ownership rejection, all-tenant purge confirmation, tenant safety reporting, cross-tenant CMS-page snapshot cleanup, auto-recache/drift wrong-tenant canaries, health/metrics stability, and an authenticated-admin Playwright scenario for the tenant-safety/purge/enqueue workflow. The Docker test database bootstrap now disables unsupported MySQL client SSL verification only in the testing environment, so schema loading no longer fails before the prerender tests can run. Root Cause: tenant-local detail routes were accepted without a tenant scope, and cleanup treated every /page/* snapshot as intrinsically valid dynamic content. Prevention: tenant-owned detail routes are rejected unless tenant-scoped and owned by that tenant according to the sitemap eligibility rules, and purge validates CMS-page ownership before preserving /page/* snapshots.
- The newsletter builder's block palette is now a clean 2-column grid of all 15 email blocks — matching the MJML newsletter demo. An earlier attempt to relabel/describe/group the blocks (customizeBlocks) never actually applied in the browser — it ran before grapesjs-mjml finished registering its blocks (that happens during the editor's onReady cycle, after init() returns), so it was silently overwritten — and a related CSS change had meanwhile left the palette as a cramped single column. The non-functional relabelling was removed (the palette now uses grapesjs-mjml's own default block labels, which match the demo), and the palette is a proper 2-column grid of rounded, themed, icon-on-top + label-below cards (verified against a rendered harness: 2 cards per row, 24px icons). All 15 blocks are surfaced (1/2/3 Column, Text, Button, Image, Divider, Group Social, Social Element, Spacer, Navbar, Navbar Link, Hero, Wrapper, Raw).
- You can now set the hero (and section) background image in the newsletter builder — the "couldn't change the hero image" gap is closed. grapesjs-mjml registers mj-hero without a background-url trait, so a dropped hero had no UI to set its background picture at all. The Settings tab now surfaces Background image URL, Background colour and Height for a selected mj-hero (and background image/colour for mj-section), added on selection via a new builderTraits helper; and the image pipeline is now hero-aware — selecting a hero and using Insert image (or dropping an upload onto it) sets its background-url (an absolute, email-safe URL) instead of inserting a stray mj-image. New newsletter_builder.trait_* strings across all 11 locales (drift 0/0). Regression coverage: imageActionFor (vitest) pins hero → background, image → replace src, anything else → insert a new image.
- The newsletter Design Studio's image feature now works end-to-end — the button, drag-and-drop, and (crucially) the delivered email — and the builder is properly labelled and more fully featured. Three separate defects made images unusable: (1) the "upload" affordance ran through GrapesJS's default AssetManager modal, whose file-picker depends on Font Awesome glyphs this app doesn't ship, so it rendered dead; (2) a dropped image showed in the editor preview but never rendered in the sent email, because on a canvas drop there is no asset-manager target, so GrapesJS's own blob:/base64 preview stayed in the saved model (a live reference in the browser, a dead one in an inbox) — and the insert code also fell back to a relative /storage path, which no email client can resolve; (3) inserting an image mangled the rest of the email, because it appended a raw MJML string with only quote-escaping, corrupting the surrounding MJML nesting. The image path is now a single, reliable pipeline: every entry point (toolbar Insert image, asset-manager drop, edit-existing) uploads to our own domain and applies the absolute URL only (never the relative path — extracted into a tested resolveUploadedUrl), insertion uses the GrapesJS component-definition object API (insertImageComponent — no string re-parse, no sibling corruption), a component:add sweep overwrites any stray blob:/data: image with the uploaded URL, and inserting an image selects it and opens Settings so alt text / link are immediately editable. A server-side send-path safety net (EmailHtmlSanitizer::normalizeEmailImageSources, wired into NewsletterService::renderFullHtmlEmail) guarantees a delivered email never carries a broken image regardless of what was saved: it absolutizes root-relative /storage & /uploads <img src> to APP_URL, rewrites protocol-relative //host to https://, and drops blob:/non-image-data: images. Alongside the fix, the builder was made clearer and more capable (kept in its full-screen studio, which stays): the block palette is now labelled — each block shows a lucide icon, a bold title and a one-line description, grouped under Layout / Content (GrapesJS's native drag-to-canvas is preserved; only the card rendering is ours, via customizeBlocks), and the collapsed side-rails now name themselves instead of being silent bars; a Send test to me action (wiring the existing POST /v2/admin/newsletters/{id}/send-test), a desktop/mobile live-preview modal, editable subject + preheader in the studio header, an autosave status chip, and a first-run "start from a template / blank" card were added. New newsletter_builder.* strings (block labels/descriptions, preview, send-test, empty-state) translated across all 11 locales (drift 0/0). Regression coverage: insertImageComponent/resolveUploadedUrl (vitest, GrapesJS-free), and EmailHtmlSanitizerTest + NewsletterRenderFormatsTest for the send-path image normalization (relative→absolute, blob/junk-data dropped, absolute untouched). Root Cause: the image flow relied on GrapesJS's Font-Awesome-dependent asset modal and left a client-only blob:/base64 (or a relative /storage) src in the saved model, and inserted images as raw MJML strings; nothing on the send path guaranteed reachable, absolute image URLs. Prevention: one shared upload→apply pipeline that inserts only absolute URLs via the object API, a defensive blob sweep, and a tested server-side normalizeEmailImageSources net on every html/builder send.
- Image upload in the newsletter Design Studio (and every other uploader) works again — the upload endpoint was registered at the wrong path, so every upload 404'd. The whole newsletter image pipeline — the GrapesJS asset manager, the "Insert image" toolbar button, and InsertImageButton — uploads through adminNewsletters.uploadImage(), which POSTs to POST /api/v2/upload; the UploadController is written as a v2 endpoint ($isV2Api = true, docblocked POST /api/v2/upload) and returns exactly the { url, path, mime_type, size_bytes } shape the frontend and the uploadResponseSchema Zod validator expect. But the route was only ever registered as POST /upload (→ /api/upload), a path nothing in the app calls — so every real upload hit the non-existent /api/v2/upload and came back 404, and the image silently never appeared. Registered the canonical POST /v2/upload route (kept POST /upload as a harmless legacy alias, same controller + throttle). No frontend change was needed — the client was always correct; the server route simply never matched its own contract. Root Cause: routes/api.php registered the generic upload endpoint as /upload while the controller's $isV2Api contract and every caller targeted /v2/upload, so all uploads 404'd. Prevention: UploadControllerTest now asserts POST /api/v2/upload is registered and auth-gated (a missing route returns 404, so the 401/422 assertions pin the route's existence), alongside the retained /upload coverage. Requires a PHP-app deploy to take effect in production (route registration is server-side).
- A tenant's custom header logo no longer spills over and bleeds past the header on mobile. Uploaded custom logos are curated per-tenant and can be large — a tall square/stacked mark renders at 64px inside the 56px mobile navbar row (and Layout then added a further --logo-extra: 1.75rem top offset), while a wide wordmark at max-w-[200px] crowds the hamburger and action icons on a ~360px phone — so the logo overflowed the header on small screens. On mobile (< sm, 640px) the navbar now shows the compact brand-mark icon — the exact initials Avatar fallback that tenants without a custom logo already get (initials on the tenant's brand colour, auto-contrast text) — and swaps back to the full custom logo at sm+, unchanged. Implemented as an opt-in collapseLogoOnMobile prop on the shared TenantLogo component (Navbar only — the mobile drawer and footer are untouched), using pure Tailwind responsive gates (sm:hidden / hidden sm:inline-flex) so there's no JS media query and it's prerender/SSR-safe; the light/dark logo swap is preserved. Layout now applies --logo-extra at sm+ only, so square-logo tenants no longer get a dead gap below the header on mobile. Also fixed a latent accessibility gap: the brand/home link now always carries aria-label={tenant name} (previously, when the logo image was hidden and the avatar is decorative, or below 480px with no logo, the link had no accessible name). Regression coverage: TenantLogo.test asserts that with a custom logo + collapseLogoOnMobile both the compact avatar and the full logo render (gated by the responsive tokens), that the swap is opt-in, and that the link exposes an accessible name. Root Cause: the header rendered the full-size custom logo at every breakpoint, so large logos exceeded the compact mobile navbar's height and width. Prevention: the Navbar opts into a mobile icon swap centralized in TenantLogo; jsdom can't evaluate media queries, so the test asserts on the presence of the responsive class tokens rather than computed visibility.
- The community switcher now signs a logged-in member out before moving them to another community, instead of stranding them half-authenticated. Switching from a parent timebank (e.g. Timebank UK) into one of its sub-communities (Crew Kirk, Stratford) via the utility-bar "Switch community" dropdown kept the member's session token — but that token is scoped to the original tenant, and the API rejects it against any other community with 403 tenant_mismatch (app/Http/Middleware/Authenticate.php). The result was a logged-in-looking UI throwing errors on every tenant-scoped request. The switcher (Navbar.tsx handleTenantSwitch) now calls logout() first (clearing the access token and tenant identity, invalidating the token server-side) so the destination boots cleanly logged-out and the member re-authenticates against the new community. Platform super admins are exempt — they can legitimately cross tenants with one token, matching the server's cross-tenant rule exactly (a new isPlatformSuperAdminUser() predicate that, unlike isSuperAdminUser(), deliberately excludes tenant-scoped is_tenant_super_admin, since a tenant super-admin hits the same 403 elsewhere). Regression coverage: two Navbar.test cases assert a normal member is logged out before the switch and a platform super admin is not. Root Cause: the switcher performed a same-origin navigation to the sub-tenant while leaving the original tenant's token in localStorage, which the API then rejected as a tenant mismatch on every request. Prevention: the frontend exemption predicate is documented as MUST-mirror the server rule in Authenticate.php, and the two Navbar tests lock the logout/no-logout behaviour by role.
- The moderation Reports page (broker & admin panels) now shows who and what each report is actually about — previously it was untriageable. Every row listed only the reporter, a bare content-type chip, and a "reason" that was printed twice (the API mapped both the reason and description columns to the single reports.reason value, so the two table columns were always identical), with no way to see the reported member or content. The reports table has always stored the target polymorphically (target_type + target_id), but the API returned only the raw target_id integer. A new ReportTargetResolver service now batch-resolves each page of reports into display metadata — the reported member's name/avatar, or the reported content's preview + title + author (resolving user, post, comment, listing, review, event in one query per type, no N+1, tenant-scoped) — and flags deleted targets as removed. The table's redundant "Description" column is replaced by a "Reported" column showing that target inline, and each row opens a self-contained detail drawer (reporter, full target with author, reason, timestamps, and the existing Resolve/Dismiss actions) so a moderator can triage without leaving the panel; links to a reported member stay inside the current (broker/admin) panel. Also fixed: the content-type filter silently ignored comment/review/event (the backend allow-list omitted them). New admin.moderation.* UI strings translated across all 11 locales (drift 0/0). Regression coverage: AdminReportsControllerTest asserts user/listing targets resolve to a label + author and that a deleted target is reported as target_exists=false. Root Cause: the reports API never resolved the polymorphic target and duplicated reason into description, so the UI had nothing meaningful to render. Prevention: target resolution lives in one tested service; the feature test locks the enriched response shape.
- Admins can delete feed posts again — and so can everyone whose action goes through a confirm dialog. The shared confirmation dialog was silently answering "no" to every "Confirm". Clicking ⋯ → Delete post → Confirm on another member's feed post did nothing at all: no network request, no toast, no log — which is exactly why three prior fixes to the delete endpoint and permission gate never helped (the delete was never sent). The app-wide ConfirmDialog (useConfirm(), used by ~71 destructive actions) rendered its Confirm button with both slot="close" and onPress={() => resolveAndClose(true)}. In a controlled HeroUI v3 AlertDialog, slot="close" fires onOpenChange(false) → resolveAndClose(false), which raced the button's own resolveAndClose(true); because resolveAndClose settles the promise on its first call, the close-driven false won in a real browser, so confirm() resolved false and every delete handler bailed at if (!ok) return; before calling the API. Removed slot="close" from both action buttons so each has a single deterministic resolver (Confirm → true; Cancel / ✕ / backdrop / ESC → false); the dialog still closes via the controlled open state (pending → null). This restores every confirm-gated destructive action platform-wide, not just feed deletion. Root Cause: the Confirm button combined two mutually-exclusive HeroUI v3 close mechanisms — slot="close" (auto-close) and a result-capturing onPress — whose false/true resolvers raced on a single click, and the false path won in real browsers. Prevention: ConfirmDialog.test now asserts confirm() resolves true on confirm (previously the boolean was deliberately left unasserted because the dual-resolver ordering "differed between jsdom and a real browser"); removing slot="close" eliminates the ordering dependency, so jsdom and the browser now agree. HeroUI v3 AlertDialog close semantics were verified via the heroui-react MCP docs (the "Close Methods" and "Controlled State" sections show slot="close" and a result-capturing onPress are mutually exclusive).
- The notification bell dropdown no longer renders rows squashed and overlapping on top of each other. Each notification row in the flyout (desktop popover + mobile drawer) is a HeroUI v3 <Button>, and HeroUI's base button style hard-codes a fixed height (h-10/md:h-9 = 36px). The row markup added min-h-9, but a min-height cannot cancel a fixed height, so every row stayed pinned at 36px while its wrapped 2–3 line notification text (~60px) overflowed downward onto the next row — producing the cascading overlap. The row Button now sets h-auto (height follows content) with a min-h-14 tap-target floor, so each row grows to fit its text; the message text also gained line-clamp-2 so a single long notification can't dominate the compact dropdown (full text remains on the /notifications page) and the text→timestamp gap was widened mt-0.5→mt-1. The HeroUI Button is retained for its focus-ring/keyboard/ARIA behaviour — only its fixed height is neutralised. Root Cause: a fixed-height HeroUI Button was used as the container for multi-line content; min-h-* does not override a fixed height. Prevention: multi-line clickable rows must use h-auto (not just min-h-*) when built on a component whose base style sets a fixed height. HeroUI Button styles were checked via the heroui-react MCP (get_component_source_styles → button.css) to confirm the fixed-height root cause.
- The category dropdown on the Create Listing page no longer shows a raw [missing] listings:form.category_search marker. The category Autocomplete's search box called t('form.category_search'), but that key was never added to the listings namespace, so i18next fell back to rendering the literal [missing] namespace:key debug marker as the search placeholder. Added form.category_search ("Search categories") to the English source and translated it into all 10 non-English locales (drift 0/0). Root Cause: a translation key was referenced in CreateListingPage.tsx without ever being added to the locale files. Prevention: check-i18n-drift.mjs (CI-gated) reports 0/0; any future missing key would fail the gate.
- The newsletter "Design" builder is usable again — rebuilt with its own always-visible block palette and a labelled toolbar. The GrapesJS drag-and-drop builder rendered its canvas but exposed no way to add content: the block palette and the Style/Settings/Layers panels are toggled by GrapesJS's default toolbar buttons, whose glyphs come from Font Awesome — which this app does not ship (it uses lucide-react) — so every toggle rendered as a blank square and the block palette was stranded behind an invisible "open-blocks" button. The builder now suppresses GrapesJS's default chrome (panels: { defaults: [] }) and pins each manager into our own React layout: a permanent left block palette (BuilderBlockPalette, hosting the MJML BlockManager), a labelled lucide toolbar (BuilderToolbar — undo/redo, desktop/tablet/mobile device switch, outline toggle, view-compiled-HTML modal, clear) with tooltips + aria-labels, and a tabbed Style / Settings / Layers inspector (BuilderInspector) whose manager nodes stay permanently mounted. The MJML engine (grapesjs 0.21.13 + grapesjs-mjml 1.0.8), design_json save/restore, image upload (POST /v2/upload) and the sent-newsletter read-only overlay are unchanged, as is the content/content_format='builder'/design_json save contract. 13 new strings translated into all 11 locales. Root Cause: GrapesJS 0.21's default panel buttons depend on Font Awesome glyphs the app doesn't load, so the block palette had no visible, labelled way to be reached. Prevention: the builder no longer relies on GrapesJS's default panels at all; NewsletterBuilder.test asserts panels:{defaults:[]} + the pinned manager appendTo targets and the always-visible palette, and BuilderToolbar.test asserts every control is labelled and its click reaches the editor handler.
- The newsletter Design builder is now genuinely usable — a full-screen studio, working image insert, starter templates, and no more garbage renders. After the chrome rebuild the builder was still squeezed into the newsletter form's two-thirds-width content column and then spent a fixed 224 px palette + 288 px inspector, leaving roughly 250 px of actual canvas — so columns were unworkable, three-column layouts effectively unusable, and the whole thing looked unfinished. Design mode now opens a dedicated full-screen studio (NewsletterDesignStudio, route /admin/newsletters/edit/:id/design) that fills the viewport with its own header (Back · Save · Done) and background-autosaves through the existing partial update endpoint (only content/content_format/design_json, so targeting and scheduling set on the form are never clobbered); the inline Design tab becomes a launcher card that opens it (saving a draft first to obtain an id). The palette and inspector are now collapsible, oversized block cards shrink from 68 px to 44 px, and the canvas gains a neutral gutter so the email reads as a sheet. Image insert actually works and is discoverable: the asset-manager upload now applies the uploaded file to the target block and closes the modal (previously it only added the asset to the gallery and left the image blank), and a new Insert image toolbar button uploads via POST /v2/upload and drops an mj-image at the selection. A Templates picker in the builder loads starter designs straight into the canvas, design_json now round-trips through the gallery/API, and an idempotent migration seeds five curated MJML starter templates (Announcement, Newsletter digest, Event invite, Single call-to-action, Welcome) — stored as MJML in content so they parse into real mj-* components — for every tenant. Legacy design_json from the pre-MJML builder no longer renders as broken, unaligned junk: a restore is only accepted if it still compiles to valid table HTML, otherwise the canvas is reset and a dismissible "built in an older editor" notice is shown. 28 new newsletter_builder.* strings translated across all 11 locales (drift 0/0). Root Cause: the builder was mounted inside a narrow form column with fixed side panels (no room for the canvas), and the custom asset uploader added images to the gallery without ever assigning them to the target block. Prevention: the builder lives in its own full-screen route with collapsible panels; NewsletterBuilder.test asserts the restore-validity guard (a design that doesn't compile to MJML is dropped, not rendered), and the upload path applies the asset to the editor target.
- GDPR admin: "Mark completed" no longer 500s, the compliance dashboard shows real numbers, and account erasure actually wipes 2FA secrets and exchange notes. Setting a data-subject request to completed (PUT /v2/admin/enterprise/gdpr/requests/{id}) returned a 500 ("Failed to update GDPR request") — the update wrote a completed_at column that does not exist on gdpr_requests (the real column is processed_at); only the completed branch appended it, which is exactly why Mark processing and Reject worked but Mark completed failed. The same phantom column silently zeroed three GDPR-dashboard analytics (average processing time, on-time compliance score, and month-over-month completed counts) behind swallowed try/catches, so the compliance figures had been wrong. Two further swallowed schema-drift bugs on the Article-17 erasure path meant personal data survived a right-to-erasure request: the 2FA step targeted non-existent users.totp_secret / totp_backup_codes columns (so the AES-encrypted secret in user_totp_settings and any "remember this device" trusted-device tokens were never deleted), and the exchange-notes step referenced a non-existent exchange_requests.provider_notes column (so the whole UPDATE threw and no notes were cleared). Completing a request now records processed_at / processed_by; erasure deletes the real TOTP store (user_totp_settings + user_trusted_devices) and clears the exchange free-text that actually exists (requester_notes, broker_notes, requester_feedback, provider_feedback, cancellation_reason, decline_reason); both completion writes are tenant-scoped; and if a critical erasure step ever fails again the request is left processing (logged at error) instead of being falsely reported complete. Root Cause: controller and service SQL referenced columns that do not exist in the schema, hidden behind broad try/catch swallows that turned hard failures into silent no-ops. Prevention: regression tests assert the endpoint returns 200 with processed_at/processed_by set (AdminEnterpriseControllerTest), that erasure removes user_totp_settings/user_trusted_devices (MemberSelfServiceTest), and a source guard (GdprServiceTest) locks the corrected TOTP/exchange column set so the drift cannot silently return.
- Video embeds (YouTube and every other provider) render again — the SPA's own Content-Security-Policy was silently blocking them, not YouTube. After the "restore video embeds" work started emitting youtube-nocookie.com player iframes and img.youtube.com thumbnails, the React app's Content-Security-Policy response header (set by nginx) still listed only Stripe + Cloudflare under frame-src and no video hosts under img-src, so every browser refused to frame the player (Framing 'https://www.youtube-nocookie.com/' violates ... frame-src) or load the poster (Loading the image 'https://img.youtube.com/...' violates ... img-src) — before any request ever reached YouTube. The feed, the knowledge-base resource pages, and the job-vacancy employer videos were all affected. frame-src now allows the six supported providers (youtube-nocookie.com, youtube.com, player.vimeo.com, open.spotify.com, w.soundcloud.com, player.twitch.tv, clips.twitch.tv, www.tiktok.com) and img-src allows the YouTube thumbnail hosts (img.youtube.com, i.ytimg.com), across every vhost block in both react-frontend/nginx.bluegreen.conf (production) and react-frontend/nginx.conf (single-color fallback). Root Cause: the CSP allow-list was never updated to match the hosts the newly-restored embed feature emits; the browser, not YouTube, was blocking the frames and images. Prevention: the CSP frame-src/img-src must be extended whenever a new embedded third-party origin is introduced — the embed builder (LinkPreviewService::videoEmbedFor) and the CSP are now kept in sync on the same provider list. Requires a React-container rebuild/deploy to take effect (the header is baked into the nginx image).
- Feed posts containing a YouTube/Vimeo/streaming URL embed an inline player again (regression). Video embeds had silently stopped working platform-wide: LinkPreviewService built the player from the video id in the URL alone, but only after a successful server-side scrape of the page — and providers like YouTube now block datacenter-IP requests, so the scrape failed on the production server and the whole preview (video id and all) was discarded. On production this produced zero video previews, ever. Known media providers are now detected from the URL and the embed is built before, and independently of, any outbound fetch, so the player renders even when the page can't be scraped. Coverage broadened from YouTube/Vimeo to also include Spotify, SoundCloud, Twitch and TikTok (the frontend player renders each provider's iframe; Twitch gets its required &parent=<host> appended client-side; Spotify/SoundCloud render as compact audio players). Root Cause: video-embed generation was gated behind an Open Graph scrape that streaming providers now reject from server IPs, even though the embed needs only the URL. Prevention: LinkPreviewVideoEmbedTest asserts every provider yields a content_type=video embed with Http::assertNothingSent().
- Listings (offers and requests) now reliably appear in the community feed. Publishing a listing to feed_activity was a fragile side-effect that only fired on the exact create happy-path in ListingsController::store(); listings created any other way — or before that code existed — never reached the feed (e.g. Timebanking UK's active offers were absent from its feed while showing normally in Listings). Feed publish/unpublish is now driven by the Listing model lifecycle (ListingObserver on create/update/delete, keyed on visibility — status=active and not moderation-held), so every creation path publishes and later status changes (approve/suspend/reactivate) show or hide the feed card correctly. A new idempotent php artisan feed:backfill-listings [--tenant=] [--dry-run] command retro-publishes existing active listings that are missing a feed row. Root Cause: feed publishing lived only in one controller path with no model-level guarantee and no backfill for rows that missed it. Prevention: ListingFeedPublishTest covers observer-on-create and the backfill command.
- Admin feed-post deletion: aligned the client permission gate with the backend and added the missing success-path test. The feed page computed its "is admin" flag from a narrower set of roles than the backend authorises — it omitted god, is_god and is_tenant_super_admin (all accepted by BaseApiController::callerIsAdminTier/requireBrokerOrAdmin), so a user elevated only via those would see no Delete option even though the API would allow the delete. The check is now a shared isAdminTier() helper kept in sync with the backend. The server AdminFeedController::destroy path was verified correct end-to-end and now has success-path coverage (test_destroy_successfully_deletes_another_members_post — asserts the feed_activity row is removed and a delete_feed_item audit-log row is written), which it previously lacked. Root Cause: the frontend admin-tier check had drifted narrower than the backend definition. Prevention: single shared isAdminTier helper + the new backend success-path test.
- Feed moderation "Unauthorized": the broker-panel Delete/Hide buttons were blocked client-side and the request never reached the server. The shared FeedModeration screen (mounted at both /admin/moderation/feed and /broker/moderation/feed) re-validated the user's role inside handleAction with a hand-rolled check that accepted only admin/super_admin/moderator plus the two super-admin flags — a much narrower set than the backend requireBrokerOrAdmin() guard on /v2/admin/feed/posts/* (which also accepts tenant_admin, broker, coordinator, and the is_admin/is_god flags). A legitimate tenant_admin or broker therefore hit toast.error('Unauthorized') and an early return before any DELETE was sent — which is why the two prior investigations found the server path correct and zero delete_feed_item rows in the audit log: the request was dying in the browser, not the backend. The gate now uses a shared canModerateContent() helper (in src/lib/roles.ts) that mirrors requireBrokerOrAdmin(). Root Cause: a client-side authorization gate drifted stricter than the server it is supposed to mirror, silently blocking authorized users before the network call. Prevention: canModerateContent() is the single client mirror of requireBrokerOrAdmin() (documented as MUST-stay-in-sync), plus a FeedModeration regression test asserting a tenant_admin without super flags reaches deleteFeedPost and sees no unauthorized toast.
- "Send to a specific group" (or county/town) silently emailed the entire membership. A newsletter's target_groups / target_counties / target_towns were saved by the admin UI but never read at send time — sendNewsletter() didn't even select the columns — so a group-targeted newsletter went to everyone, and the recipient-count preview showed the full membership (masking the bug). The send path now reads the stored targeting and narrows every audience through a new filterUserIdsByTargeting() reusing the existing group-membership/location SQL builders (facets OR-combined, then intersected with the audience; unregistered subscribers excluded while targeting is active); sendNow() self-loads the targeting so scheduled/recurring sends inherit the fix; and the recipient-count endpoint + compose-form preview apply the same filter so the number always matches what will actually send. Related footgun fixed: target_audience = 'segment' with no segment selected used to fall through to all members — it now refuses with a validation error. Root Cause: the targeting columns were write-only; no code path between sendNewsletter() and recipient resolution ever consumed them. Prevention: NewsletterTargetingTest pins group/county/town filtering, OR-combination, the segment guard, non-member-subscriber exclusion, and sendNow() row self-loading, plus a controller feature test.
- Deploy: made the newsletter multi-format migration pass the blue-green migration-safety gate. 2026_07_03_100000_add_content_format_to_newsletters declared ->default('richtext') on the source line after ->enum(...); the gate's line-based linter (check-migration-safety.sh) only inspects the column-definition line, so it read the addition as a non-nullable column with no default and aborted the deploy (which is correct to refuse — such an add would break the still-live color during a blue-green switch). The column was in fact always safe; moving the default onto the enum line satisfies the linter with no schema or logic change.
- CI: brought the full test + security matrix green across every module. A manual full-pipeline run (which forces all jobs, bypassing change-detection) surfaced pre-existing failures the normal path-filtered pushes had skipped: (1) three unit tests — one legal (LegalDocumentServiceTest — the new tenant-scoped getAllForTenant conditional now uses a plain if instead of the query-builder ->when() so the DB-mock chain matches) and two newsletter model tests (Newsletter/NewsletterTemplate $fillable assertions updated for the new content_format/design_json multi-format fields); (2) a hardcoded "Space" keyboard-shortcut label on the podcast episode page, now t('player.key_space') and translated into all locales; (3) the OWASP Dependency-Check false positive that failed --failOnCVSS 7 — an incidental cygwin1.dll in the scanned workspace was fingerprinted as Red Hat Cygwin 1.3.22 (CVE-2008-3323 / CVE-2007-6181, both 2007-2008), now suppressed by exact CPE in owasp-suppressions.xml, mirroring the existing joomla/string wrong-CPE suppression.
- Build: re-synced react-frontend/package-lock.json so CI's npm ci passes (recurring drift). A transitive rxjs@7.8.2 (pulled in via heroui-cli/i18next-cli → posthog-node) keeps dropping out of the lockfile whenever it is regenerated under npm 11 — the local default, which tolerates the gap — but the npm 10.x used by CI (node 22) and the Docker images rejects it with Missing: rxjs@7.8.2 from lock file, breaking every npm ci-based job (CI Pipeline's React build, Security Scan, Lighthouse CI) while local npm ci reports "in sync". The newsletter drag-and-drop builder's dependency additions re-dropped the heroui-cli rxjs node. Regenerated with npm 10.9.3 --package-lock-only (adds only the one missing entry, no version churn, node_modules untouched so the live dev server is unaffected); verified npm ci exits 0 at npm 10.9.3. Prevention: always regenerate this lockfile with npm 10.x, never npm 11.
- CI: resolved a HIGH underscore.js DoS (CVE-2026-27601 / GHSA-qpx9-hpmf-5gmw) that turned the Security Scan red. A freshly-disclosed underscore DoS (unlimited recursion in _.flatten/_.isEqual; vulnerable <=1.13.7, fix 1.13.8) landed in the scanners' databases and began failing the Security Vulnerability Scan on every push, independent of any code change — flagged by both Trivy and the blocking production npm audit --omit=dev --audit-level=high. underscore reaches the production bundle only via grapesjs (0.21.13 pinned its own nested copy to exactly 1.13.1; also pulled by backbone/backbone-undo) — the admin-only newsletter email builder. Fixed by adding "underscore": ">=1.13.8" to the react-frontend npm overrides block (the same mechanism already used there for basic-ftp/esbuild/postcss/serialize-javascript/tmp/uuid); the lockfile now dedupes every underscore instance to 1.13.8 and drops grapesjs's private 1.13.1 copy. This does not change grapesjs's own version (stays 0.21.13) — only its transitive underscore bumps 1.13.1→1.13.8, a patch-level security release with no API changes. npm audit --omit=dev --audit-level=high now reports 0 vulnerabilities; lockfile regenerated with npm 10.9.3 --package-lock-only (no version churn, rxjs@7.8.2 retained, node_modules untouched).

### Changed

- Polished the newsletter builder's Style / Settings / Layers inspector panels. The GrapesJS style, trait and layer managers are now themed to match the app — clean uppercase sector headers, app-styled inputs/selects/number-and-unit fields, segmented controls (e.g. text-align), a tidy Settings (trait) panel, and a cleaner Layers tree — instead of GrapesJS's default chrome, and they follow the light/dark theme via CSS tokens. Pure presentation (react-frontend/src/styles/newsletter-builder.css only): grapesjs-mjml already exposes the full MJML style set (typography, spacing, background including background image, border), and neither those properties nor their MJML mapping changed.
- The admin Legal Documents area (Terms, Privacy, Cookie policy, etc.) was rebuilt so legal documents can actually be authored and saved. The module's versioning engine, public legal pages, and login re-acceptance gate were already sound, but the admin authoring UI on top of them was broken in ways that blocked entering any content. "Saved but didn't save" (the reported edit bug): the document create/edit form was built against fields that don't exist on the documents table (content, version, status live on versions, not the document), so a save silently persisted only the title while the API reported success — and the content textarea on the create page was thrown away entirely, never becoming a version. The form is now a focused Document settings form (title, type, and acceptance rules — require acceptance, when to require it, notify-on-update, active), the type is locked after creation (it anchors the unique key and every stored version/acceptance), and creating a document now drops you straight into the version editor to write the first version. The unscrollable modal (the "window that won't scroll and disappears when you click away"): the rich-text version editor lived in a modal whose internal markup broke the scroll container, so on a long document the page couldn't scroll and the Submit button was unreachable — and clicking the backdrop discarded everything typed. It is now a full-page editor with a metadata panel, the rich-text editor at full width, and a sticky action bar so Save is always reachable; unsaved-changes are guarded on navigation and on browser close. The remaining confirmation dialogs (Publish, Delete, Notify) no longer close on an accidental backdrop click. The document list now shows the real current version number, version count, and a derived status (Published / Draft only / No content) instead of a phantom always-"1.0"/broken-badge, with a primary "Manage content" action and a first-run empty state prompting new communities (e.g. Timebank UK) to set up their terms, privacy, and cookie policy. Backend hardening: the version-update service verified nothing before reporting success — a tenant-scoped existence check now guarantees a save that reports success actually persisted (cross-tenant edits return 404, published versions return 400); publishing a missing/cross-tenant version returns 404 instead of a generic 500; document creation validates the type and acceptance mode and returns a clean translated 422 on a duplicate type (previously an opaque 500); the admin list now includes deactivated documents; and version history is ordered newest-first by recency rather than by string-sorting version numbers ("10.0" no longer sorts before "9.0"). New backend regression tests cover version-update persistence and tenant isolation, publish flag/pointer updates, ordering, the full create→version→publish→public-endpoint lifecycle, and document-metadata CRUD (title/flags persist, type-change and duplicate-type rejected, cross-tenant 404, inactive documents listed). Frontend tests were rewritten for the new settings form, the new full-page editor, and the list. New admin UI strings added and translated into all 10 non-English locales (drift 0/0); two new backend api.* strings added across all 11 locales.
- The Podcasts module is out of alpha — production-hardened end to end, with configurable media storage (local today, any S3-compatible provider when the platform grows). The full-module audit and upgrade covers storage, safety, creator tools, listener experience, admin operations, and i18n. Storage: podcast audio keeps landing on the persistent local disk by default, but the long-scaffolded cloud switch now actually works — the missing league/flysystem-aws-s3-v3 adapter is installed (flipping podcasts.media_storage_driver to cloud previously fataled at upload), the existing s3 disk serves every S3-compatible provider (AWS S3, Cloudflare R2, DigitalOcean Spaces, MinIO) via AWS_ENDPOINT, and the admin config API now validates storage settings atomically (unknown disk, uninstalled driver, or a non-http CDN base URL are rejected with translated 422s instead of breaking every subsequent upload). Two new operator tools de-risk the eventual migration: a storage doctor (POST /v2/admin/podcasts/storage/verify + php artisan podcasts:storage-doctor) proves credentials with a write/read/delete probe before the switch, and php artisan podcasts:migrate-media stream-copies existing audio to the target disk with sha256 verification, per-episode atomic repointing (no 404 window — the media proxy serves whichever disk the row names), resumability, and opt-in source deletion. The admin UI's dead "Azure Blob" disk choice (no such disk is configured) was removed. Media pipeline: the placeholder processing job now does real work — getID3 verifies uploads are genuinely audio (the upload path only trusts the client MIME type; imposter files are failed as not_audio instead of served forever), episode duration is auto-detected when the creator leaves it blank, and an optional ClamAV hook (CLAMAV_ADDRESS) quarantines infected uploads outright (object deleted, media endpoint refuses infected rows) while staying honest — without a scanner the status remains scan_unavailable, never clean, and uploads are never blocked. A new media_failure_reason column tells admins why media failed. Hardening: the audio proxy, transcripts, chapters, and RSS feeds gained rate limits (audio at 180/min per listener — generous for range-request scrubbing, bounded against bandwidth abuse); chapter payloads are capped at 200 and sorted by start time; visibility=private/members now 422s with a translated message when private shows are disabled (previously silently downgraded to public); listen recording serializes per listener instead of locking the whole episode row; and the release-due scheduler query got its missing index. Creators: the studio gained upload cancel (form preserved) and retry, client-side file size/type validation before a multi-hundred-MB upload starts (limits ride along in /v2/podcasts/mine meta), a "Scheduled for {date}" chip on scheduled episodes, a creator-facing RSS preflight (GET /v2/podcasts/{id}/validate-feed — the same checks admins run, now also reporting how many episodes the feed builder would silently skip), and a Listener stats panel backed by a new owner-scoped GET /v2/podcasts/{id}/stats (listens over time, unique listeners, completion rate, top episodes). Listeners: a global player context owns a single audio element, so playback survives navigation — with a persistent mini-player docked above the mobile tab bar (artwork, deep-link, play/pause, seek, close), per-episode resume positions (localStorage, cleared on completion) with a "Resume from X / Start over" affordance, a persisted playback-speed preference, keyboard shortcuts on the episode page (space play/pause, arrows seek/volume — never hijacking form fields), a Retry action on audio load errors, and a mobile-safe scrolling chapter list. Completion analytics moved into the player context so finishing an episode is recorded even after leaving the page. Admins: the dashboard's shows/episodes tables paginate server-side (previously silently truncated at 200 rows) and support bulk approve/reject with partial-failure reporting. i18n: the module is fully translated — all 24 backend API strings (previously English placeholders in every non-English locale) and all ~51 new UI strings across the 10 non-English locales; drift 0/0. The module registry drops stage: 'alpha'; the podcasts feature remains off by default (opt-in per tenant, unchanged). Regression coverage grew from 94 to 126 backend tests plus new player-context, mini-player, studio, and admin suites — including PodcastStudioPage.test.tsx, which was one of the ~20 test files hitting the known uiMock fork-worker hang and now runs.
- Partner Timebanks panel, phase C: a plain-English and consistency polish across all 17 embedded pages. A six-way parallel audit of every page produced 90 copy rewrites (validated key-by-key against the EN source — key names unchanged, all {{interpolation}} tokens preserved) and 30 component fixes, applied one-agent-per-file. Copy: the highest-read strings — page descriptions, section headings, switch labels, empty states, destructive confirmations — now describe outcomes in coordinator language instead of protocol vocabulary (e.g. the master toggle "Federation Enabled → Partner with other timebanks", with its description now saying exactly what turning it on shares; the auto-approve toggle now spells out its risk and steers to the safe default; "Not Enabled for Tenant" → "Partnering is not switched on for this community"). Components: bare "no data" texts across the panel became BrokerEmptyStates (several with create-CTAs); ad-hoc status chips became the panel-wide BrokerStatusChip vocabulary (API keys, credit agreements, the settings master switch); rotating the aggregates signing secret now requires an explicit confirmation dialog (it invalidates partner verification and previously fired on a single click); the auto-approve switch disables while partnership requests are off; the partnership-limit input clamps typed values to its declared 1–100 range; Network settings shows an "unsaved changes" hint; the API-docs webhooks link became a tenant-aware router link (was a raw full-reload <a href> that broke inside the panel); the created-API-key display no longer renders a white box in dark mode. The sweep also surfaced and fixed four production bugs where the UI rendered raw i18n keys: 17 missing partnership level/permission/audit keys (Partnerships), 2 missing webhook status keys (an inactive or failing webhook showed federation.status_inactive literally), 12 missing activity event-type keys, and 9 mangled CSV export headers ("CSV Actor" → "Actor"); plus directory/neighbourhood counters that rendered label text where numbers belonged now interpolate the real counts. 56 new EN keys added; the 90 changed + 56 new keys were re-translated into all 10 non-English locales per-key (stale values deleted first — never --force, so human-reviewed translations elsewhere were untouched). Also fixed: scripts/translate-i18n-gaps.mjs silently skipped admin.json via a stale "admin is English-only" exclusion left over from the policy voided on 2026-06-06 — admin fills now translate like every other namespace.
- Partner Timebanks panel: two page consolidations. (1) The partner directory and "our listing" editor — previously two separate pages — are now one tabbed page at /partner-timebanks/directory ("Browse partners" / "Our listing", deep-linkable via ?tab=profile, which the Overview setup checklist uses); the separate /directory/profile route was removed. (2) Creating a federation API key no longer navigates to a separate wizard page: the API Keys page's "Create key" buttons open the same CreateApiKey form in a modal, which closes back onto the refreshed list. CreateApiKey gained an optional onDone embedded mode (suppressing its standalone header/guidance chrome) and ApiKeys an optional onCreateClick/refreshToken pair; rendered standalone both behave exactly as before, and the deep-linkable /partner-timebanks/api-keys/create route is retained as a fallback outside the primary flow.
- All partner-timebank / federation surfaces now live in a dedicated "Partner Timebanks" panel at /partner-timebanks, with the technical plumbing gated to super admins. The admin sidebar's technical 14-link "Partner Timebanks" section and the separate "Partner APIs & Integrations" section were replaced by a single overview-zone entry (alongside Dashboard / Broker Panel). Every admin can open the read-mostly panel — Overview, Partnerships, Partner directory, Neighbourhoods, Credit agreements, Activity feed, Analytics — while the setup/plumbing surfaces (External platforms, Credit Commons, Inbound API partners, Partner cooperatives, API keys, Event webhooks, Developer guide, Shared statistics, Data management, Network settings, and the Overview's setup checklist/settings shortcuts) are visible and routable only for super admins (super_admin role, is_super_admin, is_tenant_super_admin, or god — the same gate as the Communications section). The sidebar entry shows for all admins when federation is on; on tenants with only partner_api/caring_community (all-super-admin content) it shows for super admins alone. The panel clones the broker-panel architecture (own layout, sidebar, breadcrumbs, route guard, lazy code-split chunk) and reuses the broker's presentational primitives; every existing page is embedded unchanged as a thin wrapper (no page rewrites). Its sidebar organises the 17 destinations into plain-English sections: Partner network (Partnerships, Partner directory, Neighbourhoods, Credit agreements), External connections (External platforms, Credit Commons, Inbound API partners), Caring Community (Partner cooperatives — the section is gated on the caring_community module and disappears entirely when it is off), Access & security (API keys, Event webhooks, Developer guide), Activity & data (Activity feed, Analytics, Shared statistics, Data management), and Settings. A new native Overview dashboard answers "are we connected, is anything waiting on me, is anything broken" from the existing analytics/settings/profile endpoints, with a partnering-off banner, a recent-connection-errors banner, KPI stat cards, a 4-step setup checklist, and a most-active-partners card. Credit Commons was evaluated against the code and confirmed to be a general external protocol (one of the four federation_external_partners adapter types alongside Komunitin/TimeOverflow), not a Caring Community surface, so it stays under External connections. partner-timebanks was added to the reserved route prefixes in both RESERVED_PATHS (React) and TenantContext::getReservedPaths() (PHP) so path-based tenants resolve the panel correctly. New partners i18n namespace (~131 keys) machine-translated into all 10 non-English locales (drift 0/0). Backend endpoints are unchanged (still admin-gated); this is a navigation/visibility restructure — no federation table, protocol contract, or API behaviour changed.
- Smart Matching 2.0 — the matching engine was rebuilt end-to-end so matches are close, relevant, reciprocal, and explainable. The headline fix: members were being matched with people on the other side of the country (e.g. for gardening) because distance was never a hard constraint — and for members without a saved location the distance filter was skipped entirely, matching them against the newest listings tenant-wide labelled "nearby". Matching now enforces hard gates in the candidate query: physical listings beyond the effective max distance (the member's preference, capped by the tenant setting) are excluded outright, remote/hybrid listings are distance-exempt, listings whose owner has been inactive for 90+ days are skipped, and members without a location get remote-friendly matches plus an in-app prompt to set their location (a per-tenant "match community-wide" escape hatch exists for genuinely nationwide communities). Scoring moved from a flat weighted sum to a three-pillar geometric mean (Relevance × Feasibility × Trust) so one terrible dimension can no longer be averaged away, with new signals: two-way directional skills matching (what you offer ↔ what they need, with proficiency weighting), availability overlap, member activity recency, review trust (Bayesian-smoothed), and completed-exchange history. The engine also learns: dismissals (with new reasons "too far" / "not my skills" / "not interested"), repeated owner dismissals, and past interactions adjust future results, and no member can monopolise a results page. Group matching is now real — powered by the full group recommendation engine (shared memberships, interests, location, activity) with its own cache, surfaced to members on the Matches page and as a "Recommended groups" strip on the Groups page. When a community has AI enabled, embeddings become a true semantic matching signal (including profile-to-profile similarity) and members see a short AI-written "why this match" explanation on their top matches, generated in the background within existing per-tenant AI cost limits — with the algorithmic reasons always present as fallback. Two long-standing score corruption bugs were fixed along the way: the semantic/KNN boosts collapsed every boosted match to a score of 1/100, and the cache warmer wrote every entry as exactly 100.00 (analytics and hot-match notifications were running on garbage). The member experience was rebuilt to match: the Matches page gained distance/remote chips, mutual-match highlighting, a "why this score" breakdown, save/message/dismiss-with-reason actions and honest empty states; a new Match Preferences page (pause matching, distance, quality threshold, categories, notification frequency) replaces the dead settings redirect; the dashboard "Suggested for You" widget is now powered by the real engine; hot/mutual match emails deep-link to the highlighted match; and the admin panel gained gate/AI controls plus location-data-readiness, feedback-reason, and pillar analytics. Match approvals, safeguarding gates, and broker workflows are unchanged. (Deployed to production 2026-07-02; the per-tenant match cache was cleared post-deploy so the corrupted scores regenerate under the new engine.) The committed schema dump (database/schema/mysql-schema.sql) was refreshed from production to include the three new matching migrations (match_cache v2 columns, group_match_cache, match_preferences.matching_paused).
- The admin sidebar's "Communications" section (Email Settings, Email Deliverability, Deliverability) is now visible only to super admins. The section — and, when it is the only surviving section in the "Communications" zone, the zone header itself — is gated on isSuperAdmin in AdminSidebar's nav config (matching the existing advertising/module_configuration conditional-spread pattern), so ordinary admins no longer see the platform email/deliverability links. isSuperAdmin covers the super_admin role, is_super_admin, is_tenant_super_admin, and god. The sibling Marketing/Advertising sections in the same zone are unchanged; on tenants that expose those, the zone header still renders for non-super-admins. No routes or endpoints changed — this is sidebar visibility only.
- Build hygiene: restored the SPDX header on the generated react-frontend/src/resources.d.ts. The i18next-cli types generator rewrites this tracked file without an SPDX header, which trips the blocking SPDX License Compliance CI gate; the standard header is re-added (as scripts/add-spdx-headers.mjs does). No runtime effect — regenerating this file must always be followed by re-adding the header.
- Content moderation and safeguarding configuration are now broker duties, living in the broker panel. The broker panel gained a Moderation section — Content Queue, Feed Posts, Comments, Reviews, and Reports — plus a Safeguarding Options page, each reusing the existing admin module component unchanged inside the broker shell (the same thin-wrapper pattern as the broker Safeguarding dashboard; no page rewrites). To let genuine broker/coordinator users operate them, the underlying endpoints were re-gated from admin-only to broker-or-admin: the content moderation queue (/v2/admin/moderation/queue + review/stats), feed post moderation (/v2/admin/feed/posts* + stats), comments, reviews, member reports (/v2/admin/reports index/stats/show/resolve/dismiss), and safeguarding options CRUD. Each mutating controller adds a self-dealing guard mirroring the match-approval and balance guards — a broker cannot moderate content they are a party to (their own feed post/comment, a review they wrote or received, their own queued content, or a report they filed / that targets them; content-target report ownership is not resolved and is documented as such), while admins retain full latitude. Mutations are throttled 60/min. Deliberately kept admin-only: moderation settings (tenant policy), feed announcer grant/revoke (privilege management), and the safeguarding statement/member-activity export (legal). New api.broker_cannot_moderate_own_content string across all 11 locales; broker.moderation_*/safeguarding_options UI strings in the broker namespace. Regression test: tests/Laravel/Feature/Controllers/BrokerModerationAuthorizationTest.php (19 cases). The existing admin controller tests (member-403 assertions) continue to pass unchanged.
- CI no longer stacks stale runs or runs the whole pipeline for unrelated changes. Two structural fixes to cut the "CI queued for hours after a small change" problem: (1) every push/PR workflow (ci.yml, e2e-tests.yml, security-scan.yml, lighthouse.yml, docs-lint.yml, pr-checks.yml) now has a concurrency group keyed on ${{ github.workflow }}-${{ github.ref }} with cancel-in-progress: true, so a new push cancels its own superseded in-flight run on that ref instead of leaving orphaned runs to saturate the runner pool during an iterative fix session. (2) ci.yml gained a changes preflight (dorny/paths-filter, mirroring the existing i18n-changed job) that gates the expensive stages: a frontend-only change skips the sharded PHP suite + static analysis, a backend-only change skips the React build + API contract + E2E smoke + accessibility cluster, and a docs/CI-only change skips both plus the post-merge Docker image build. The php-checks aggregate gate now treats a skipped suite (no PHP-relevant paths touched) as a pass, and docker-verify tolerates skipped frontend jobs so a backend-only push still rebuilds/verifies the PHP image. workflow_dispatch still forces a full run. No test, gate, or coverage was weakened — unrelated jobs are skipped, not removed.

### Removed

- Removed the rubix/ml dependency, eliminating the last strong-copyleft (GPL) package from the production dependency tree. rubix/ml was an optional recommendation-training accelerator, used only behind a class_exists(\Rubix\ML\Kernels\Distance\Euclidean::class) guard in scripts/train_recommendations.php with an existing pure-PHP distance-kernel fallback — so removing it is behaviour-preserving (training already ran the pure-PHP path whenever Rubix wasn't installed). Dropping it also removes its transitive chain rubix/ml → wamania/php-stemmer → joomla/string (GPL-2.0-or-later) plus the amphp/* and andrewdalpino/okbloomer packages (production Composer packages 130 → 117). The whole tree is now permissive (MIT/BSD/Apache/ISC, with electable james-heinrich/getid3 → MPL-2.0 and non-copyleft react-leaflet Hippocratic-2.1). npm run check:licenses now passes with zero known-copyleft exceptions (KNOWN_EXCEPTIONS = {}), and THIRD_PARTY_NOTICES.md / THIRD_PARTY_LICENSES.md are refreshed. This clears the one dependency that would have blocked distributing Project NEXUS under a separate proprietary/closed-source licence. No app/ code referenced Rubix; the app boots and tests pass unchanged.
- Removed SendGrid entirely — Postmark is now the sole platform email provider (owner decision; the SendGrid account was cancelled). The SENDGRID_API_KEY was already removed from the production environment; this removes the code. Deleted: the SendGrid event-webhook controller (SendGridWebhookController) and its public /webhooks/sendgrid/events route; the sendgrid:sync-suppressions and emails:reconcile-transient-failures cron commands + their scheduler entries (both were SendGrid-only — email_suppression is now fed solely by the Postmark webhook); the sendViaSendGrid() and usePlatformSendGridFallback() Mailer methods; the config/mail.php sendgrid block; and the SendGrid option in the admin Email Settings UI. The Mailer fallback chain is now Postmark → SMTP (was Postmark → SendGrid → SMTP), the platform-provider default is postmark, and a tenant still stored with email_provider = 'sendgrid' now falls through to the platform provider instead of activating a dead driver. The From-address helpers Postmark reuses were renamed rather than deleted (resolveSendGridFromPrefix → resolveFromPrefix, SENDGRID_DOMAIN → SENDING_DOMAIN), and the deliverability dashboard's delivery-confirmation check now watches provider='postmark'. The email_log.provider enum keeps its historical 'sendgrid' value (no migration); the now-unused sendgrid/sendgrid Composer package is left in place pending a separate lockfile regeneration. Tests updated (MailerTest, EmailMailerRoutingTest) and the obsolete SendGrid command/webhook tests removed. No user-facing behaviour change — platform email already sends via Postmark.
- Removed all /admin/federation/* pages, /admin/api-partners, and the caring hub's "Partner Cooperatives" link (owner-approved, no redirects). With the Partner Timebanks panel now the sole home for external-partner setup, the 16 admin federation routes and the Inbound API Partners route were deleted outright — routes, lazy imports, and both sidebar sections — following the same retirement pattern as safeguarding/moderation (a stale bookmark lands on the admin not-found page). The caring hub (/caring) also lost its Partner Cooperatives entry, since hasFullCaringAccess admits regular admins who must no longer see external-partner surfaces; the page itself lives on at /partner-timebanks/caring/peers, and the legacy /admin/caring-community/federation-peers redirect was retargeted there. In-app links that pointed at the old admin pages were retargeted to the panel: the ~37 cross-links inside the shared PartnerTimebankGuidance sidebar, the API-keys create/cancel navigation, the Enterprise system-config related-page link, and the module registry's federation detail link (which had pointed at a nonexistent /admin/federation/settings path — a pre-existing dead link, now fixed). The admin module components stay on disk and render inside the new panel's thin wrappers, so no functionality was lost. Orphaned admin_nav federation label keys remain in the locale bundles per the established cosmetic-cleanup precedent. No API endpoints were removed or re-gated.
- Removed the "Recent" pages list from the admin sidebar. The admin sidebar previously tracked the last five admin pages a user visited (persisted in localStorage under admin_recent_pages) and rendered them under a "RECENT" heading above the navigation zones. The section added clutter to an already-dense sidebar, so it has been removed in full: the rendered list, the localStorage read/write helpers, the visit-tracking wired onto every nav link, and the label-lookup map that only fed it. Navigation, the needs-attention strip, and synonym-aware search are unchanged; stale localStorage entries are simply ignored. No API or route changes.
- Removed the admin Content Queue, Moderation section, and Safeguarding pages (owner-approved, no redirects). Following the match-approvals tidy-up, the remaining broker/safety duplicates were removed from the admin panel outright: the /admin/moderation/queue|feed|comments|reviews|reports routes, /admin/safeguarding, and /admin/safeguarding-options, along with the admin sidebar's entire Moderation section and the Safeguarding trio (Safeguarding, Member Safeguarding, Safeguarding Options). The now-single-item "Matching & Safety" section was dissolved — Smart Matching moved into the Intelligence & Diagnostics section — and the admin sidebar's safeguarding flag-count poll (/v2/admin/safeguarding/dashboard) was removed. The admin module components stay on disk and are reused by the new broker pages, so no admin coverage was lost; only the admin routes, nav entries, and breadcrumb mappings are gone (a stale bookmark lands on the admin not-found page). Three in-app links that pointed at the old admin pages (Enterprise system config, Onboarding settings, Municipal impact "trust pack") now target the broker equivalents. No API endpoints were removed.
- Removed the duplicate admin Match Approvals pages and the legacy /admin/broker-controls/* redirects (owner-approved, no redirects). With the broker panel now the sole home for every broker duty, /admin/match-approvals and /admin/match-approvals/:id were deleted outright — routes, the MatchApprovals/MatchDetail components and their test coverage, the admin sidebar entry, and the breadcrumb mappings. The Smart Matching overview's "Broker approvals" shortcut now points at /broker/match-approvals (which every admin tier can open, since the endpoints are broker-or-admin). The old /admin/broker-controls → /broker bookmark redirects were also removed as part of the same tidy-up; a stale bookmark now lands on the admin not-found page. No API endpoints changed — only the duplicate admin UI surfaces are gone.
- Retired the separate experimental Next.js public frontend (next-public-frontend/). The inert, shadow-only Next.js SSR app for public pages has been removed in full, along with all of its wiring: the next_public_frontend blue-green compose service (the next-public-shadow profile), the admin readiness endpoint GET /v2/admin/config/next-public-frontend plus its AdminNextPublicFrontendController and NextPublicFrontendReadinessService, the admin "Next.js public frontend readiness" page and its adminApi client method/types, the NEXT_PUBLIC_FRONTEND_ROUTING_ENABLED (config/app.php) cutover flag, the check:next-public:inert / check:next-public:dry-run scripts and their tests, the Apache foundation-canary example, and the cross-app @nexus/public-shared Vite alias with its shared-component source. Public pages continue to be served by the React/Vite SPA with the existing bot-only prerender engine for SEO — no production routing, prerender behaviour, or live API payload changed (the app was never enabled). The opt-in public_contract API contract (attached only for explicit include=public_contract / X-Public-Contract callers) is retained as harmless inert code; the now-unused advanced.next_public.* admin translation keys remain in the locale bundles pending a separate cosmetic i18n cleanup.

### Fixed

- The blue/green deploy no longer aborts at the "Required Env Var Check" gate now that SendGrid is gone. scripts/deploy/phases/validate-env.sh still listed SENDGRID_API_KEY and SENDGRID_WEBHOOK_VERIFICATION_KEY as mandatory, but SendGrid was fully removed in the Postmark-only cutover and the production .env has SENDGRID_API_KEY commented out — so every deploy failed at env validation before any build or migration ran (and because the "missing var" message is written to stderr, the detached deploy log showed only the gate header followed by the cleanup/notify lines, reading as a silent failure). The gate now requires POSTMARK_SERVER_TOKEN — the provider actually in use — instead of the two dead SendGrid keys, so the env check tracks the live email stack. Deploy-tooling only; no application behaviour changed.
- Gamification feed cards ("Reached Level 2!", badge announcements) are now real per-member posts that admins can actually moderate. Every badge and level-up card was written into the feed with a literal source_id = 0; combined with the feed table's uniqueness key and upsert, ALL such cards in a community collapsed into a single shared row per type whose author, text, and date were silently overwritten by every subsequent award. The visible symptoms: gamification posts with a missing/blank author, and admin "Delete (Admin)" / broker Feed Posts delete unable to get rid of them — the card was served with id 0, and even a successful delete came back the next time anyone levelled up. Cards are now recorded with source_id = user_id (one card per member per type — a member's card updates in place to their latest badge/level, other members' cards are untouched), so attribution is stable and moderation surfaces can target them like any other feed item. A migration purges the legacy collapsed source_id = 0 rows (and any stray engagement rows keyed to id 0) on every tenant — this is what removes the two orphaned cards on the Timebanking UK feed. Also hardened the author display path end-to-end: the public feed's author hydration now falls back to first + last name when the users.name column is an empty string (the admin moderation list already did this), and the React feed no longer accepts an empty string as a valid author name or avatar (it falls back to the localized unknown-member label instead of rendering a nameless card with an initials-less avatar). Regression tests: tests/Laravel/Feature/GamificationFeedCardsTest.php (write path, per-user upsert, admin delete, empty-name fallback) and two new getAuthor cases in react-frontend/src/components/feed/types.test.ts.
- Repaired the seven auto-generated federation/api-partners admin test files that had been silently broken since creation (19 failing tests plus two files that hung the vitest run). The tests stubbed shared admin components by mocking the '../../components' barrel, but the components under test import those files directly (../../components/PageHeader, ConfirmModal, StatCard, DataTable, EmptyState), so the stubs never intercepted and the real components rendered without the expected test ids. Every mock now targets the direct module path. The two hanging files (PartnerDirectory.test.tsx, ApiPartnersAdminPage.test.tsx) had a second defect: mocking all of @/components/ui with the @/test/uiMock proxy while also importing @/test/test-utils at the top level crashes the vitest fork worker (IPC "Channel closed") and the run waits forever at collect — they now use the partial importOriginal mock pattern of their sibling tests, stubbing only the React-Aria components that misbehave in jsdom (Select, Switch, useConfirm). One stale assertion was also fixed: the PartnerDirectory refresh test clicked the Refresh button while it was still isLoading-disabled during the debounced initial load. All 87 tests across the seven files now pass. Test-only change — no runtime behaviour affected.
- The Partner Timebanks panel chrome (sidebar title, "Full Admin Panel" link, Caring Community section, breadcrumbs, Overview title, and the top-partners "{{count}} exchanges" counter) now renders translated in all 10 non-English locales. These panel-layout keys were added after the phase C translation pass and gap-filled with English verbatim, which also turned the non-admin i18n gap-regression CI gate red on main (+12 gaps per locale). Each value is translated using the terminology already established inside the same locale file (e.g. de "Partner-Zeitbanken", fr "Banques de temps partenaires", ga "Bainc Ama Comhpháirtithe"; "Caring Community" follows each locale's existing admin translation). The three "Credit Commons" keys stay verbatim in every locale by design — it is the proper name of the accounting protocol — and are now recorded in .github/i18n-gap-baseline.json as accepted cognates. Drift stays 0/0.
- The broker Members page now shows real community counts and paginates (was "Total members: 1" for a 256-member community, with no way past the first rows). Every broker surface that counts members read the collection total from the wrong place: the api client unwraps the backend's { data, meta } envelope into res.data (the row array) and res.meta (the pagination meta), but the Members page KPI cards, the members table, the Onboarding pending-members list, and the broker sidebar's pending-members badge all looked for meta inside res.data — which never exists — and fell back to counting the rows the page happened to receive. The KPI cards run limit=1 count queries, so "Total members" and "Active members" rendered as 1 (verified against production: hOUR Timebank has 256 members, 255 active); the table's total equalled the visible page size, so DataTable computed one page and never rendered pagination; the sidebar badge was capped at 1. All four sites now read res.meta.total (row count kept only as a defensive fallback), restoring correct cards, working pagination, correct tab count chips, and an honest sidebar badge. The page also got its finishing pass: a role filter (All roles / Member / Broker / Admin / Tenant admin / Organisation admin — the list endpoint already supported ?role=, it was just never exposed in the UI), deep-linked ?status= navigation from the stat cards and dashboard tiles now resets pagination and bulk selection so a stale page number can't land on an empty result set, and the Refresh button's spinner now tracks either in-flight request rather than requiring both. Three new broker.json keys translated across all 11 locales (drift 0/0). Regression test: react-frontend/src/broker/pages/__tests__/MembersPage.pagination.test.tsx.
- Removed two orphaned translation keys (stats_source_types, source_job) from all 10 non-English matches.json locale bundles. Their English source keys were removed in the member Matches page revamp (c592e2699), leaving the translated copies behind as "extra keys" warned about by scripts/check-i18n-drift.mjs. The keys were unreferenced, so nothing visible changes; the drift check is back to 0 missing / 0 extra.
- Group recommendations no longer crash on a non-existent connections.addressee_id column. GroupRecommendationEngine::getConnectionGroups joined and filtered on c.addressee_id, but the connections table has receiver_id (not addressee_id), so the query threw SQLSTATE[42S22] Unknown column 'c.addressee_id' every time connection-based group suggestions were computed — the platform's highest-volume production error (~121 events/day). Both references now use c.receiver_id. Regression test: tests/Laravel/Feature/Matching/DistanceKmAndConnectionsSchemaRegressionTest.php.
- Proximity search and the Smart Matching cache warm-up no longer fail with "Column 'distance_km' in ORDER BY is ambiguous". Four content/reference tables (users, categories, attributes, listing_attributes) carried a stray, always-NULL distance_km varchar(255) column added in error. Every proximity query computes (haversine) AS distance_km and JOINs one of those tables (typically users for the member's location), so the physical column collided with the computed alias and made ORDER BY distance_km / HAVING distance_km ambiguous (SQLSTATE[23000] 1052) — breaking the matching cache warm-up cron and the Explore / Events / Listings / Marketplace / Caring / Volunteer proximity searches. Migration 2026_07_02_000010_drop_stray_distance_km_columns drops the four stray columns (verified 100% NULL on production, with nothing writing to them); the legitimate match_cache / match_approvals / match_history distance_km decimal(8,2) columns are retained, and the committed schema dump was updated to match. Because this is a DROP COLUMN migration, the blue-green deploy must be run with DEPLOY_ALLOW_DESTRUCTIVE_MIGRATION=1. Regression test: tests/Laravel/Feature/Matching/DistanceKmAndConnectionsSchemaRegressionTest.php.
- The admin sidebar's "Donations & Support" link (support-tier configuration) reappeared for tenant super admins. The member_premium block in AdminSidebar — the Donations & Support page (/admin/member-premium, where a tenant configures its monthly/yearly support tiers and Stripe sync) and the Recurring Supporters list (/admin/member-premium/subscribers) — was gated on isGod && hasFeature('member_premium') since c2f230855 (2026-06-05), when it was bundled with the genuinely platform-level Plans & Pricing / Billing links. 5d64eba68 (2026-06-29) then reworked that module from a paid "Member Premium" tier into a tenant-level support-donations feature but left the isGod gate in place — so any tenant super admin who is not a platform "god" lost the sidebar link even though the routes themselves are only feature-gated (FeatureGatedElement feature="member_premium", reachable by any tenant admin). The link was strictly more restrictive than the page it pointed to. The block is now gated on hasFeature('member_premium') alone, matching the route, so the Donations & Support and Recurring Supporters links return for tenant super admins with the feature enabled; the god-only Plans & Pricing / Billing links are unchanged. Also corrected two stale sidebar test assertions that still expected the pre-5d64eba68 labels "Member Premium" / "Premium Subscribers" (the live labels are "Donations & Support" / "Recurring Supporters"). Regression test: react-frontend/src/admin/components/__tests__/AdminSidebar.test.tsx (hides god-only Plans & Billing but shows Donations & Support to non-god super admins).
- The broker panel now renders in each broker's own language instead of English placeholders (~255 untranslated strings per locale). When the broker panel was built and ported (0b38c7da4, 2026-07-01/02), its new broker.json keys were gap-filled with the English source into all ten non-English locales so the keys existed — which satisfies the key-existence drift gate (scripts/check-i18n-drift.mjs, still 0/0) and is not a mechanical "stub" (scripts/check-i18n-stubs.mjs) — but the values were still English. ~255 translatable strings per language (~2,530 total) sat in .github/i18n-gap-baseline.json as accepted gaps, so no CI gate ever flagged them and a German / French / … / Arabic broker saw an entirely English console. All ten locales (ga de fr it pt es nl pl ja ar) have now been translated in-context, matching each file's established terminology (e.g. fr Courtier / Vérification / Appariement / Annonce, de Broker / Prüfung / Überprüfung, ja ブローカー / 審査 / マッチ), preserving every {{token}} interpolation and _one/_other plural variant exactly, and keeping bare acronyms/symbols verbatim (DBS, ID, AccessNI, 2FA, the em-dash —, {{km}} km). The two intentionally language-neutral glyph hints (⌘K, esc) remain identical across locales by design. .github/i18n-gap-baseline.json was refreshed to lock in the win (non-admin English-fallback gaps 4009 → 1482; broker gaps ~2,530 → ~28 residual cognates). Value-only change — no key added, removed, or reordered; drift stays 0/0 with no new stubs. Each locale was then adversarially re-reviewed by a native-speaker pass, which corrected a further set of machine-translation defects — most notably a full Irish (ga) QA sweep (an invented non-word for "Follow up", a calqued/aircraft-"landing" empty-state, a meaning-inverted dashboard heading, and several pre-existing mistranslations such as dashboard.description and dashboard.view_all), German/Spanish bare day abbreviations ({{count}} T / {{count}} d → {{count}} Tg. / {{count}} días, applied across the whole time-ago/expiry class), and French/Spanish "message copy" false-friends (copie de message / copia de mensaje).
- The broker ⌘K / esc keyboard hints were hardcoded, turning the i18n literals CI gate red on main. The broker header search affordance (⌘K) and the command-palette close hint (esc) shipped in 0b38c7da4 as bare JSX text inside <Kbd>, which the non-admin hardcoded-string gate (scripts/check-i18n-literals.mjs) counts as untranslated literals. Both are now routed through t('broker') (header.search_shortcut, palette.close_key), with the language-neutral glyph value mirrored across all 11 locales and the i18n gap baseline refreshed for the two parity keys. No visible change — the glyphs render identically; this only satisfies the translation-coverage gate.
- Match-approval statistics were always zero, and matches pending longer than 30 days vanished from every counter. Two latent defects in MatchApprovalWorkflowService::getStatistics(), caught during the broker-panel visual pass: (1) the service returned keys (pending/approved/rejected/avg_review_hours) that neither the admin nor the broker Match Approvals UI ever read — both consume the MatchApprovalStats shape (pending_count/approved_count/rejected_count/avg_approval_time), so the stat cards, tab chips, and the new broker sidebar badge rendered 0 forever; the service now returns both key sets. (2) The pending count was windowed by submitted_at >= N days, so a match awaiting review for more than 30 days silently disappeared from the queue counters (while still listed) — exactly the items most in need of attention; pending is now counted un-windowed as a live queue, while approved/rejected/rate remain period activity metrics. Regression test: BrokerMatchApprovalAuthorizationTest::test_approval_stats_count_old_pending_matches_and_use_ui_field_names.
- Brokers cannot adjust their own time balance (self-dealing guard). Balance adjustment is now broker-accessible (from the member-detail modal), but AdminTimebankingController::adjustBalance rejects any attempt by a broker/coordinator to adjust their own account (403) so they can't mint themselves time credits; admins retain full latitude, and all adjustments remain audited. Covered by BrokerUserActionsAuthorizationTest.
- Brokers can now edit an active monitoring record and act on an exchange from its detail page. Two gaps in the broker panel: (1) the User Monitoring page was add-or-remove only, so changing a monitored member's reason, messaging-disabled toggle, or expiry meant removing and re-adding the record; it now has an Edit action that reuses the upserting setMonitoring endpoint (frontend-only) and preserves the record's remaining expiry when the broker doesn't pick a new duration, so a reason-only edit never silently clears the expiry. (2) The Exchange Detail page was read-only, forcing brokers back to the list to approve/reject a pending_broker exchange; it now carries the same Approve/Reject action modal as the list and refreshes in place. The existing page test suites (ExchangeDetailPage.test.tsx, UserMonitoringPage.test.tsx) continue to pass and were updated for the new toast dependency.
- The broker panel no longer locks brokers out of safeguarding/vetting/members on tenants without the exchange feature. BrokerRoute gated the entire /broker panel on the exchange_workflow tenant feature, so a broker (or coordinator) on a community that runs safeguarding, vetting, insurance, member-approval and monitoring but does not use the exchange-approval workflow was redirected away from every broker tool. Access is now role-based only (hasBrokerPanelAccess); the exchange_workflow feature gates just the exchange-specific surfaces — the Exchanges sidebar item (BrokerSidebar), the /broker/exchanges* routes (a new ExchangeFeatureRoute guard bounces deep-links back to the broker dashboard), and the Exchange-Workflow card on the broker Configuration page. Regression tests: react-frontend/src/broker/BrokerRoute.test.tsx, react-frontend/src/broker/__tests__/BrokerSidebar.test.tsx.
- The "Delete My Account" button no longer stays permanently disabled (GDPR Article 17 erasure was unreachable for some users). Two independent defects blocked the account-deletion confirmation modal (react-frontend/src/pages/settings/SettingsPage.tsx). (1) The "Type DELETE to confirm" text field had no autofill suppression, so Chrome / password managers filled it with the account email — the field sits directly above the current-password input and browsers treat the nearest preceding text field as the username slot — and that value can never equal the required keyword, leaving the delete button greyed out forever. (2) The code required the literal English DELETE while the Spanish UI instructed "Escribe ELIMINAR para confirmar" and French showed the placeholder "SUPPRIMER", so members in those locales could never satisfy the gate by following the on-screen instruction. The confirmation input is now an autofill-proof DeleteConfirmationField component that renders read-only until the user actually focuses/clicks it — Chrome ignores autoComplete="off" for a text field next to a password input but never autofills read-only fields, so the account email can no longer be injected on open (with autoComplete="off" + a non-credential name as belt-and-braces); the modal auto-focuses its close button, so the read-only guard is only lifted on genuine user interaction. The keyword match is extracted into react-frontend/src/pages/settings/deleteConfirmation.ts (isDeleteConfirmed), which is case-insensitive, whitespace-trimmed, and accepts either the canonical DELETE or the localized keyword the UI actually showed the user (so a re-localized placeholder can never silently lock members out again); and the es/fr settings.json delete_modal strings were realigned to DELETE for consistency with the other nine locales. The backend password re-authentication remains the real safety gate; only the reachability of the confirm button changed. Regression tests: react-frontend/src/pages/settings/deleteConfirmation.test.ts, react-frontend/src/pages/settings/DeleteConfirmationField.test.tsx.
- Postmark email delivery/open/bounce status is now recorded in email_log. The email_log.provider column was enum('sendgrid','gmail_api','smtp'); once platform mail routes through Postmark, Mailer::logEmail() writes provider='postmark', which MySQL/MariaDB non-strict mode silently coerced to '' — breaking PostmarkWebhookController row matching (WHERE provider='postmark') and therefore the delivery/open/bounce status updates on email_log. A migration adds 'postmark' to the enum. Sending itself and bounce-driven suppression were unaffected. The committed schema dump (database/schema/mysql-schema.sql) was refreshed from production to match.
- The email-trigger audit matrix now registers the donation and GDPR admin notifications. EmailTriggerAuditService::eventMatrix() was missing rows for the donation_admin (tenant-admin "donation received" alert) and admin_gdpr_action (tenant-admin "member initiated a GDPR action" alert) categories, so the EmailTriggerAuditServiceTest::test_dispatcher_categories_are_represented_in_audit_matrix guard failed on main. Both categories are now covered, restoring a complete email-trigger audit surface. No user-facing behaviour change.
- Container image builds no longer fail on transient pecl.php.net / package-mirror outages. CI's "Docker Build Verify" and production blue-green image builds repeatedly broke on an unchanged RUN pecl install redis line when pecl.php.net returned 504 Gateway Timeout or served truncated tarballs. Every pecl install (redis, pcov) in Dockerfile, Dockerfile.prod, and Dockerfile.bluegreen is now wrapped in a retry-with-backoff loop that clears the stale download dir between attempts and only fails the build after 5 genuine failures; composer install in the blue-green image retries similarly, and all apt-get update/upgrade/install calls now pass -o Acquire::Retries=3. Verified by an end-to-end build of the hardened layer (php -m confirms the redis extension loads). PHP ini parity between Dockerfile and Dockerfile.prod is unchanged (scripts/check-dockerfile-drift.sh still passes).
- Online members no longer show as "offline" in messages and across the platform. The React app keeps presence alive solely through the 60-second /v2/presence/heartbeat, but the is_online/online_status shown in conversations, member profiles, group chats, and the feed sidebar were derived from users.last_active_at — a column only ever refreshed by the unused /auth/heartbeat endpoint. As a result last_active_at went stale ~5 minutes into every session and actively-browsing members read as offline everywhere that column is consulted, regardless of the status they set. PresenceService::heartbeat() now also refreshes users.last_active_at on its existing once-per-60s, tenant-scoped DB-write path (guarded so a users write can never abort the presence write), so every last_active_at-derived online indicator becomes accurate again in one place. The 1:1 conversation header dot/label additionally now read live canonical presence via PresenceContext (consistent with the conversation list) and fall back to the now-fresh is_online. Regression tests: tests/Laravel/Unit/Services/PresenceServiceTest.php (test_heartbeat_bridges_users_last_active_at, test_heartbeat_bridge_is_tenant_scoped).
- The safeguarding restriction notice now appears as soon as a conversation opens — before the member types — without alerting staff on page load. When a member opens a conversation with someone they cannot contact directly (coordinator-mediated contact or required vetting), the conversation payload now carries a server-authoritative safeguarding preflight block, so the upgraded safeguarding panel renders immediately and the composer is replaced with an explanation. Opening a conversation no longer emails or notifies brokers/admins; only an actual blocked send attempt or the new explicit action does. A new "Request coordinator help" button in the panel calls POST /api/v2/messages/{id}/request-coordinator, which re-checks the restriction server-side, dispatches a dedicated SafeguardingCoordinationRequested event (email + bell alert to active admin/tenant_admin/broker/super_admin staff in each recipient's locale), writes an activity_log audit row, is idempotent within a 10-minute window, and confirms success to the sender. The existing blocked-send gate and its SafeguardingContactAttemptBlocked alert are unchanged; the gate logic is now shared (MessageService::evaluateSafeguardingContactGate) so the preflight notice, the send block, and the coordinator request can never diverge. Two safeguarding.php detail strings that previously claimed "the team has been alerted" / "your message has not been sent" were made context-neutral so they are accurate on page load too. Regression tests: tests/Laravel/Feature/Controllers/MessagesControllerTest.php, tests/Laravel/Unit/Listeners/NotifySafeguardingCoordinationRequestedTest.php, react-frontend/src/pages/messages/ConversationPage.test.tsx.
- PHP CI shards now fail with actionable timing diagnostics instead of abrupt runner cancellations. The PHPUnit shard job timeout has been raised to 45 minutes while each PHPUnit invocation has a 40-minute guard, shard file list, elapsed-time summary, and explicit timeout error so slow-shard drift is visible before GitHub cancels the job.
- Safeguarding message blocks now enforce coordinator-mediated contact and alert tenant staff. Provider-side safeguarding declarations that only record a required vetting type no longer over-block member messaging, while true vetted-contact and coordinator-mediated-contact blocks now return structured guidance, lock the message composer behind an in-thread safeguarding panel, and dispatch email plus bell alerts to active tenant admin, tenant_admin, and broker users for follow-up. Regression tests: tests/Laravel/Feature/Controllers/MessagesControllerTest.php, tests/Laravel/Unit/Listeners/NotifySafeguardingContactAttemptBlockedTest.php, tests/Laravel/Unit/Services/SafeguardingTriggerServiceTest.php, tests/Laravel/Feature/I18n/EmailLocaleIntegrationTest.php, react-frontend/src/pages/messages/ConversationPage.test.tsx.
- Listings public contracts are now opt-in so live API payloads stay unchanged. The public_contract block on /api/v2/listings and /api/v2/listings/{id} is only attached for explicit Next public-frontend callers using include=public_contract or X-Public-Contract: 1; normal SPA, mobile, and prerender reads retain their prior response shape. Regression tests: tests/Laravel/Feature/Controllers/ListingsControllerTest.php, next-public-frontend/src/lib/__tests__/tenant-api.test.ts.
- i18n: propagated 72 new advanced.next_public.* admin translation keys to all 10 non-English locales. The next-public workstream added these keys to en/admin.json without backfilling non-EN files, breaking the translation drift CI gate. English placeholder values have been added to ar, de, es, fr, ga, it, ja, nl, pl, and pt pending proper translation.
- i18n: translated the safeguarding.errors.vetting_check_failed string into the 9 non-English locales where it was leaking verbatim English. The key existed in ar, de, es, fr, it, ja, nl, pl, and pt safeguarding.php but held the untranslated English source (only ga already had a real translation). Proper in-language translations were added, matching the tone of the adjacent safeguarding error strings (e.g. coordination_request_failed). Key parity across all locales is unchanged (62 keys each).

### Added

- The broker panel now has Match Approvals pages, and its frame was upgraded to the new broker design language. /broker/match-approvals (queue) and /broker/match-approvals/:id (detail) port the admin matching module into the broker panel restyled on the new broker primitives: severity-colored stat cards, a deep-linkable status filter (?status=pending|approved|rejected|all), a score gauge with quality label, party/listing cards, review-details card, and approve / reject-with-reason actions (the backend self-dealing guard's message surfaces in the toast when a broker tries to review their own match). Both routes and the new sidebar item are gated on the exchange_workflow feature exactly like Exchanges, and the sidebar item carries a live pending-count badge fed by the (now broker-accessible) approval-stats endpoint. Frame polish: sidebar gets an active-rail indicator, brand tile, and hover micro-interactions; the header gains a Help Center shortcut. All strings live in the broker.matching.* namespace. Regression tests: MatchApprovalsPage.test.tsx, MatchApprovalDetailPage.test.tsx, updated BrokerSidebar/BrokerLayout suites.
- Brokers can now review smart-match approvals without the admin panel — with a self-dealing guard. The five match-approval endpoints (/v2/admin/matching/approvals list/detail/stats and the approve/reject mutations) moved from admin-only to broker-or-admin in routes/api.php, since reviewing proposed member↔listing matches is a core broker duty; matching configuration, cache, and analytics stay admin-only. A new AdminMatchingController::guardBrokerNotParty() check rejects (403) any broker/coordinator attempt to approve or reject a match they are a party to — either as the matched member or as the listing owner — mirroring the adjust-balance self-dealing guard; admins retain full latitude. Approve/reject decisions are now also written to the org_audit_log (match_approved / match_rejected) and throttled to 60/min like the other broker decision endpoints. A new api.broker_cannot_review_own_match string was added across all 11 locales. Regression test: tests/Laravel/Feature/Controllers/BrokerMatchApprovalAuthorizationTest.php (15 cases pinning what brokers can and cannot do).
- The broker Onboarding page now surfaces at-a-glance KPI cards above the funnel. Four metric cards — total Registered, Overall conversion % (first stage → last), Pending approvals, and the biggest drop-off stage with its conversion rate — are derived from the existing funnel + pending data (no new endpoint). (A month-over-month registration trend chart was left out for now because the broker funnel endpoint returns stage counts only, not a time series.) New KPI labels added across the 11 locales.
- Broker member notes now support the full CRM note workflow instead of being add-only. When adding a note a broker can pick a category (general / outreach / support / onboarding / concern / follow-up) rather than every note being silently filed as broker, and each existing note can be edited, deleted, or pinned (pinned notes sort to the top). Backed by the existing adminCrm.updateNote / deleteNote endpoints. New note labels added across the 11 locales.
- Brokers get a full member-detail view with the operational actions inline, so they no longer need the admin User-edit page. A new "View details" action on each member opens a modal (react-frontend/src/broker/components/MemberDetailModal.tsx) showing the member's balance, join date, last-active, onboarding state, and vetting/insurance/consent status, plus the operational action set: approve / suspend / reactivate, resend verification email, send password-reset email, reset 2FA, adjust time balance (amount + reason, in a confirmation sub-modal), and a safe-edit form for name / phone / bio / tagline / location. Privileged actions (role/status change, ban, delete, impersonate) are deliberately absent from the UI and rejected server-side for brokers. Smoke-tested in MemberDetailModal.test.tsx (loads member, exposes the operational actions, hides privileged ones, and drives a balance adjustment). All strings added across the 11 locales.
- The broker Members page gained the admin-Users triage tools brokers were missing: role & verification visibility, stuck-member filters, and bulk approval. It now shows a Role column and an Email verified / unverified column, adds "Never logged in" and "Onboarding incomplete" status tabs so brokers can triage members who are stuck, and supports multi-select bulk Approve / Suspend (wired to the now-broker-accessible bulk-approve / bulk-suspend endpoints) via a selection action bar. All new labels are translated across the 11 locales.
- Brokers can now perform the operational member-management actions that previously required the full admin panel — with hard guards against privilege escalation. So a broker/coordinator never has to leave /broker to do their job, the member endpoints the broker panel needs were moved from the admin-only group into broker-or-admin in routes/api.php: bulk approve/suspend, 2FA reset, verification / password-reset / welcome emails, a read-only GDPR-consent view, safe profile edits (PUT /v2/admin/users/{id}), and (audited) time-balance adjustment (/v2/admin/timebanking/adjust-balance). Privilege escalation is blocked in depth: AdminUsersController@update now rejects any broker attempt to change role, status, email, profile_type, or organization_name (403); a new BaseApiController::callerIsAdminTier() distinguishes brokers from admins; securityTier() places brokers above ordinary members but below admins, so a broker can reset a member's 2FA/password but never touch another broker or an admin; and creating/importing users, banning, deleting, badge management, direct password set, impersonation, and super-admin promotion all remain admin-only or super-admin-only. Brokers stay tenant-scoped and every action is audited with the actor id. A new api.broker_cannot_edit_field string was added across all 11 locales. Regression test: tests/Laravel/Feature/Controllers/BrokerUserActionsAuthorizationTest.php (15 cases pinning what brokers can and cannot do).
- Tenant admins are now notified the moment a member initiates a GDPR action. Previously, when a member used the Privacy settings to submit a data-rights request (access/erasure/rectification/restriction/objection/portability), delete their account, export their personal data, or change a consent preference, admins were told nothing — the request became a silent pending row surfaced only by a dashboard count nobody had to open, or by the overdue-request cron once it was already ~25 days old (near the Art.12(3) deadline). A new GdprActionOccurred event is now dispatched from the member entry points (GdprService::createRequest and updateUserConsent, UsersController::deleteAccount, MemberDataExportController::create), and a NotifyAdminOfGdprAction listener fans out a bell notification, a push, and an email — each rendered in the recipient admin's own preferred_language via LocaleContext — to every active super_admin/admin/tenant_admin, linking to the admin GDPR queue. Data-rights requests are flagged as action-needed within 30 days; deletion/export/consent are informational. Admin-created requests (a different code path) deliberately do not fire this. Every dispatch is failure-isolated so notification wiring can never fail the member's action, and the fan-out is idempotent against queue re-delivery. New emails_misc.admin_notify.gdpr_* strings and notifications.push_gdpr_* titles were added across all 11 locales. Regression test: tests/Laravel/Feature/Gdpr/GdprAdminNotificationTest.php.
- Postmark delivery, bounce, and spam-complaint events are now ingested via a webhook. A new PostmarkWebhookController (POST /api/v2/webhooks/postmark) mirrors the SendGrid event handler for the Postmark send path: it authenticates via HTTP Basic auth (or an X-Postmark-Webhook-Secret header) against POSTMARK_WEBHOOK_SECRET, then updates email_log (delivered/bounced/opened/status, matched on Postmark's MessageID), maintains the email_suppression cache the Mailer consults before every send, records NewsletterBounce rows (hard/soft/complaint), and feeds EmailMonitorService. Tenant is resolved from the Postmark Metadata.tenant_id we attach at send time. Reuses existing api.* strings (no new translation keys). Inert until the webhook is configured in Postmark and POSTMARK_WEBHOOK_SECRET is set.
- Platform email can now be routed through Postmark instead of SendGrid (opt-in send path). A new postmark driver in App\Core\Mailer sends via the Postmark Email API (raw HTTP, no SDK dependency) and is selected only when MAIL_PLATFORM_PROVIDER=postmark and POSTMARK_SERVER_TOKEN are set — otherwise sending behaviour is completely unchanged (SendGrid/SMTP). It reuses the existing category→From-prefix mapping (so every per-category sender address on the verified project-nexus.net domain carries over) and the existing per-tenant sender-name resolution, and it routes newsletter/digest categories to the Postmark broadcast message stream and everything else to the transactional stream (independent IP reputation). A Postmark send failure falls back automatically to the platform SendGrid account, then SMTP. Adds mail.platform_provider and mail.postmark.* config. No production behaviour changes until the env flag is flipped; the Postmark delivery/bounce/spam-complaint event webhook is tracked as a separate follow-up.
- Tenant admins are now notified when a monetary donation is received. Previously a successful Stripe donation emailed only the donor a receipt — organisation admins got nothing, so donations could arrive completely unannounced. A new DonationAdminNotificationService (modelled on SupportReportNotificationService) now fires from the Stripe webhook success path (StripeDonationService::handlePaymentSucceeded), but only on a donation's first completion so re-delivered webhook events never re-notify. It sends every active tenant admin (admin/tenant_admin/super_admin/god or the is_*admin flags) an in-app bell notification plus an email — each rendered in that admin's own preferred_language via LocaleContext::withLocale — showing the amount, donor, fund, message, and date with a button to the admin donations area. Anonymous donations still show the donor's name to admins (needed for reconciliation/Gift Aid) but flag the anonymous intent so it isn't surfaced publicly. Every step is failure-isolated: a bounced admin email can never fail the webhook (which would make Stripe retry the event for days). New emails.donation_admin.* strings were added across all 11 locales. Regression test: tests/Laravel/Unit/Services/DonationAdminNotificationServiceTest.php.
- The admin header now links to the Support Reports area with a live open-request indicator. A new support (life-buoy) icon button sits beside the notification bell in the admin top bar and navigates to /admin/support-reports. On mount it fetches GET /v2/admin/support-reports/stats and, when there are open requests, overlays a small red count badge (clamped to 99+); the badge is hidden at zero. The fetch is best-effort — if the stats endpoint is unavailable for the current admin the badge simply stays hidden — and the button's aria-label announces the open count for screen readers. Two new admin_nav translation keys (support_requests, support_requests_count) were added across all 11 locales. Regression tests: react-frontend/src/admin/components/AdminHeader.test.tsx.
- The shadow Next.js public frontend now ports the React public visual system instead of approximating it. The inert Next app maps HeroUI v3 to the NEXUS brand tokens, loads the React-aligned Inter typography, carries React-style public chrome markers, renders listings/events/jobs/marketplace/organisations with dedicated SSR card/detail layouts, moves static/blog/CMS/legal content into the shared .legal-content prose container, and aligns home/jobs/about/help H1 copy with the live React baseline. The post-port live-vs-shadow screenshot matrix is recorded in next-public-frontend/visual-parity-checklist.md; production routing, React/Vite serving, Apache/Plesk config, prerender serving, the live sitemap, and cutover flags remain unchanged. Regression tests: next-public-frontend/src/lib/__tests__/design-system-foundation.test.ts, next-public-frontend/src/ui/__tests__/PublicPage.test.tsx, next-public-frontend/src/ui/__tests__/PublicListingsPage.test.tsx, next-public-frontend/src/ui/__tests__/PublicEventsPage.test.tsx, next-public-frontend/src/ui/__tests__/PublicJobsPage.test.tsx, next-public-frontend/src/ui/__tests__/PublicMarketplacePage.test.tsx, next-public-frontend/src/ui/__tests__/PublicOrganisationsPage.test.tsx, next-public-frontend/src/ui/__tests__/PublicRouteNoJsHtml.test.tsx.
- The shadow Next.js public frontend now has a committed visual parity and performance checklist. The readiness artifact records the live-vs-shadow comparison matrix for hour-timebank, captures the current H1 and visual-delta gaps, documents why the local React dev surface could not be used as the baseline during this pass, and records the image/caching posture required before a future cutover. This remains shadow-only and does not change production routing, React/Vite serving, Apache/Plesk routing, the live sitemap, or prerender serving.
- The shadow Next.js public frontend now has locale-aware SEO metadata and intrinsic public media rendering. Public routes now emit all 11 locale hreflang alternates plus x-default, OpenGraph locale, tenant fallback OG/Twitter images, tenant-default <html lang> with RTL direction for Arabic, single-H1 no-JavaScript checks, and Next image rendering with intrinsic dimensions for logos, cards, hero media, and galleries. This remains shadow-only and does not change production routing, React/Vite serving, Apache/Plesk routing, the live sitemap, or prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/metadata.test.ts, next-public-frontend/src/lib/__tests__/listings-seo.test.ts, next-public-frontend/src/lib/__tests__/design-system-foundation.test.ts, next-public-frontend/src/ui/__tests__/PublicRouteNoJsHtml.test.tsx.
- The shadow Next.js public frontend static, blog, CMS, and generic public content renderers now use HeroUI v3. Home support content, fallback static/legal pages, blog index/detail, CMS pages, and generic public collection/detail content now share the same SSR-safe HeroUI Card, Chip, and Link primitives as the rich route families, removing the remaining bespoke public-panel and post-list rendering from PublicPage.tsx. This remains shadow-only and does not change production routing, React/Vite serving, Apache/Plesk routing, the live sitemap, or prerender serving. Regression tests: next-public-frontend/src/ui/__tests__/PublicPage.test.tsx, next-public-frontend/src/ui/__tests__/PublicRouteNoJsHtml.test.tsx.
- The shadow Next.js public frontend rich dynamic route families now render through HeroUI v3. Listings, events, jobs, marketplace items, and organisations now share SSR-safe HeroUI Card, Chip, and Link route primitives for index cards, detail layouts, media galleries, breadcrumbs, facts, and empty states while preserving existing public contracts, module gating, SEO JSON-LD, and no-JavaScript rendering. This remains shadow-only and does not change production routing, React/Vite serving, Apache/Plesk routing, the live sitemap, or prerender serving. Regression tests: next-public-frontend/src/ui/__tests__/PublicListingsPage.test.tsx, next-public-frontend/src/ui/__tests__/PublicEventsPage.test.tsx, next-public-frontend/src/ui/__tests__/PublicJobsPage.test.tsx, next-public-frontend/src/ui/__tests__/PublicMarketplacePage.test.tsx, next-public-frontend/src/ui/__tests__/PublicOrganisationsPage.test.tsx, next-public-frontend/src/ui/__tests__/PublicRouteNoJsHtml.test.tsx.
- The shadow Next.js public frontend shared chrome now renders through HeroUI v3 components. The isolated SSR shell now uses HeroUI Surface, Card, Chip, and Link primitives with Tailwind v4 utility layout for the tenant header, public navigation, home hero, call-to-action links, and AGPL footer while preserving crawler-readable no-JavaScript HTML. This remains shadow-only and does not change production routing, React/Vite serving, Apache/Plesk routing, the live sitemap, or prerender serving. Regression tests: next-public-frontend/src/ui/__tests__/PublicPage.test.tsx, next-public-frontend/src/ui/__tests__/PublicRouteNoJsHtml.test.tsx.
- The shadow Next.js public frontend now has a HeroUI v3 and Tailwind CSS 4 design-system foundation. The isolated Next app imports HeroUI styles through Tailwind v4, pins the React-aligned HeroUI/Tailwind versions, mirrors the shared token/glass styling sources, and retires the bespoke route/card globals before the HeroUI component re-skin. This remains shadow-only and does not change production routing, React/Vite serving, Apache/Plesk routing, the live sitemap, or prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/design-system-foundation.test.ts.
- The shadow Next.js public frontend now has React-aligned shared public chrome. The isolated SSR shell expands public navigation for listings, events, jobs, marketplace, organisations, resources, blog, help, and contact; renders a tenant-token home hero with a no-JavaScript brand panel; and upgrades the footer into platform/support/legal link groups while preserving AGPL Section 7(b) attribution. This remains shadow-only and does not change production routing or prerender serving. Regression tests: next-public-frontend/src/ui/__tests__/PublicPage.test.tsx, next-public-frontend/src/ui/__tests__/PublicRouteNoJsHtml.test.tsx, next-public-frontend/src/lib/__tests__/public-messages-validation.test.ts.
- The shadow Next.js public frontend message catalog now covers all 11 platform locales. The isolated Next public app registers en, ga, de, fr, it, pt, es, nl, pl, ja, and ar, expands every public chrome key across those locale files, and adds a parity test so future route-family keys cannot drift before any canary or cutover. This remains shadow-only and does not change production routing or prerender serving. Regression test: next-public-frontend/src/lib/__tests__/public-messages-validation.test.ts.
- The shadow Next.js listings route now has a locked rich public contract and real SSR renderer. /api/v2/listings and /api/v2/listings/{id} add a public-only public_contract object with card/detail fields, neutral location, provider, images/gallery, time-credit value, status, dates, and pagination metadata while preserving existing SPA fields. The inert Next public frontend now uses typed listings fetchers, tenant locale forwarding, module gating, no-JavaScript index/detail rendering, OpenGraph/Twitter metadata, and schema.org JSON-LD for listings only. This remains shadow-only and does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Feature/Controllers/ListingsControllerTest.php, next-public-frontend/src/lib/__tests__/tenant-api.test.ts, next-public-frontend/src/ui/__tests__/PublicListingsPage.test.tsx, next-public-frontend/src/lib/__tests__/listings-seo.test.ts, next-public-frontend/src/lib/__tests__/module-gates.test.ts.
- The shadow Next.js events route now follows the listings rich-route contract pattern. /api/v2/events and /api/v2/events/{id} keep existing SPA/prerender payloads unchanged unless explicit Next callers opt in to public_contract, then expose public card/detail fields for image, category, neutral location, organiser, date range, status, and dates. The inert Next public frontend now has typed event fetchers, events module gating, no-JavaScript index/detail rendering, OpenGraph/Twitter metadata, schema.org/Event JSON-LD, and en/ga chrome keys for events only. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Feature/Controllers/EventsControllerTest.php, next-public-frontend/src/lib/__tests__/tenant-api.test.ts, next-public-frontend/src/ui/__tests__/PublicEventsPage.test.tsx, next-public-frontend/src/lib/__tests__/events-seo.test.ts, next-public-frontend/src/lib/__tests__/module-gates.test.ts.
- The shadow Next.js jobs route now has an opt-in public contract and JobPosting renderer. /api/v2/jobs and /api/v2/jobs/{id} keep existing SPA/prerender payloads unchanged unless explicit Next callers opt in to public_contract, then expose public job fields for employer, logo/gallery, neutral location, compensation, skills, deadline, status, and dates without private contact/application data. The inert Next public frontend now has typed jobs fetchers, job_vacancies module gating, no-JavaScript index/detail rendering, OpenGraph/Twitter metadata, schema.org/JobPosting JSON-LD, and en/ga chrome keys for jobs only. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Feature/Controllers/JobVacanciesControllerTest.php, next-public-frontend/src/lib/__tests__/tenant-api.test.ts, next-public-frontend/src/ui/__tests__/PublicJobsPage.test.tsx, next-public-frontend/src/lib/__tests__/jobs-seo.test.ts, next-public-frontend/src/lib/__tests__/module-gates.test.ts.
- The shadow Next.js marketplace route now has an opt-in Product/Offer public contract. /api/v2/marketplace/listings and /api/v2/marketplace/listings/{id} keep existing SPA/prerender/mobile payloads unchanged unless explicit Next callers opt in to public_contract, then expose public marketplace fields for item images/gallery, category, neutral location, price, seller display, delivery, condition, quantity, expiry, status, and dates without private contact/order data. The inert Next public frontend now has typed marketplace fetchers, marketplace module gating, no-JavaScript index/detail rendering, OpenGraph/Twitter metadata, schema.org/Product + Offer JSON-LD, and en/ga chrome keys for marketplace only. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Feature/Controllers/MarketplaceListingControllerTest.php, next-public-frontend/src/lib/__tests__/tenant-api.test.ts, next-public-frontend/src/ui/__tests__/PublicMarketplacePage.test.tsx, next-public-frontend/src/lib/__tests__/marketplace-seo.test.ts, next-public-frontend/src/lib/__tests__/module-gates.test.ts.
- The shadow Next.js organisations route now has an opt-in Organization public contract. /api/v2/volunteering/organisations and /api/v2/volunteering/organisations/{id} keep existing SPA/prerender/mobile payloads unchanged unless explicit Next callers opt in to public_contract, then expose public organisation fields for logo, website, public contact email, owner display, aggregate stats, type, status, and dates while keeping wallet fields out of public contracts. The inert Next public frontend now has typed organisation fetchers, organisations module gating, no-JavaScript index/detail rendering, OpenGraph/Twitter metadata, schema.org/Organization JSON-LD, and en/ga chrome keys for organisations only. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Feature/Controllers/VolunteerControllerTest.php, next-public-frontend/src/lib/__tests__/tenant-api.test.ts, next-public-frontend/src/ui/__tests__/PublicOrganisationsPage.test.tsx, next-public-frontend/src/lib/__tests__/organisations-seo.test.ts, next-public-frontend/src/lib/__tests__/module-gates.test.ts.
- The shadow Next.js app now has inert sitemap and robots metadata routes. next-public-frontend/app/sitemap.ts builds sitemap entries from the shadow route-ownership manifest plus opted-in tenant public content for listings, events, jobs, marketplace items, and organisations, while app/robots.ts points crawlers at the shadow sitemap. These routes exist only inside the isolated Next app and do not alter the live Laravel sitemap, Apache/Plesk serving, prerender engine, or any cutover flag. Regression test: next-public-frontend/src/lib/__tests__/public-sitemap.test.ts.
- The former member-premium module is now presented as community donations and support. The member-facing page is labelled "Donate" with the subtitle "Support this community", adds a one-off donation CTA using the existing Stripe donation checkout, and reframes recurring monthly/yearly tiers as support levels with recognition rather than paid feature access. Admin navigation and module configuration now show "Donations & Support" and link directly to the support admin page. Donation Stripe routing can now be configured per tenant with Stripe Connect onboarding/status controls; when unset, payments continue through the platform Stripe account with tenant metadata for reporting. One-off donation rows and recurring support subscriptions store the original payment route and Stripe account for later refunds, cancellation, billing-portal access, admin reporting, and CSV export. Regression tests: react-frontend/src/pages/premium/PricingPage.test.tsx, react-frontend/src/admin/modules/premium/MemberPremiumAdminPage.test.tsx, react-frontend/src/admin/modules/volunteering/DonationRefunds.test.tsx, react-frontend/src/admin/modules/config/moduleRegistry.test.ts, tests/Laravel/Unit/Services/DonationStripeAccountServiceTest.php, tests/Laravel/Unit/Services/StripeDonationServiceTest.php, tests/Laravel/Unit/Services/MemberPremiumServiceTest.php, tests/Laravel/Unit/Models/VolDonationTest.php.
- Admins can inspect the shadow-mode Next.js public frontend readiness without enabling it. A new read-only admin API endpoint and /admin/seo/next-public-frontend page report the Next app package, route ownership manifest, shadow runtime commands, retained Vite private-route ownership, unchanged prerender fallback, and remaining cutover blockers. This does not activate production routing, does not alter Apache/Plesk edge behavior, and does not change the current prerender serving path. Regression tests: tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx, react-frontend/src/admin/api/adminApi.test.ts.
- The Next.js public frontend readiness page now validates the shadow route manifest. The read-only admin endpoint checks for the pinned lockfile, required package scripts, configured shadow Compose profile, duplicate public route patterns/keys, malformed route entries, and accidental collisions between Next public routes and Vite private prefixes. The admin page shows route counts and manifest validation status only; it still cannot enable production routing or change prerender behaviour. Regression tests: tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The shadow Next.js public frontend now owns the second public route batch in manifest-only mode. Listings, events, jobs, organisations, resources, knowledge-base, and marketplace index/detail routes render crawler-readable placeholder HTML inside the isolated Next app while create/edit/member workflows remain Vite-owned. This is still shadow-only and does not change production routing or prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/public-routes.test.ts, next-public-frontend/src/ui/__tests__/PublicPage.test.tsx, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js public frontend now hydrates second-batch public routes from Laravel APIs where safe. Listings, events, jobs, volunteering organisations, resources, knowledge-base, and marketplace discovery/detail pages can render crawler-readable titles, descriptions, canonical metadata, and tenant-aware links from existing public API responses while falling back safely when data is absent. This remains shadow-only and does not change production routing or prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/tenant-api.test.ts, next-public-frontend/src/ui/__tests__/PublicPage.test.tsx.
- The Next.js public frontend readiness API now reports Laravel API-backed route coverage. Admin readiness data distinguishes manifest-only public routes from routes with shadow content sources, reports that Laravel public APIs remain the source of truth, and validates that API-backed route keys still exist in the shadow route manifest. This does not enable production routing or alter prerender serving. Regression test: tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The Next.js public frontend readiness page now shows API-backed public route coverage. The read-only admin page lists the Laravel public API methods/endpoints backing shadow public routes, giving administrators cutover visibility without adding any serving switch, edge route, or prerender change. Regression test: react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The shadow Next.js public frontend now uses a shared content-source manifest. next-public-frontend/content-sources.json is read by both the Next API client and the Laravel readiness service, reducing drift between SSR data fetching and admin cutover reporting while keeping production routing and prerender serving unchanged. Regression tests: next-public-frontend/src/lib/__tests__/content-sources.test.ts, next-public-frontend/src/lib/__tests__/tenant-api.test.ts, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js public frontend now has a dedicated manifest consistency check. npm --prefix next-public-frontend run check:manifests validates that route ownership remains shadow-only, public route keys are unique, API-backed route keys exist in the route manifest, content sources use Laravel public APIs, and Next is not allowed to query the database. This is an inert verification command only and does not change production routing or prerender serving. Regression test: next-public-frontend/src/lib/__tests__/shadow-manifest-validation.test.ts.
- The shadow Next.js public frontend now runs manifest validation in its default check command. npm --prefix next-public-frontend run check now starts with check:manifests, and the admin readiness API reports that manifest validation script presence. This remains verification-only and does not change production routing or prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/package-scripts.test.ts, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js public frontend now has a no-JavaScript public-route HTML check. npm --prefix next-public-frontend run check:no-js-html renders every Next-owned public route pattern plus tenant-slug home routes to static HTML and verifies tenant branding, canonical URL, JSON-LD, heading, and AGPL attribution are present without browser JavaScript. The default Next check command now includes this local verification; production routing and prerender serving remain unchanged. Regression tests: next-public-frontend/src/ui/__tests__/PublicRouteNoJsHtml.test.tsx, next-public-frontend/src/lib/__tests__/package-scripts.test.ts.
- The Next.js public frontend readiness API now reports the no-JavaScript HTML check. The read-only admin readiness payload includes check_no_js_html alongside the existing shadow verification scripts, so administrators can see whether crawler-readable route coverage is wired before any canary or cutover work. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The Next.js public frontend readiness page now shows the pre-cutover verification commands. The shadow-only admin panel lists the exact local commands for the isolated Next check, React build, React typecheck, and readiness API regression test so operators can prepare a future canary without adding an activation switch. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The Next.js public frontend readiness page now includes the service-level readiness regression in its verification commands. The read-only admin payload tells operators to run both the readiness service unit suite and admin endpoint feature suite before any future canary, reducing the chance of backend manifest validation drift. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The Next.js public frontend readiness page now shows per-route cutover gates. The read-only admin payload derives route-level gate rows from the shadow ownership and content-source manifests, showing each public route's source type and keeping every route blocked for live cutover until parity tests are explicitly completed. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The Next.js public frontend readiness page now shows structured live-cutover gate blockers. The read-only admin payload and page list the manual verification commands, explicit approval requirement, missing edge-route configuration, and retained prerender fallback requirements for each future cutover step. This remains guidance only and does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The shadow Next.js tenant resolver now tolerates malformed encoded path segments. Bad crawler or proxy input such as an incomplete percent-encoded segment no longer throws during server-side tenant request normalization; the segment is kept as-is and the request remains in shadow-mode resolution. This does not enable production routing or alter prerender serving. Regression test: next-public-frontend/src/lib/__tests__/tenant-request.test.ts.
- The shadow Next.js manifest gate now validates route/API parameter alignment. npm --prefix next-public-frontend run check:manifests now blocks API-backed routes whose endpoint placeholders drift from their public route pattern params, preventing mismatched SSR detail routes before any canary or cutover work. This does not enable production routing or alter prerender serving. Regression test: next-public-frontend/src/lib/__tests__/shadow-manifest-validation.test.ts.
- The shadow Next.js public frontend now validates route label translations. npm --prefix next-public-frontend run check:messages verifies every Next-owned public route label key resolves in the local public message catalog, and the default isolated Next check command now includes that gate. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/public-messages-validation.test.ts, next-public-frontend/src/lib/__tests__/package-scripts.test.ts.
- The shadow Next.js manifest gate now blocks duplicate API-backed route keys. npm --prefix next-public-frontend run check:manifests catches duplicate content-sources.json route keys before they can make SSR data lookup ambiguous. This does not enable production routing or alter prerender serving. Regression test: next-public-frontend/src/lib/__tests__/shadow-manifest-validation.test.ts.
- The shadow Next.js manifest gate now enforces Laravel public API endpoints. npm --prefix next-public-frontend run check:manifests blocks API-backed content sources that are not relative /v2/... Laravel API paths, keeping shadow SSR data fetching on the approved backend contract. This does not enable production routing or alter prerender serving. Regression test: next-public-frontend/src/lib/__tests__/shadow-manifest-validation.test.ts.
- The Next.js public frontend readiness API now mirrors the Laravel API guards. The read-only admin readiness service blocks unsafe content-source metadata, malformed API-backed entries, duplicate API-backed route keys, endpoints outside relative /v2/... Laravel public API paths, non-GET methods, and public route/API parameter drift, matching the isolated Next manifest check before any canary or cutover work. This does not enable production routing or alter prerender serving. Regression test: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php.
- The shadow Next.js manifest checks now require critical Vite private ownership to stay with React. Both the isolated Next manifest checker and Laravel readiness API block removal of private/gated prefixes and create/edit mutation patterns from the route ownership manifest, reducing the chance of a future public canary accidentally absorbing logged-in routes. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/shadow-manifest-validation.test.ts, tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php.
- The shadow Next.js manifest checks now block public routes that collide with Vite private mutation patterns. Public route entries such as /events/new are rejected if the route ownership manifest also marks them as Vite-owned create/edit flows, keeping future canary work fail-closed before route ownership can become ambiguous. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/shadow-manifest-validation.test.ts, tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php.
- The shadow Next.js route manifest now reserves current Vite create/edit workbench paths. Current React action routes such as /events/create, /listings/edit/:id, /marketplace/sell, /organisations/register, and protected course/podcast/ideation/volunteering workbench paths are now explicitly Vite-owned before public detail patterns can catch them. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/public-routes.test.ts, next-public-frontend/src/lib/__tests__/shadow-manifest-validation.test.ts, tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js public route manifest now separates marketplace discovery subroutes from item details. /marketplace/search, /marketplace/map, /marketplace/collections, and /marketplace/free are explicit public shadow routes, while job workbench routes such as /jobs/alerts, /jobs/my-applications, /jobs/talent-search, /jobs/bias-audit, and /jobs/employer-onboarding remain Vite-owned before /jobs/:id can catch them. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/public-routes.test.ts, next-public-frontend/src/lib/__tests__/public-messages-validation.test.ts, next-public-frontend/src/lib/__tests__/shadow-manifest-validation.test.ts, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js tenant resolver now reserves remaining protected Vite workbench segments. Courses, caring-community member tools, federation onboarding, group-exchange creation, premium management, reviews creation, and volunteering organisation dashboards are explicitly Vite-owned so shared-host paths cannot be parsed as tenant slugs during future canary work. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/public-routes.test.ts, next-public-frontend/src/lib/__tests__/tenant-request.test.ts, next-public-frontend/src/lib/__tests__/shadow-manifest-validation.test.ts, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js tenant resolver now reserves auth helper routes for Vite. Auth callback, password reset, email verification, identity verification, and optional identity verification prefixes are marked Vite-owned so shared-host paths cannot be parsed as tenant slugs during future canary work. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/public-routes.test.ts, next-public-frontend/src/lib/__tests__/tenant-request.test.ts, next-public-frontend/src/lib/__tests__/shadow-manifest-validation.test.ts, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js public route manifest now covers developer, analytics, volunteering, ideation, and hOUR public pages. Developer docs, regional analytics marketing, explore, hOUR tenant information pages, municipality calendar, volunteering opportunities, and ideation challenges now have explicit shadow Next ownership, while pilot forms, newsletter unsubscribe, invite redemption, partner analytics dashboard, advertising tools, and guardian-consent token flows remain Vite-owned. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/public-routes.test.ts, next-public-frontend/src/lib/__tests__/tenant-request.test.ts, next-public-frontend/src/lib/__tests__/shadow-manifest-validation.test.ts, next-public-frontend/src/lib/__tests__/content-sources.test.ts, next-public-frontend/src/lib/__tests__/public-messages-validation.test.ts, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js manifest now has full concrete React route ownership coverage. A new regression test reads the current React route table and fails if any concrete route is neither Next-public nor explicitly Vite-owned. The manifest now adds shadow public ownership for groups, courses, podcasts, marketplace seller/category pages, coupons, clubs, and the caring-community hub, with Laravel public API content sources where safe; current coupon endpoints remain auth-only and are explicitly rejected as public SSR content sources. Protected caring-community tools, federation, exchanges, search, activity, chat, premium returns, donation receipts, seller coupons, and other member/workbench routes remain Vite-owned. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/react-route-coverage.test.ts, next-public-frontend/src/lib/__tests__/shadow-manifest-validation.test.ts, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js manifest checks now block private Laravel API namespaces as public SSR content sources. API-backed public routes may still use approved /v2/... public content endpoints, but readiness now rejects admin, auth, dashboard, feed, messages, notifications, settings, super-admin, wallet, and broker namespaces before any future canary. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/shadow-manifest-validation.test.ts, tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php.
- The shadow Next.js manifest checks now require API-backed endpoints to be plain paths. Content-source endpoints with query strings or fragments are rejected before any future canary, so SSR data fetching cannot smuggle private include flags through the manifest. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/shadow-manifest-validation.test.ts, tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php.
- The shadow Next.js runtime now fails closed on unsafe content-source endpoints. Even if the manifest check is skipped, the isolated Next API resolver refuses non-/v2/ paths, private Laravel namespaces, query strings, and fragments before building SSR fetch URLs. This does not enable production routing or alter prerender serving. Regression test: next-public-frontend/src/lib/__tests__/content-sources.test.ts.
- The shadow Next.js public frontend now blocks traversal-style API content sources. Both manifest readiness checks and the runtime content-source resolver reject raw or encoded path traversal segments before any future canary can fetch Laravel public API data. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/shadow-manifest-validation.test.ts, next-public-frontend/src/lib/__tests__/content-sources.test.ts, tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php.
- The shadow Next.js runtime now shares the private Laravel API namespace guard with manifest validation. The runtime content-source resolver rejects auth-only coupon endpoints using the same Next-side private namespace helper as the manifest gate, preventing validator/runtime drift before any future public canary. This does not enable production routing or alter prerender serving. Regression test: next-public-frontend/src/lib/__tests__/content-sources.test.ts.
- The shadow Next.js runtime now fetches detail routes with named manifest parameters. API-backed shadow detail pages such as courses and podcast episodes now pass the route's actual parameter map into the Laravel endpoint template instead of only supporting id, so declared public content sources can render real SSR content before any future canary. This does not enable production routing or alter prerender serving. Regression test: next-public-frontend/src/lib/__tests__/tenant-api.test.ts.
- The Next.js public frontend readiness API now verifies API-backed routes are public Laravel GET routes. The read-only admin checker fails closed when a shadow SSR content source points at an unregistered Laravel API endpoint or a registered endpoint that still requires authentication, preventing future canary work from depending on private or missing APIs. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadinessValidationIssues.test.tsx.
- The Next.js public frontend readiness page now includes an operator playbook. The read-only admin payload and page show the future shadow verification, reviewed Apache config preparation, private Vite regression, public-route canary, and fallback-monitoring stages while keeping activation unavailable and requiring a separate explicit cutover instruction. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The Next.js public frontend readiness page now documents the tenant-resolution contract. The read-only admin payload and page show that shadow Next resolves tenants through Laravel /v2/tenant/bootstrap, passes slug for shared-host path tenants, forwards Origin for custom-domain tenants, and does not query the database. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The Next.js public frontend readiness page now verifies the tenant-bootstrap route status. The read-only admin payload checks Laravel's registered route collection and reports whether /v2/tenant/bootstrap is a public GET route before any future canary work depends on it. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The Next.js public frontend readiness page now shows the future edge canary configuration status. The read-only admin payload and page document that Apache/Plesk remains the production edge, the NEXT_PUBLIC_FRONTEND_ROUTING_ENABLED flag is off, no route file is configured by this module, and no activation controls are available without a separate cutover instruction. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The Next.js public frontend readiness page now groups future canary work into route batches. The read-only admin payload and page separate foundation public pages, API-backed public content, and retained Vite private routes with route counts, blockers, and verification commands, making the eventual canary sequence clearer without adding any activation switch. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The Next.js public frontend readiness page now classifies remaining public route work. The read-only admin payload and page separate static/manual-review routes, auth-only backend routes, backend contract gaps, and unclassified manifest-only routes with aggregate public/API-backed/remaining counts, required next actions, and verification commands, while explicitly reporting no production effect and no activation controls. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadinessValidationIssues.test.tsx.
- The Next.js public frontend readiness page now shows cutover eligibility. The read-only admin payload and page summarize that live cutover remains blocked, list concrete blockers such as remaining public route work, route parity, edge routes, and explicit cutover approval, and expose the next required actions without adding any activation controls. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadinessValidationIssues.test.tsx.
- The Next.js public frontend readiness page now shows pre-cutover dry-run checks. The read-only admin payload and page list the exact shadow manifest/no-JavaScript HTML, platform legal content review, auth-only public contract review, private Vite regression, and inertness commands that must be run before any future canary, while still exposing no activation controls and no production routing effect. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The shadow Next.js content-source manifest now backs more foundation pages from a public Laravel API. About, features, contact, trust-and-safety, and the timebanking guide now resolve through a read-only translated /v2/public-page-content/{pageKey} contract, reducing static/manual-review route gaps while keeping production routing and prerender serving unchanged. Regression tests: tests/Laravel/Feature/Controllers/StaticPublicPageControllerTest.php, tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, next-public-frontend/src/lib/__tests__/content-sources.test.ts.
- The shadow Next.js content-source manifest now backs the legal hub from the translated public-page API. /legal resolves through /v2/public-page-content/legal with tenant-aware legal document summaries, reducing remaining route work while keeping production routing and prerender serving unchanged. Regression tests: tests/Laravel/Feature/Controllers/StaticPublicPageControllerTest.php, tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, next-public-frontend/src/lib/__tests__/content-sources.test.ts.
- The shadow Next.js content-source manifest now backs the changelog from a public Laravel API. /changelog resolves through /v2/public-changelog, which summarizes the public CHANGELOG.md file for crawler-readable shadow rendering while keeping production routing and prerender serving unchanged. Regression tests: tests/Laravel/Feature/Controllers/PublicChangelogControllerTest.php, tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, next-public-frontend/src/lib/__tests__/content-sources.test.ts.
- The shadow Next.js content-source manifest now backs locale-authored public static pages from a Laravel API. Developer docs, regional analytics, caring-community overview, and hOUR partnership/social-prescribing/impact/strategic-plan pages now resolve through /v2/public-static-route-content/{pageKey} using existing public locale assets, reducing remaining route work while keeping production routing and prerender serving unchanged. Regression tests: tests/Laravel/Feature/Controllers/PublicStaticRouteContentControllerTest.php, tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, next-public-frontend/src/lib/__tests__/content-sources.test.ts.
- The Next.js public frontend readiness API now distinguishes privacy-sensitive remaining routes. Ideation idea detail now stays blocked with auth-scope/privacy-review actions instead of being treated as a simple parameter mismatch, and static platform legal routes require an authoritative content source before any future public cutover. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js content-source manifest now treats development status as feature-status content. /development-status is counted as API-backed through the existing translated /v2/public-page-content/features endpoint, matching the current React redirect target while keeping production routing and prerender serving unchanged. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, next-public-frontend/src/lib/__tests__/content-sources.test.ts.
- The shadow Next.js content-source manifest now backs platform legal pages from public locale assets. /platform/terms, /platform/privacy, and /platform/disclaimer resolve through the allowlisted /v2/public-static-route-content/... contract, moving readiness coverage to 72 API-backed public routes while keeping production routing and prerender serving unchanged. Regression tests: tests/Laravel/Feature/Controllers/PublicStaticRouteContentControllerTest.php, tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, next-public-frontend/src/lib/__tests__/content-sources.test.ts.
- The shadow Next.js operator README now lists the remaining non-cutover blockers. The README names the remaining auth-scope routes that require public visibility and privacy review before any future public API. This does not enable production routing or alter prerender serving.
- The shadow Next.js content-source manifest now marks the homepage as tenant-bootstrap backed. / is explicitly associated with the existing public /v2/tenant/bootstrap contract so readiness reporting recognises tenant branding as the Laravel source of truth, with no new endpoint and no production routing change. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, next-public-frontend/src/lib/__tests__/content-sources.test.ts.
- The Next.js public frontend readiness page now links to an inert Apache/Plesk canary template. A new example-only scripts/deploy/apache/next-public-foundation-canary.conf.example file documents exact-match foundation public-page proxy rules for a future reviewed canary, and the read-only admin payload/page verifies the template exists while confirming it is not included by deploy or compose. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The Next.js public frontend readiness page now exposes a cutover artifact inventory. The read-only admin payload and page list the route manifest, content-source manifest, inert Apache canary template, shadow runtime config, routing flag config, prerender fallback, and required future verification commands while confirming there are no activation controls or production effects. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The Next.js public frontend readiness page now audits the inert Apache canary template against route ownership. The read-only admin payload/page expands the example RewriteRule paths, confirms they are exact public routes, and reports private-route collisions or unsupported rules as blockers before any future cutover. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The Next.js public frontend readiness checklist now flags accidental Apache canary inclusion. The read-only backend/admin safety checks expose a pass/blocker row confirming the inert Apache template is not referenced by deploy or compose, making future route-inclusion drift visible before any explicit cutover. This does not enable production routing or alter prerender serving. Regression tests: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The shadow Next.js module now has a local production-inertness verifier. npm run check:next-public:inert reads repo files only and fails if the cutover env flag is enabled, the Apache canary template is referenced by deploy/compose, the Next service leaves the opt-in next-public-shadow profile, or the prerender fallback disappears. The readiness payload lists this as a required pre-cutover command. This does not enable production routing or alter prerender serving. Regression tests: scripts/test/check-next-public-inert.test.mjs, tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php.
- The shadow Next.js module now has an aggregate pre-cutover dry-run command. npm run check:next-public:dry-run runs the inertness guard, isolated Next check, backend/admin readiness contracts, React typecheck, and React production build in sequence, stopping on the first blocker while reporting no production routing effect. The readiness payload and operator instructions list it as a required future cutover command. Regression tests: scripts/test/check-next-public-dry-run.test.mjs, tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php, react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadiness.test.tsx.
- The shadow Next.js content-source manifest now backs Explore and Clubs from public Laravel APIs. The Next public frontend can render those shadow routes from /v2/explore and /v2/clubs, and the read-only readiness API reports them as API-backed route coverage. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/content-sources.test.ts, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js content-source manifest now backs marketplace map and free-item pages from public Laravel APIs. The Next public frontend can render /marketplace/map from /v2/marketplace/listings/nearby and /marketplace/free from /v2/marketplace/listings/free, with readiness reporting both routes as API-backed coverage. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/content-sources.test.ts, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js content-source manifest now backs marketplace search from the public listings API. The Next public frontend can render /marketplace/search from /v2/marketplace/listings, and the read-only readiness API reports that route as API-backed coverage. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/content-sources.test.ts, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js content-source manifest now backs marketplace category pages from a public Laravel API. /marketplace/category/:slug can resolve category-filtered listings through the new read-only /v2/marketplace/categories/{slug}/listings endpoint, while production routing and prerender serving remain unchanged. Regression tests: next-public-frontend/src/lib/__tests__/content-sources.test.ts, tests/Laravel/Feature/Controllers/MarketplaceListingControllerTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js content-source manifest now backs the municipality calendar from a public Laravel API. /municipality-calendar can resolve the tenant's first consenting municipality through the new read-only /v2/municipality/events-calendar endpoint, returning an empty calendar when no municipality has opted in. Production routing and prerender serving remain unchanged. Regression tests: next-public-frontend/src/lib/__tests__/content-sources.test.ts, tests/Laravel/Feature/Verein/MunicipalityCalendarTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js content-source manifest now backs the FAQ page from the public help API. The Next public frontend can render /faq from /v2/help/faqs, and the read-only readiness API reports that foundation route as API-backed coverage. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/content-sources.test.ts, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js content-source manifest now backs the Help page from the public help API. The Next public frontend can render /help from /v2/help/faqs, and the read-only readiness API reports that foundation route as API-backed coverage. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/content-sources.test.ts, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js content-source manifest now backs tenant legal document pages from public Legal APIs. Terms, privacy, cookies, accessibility, community guidelines, acceptable-use, and their version-history routes now point at existing public /v2/legal/... endpoints for readiness reporting. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/content-sources.test.ts, tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The Next.js public frontend readiness API now fails closed if the cutover env flag is enabled. The read-only admin safety checklist reports route_cutover_disabled as a blocker whenever NEXT_PUBLIC_FRONTEND_ROUTING_ENABLED=true, making accidental canary activation visible without changing production routing or prerender serving. Regression test: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php.
- The Next.js public frontend readiness API now reads the cutover flag through cached Laravel config. The NEXT_PUBLIC_FRONTEND_ROUTING_ENABLED guard remains fail-closed while avoiding direct runtime env() reads outside config, so production config caching and PHPStan agree on the cutover safety check. Regression test: tests/Laravel/Unit/Services/NextPublicFrontendReadinessServiceTest.php.
- Security scan dependencies now resolve patched Vite and WebSocket packages. The root and React frontend lockfiles use vite@7.3.6, and the React frontend lockfile resolves Lighthouse's direct ws dependency to 7.5.11, clearing the OWASP Dependency-Check findings for the vulnerable vite@7.3.2 and ws@7.5.10 resolutions.
- The Next.js public frontend readiness page now translates all shadow validation blockers. The admin page has locale keys for the content-source and API-backed route validation issues surfaced by the read-only readiness API, so future blockers render as operator-readable labels instead of raw i18n keys. This does not enable production routing or alter prerender serving. Regression test: react-frontend/src/admin/modules/advanced/next-public/NextPublicFrontendReadinessValidationIssues.test.tsx.
- The shadow Next.js public frontend now has operator instructions. next-public-frontend/README.md documents the shadow-only status, safe local commands, backend/admin readiness verification command, opt-in compose profile, route/content-source manifests, admin readiness endpoint, and explicit pre-cutover checklist. This does not enable production routing or alter prerender serving.
- The shadow Next.js operator instructions now document the Apache canary audit workflow. next-public-frontend/README.md explains that scripts/deploy/apache/next-public-foundation-canary.conf.example is example-only, not included by deploy or compose, and audited read-only by the admin readiness API before any future explicit cutover. This does not enable production routing or alter prerender serving.
- The shadow Next.js route manifest now explicitly keeps auth and onboarding in Vite. Login, registration, and onboarding prefixes are recorded as Vite-owned routes in next-public-frontend/route-ownership.json, preventing a future public canary from accidentally treating them as Next-owned public pages, and shared-host tenant resolution now has regression coverage proving those prefixes are not parsed as tenant slugs. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/public-routes.test.ts, next-public-frontend/src/lib/__tests__/tenant-request.test.ts, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- The shadow Next.js public frontend now covers the remaining public legal/static page set. Features, changelog, legal hub, legal version history pages, cookie/community-guidelines/trust-safety/acceptable-use pages, platform legal pages, and the timebanking guide now have explicit shadow route ownership and crawler-readable translated HTML. This does not enable production routing or alter prerender serving. Regression tests: next-public-frontend/src/lib/__tests__/public-routes.test.ts, next-public-frontend/src/lib/__tests__/public-messages-validation.test.ts, next-public-frontend/src/ui/__tests__/PublicRouteNoJsHtml.test.tsx, tests/Laravel/Feature/Controllers/AdminNextPublicFrontendControllerTest.php.
- Donations & Support payment routing is hardened for production. Tenant Stripe Connect accounts are used for one-off and recurring donations only once onboarding is ready; otherwise payments keep using the platform fallback with tenant metadata. Recurring support Stripe Price IDs now record the Stripe account they belong to and checkout refreshes prices when the active route changes. Admin donation exports now include payment route, Stripe account, and PaymentIntent details. Regression tests: tests/Laravel/Unit/Services/DonationStripeAccountServiceTest.php, tests/Laravel/Unit/Services/MemberPremiumServiceTest.php, tests/Laravel/Unit/Services/VolunteerDonationServiceTest.php, react-frontend/src/admin/modules/premium/MemberPremiumAdminPage.test.tsx.
- Donations & Support now has production finance operations coverage. The support admin page shows tenant-scoped completed volume, platform fallback liability, tenant Connect volume, Gift Aid-ready declarations, open disputes, active recurring supporters, and failed receipt follow-up. New admin APIs expose the same finance overview, tenant-scoped dispute list, Gift Aid CSV, and annual donor receipt CSV; Stripe dispute webhooks are recorded against donation tenants and one-off donations can store Gift Aid and fund-code data. The accessible support page now uses "Donate" / "Support this community" language instead of premium-member wording. Regression tests: tests/Laravel/Unit/Services/DonationOperationsServiceTest.php, tests/Laravel/Feature/Controllers/MemberPremiumAdminFinanceControllerTest.php, tests/Laravel/Feature/Controllers/DonationPaymentControllerTest.php, tests/Laravel/Feature/GovukAlpha/CommerceParityTest.php, tests/Laravel/Unit/Services/StripeDonationServiceTest.php, react-frontend/src/admin/modules/premium/MemberPremiumAdminPage.test.tsx, react-frontend/src/admin/api/adminApi.test.ts.
- One-off donations now expose fund allocation and Gift Aid in the donor checkout. Donors can choose a community fund before paying by card, and GBP donations can collect a UK taxpayer Gift Aid declaration that is sent to the existing Stripe donation intent API for tenant-scoped reporting and export. Regression test: react-frontend/src/components/donations/DonationCheckout.test.tsx.
- Donations & Support Stripe sync no longer shows a false success while a support level still needs sync. The admin page validates the returned support level before showing the “synced” toast, and the backend now verifies the persisted monthly/yearly Stripe Price IDs and active Stripe account scope before returning success. Regression tests: react-frontend/src/admin/modules/premium/MemberPremiumAdminPage.test.tsx, tests/Laravel/Unit/Services/MemberPremiumServiceTest.php.

### Changed

- The in-platform changelog now renders as a polished release-notes page. The footer changelog link still uses the same CHANGELOG.md source as GitHub, but the React page now has a wider layout, clearer section rhythm, styled release/category headings, and readable release-note rows instead of a cramped generic Markdown card. Regression tests: react-frontend/src/pages/public/ChangelogPage.test.tsx, react-frontend/src/components/content/MarkdownRenderer.test.tsx.
- Imported Markdown knowledge-base articles now use a dedicated document presentation. Knowledge-base .md imports keep the same stored Markdown content, but the React article page now renders them with clearer heading hierarchy, readable paragraphs and lists, styled blockquotes, tables, code blocks, links, and images instead of the generic Markdown treatment. Regression test: react-frontend/src/components/content/MarkdownRenderer.test.tsx.

### Fixed

- Registration email verification notices now explicitly tell new members to check Junk or spam. The post-registration verification callout bolds the spam-folder instruction so first-time registrants know where to look if the message is missing. Regression test: react-frontend/src/pages/auth/RegisterPage.test.tsx.
- Wallet credit transfers now allow the Idempotency-Key request header in CORS preflight responses. Custom-domain tenants such as Timebanking UK can submit transfer requests again instead of being stopped by the browser before the wallet API receives the POST. Regression test: tests/Laravel/Unit/Middleware/EnsureCorsHeadersTest.php.
- Broker safeguarding now reuses the full admin safeguarding dashboard. The broker panel now renders the same flagged messages, guardian assignments, member-preference table, and assignment actions as /admin/safeguarding, while drill-down cards stay scoped to /broker/safeguarding. This fixes member preference rows rendering option objects instead of option labels. Regression tests: react-frontend/src/admin/modules/safeguarding/SafeguardingDashboard.test.tsx, react-frontend/src/broker/pages/SafeguardingPage.test.tsx.

## 1.5.4 - 2026-06-29

### Added

- Admin user management now shows email activation status and can resend verification emails. The users table includes an email activation column, and the edit-user screen shows activation status with a resend action for members who have not completed email verification. The admin resend endpoint reuses the existing verification-token email flow and skips already verified accounts. Regression tests: tests/Laravel/Feature/Controllers/AdminUsersControllerTest.php, react-frontend/src/admin/modules/users/UserList.test.tsx, react-frontend/src/admin/modules/users/UserEdit.test.tsx, react-frontend/src/admin/api/adminApi.test.ts.
- The utility bar now has a backend-programmable tenant switcher. Tenant bootstrap exposes active child tenants as switcher options by default, resolving each item to the correct absolute URL: a child with its own custom domain uses that domain, while a child under a custom-domain parent such as uk.timebank.global uses https://uk.timebank.global/{child-slug}. The React utility bar renders the payload as a HeroUI dropdown and navigates cross-domain switches in the same tab. Regression tests: tests/Laravel/Feature/Controllers/TenantBootstrapControllerTest.php, react-frontend/src/components/layout/Navbar.test.tsx.
- A shadow-mode Next.js public frontend now exists for the hybrid SSR migration. The new isolated next-public-frontend/ app renders the first public/indexable route slice (/, tenant-slug homes, about/help/contact/FAQ/legal pages, blog index/detail, and CMS pages) with server-side HTML, tenant bootstrap via the Laravel public API, canonical metadata, JSON-LD, translated public copy, AGPL attribution, and explicit route ownership that keeps logged-in/gated product routes in the existing Vite SPA. It is disabled by default behind local scripts and a next-public-shadow Compose profile; the current prerender system and production routing are unchanged.
- Admins can now edit the footer partner-logo label. The Branding & Legal settings screen has a partner-logo label field alongside the existing logo and link controls, and the frontend footer uses that saved label for the bottom-left partner-logo heading and image alternative text. Leaving it blank keeps the translated default "Community Partner" label. Regression tests: react-frontend/src/admin/modules/system/AdminSettings.test.tsx, react-frontend/src/components/layout/Footer.test.tsx, tests/Laravel/Feature/Controllers/AdminSettingsControllerTest.php, tests/Laravel/Feature/TenantBootstrapTest.php.
- A rerunnable local walkthrough-video pipeline now lives under tools/video-walkthroughs/. It generates British voiceover audio with edge-tts, records the real React hour-timebank onboarding/listing flow with Playwright, and assembles clean and captioned MP4 outputs plus SRT captions with FFmpeg.

### Fixed

- Newsletter admin links now respect the newsletter module toggle. The admin dashboard no longer shows the "Send Newsletter" quick action when the tenant's newsletter feature is disabled, and regression coverage now locks the newsletter module registry and sidebar gating behavior. Regression tests: react-frontend/src/admin/modules/dashboard/AdminDashboard.test.tsx, react-frontend/src/admin/components/AdminSidebar.test.tsx, react-frontend/src/admin/modules/config/moduleRegistry.test.ts.
- Admin email-activation translations now pass the release drift gate. The email activation status and resend-verification controls added to admin user management now have matching admin-locale keys across all supported languages, and stale Arabic/Polish listing plural keys that no longer exist in the English source were removed.
- A community's accessible (GOV.UK) custom domain now works like the React custom domains — slug-less, with the tenant resolved from the host. When a community has its own accessible custom domain (e.g. accessible-uk.timebank.global), the whole accessible site is now served at clean, slug-less paths (/, /login, /listings, …). Previously the bare domain 302-redirected to https://accessible-uk.timebank.global/timebanking-org/alpha and every in-page link carried that internal timebanking-org/alpha prefix. Tenants WITHOUT a custom accessible domain are unchanged — the shared platform domain still uses /{tenantSlug}/alpha/…. Implemented centrally, with no per-page rewrites: TenantContext flags requests that arrived via a tenant's accessible_domain; the accessible route set is also registered at the bare root for those hosts (InjectHostTenantSlug supplies the slug the controllers expect; EnsureAccessibleCustomDomain keeps the bare routes off the shared/API hosts); and StripTenantSlugOnAccessibleDomain removes the /{slug}/alpha prefix from every generated link and redirect on those hosts. Regression test: tests/Laravel/Feature/GovukAlpha/AccessibleCustomDomainRootTest.php (and the existing accessible parity suite stays green).
- The deploy-time browser-journey safety gate now actually exercises the candidate build instead of false-failing. The gate loads the freshly built blue/green candidate frontend from 127.0.0.1 and runs the @smoke journeys against it before traffic is switched. But the production React bundle hard-codes the live API origin (https://api.project-nexus.ie), so the candidate SPA's own bootstrap/data fetches went cross-origin to the live colour and were CORS-blocked from the 127.0.0.1 gate origin — leaving the SPA stuck on "Loading community" so nearly every page journey timed out (a false failure on the gate's first real run, unrelated to the code being shipped). A new pinSpaApiToCandidate() helper (wired into the gate's beforeEach) intercepts those calls via Playwright request routing and proxies them to the candidate API (reachable through the runner's --network host), fulfilling with CORS headers — so the gate now genuinely tests the candidate's own frontend and API end-to-end. The handler aborts best-effort (never double-handles) when a page navigates mid-request. Verified by running the full @smoke suite against a live candidate: 19 passed, 2 skipped (admin, no creds). Harness only — no runtime/app behaviour change. Files: e2e/helpers/test-utils.ts, e2e/tests/smoke.spec.ts, playwright.deploy.config.ts.
- "Resend verification email" no longer says it sent when the request was actually rejected. On the email-verification page, clicking "Resend verification email" showed a "we sent a new link — check your inbox" confirmation the moment the request was sent, without checking it worked — so a rejected resend (most often rate-limiting, since resends are throttled) left you waiting for an email that never came. It now only confirms once the server accepts the request, and otherwise shows an error and keeps the resend button so you can try again. Verified live by forcing the resend to be rate-limited: the error appears instead of a false confirmation. Regression test: react-frontend/src/pages/auth/VerifyEmailPage.test.tsx. (The login page's separate resend stays intentionally silent — that endpoint always reports success to avoid revealing whether an email is registered.)
- Replying to a story no longer says "sent" (and discards your message) when the reply was actually rejected. In the story viewer, sending a reply showed a "reply sent" confirmation and cleared the box the moment the request was sent, without checking it worked — so a rejected reply (e.g. rate-limited or a since-expired story, which comes back as data rather than an error) silently dropped your message with a false confirmation. The reply now only clears and confirms once the server accepts it, and otherwise shows an error and keeps your text so you can retry. The same story viewer also no longer advances past your own story as if deleted when a delete is rejected, and no longer leaves a reaction highlighted when the server rejects it. Regression test (proven to fail before the fix, pass after): react-frontend/src/components/stories/StoryViewer.test.tsx.
- Hiding, muting, or deleting from a hashtag feed no longer makes posts vanish with a false "done" when the server rejected the action. On a hashtag (#tag) page, "Hide", "Mute user" and "Delete" removed the post(s) from the list and showed a success message the moment the request was sent, without checking whether it worked — so a rejected action (e.g. not yours to delete, already gone, or rate-limited, which come back as data rather than an error) made the post(s) disappear with a fake confirmation, only to reappear on refresh (mute was worst: it wiped every post by that author). These now only remove the post(s) once the server confirms, and show an error otherwise; a rejected poll vote on the same feed now also surfaces an error instead of silently doing nothing. Mirrors the same fix just made to the profile timeline. Regression tests: react-frontend/src/pages/feed/HashtagPage.test.tsx.
- Hiding or deleting a post from a profile timeline no longer makes it vanish with a false "done" when the server rejected the action. On a profile's activity feed, "Hide" and "Delete" removed the post from the list and showed a success message the moment the request was sent, without checking whether it worked — so a rejected hide/delete (e.g. not yours to delete, already gone, or rate-limited, which come back as data rather than an error) made the post disappear with a fake confirmation, only to reappear on the next refresh. Hide/delete now only remove the post once the server confirms, and show an error otherwise; a rejected poll vote on the same feed now also surfaces an error instead of silently doing nothing. Regression test (proven to fail before the fix, pass after): react-frontend/src/components/profile/ProfileFeed.test.tsx.
- A reaction on someone's Appreciation Wall no longer changes the count when the reaction was actually rejected. Tapping a reaction (heart/clap/star) on a thank-you note updated the count immediately without checking whether the server accepted it, so a rejected reaction (e.g. rate-limited or a since-removed note, which comes back as data rather than an error) left the count off by one until the page was reloaded. The count now only changes once the server confirms the reaction. Regression test (proven to fail before the fix, pass after): react-frontend/src/pages/profile/AppreciationWallPage.test.tsx.
- The user hover card no longer shows (and remembers) a "Pending" state when a connection request was actually rejected. Hovering a member and clicking "Connect" immediately marked the card "Pending" without checking whether the server accepted the request — and cached that state, so every later hover of that person showed the false "Pending" too. A rejected request (e.g. you'd already sent one, were blocked, or hit a rate limit, which come back as data rather than an error) therefore left a sticky fake "Pending". The card now only switches to "Pending" once the server confirms the request, and stays on "Connect" (so you can retry) otherwise. Regression test (proven to fail before the fix, pass after): react-frontend/src/components/social/UserHoverCard.test.tsx.
- Registration emails no longer depend on queue workers. The member welcome/activation email and the admin "new registration" alert now run inline during signup instead of being queued, so a stalled Redis/Horizon worker can no longer leave new members without verification links or hide new registrations from admins. The existing duplicate-delivery guards remain in place, email-log rows now carry registration-specific idempotency keys, and email health now treats missing/failed admin registration alerts as critical incidents.
- Platform SendGrid fallback now keeps the authenticated From domain. If a tenant SMTP path fails and the app falls back to the platform SendGrid account, the fallback now uses the same category-specific project-nexus.net From addresses as normal platform SendGrid mail instead of reusing a raw configured From address such as noreply@project-nexus.ie. This prevents fallback-only messages from failing SPF/DMARC alignment when the env From domain is not SendGrid-authenticated. Regression test: tests/Laravel/Unit/Core/MailerTest.php.
- Platform SendGrid Reply-To is no longer hard-coded to a personal mailbox. The platform SendGrid Reply-To now comes from SENDGRID_REPLY_TO / mail.sendgrid.reply_to when configured, and no Reply-To is forced when it is not configured. This removes the hard-coded jasper@hour-timebank.ie header from platform mail and prevents personal-address leakage in message headers. Regression test: tests/Laravel/Unit/Core/MailerTest.php.
- The "People You May Know" feed widget no longer says a connection request was sent when it was actually rejected. Clicking "Connect" immediately showed the card as "Pending" and a "request sent" confirmation without checking whether the server accepted it — so a rejected request (e.g. you'd already sent one, the person blocked requests, or you hit a rate limit, which come back as data rather than an error and show no automatic message) left the card stuck on "Pending" with a false success message until refresh. The card now reverts to "Connect" and shows the actual reason when the request is rejected. Verified live by forcing the request to fail: the card returns to "Connect" and an error ("You have already sent a request") appears instead of a fake confirmation. Regression test: react-frontend/src/components/feed/ConnectionSuggestionsWidget.test.tsx.
- The "WCAG 2.2 AA Version" link now uses a community's bare accessible custom domain instead of appending /alpha. When a community has its own accessible (GOV.UK) custom domain configured in admin (e.g. accessible-uk.timebank.global), the utility-bar link to the accessible version appended an /alpha path — so it pointed at https://accessible-uk.timebank.global/alpha rather than the clean domain set in the admin panel. The link now uses the bare custom domain (the host resolves the tenant and the server redirects the root to the canonical accessible home), matching what the administrator configured; deep-link subpaths still target the /alpha route namespace. Affects both the desktop navbar and the mobile drawer. Regression test: react-frontend/src/lib/accessible-frontend.test.ts.
- The PWA install banner now uses the community's tenant name instead of always saying NEXUS. The banner title is rendered through the existing translation key with a tenant-name placeholder, falling back to NEXUS only when tenant branding is unavailable. Regression test: react-frontend/src/components/pwa/InstallBanner.test.tsx.
- A group's Analytics tab no longer crashes when a retention-cohort row comes back without a retention figure. The retention table formatted each cohort's percentage directly, so if a cohort arrived without a retention figure (a partial/degraded backend response) the tab threw while rendering that row and blanked the whole Analytics view — even though the other figures on the same tab already guarded against this. Missing retention is now treated as 0%. Locked with a regression test proven to fail before the fix (the render threw) and pass after. Regression test: react-frontend/src/pages/groups/tabs/GroupAnalyticsTab.test.tsx.
- The Leaderboard no longer crashes to an error screen when a ranking row comes back without a score. Each row formatted its score directly, so if an entry arrived with neither a score nor an XP value (a partial/degraded backend response) the page threw while rendering that row and blanked the entire Leaderboard with a "Something went wrong" message. Missing scores are now treated as zero and the row renders normally. Verified live by forcing an entry with no score or XP: the row shows "0" with no error, and normal entries still show their real scores. Regression test: react-frontend/src/pages/leaderboard/LeaderboardPage.test.tsx.
- The Achievements page no longer crashes to an error screen when your gamification profile comes back incomplete. The XP profile card read your total XP and level-progress figures directly, so if the server returned a partial profile (missing those fields — which can happen when it falls back after an internal error) the card threw while rendering and blanked the entire Achievements page with a "Something went wrong" message. The card now treats missing figures as zero and renders normally, so a temporary backend hiccup degrades gracefully instead of taking down the page. Verified live by forcing an incomplete profile response: the page renders (Level 5 / 0 XP total) with no error, and a normal response still shows the real figures. Regression test: react-frontend/src/pages/achievements/AchievementsPage.test.tsx.
- Voting on a poll attached to an event now tells you when the vote is rejected instead of silently doing nothing. On the event detail page, voting on an event poll only updated the results and showed a confirmation when the request succeeded — but if it was rejected (the poll closed, you already voted, or an invalid option — which comes back as data rather than an error and shows no automatic message) the click did nothing at all, with no feedback. It now shows the reason (or a generic "Failed to record vote") on a rejected vote, matching the standalone Polls page. Verified live by forcing a vote to fail: the error ("This poll is closed") now appears where the click previously did nothing.
- "Download my data" on the job applications page now confirms success and reports failure instead of doing nothing. The GDPR export button fetched your data and triggered a download only when the request succeeded — but if it was rejected (a server or connection problem, which comes back as data rather than an error and shows no automatic message) nothing happened at all: no file, no message, leaving you unsure whether your data request worked. It now shows a success confirmation when the download starts and an error message if the export is rejected. Verified live by forcing the export to fail: the error ("Export is temporarily unavailable") now appears where previously the button did nothing. Regression test: react-frontend/src/pages/jobs/MyApplicationsPage.test.tsx.
- The check-in History view on a goal now shows a "couldn't load" message (with a Retry button) instead of looking empty when it fails to load. Opening a goal's check-in dialog and switching to History fetched the past check-ins, but if that request failed (a server or connection problem, which comes back as data rather than an error and shows no automatic message) the view fell through to its "no check-ins yet" empty state — so a load failure was indistinguishable from a goal that genuinely has no check-ins. It now shows the error message and a Retry button on a failed load (reusing the same wording as the goal progress timeline). Verified live by forcing the check-ins request to fail: the error + Retry appear in place of the empty state. Regression test: react-frontend/src/pages/goals/components/GoalCheckinModal.test.tsx.
- An employer's bulk "move applicants to a stage" action no longer reports success when the server rejected it. On the job applications pipeline (Kanban), selecting several applicants, choosing a stage, and pressing Apply showed "Applications updated" and cleared the selection the moment the request was sent, without checking whether it worked — so a rejected bulk update (an invalid stage, a vacancy you don't own, or applicant ids outside that vacancy — which comes back as data rather than an error and shows no automatic message) left every applicant unchanged while the employer was told it succeeded. It now shows the reason and keeps the selection so you can retry, matching the success-gated pattern already used by the page's AI-rank and per-card actions. Verified live by forcing the bulk request to fail: the error ("Some applications could not be updated") appears and the selection is kept (it previously cleared with a false "updated" message).
- A goal's progress timeline now shows a "couldn't load" message (with a Retry button) instead of looking empty when it fails to load. If the request for a goal's history failed (a server or connection problem, which comes back as data rather than an error and shows no automatic message), the timeline fell through to its "no activity yet" empty state — so a load failure was indistinguishable from a goal that genuinely has no history. It now shows the existing error message and Retry button on a failed load. Verified live by forcing the history request to fail: the error + Retry appear in place of the empty state. Regression test: react-frontend/src/pages/goals/components/GoalProgressHistory.test.tsx.
- Saved searches no longer disappear or fail silently when the server rejects the action. Deleting a saved search removed it from the list and said "deleted" the moment the request was sent, without checking whether it worked — so a rejected delete (e.g. not-found or rate-limited) made the row vanish and showed a success message while the search was still saved on the server, until the next refresh brought it back. Separately, saving a search that the server rejected (e.g. a duplicate name) gave no feedback at all — the form just sat there. Now a delete only removes the row once the server confirms it (and shows an error otherwise), and a rejected save shows the reason and keeps the form open to retry. The save form also guards against a double-Enter creating two duplicates. Verified live: a forced-to-fail save shows its error and keeps the form open, and a double-Enter sends exactly one request. Regression test: react-frontend/src/components/search/SavedSearches.test.tsx.
- The club/association invitations page no longer shows an empty "Received" list when it actually failed to load. If the request for your federation invitations failed (a server or connection problem, which comes back as data rather than an error and shows no automatic message), the page fell through to its empty state — so a load failure looked identical to genuinely having no invitations. It now shows a "couldn't load" message instead. Verified live by forcing the request to fail: the error message appears rather than a misleading empty list. Regression test: react-frontend/src/pages/profile/MyVereinInvitationsPage.test.tsx.
- Creating a collection now tells you why it failed instead of silently doing nothing. On the My Collections page, if the server rejected a new collection (for example a duplicate or invalid name — which comes back as data rather than an error and shows no automatic message), the "New collection" dialog just stayed open with no feedback, so it looked like the button hadn't worked. It now shows the reason and keeps the dialog open so you can fix the name and try again. Verified live by forcing a create to fail: the error ("A collection with that name already exists") appears and the dialog stays open. Regression test: react-frontend/src/pages/profile/MyCollectionsPage.test.tsx.
- Posting a volunteer opportunity can no longer be submitted twice and create a duplicate. Like the other creation forms, the "Post Opportunity" form submits on Enter (bypassing the disabled button), so a double-Enter or a fast double-click on a slow connection could send the request twice and create two identical opportunities. A synchronous re-entry guard now ensures only the first submit is sent. Verified live (a double-submit now fires exactly one request) and locked with a regression test. Regression test: react-frontend/src/pages/volunteering/CreateOpportunityPage.test.tsx.
- Registering an organisation can no longer be submitted twice and create a duplicate. Like the event, group, and listing forms, the "Register Organisation" form submits on Enter (bypassing the disabled button), so a double-Enter or a fast double-click on a slow connection could send the registration twice and create two pending organisations. A synchronous re-entry guard now ensures only the first submit is sent. Verified live (a double-submit now fires exactly one request) and locked with a regression test. Regression test: react-frontend/src/pages/organisations/RegisterOrganisationPage.test.tsx.
- Creating an event can no longer be submitted twice and make a duplicate. On the Create Event form the submit button shows a spinner while saving but, because pressing Enter in a field submits the form directly (bypassing the button), a double-Enter or a fast double-click on a slow connection could fire the create request twice and create two identical events. A synchronous re-entry guard now ensures only the first submit is sent, matching the fix already on the Create Group, Create Listing, and Request Exchange forms. Verified live: a double-submit on a fully filled event form now fires exactly one create request (it was two before).
- The Leaderboard "Community Impact" tab no longer crashes the whole Leaderboard when its figures come back incomplete. If the server returned a partial community-stats response (missing totals, or this-month figures in an unexpected shape — which can happen when it falls back after an internal error), the tab tried to format a missing number and threw, blanking the entire Leaderboard with a "Something went wrong" message. The tab now treats missing figures as zero and renders normally, so a temporary backend hiccup degrades gracefully instead of taking down the section. Verified live by forcing the incomplete response: the tab renders zeros with no error, and a normal response still shows the real figures. Regression test: react-frontend/src/pages/leaderboard/CommunityImpactTab.test.tsx.
- The Leaderboard "Spotlight" tab no longer says "No Spotlight Yet" when it actually failed to load. When the featured-members request failed (a server or connection problem, which comes back as data rather than an error and shows no message), the tab fell through to its empty state — so a load failure looked exactly like a community with no members to feature. It now shows a distinct "couldn't load" message instead, so a genuine empty result and a failed load are no longer confused. Verified live by forcing the request to fail: the error message appears in place of the empty state, while a normal response still shows the featured members.
- AI chat thumbs-up/down no longer stays highlighted when your rating fails to save. Rating an AI answer highlighted the thumb immediately, but if the request to save it was rejected (a permissions or validation problem, or a connection blip — which come back as data rather than an error and show no message) the highlight stuck as though it had saved, because the undo only ran on a thrown error. The rating now reverts the highlight when the server doesn't accept it, so what's highlighted reflects what was actually saved. Verified live by forcing the rating request to fail: the thumb highlights briefly, then reverts when the save is rejected, while a successful rating stays highlighted.
- Unblocking a member now tells you if it failed instead of silently pretending it worked. On the Blocked Users page (Settings → Privacy → Blocked Users), confirming an unblock closed the dialog and removed the person from the list only when the request succeeded — but if the request was rejected (a permissions or validation problem, or a connection blip, which come back as data rather than an error and show no automatic message) the dialog still closed with no feedback while the member stayed blocked, so it looked done when it wasn't. A rejected unblock now shows an error and leaves the member in the list so you can retry. Verified live by blocking a member and forcing the unblock to fail: the error message appears and the member remains listed. Regression test: react-frontend/src/pages/settings/BlockedUsersPage.test.tsx.
- The Leaderboard "My Journey" tab no longer crashes to an error screen when its data can't be fully loaded. If the server hit a problem building your personal-journey summary it still replied "success" but with an empty summary, and the tab then tried to format a missing XP number and threw — blanking the entire Leaderboard with a "Something went wrong" message. The tab now treats a missing/empty summary defensively and shows zeros instead of crashing, so a temporary backend hiccup degrades gracefully rather than taking down the whole section. Verified live by forcing the empty-summary response: the tab renders (Level 0 / 0 XP) with no error instead of crashing, and a normal response still shows the real figures. Regression test: react-frontend/src/pages/leaderboard/PersonalJourneyTab.test.tsx.
- Comments now tell you when posting, editing, or deleting fails instead of pretending it worked. The shared comments component (used under blog posts, resources, listings, events, and the feed) ignored whether the server actually accepted each action — because a rejected request (e.g. a permission or validation problem, rate-limiting, or an already-deleted comment) comes back as data rather than an error and shows no automatic message. So confirming a delete closed the dialog as if the comment were gone while it stayed in the list, and a rejected post, reply, or edit simply did nothing — your text just sat there with no explanation. Each action now confirms success first: a failed delete keeps the confirmation open and shows an error, a failed post/reply keeps your text and shows an error, and a failed edit keeps the editor open and shows an error. Verified live on a real comment by forcing the delete and the post to fail: the dialog stays open / the text is preserved and the matching error message appears. Regression test: react-frontend/src/components/social/CommentsSection.test.tsx.
- Reordering resources no longer leaves a fake new order on screen when the save fails. On the Resources page, an administrator's "Reorder" up/down controls moved a file immediately and then saved the new order — but if the save was rejected (a permissions or validation problem, or a connection blip, which come back as data rather than an error), the failure path never ran: the list stayed in the new order with no warning, so it looked saved until a later reload silently snapped it back. The move now rolls back to the previous order and shows an error when the save is rejected, so what you see always matches what was actually saved. Verified live as an admin by forcing the reorder save to fail: the list reverts to its original order and shows "Failed to save new order" instead of leaving the fake order in place.
- Editing, deleting, or unlinking an ideation campaign now reports failures instead of pretending to work. On a campaign's page, saving an edit, deleting the campaign, and unlinking a challenge all showed success regardless of whether the server accepted the request — so a rejected edit closed the dialog and said "updated", a rejected delete navigated away as though the campaign were gone, and a rejected unlink claimed the challenge was removed. Each now confirms the server accepted the action first and shows an error otherwise (the edit dialog stays open on failure so you can retry). Verified live: a save forced to fail now shows the error and keeps the dialog open instead of falsely reporting the campaign was updated.
- Idea comments and idea admin actions now report failures instead of pretending to work. On an idea's page, posting a comment, deleting a comment, changing an idea's status, and deleting an idea all ran their success path without checking whether the server accepted the request. So a failed comment cleared your text and said "comment added" while nothing was posted, a failed delete still removed the comment from view, a failed status change claimed it updated, and a failed idea delete navigated you away as though the idea were gone. All now confirm the server accepted the action first and show an error otherwise — and a failed comment keeps your text so you can retry. Verified live: a comment forced to fail now shows the error and preserves the text instead of falsely reporting it was posted. Regression test: react-frontend/src/pages/ideation/IdeaDetailPage.test.tsx.
- Saving or deleting a bookmark collection now reports failures instead of pretending to work. On the Saved Items page, creating, renaming, or deleting a collection showed a success message and closed the dialog regardless of whether the server accepted the request — so a rejected create/rename/delete looked done (the dialog closed, "created"/"deleted" shown) while nothing had actually changed. Each now confirms the server accepted the action first and, on failure, shows an error and keeps the dialog open so you can retry. Verified live by forcing a create to fail: it now shows the error and leaves the dialog open instead of falsely reporting the collection was created.
- Group Q&A actions no longer report success when they actually failed. In a group's Questions & Answers tab, asking a question, posting an answer, accepting an answer, and voting all ran their success path without checking whether the server accepted the request. So a rejected ask/answer still showed "posted" and closed the form, a rejected accept marked an answer as accepted (which awards the answerer and closes the question) when it hadn't, and a rejected vote left the count visibly changed but wrong with no error. All four now confirm the server accepted the action first and show an error otherwise — and a failed vote no longer applies the optimistic count change. Regression test: react-frontend/src/pages/groups/tabs/GroupQATab.test.tsx.
- Group-exchange actions no longer report success when they actually failed. On a group time-exchange's page, "Start exchange", "Confirm hours", "Cancel exchange", and adding/removing a participant each fired their request but never checked whether the server accepted it — so a rejected action still showed a success message and reloaded, a failed cancel even navigated you away as though the exchange had been deleted, and a failed "add participant" claimed the member was added (which affects the credit split). All of them now confirm success before showing the confirmation (and the cancel stays on the page, showing the error, on failure), matching how "Complete exchange" already worked. Verified live on a real group exchange: a cancel forced to fail shows the error and keeps you on the exchange, and a failed "add participant" shows the error instead of pretending the member was added. Regression test: react-frontend/src/pages/group-exchanges/GroupExchangeDetailPage.test.tsx.
- Converting a shortlisted idea into a group now reports failures instead of doing nothing. On an idea's page, the "Convert to group" action (shown on shortlisted/winning ideas) gave no feedback when the request failed: it checked whether data came back rather than whether the request succeeded, and had no failure branch — so a rejected conversion silently did nothing (and an unusual response could even have navigated you to a group that wasn't created). It now confirms success before navigating and shows an error message otherwise. Same silent-failure class as the ideation-vote fix below, in the same file.
- Voting on an idea now tells you when it fails instead of doing nothing. On the ideation pages — both an idea's own page and the idea list inside a challenge — clicking the upvote button when the request failed (voting closed, not allowed, rate-limited, or a connection blip) did nothing at all: no change, no error, no feedback, leaving you unsure whether your vote registered. Both vote handlers now show an error message when the vote fails (and the idea page also no longer risks applying a phantom vote — it now confirms the request actually succeeded before updating the count). Verified live by forcing a vote to fail and seeing the error message appear. Regression test: react-frontend/src/pages/ideation/IdeaDetailPage.test.tsx.
- The "forgot password" page no longer sends two reset emails on a fast double-press. The "Send reset instructions" button shows a spinner while submitting but stays active, and pressing Enter submits the form directly — so a double-Enter (or double-click) on a slow connection fired the request twice and emailed two password-reset links. A synchronous re-entry guard now ensures only the first submit is sent. Verified live: a double-submit now fires exactly one request (it was two before). Regression test: react-frontend/src/pages/auth/ForgotPasswordPage.test.tsx.
- A member's profile page no longer shows someone else's details when the one you opened fails to load. The same bug as the event page (above): navigating straight from one profile to another — from the members list, a hover-card link, or browser back/forward — and then hitting a profile that couldn't be loaded (deleted, private/incomplete, or a connection hiccup) left the previous member's profile on screen under the new address. The page recorded the error but never cleared the already-loaded profile, so the "couldn't load" screen (gated on error && !profile) — and the friendly "incomplete profile" empty state — never appeared. A failed profile load now clears the stale profile and shows the "Unable to Load Profile" screen (Browse Members / Try Again), or the incomplete-profile state where that applies. Verified live by navigating from a loaded profile to one whose request was forced to fail. Regression test: react-frontend/src/pages/profile/ProfilePage.test.tsx.
- An event page no longer shows a different event's details when the one you opened fails to load. Navigating straight from one event to another (e.g. via a "more in this series" link or browser back/forward) and then hitting an event that couldn't be loaded — deleted, or a server/connection hiccup — left the previous event's full details on screen under the new address, with no error. (The page recorded the error internally but never cleared the already-loaded event, so its "couldn't load" screen — gated on error && !event — never appeared.) A failed event load now clears the stale event and shows the "Unable to Load Event" screen with Browse Events and Try Again. Verified live by navigating from a loaded event to one whose request was forced to fail: the stale event was replaced by the error screen. Regression test: react-frontend/src/pages/events/EventDetailPage.test.tsx.
- Creating a group or a listing can no longer be submitted twice and create a duplicate. On the "Create Group" and "Create Listing" forms the submit button showed a spinner while saving but stayed active — and, because pressing Enter in a field submits the form directly (bypassing the button), a double-Enter or a double-click on a slow connection fired the create request twice and created two identical groups/listings. (Confirmed live: a double-submit on the group form created two groups with the same name.) Both forms now have a synchronous re-entry guard so only the first submit is sent, matching the fix already on the Request Exchange form. Regression tests: react-frontend/src/pages/groups/CreateGroupPage.test.tsx, react-frontend/src/pages/listings/CreateListingPage.test.tsx.
- Search: switching to a result category with no matches no longer shows a blank area. After a search, clicking a category tab (Listings / Members / Events / Groups) that had zero matches — while other categories did have matches — showed an empty, unexplained white space (each category section was simply hidden when empty, and the page-wide "no results" message only appears when every category is empty). That tab now shows the same clear "No results found" message with search tips. Regression test: react-frontend/src/pages/search/SearchPage.test.tsx.
- Group actions no longer claim success when the server rejected them. Inside a group, several actions — inviting members by email, and hiding, deleting, reporting a post or muting a member from the group feed — optimistically updated the screen and showed a success message as soon as the request was sent, without checking whether it actually worked. So a rejected request (e.g. a deletion you aren't allowed to make, or an invalid invite address) still made the post vanish from view / showed "Invites sent" / "Reported" while the server was unchanged, until the next refresh undid it. All five actions now apply their change and show success only once the server confirms it, and show an error otherwise. The shared guard behind them (runConfirmedMutation) is unit-tested. Regression test: react-frontend/src/lib/confirmedMutation.test.ts.
- "Mark all read" (and per-notification "mark as read") no longer pretend to succeed when the request fails. On the Notifications page, marking notifications read went through a shared handler that quietly swallowed any error and never reported failure back to the page — so even when the server rejected the request, the page still cleared the notifications from view and, for "Mark all read", showed a green "All notifications marked as read" confirmation, leaving the visible list and the unread badge out of sync until the next refresh. The handlers now report whether the server actually confirmed the change, and the page only clears the items / shows the success message on a real success — otherwise it leaves them unread and shows an error. Regression tests: react-frontend/src/pages/notifications/NotificationsPage.test.tsx.
- Screen readers can now read the recipient matches when sending or donating time credits. In the "Send Credits" and "Donate" dialogs, typing a recipient's name showed a results dropdown marked up as a selection list (role="listbox"), but its items were buttons that didn't carry the matching option role — so assistive technology announced an empty list and the matches were effectively hidden from screen-reader users (a known limitation: the button component doesn't forward that role to the page). The results are now a properly labelled "Search results" group of real, focusable buttons, so every match is announced and selectable, and the invalid empty-list markup is gone. Mouse and keyboard selection are unchanged. Regression tests: react-frontend/src/components/wallet/TransferModal.test.tsx, react-frontend/src/components/wallet/DonateModal.test.tsx.
- A momentary connection or server hiccup no longer makes a listing look permanently deleted. When a listing's detail page failed to load, it always showed "Listing Not Found" with only a "Browse" link — even when the real cause was a temporary network drop, timeout, or server (5xx) error rather than a genuinely missing listing. (These failures come back as data, not as an exception, so the page couldn't tell a blip apart from a deletion.) A transient/unknown failure now shows an "unable to load — please try again" message with a Try Again button that re-attempts the load, while only an unambiguous 404/403/410 still shows the permanent not-found state. Verified live by forcing the listing request to fail and confirming Try Again both appears and recovers the listing. Regression tests: react-frontend/src/pages/listings/ListingDetailPage.test.tsx.
- Saving a listing no longer falsely reports "Saved" when the save actually failed. Tapping the bookmark/Save button on a listing filled the heart immediately (optimistically) and then, if the request was declined for an expected reason — already saved, a validation problem, or rate-limiting — still showed a green "Listing saved" confirmation and left the heart filled, because that kind of failure comes back as data rather than an exception and the button only handled exceptions. Un-saving had the same flaw. The button now checks the actual result: on failure it reverts the heart to its previous state and shows an error message, and only confirms when the save (or un-save) genuinely succeeded. Regression test: react-frontend/src/pages/listings/ListingDetailPage.test.tsx.
- Requesting a time exchange can no longer be sent twice, and a declined request no longer fails silently. On the "Request Exchange" form, the Send Request button showed a spinner while submitting but stayed active, and the submit handler had no guard against running again — so a double-click, or pressing Enter twice on a slow connection (which submits the form directly, bypassing the button), could send two requests and create duplicate pending exchanges. Separately, when a request was declined for an expected reason — you'd already requested that listing, a validation problem, or rate-limiting — nothing was shown at all: the spinner just stopped, because only server (5xx) errors raised a notification while these "your request was rejected" (4xx) responses were handled silently. Submitting is now guarded so only the first request is sent (and the button is genuinely disabled while one is in flight), and a clear error message is shown whenever a request is declined. Regression tests: react-frontend/src/pages/exchanges/RequestExchangePage.test.tsx.
- The browser tab title now updates correctly when you move between pages. Most pages set their title two ways — a per-page SEO component (which also sets the rich link-preview title) and a small usePageTitle hook — and the two were fighting because the hook's check for "is the SEO component already handling this page?" looked for a marker attribute that the SEO library no longer adds. As a result the hook never stepped aside, and the tab title was left stale or inconsistent — e.g. it still read "Log In" (or a bare "Feed") on the community feed while the page's real/share title was correctly "Community Feed". The hook now reliably detects when the SEO component owns the title and steps aside, so the tab title matches the page (and the share/og:title) on every navigation. Pages that use only the hook are unaffected. Regression test: react-frontend/src/hooks/usePageTitle.test.ts.
- Deleting a group collection or group custom field can no longer remove another community's data. Both deletions removed the child rows (a collection's group items / a custom field's stored values — neither table carries a community/tenant id) scoped only by the parent id, before the community-scoped delete of the parent itself. So an administrator who passed a parent id belonging to a different community (ids are sequential and easy to guess) would wipe that community's child rows while the parent delete silently did nothing. Both services now verify the parent belongs to the current community before touching any child rows, so a cross-community id is rejected and nothing is deleted. Regression test: tests/Laravel/Feature/Groups/GroupCollectionTenantScopingTest.php.
- Logging caring-support hours no longer destroys the fractional part of a credit. When an organisation auto-pays a carer/volunteer for logged hours, the worker's time-credit balance is credited in whole hours (rounded down), but the organisation's wallet was being debited the full fractional amount — so a 2.5-hour log took 2.5 from the organisation and gave only 2 to the worker, quietly destroying half a credit, and a log of under an hour debited a fraction while paying the worker nothing at all. The organisation is now debited exactly the same whole-hour amount the worker is credited (the fractional remainder stays in the organisation's wallet, matching how volunteer-hour payments already work), so credits are conserved. This affected both the caring-support relationship and the caring-community review payment paths. Regression test: tests/Laravel/Feature/CaringCommunity/CaringOrgPaymentConservationTest.php.
- Accepting a timebanking job offer now takes the credits from the employer instead of creating them out of nothing. When a member posts a timebank job (paid in time credits) and the applicant accepts the offer, the applicant is credited the role's time credits — but the employer who posted the role was never debited, so every timebank hire minted brand-new credits into the community and left a transaction record that claimed the employer had paid when their balance hadn't moved. Worse, anyone could post a high-credit timebank job and accept it themselves (or via a second account) to mint unlimited credits, breaking the core timebanking rule that credits only ever move between members. The employer who posted the role is now debited the same amount the applicant is credited (their balance may go negative, exactly like the volunteering org-wallet), so credits are conserved and the transaction ledger matches real balances. Communities not using timebank-type jobs are unaffected. Regression test: tests/Laravel/Feature/Jobs/JobOfferAcceptConservationTest.php (conservation + the self-accept mint case).
- Non-members can no longer react to (or see who reacted to) a private group's discussions. Reacting to content runs an access check before recording the reaction, but group discussions aren't part of the activity feed, so that check fell through to its "assume visible" default and allowed any signed-in member of the community to add a reaction to — and list the reactors of — a discussion inside a private group they hadn't joined (the discussion's own pages correctly returned "forbidden"). This leaked who was participating in private group conversations and let outsiders ping discussion authors with reaction notifications. The reaction access check now requires active group membership for discussion targets, matching the discussion pages. Regression test: tests/Laravel/Feature/Reactions/DiscussionReactionAccessTest.php.
- Group exchanges no longer create or destroy fractions of a time credit when the split doesn't divide evenly. A group exchange splits its total hours among providers (who are credited) and receivers (who are debited). Each person's share was rounded to two decimals independently within each role, so when the number of providers differed from the number of receivers and the hours didn't divide cleanly, the two totals drifted apart — e.g. a 10-hour exchange with 2 providers and 3 receivers credited 10.00 but debited 9.99, conjuring a hundredth of a credit from nothing (the opposite split destroyed one). The last person in each role now absorbs the rounding remainder so providers are credited exactly what receivers are debited, and both equal the exchange total. Regression test: tests/Laravel/Feature/Services/GroupExchangeSplitConservationTest.php.
- Checking the same attendee in twice can no longer double-charge the organizer or double-credit the attendee. Event check-in transfers time credits from the organizer to the attendee. The "already checked in" guard ran before the credit transaction (a read-then-act check), so two rapid/concurrent check-ins of the same attendee — an organizer double-clicking, a retry, or two staff at a busy event — could each mint a transfer, charging the organizer twice and crediting the attendee twice. The credit transfer now sits behind an atomic claim on the attendee's RSVP status inside the transaction, so only the first check-in mints and any repeat is collapsed (the attendee is reported as already checked in). The credit logic moved into EventService::recordCheckInCredit. Regression test: tests/Laravel/Feature/Events/EventCheckInIdempotencyTest.php.
- Credit-donation error messages now show in the member's own language. When a credit donation (to another member or to the community fund) failed, the wallet returned two hardcoded English error strings — "Donation failed. Check balance and recipient." and "Amount must be greater than 0" — straight to the member, even for members using another language. Both now render through the translation layer (the failure message added across all 11 supported languages). Regression test: tests/Laravel/Feature/Wallet/CreditDonationLocaleTest.php.
- Reviews can no longer reference a member or transaction from a different community. When leaving a review, the backend checked only that the receiver_id and transaction_id existed somewhere in the database — not that they belonged to the reviewer's own community (tenant). A member could therefore create a review row pointing at a user in another community, or attach a review to a transaction from another community (confirmed: a tenant-2 member referenced a tenant-3 user and a tenant-7 transaction). Both existence checks are now scoped to the reviewer's tenant, so cross-community references are rejected with a validation error; normal within-community reviews are unchanged. Regression test: tests/Laravel/Feature/Reviews/ReviewTenantScopingTest.php.
- Time credits now move the correct way on "request" listings — the helper is paid, not charged. When a member posts a request listing ("I need help with …") and another member answers it through the exchange workflow, the responder does the work and so should earn the time credits, with the help-seeker paying. The credit transfer always moved credits from the exchange's requester to the listing owner regardless of listing type, so on a request listing it ran backwards: the person who provided the help was debited and the person who received it was credited (confirmed live — a 1-credit exchange moved the credit the wrong way). The transfer now picks payer and payee from the listing type — offer listings still charge the recipient and pay the listing owner; request listings charge the listing owner (help-seeker) and pay the responder. The insufficient-balance check now also applies to the correct payer. Regression test: tests/Laravel/Integration/ExchangeCreditDirectionTest.php (both directions).
- Internal/test hardening (no user-facing change): the E2E fixture seeder now produces members who can actually log in. E2ETestDataSeeder set is_verified=1 but never set email_verified_at. The login gate keys email verification on email_verified_at (not is_verified), so on any tenant that requires email verification (the fail-closed default) a freshly-seeded secondary member (User B) was rejected at login with AUTH_EMAIL_NOT_VERIFIED — silently breaking every Playwright/journey/deploy-gate flow that authenticates as that actor on a fresh database. The seeder now also stamps email_verified_at, and a new regression test (tests/Laravel/Feature/Seeders/E2ETestDataSeederLoginTest.php) asserts seeded members pass CheckLoginGates. Dev/test only — the seeder still refuses to run in production.
- Onboarding-generated starter listings now appear in the new member's own language instead of always English. When a community turns on "auto-create listings from onboarding skills" (draft, pending-review, or active mode), the platform writes a starter offer/request for each skill the member picked. Those titles and descriptions ("I can help with …", "Looking for help with …") were hardcoded in English, so a member who joined in French, Irish, German, etc. got English-titled listings published under their name. The copy is now rendered through the translation layer in the creating member's preferred language (all 11 supported locales), matching how a real member-authored listing would read. No effect on communities that leave auto-listing disabled (the default).
- Smart-matching cache is being populated again — it had silently stopped at the Laravel migration. A background job that pre-computes member matches every 30 minutes was calling a method (SmartMatchingEngine::warmUpCache) that was removed when the matching engine was refactored, so it failed for every community on every run and the match_cache table — which powers the matching analytics dashboards and new-match notifications — had no writer at all and was never being filled. The cache writer has been restored (now correctly scaling the engine's score to the 0–100 range the dashboards expect, with a 7-day entry lifetime), so matching analytics/notifications work again and the scheduler stops logging a fatal error every half hour.
- Internal hardening (no user-facing change). The frontend focused-smoke CI step is now resilient to a Vitest worker-pool hang (ERR_IPC_CHANNEL_CLOSED): it runs serially in a single forked process and is temporarily non-blocking, while TypeScript and ESLint stay blocking and the same tests still run in the full suite. The email-trigger audit matrix now also lists the GDPR data-request confirmation email, and the non-English locale files were backfilled with the new screen-reader (accessibility) strings.
- The Privacy settings page no longer shows confusing duplicate data buttons, and the data-rights requests now send the email they promise. The privacy tab had two near-identical "download your data" buttons (an instant export link and a 30-day "access request" ticket) plus a "Request Data Deletion" button that only filed a ticket — easy to mistake for the real, immediate Delete Account action on the Security tab. Downloading your data is now a single link to the instant export page (which also covers data portability), the real Delete Account action (with typed confirmation and password re-authentication) opens directly from both the Privacy and Security tabs, and the remaining data-rights requests (rectification, restriction, objection) now actually send the confirmation email they always promised — in the recipient's preferred language.
- Federation write requests from approved partner communities are no longer always blocked. The CORS guard that authorizes cross-community writes (Komunitin and Credit Commons endpoints) checked the wrong table using columns that don't exist, so the lookup always errored and every federation write — even from a registered, active partner — was denied with 403 Origin not in federation whitelist. It now matches the request origin against the active remote partners registered in federation_external_partners, so writes from approved partners are allowed while unknown or non-active origins stay blocked.
- Refreshing a caring-community success story's live "municipal ROI" figure no longer errors. The live-metric refresh counted distinct care recipients against a column that doesn't exist (recipient_user_id), so every municipal-ROI story crashed with a database error the moment its figure was refreshed — regardless of which metric was requested. It now reads the correct recipient_id column, so the hourly-rate, formal-care-offset, prevention-value, hours, and recipients-supported figures all refresh correctly.
- Bulk club-member (Verein) CSV import no longer aborts the whole batch when a row is already a member. A CSV that included someone who was already an active member of the club was being flagged as a hard validation error, so the entire import failed with "CSV contains invalid rows" and none of the valid rows were imported. Existing members are now treated as a skippable info state: they are reported as already-members in the preview, quietly skipped (and counted as "skipped") on import, and the remaining valid rows are imported as expected. Genuinely bad rows (invalid email, duplicate within the file) still block the import as before.
- Stale prerender jobs are reaped again instead of getting stuck forever. The prerender:reap-stale maintenance command tried to write an updated_at column that the prerender_jobs table doesn't have, so every real reap threw a SQL "unknown column" error — only the dry-run preview worked. Prerender jobs whose worker died mid-render (host reboot, OOM-kill, deploy SIGTERM) therefore stayed claimed/running indefinitely, distorting queue dashboards. The command now updates only columns that exist, so stuck jobs are correctly failed (or requeued once with --requeue).
- Federation initial-sync after a partnership is approved could never run. The background job that records the bilateral start-of-sync audit entries when two communities become federation partners crashed the instant it was created, so the audit trail and snapshot counts were silently never written. The job declared its queue with a typed property that clashes with Laravel's queue trait under PHP 8.2+, fataling at class-load time before any work could start; it now sets the queue in its constructor (matching the sibling reconciliation job) so it dispatches and runs correctly.
- Three Regional Analytics dashboard sections that always showed "data unavailable" now work. The Demographics (age groups), Volunteer breakdown (top organisations), and Help-request analysis sections each queried a column that doesn't exist, so every request errored out. They now read the correct columns (users.date_of_birth, vol_logs.organization_id) and group help requests by contact preference, counting a request as resolved once its status reaches closed.
- The Caring Community help-request SLA breach dashboard no longer crashes and now reports accurate turnaround. An updated date library returned signed, fractional time differences where the dashboard expected whole positive numbers. As a result it threw an error the moment any help request was pending or in progress — taking the whole SLA dashboard down — and every recently-closed request reported a turnaround of zero, so they all looked "within SLA". The dashboard now loads reliably, ages requests correctly into on-track / at-risk / breached buckets against the tenant's SLA policy, and measures real resolution turnaround. The same signed-time-difference issue was also corrected in cron run-duration logging, the job "days posted" metric (admin and accessible frontends), and the social-feed "scheduled more than a year ahead" guard.
- Screen-reader users can now follow address suggestions as they arrow through them. The location autocomplete (used wherever you enter a place — listings, events, profile, jobs, search) showed its suggestion list visually but never told assistive technology which suggestion was highlighted, and never announced how many were found. Arrow-key navigation now moves the screen reader to the active suggestion (via aria-activedescendant) and the result count is announced in a polite live region, across all three address providers (Google Places, OpenStreetMap/Nominatim, and Ordnance Survey Places).
- Message read-receipts and "verified" badges are now described to screen readers. The "read" vs "sent" tick on your own messages, and the verified-member badge in the members directory, were conveyed only by icon shape and colour — with the label hidden in a hover tooltip — so screen-reader users couldn't tell them apart. They now carry explicit text labels, and a member's showcased badges are announced individually instead of collapsing into one unnamed image.
- Clickable cards can now be operated with the keyboard. Cards that act as a single button were mouse-only — keyboard and switch users couldn't reach or activate them. They are now focusable and activate with Enter or Space (cards that aren't interactive are unaffected).
- Identity-verification errors are now announced to screen readers. The inline error shown when starting or completing identity verification is now exposed as an assertive alert, and urgent (error/warning) toast notifications announce more reliably across screen readers.
- The Cmd+K search & command palette announces its result count to screen readers. A polite live region now reports how many results or quick actions matched as you type, so screen-reader users get the same feedback the visible list gives sighted users.
- @mention suggestions are now proper, announceable options for screen readers. When you type @name in a post or comment, each suggestion now exposes the listbox option role and is linked to the input, so the suggestion you've arrowed to is announced — instead of the dropdown being a list assistive tech couldn't track.
- The skill-tag and skill-search boxes can now be driven entirely by the keyboard. Adding skill tags to a listing, and searching for a skill to add to your profile, previously only let you click suggestions with a mouse. You can now arrow up/down through the suggestions and press Enter to pick one, the highlighted suggestion is announced to screen readers, and the number of matches is read out.
- The session-timeout countdown no longer floods screen readers. The "your session is expiring" warning announced a new number every single second; it now announces at sensible intervals (every 10 seconds, then each of the final 5), so the countdown is heard clearly instead of as a stream of interruptions.
- The accessible (GOV.UK) frontend now localises the screen-reader "Error:" prefix on six form errors. A handful of error messages (exchange request, group announcements, group file upload) hardcoded the English word "Error:" in the visually-hidden screen-reader prefix; they now use the existing translation key, so non-English screen-reader users hear it in their own language like every other form.
- Links that open a new tab on the accessible (GOV.UK) frontend now warn screen-reader users. Twenty links that open in a new browser tab (external websites, course videos and attachments, calendar links, RSS feeds, image previews, and gallery thumbnails) now include a visually-hidden "(opens in new tab)" note, translated into all 11 supported languages — so people aren't unexpectedly taken to a new tab without warning.

### Added

- Deployments now run a real "act like a user" safety check before going live. Production deploys run the proven @smoke browser journeys against the freshly built blue/green candidate before traffic is switched to it, so a build with a broken core page (home, login, dashboard, listings, messages, wallet, feed, events, groups, admin) aborts the cutover with the live site left untouched — instead of the breakage reaching real users who then report it. The check runs only at deploy time (never on every commit, so it has no effect on CI speed), authenticates read-only so it never writes to the production database, and is enabled by setting a dedicated low-privilege test member's E2E_GATE_USER_EMAIL / E2E_GATE_USER_PASSWORD in the server .env (until both are set it safely no-ops, so it never surprise-blocks a deploy). New: playwright.deploy.config.ts, Dockerfile.e2e, and scripts/deploy/phases/candidate-journeys.sh, wired into bluegreen-deploy.sh immediately after the candidate health check.
- New emails and notifications are now guarded against shipping in the wrong language. A CI check (scripts/check-notification-locale.mjs) blocks any background sender (service, listener, or job) that builds a member-facing email or notification without rendering it in the recipient's own language (LocaleContext::withLocale). All 104 existing senders already comply; the guard stops future ones from regressing — closing the recurring bug where queued mail silently went out in English to members who chose another language.
- Earned the OpenSSF Best Practices passing badge. The project meets the OpenSSF Best Practices criteria at the passing level; the badge is shown in the README.
- Greptile code review configuration now lives in the repository. Automated reviews are guided by the Project NEXUS tenant-isolation, localization, frontend, accessible frontend, security, deployment-boundary and AGPL licensing rules, with tracked context files for lower-noise review comments.
- Documentation now covers every module and follows the Diátaxis framework. Added maintained, code-verified guides for all remaining modules (marketplace, courses, podcasts, blog & resources, social feed, AI chat, ideation & challenges, organisations, monetization, connections & reviews, identity verification) — 24 module guides in total — plus explanation docs for internationalisation (docs/I18N.md), the database and migrations (docs/DATABASE.md), and the CI pipeline (docs/CI.md), a RELEASES.md versioning policy, a "How the documentation is organised" Diátaxis index, and a markdownlint configuration.
- A hosted documentation site and documentation CI gates. The docs now build into a searchable MkDocs Material site with an interactive Redoc API reference (deployed to GitHub Pages), and CI gained documentation quality gates: markdownlint (Markdown structure), Redocly (OpenAPI contract validity), and a MkDocs build check.

### Fixed

- Job alert subscriptions could become invisible and unmanageable. Creating a job-alert subscription did not stamp it with the member's community, so the alert could be saved against the wrong tenant — after which it never appeared in the member's alert list and could not be paused, resumed, or deleted. Alerts are now always tagged with the correct community on creation.
- Approving an AI assistant suggestion to pair two members or route a help request now actually does it. Two caring-community AI agent actions silently failed: approving a "create a support tandem" suggestion marked it approved but never created the relationship, and approving a "route this help request to a coordinator" suggestion never updated the request. Both wrote to database columns that don't exist, so the change was discarded without an error. They now create the support relationship (with the required title and start date) and move the help request into its "matched" state as intended.
- Renewing a listing that was about to expire no longer errors. One-click renewal of an active listing with a future expiry date was failing with a runtime error instead of extending it; renewal now correctly extends the expiry by 30 days and increments the renewal counter.
- Marketplace moderation decisions now keep their audit trail. When an admin flagged, approved, or rejected a listing, the rejection reason, the reviewing admin, and the review timestamp were being silently dropped instead of saved — so the record of who reviewed a listing, when, and why it was rejected was lost. Those moderation details are now persisted correctly.
- The AI assistant can now find job vacancies again. Asking the community assistant about jobs returned nothing because its job-search lookup filtered on a status value that no longer exists on the job board, so every search came back empty. It now matches open, publicly-visible vacancies correctly.

### Changed

- CI now runs the PHP test suite in parallel, cutting the slowest check from ~2 hours to roughly 15 minutes. The ~12,500-test PHPUnit suite previously ran single-threaded and was the long pole every pull request waited on. It is now split into six balanced shards that run as a matrix of parallel jobs — each with its own isolated database — while static analysis (PHPStan, syntax, skip-budget, artisan cache) runs alongside them instead of queueing behind the tests. A PHP Checks aggregate gate still reports a single pass/fail, and per-job timeouts stop a stuck run from dragging on. Fourteen pre-existing order-dependent tests that only pass in the full-suite run order (they rely on state other tests leave behind) are temporarily skipped with a tracked Quarantine [isolation-debt] marker, pending a proper test-isolation fix. No application or user-facing change.
- The test-skip budget ratchet was tightened from 288 to 286. This internal CI guard freezes the number of schema-driven skipped tests so the count can only fall; lowering the ceiling to the current count locks in recent gains and prevents the slack from silently refilling. No runtime or user-facing impact.
- Contributor guide now documents the APP_KEY flag required for in-container encryption tests. Running PHP tests via docker exec that exercise Crypt/encryption (e.g. the federation listener tests) failed with Unsupported cipher or incorrect key length because the container .env ships an invalid placeholder APP_KEY that phpunit.xml's <env force="true"> does not reliably override inside the container. The AGENTS.md Testing section now records that the test APP_KEY must be passed explicitly with -e APP_KEY=.... Documentation only — no test or source code changed.
- The two backends are now named "editions" instead of "V1/V2". This PHP/Laravel platform is the Laravel Edition — the canonical, in-production backend — and the ASP.NET Core backend is the .NET Edition, an experimental next-gen build that shares the same React frontend. The README "Related Projects" section and the About-page "source code" links (across all 11 languages) were updated to the new names; the old "V1/V2" version-number framing (which wrongly implied the .NET build supersedes this one) and the "Strangler Fig / progressively replacing the PHP API" wording were dropped. Documentation and UI-label change only — no application, API, or runtime behaviour changes.

## 1.5.3 - 2026-06-23

### Added

- Documentation, version, and changelog governance is now enforced. A root VERSION file records the platform version, npm run check:version keeps Composer, React package metadata, Laravel config, README, release status, changelog links, and current public collateral in sync, and npm run check:changelog catches release-relevant work that forgets to update CHANGELOG.md.
- A maintained architecture map now anchors the docs. docs/ARCHITECTURE.md gives future maintainers a compact view of the runtime boundaries, tenant/feature model, UI surfaces, backend organization, operations references, and remaining documentation depth risks.
- More of the accessible site now matches the main site, from a fresh module-by-module review: group file sharing (upload, list, download, delete) on the accessible group page; a volunteering safeguarding area to log training records and report concerns; a goal progress history timeline; likes and comments on individual resources; and a delete button for your own polls on the polls page.
- The AI assistant is now on the accessible site. Ask the community assistant a question from the Explore page and it replies on the same page; your previous conversations are listed so you can pick one back up. It's a simpler, no-JavaScript version of the main site's chat — you send a message and the reply appears when the page reloads, rather than streaming live.
- Voice messages on the accessible site. You can send a voice message by uploading a short audio clip — on a phone, the file picker opens the recorder directly. Voice messages play with the browser's built-in audio player and, where a transcription is available, include a "Show transcript" panel so they're accessible to everyone.
- You can now attach files and images to messages. Both the main site and the accessible site let you send up to 5 images or documents (max 10 MB each) with a message — and a message can be just an attachment with no text. Previously the main site's message box let you pick files but they were silently dropped on the way to the server; now they're stored and shown in the conversation. Attachments are type-checked (images, PDF, text, and common Office documents only).
- Group announcements can now be managed on the accessible site. Group owners and admins can post, edit, delete and pin announcements directly from the accessible group page — previously you could only see pinned announcements there, not manage them. Pinned items are clearly flagged and shown first.
- Job hiring "bias audit" is now available on the accessible site. Admins get the same hiring-fairness report as the main site — application funnel, rejection rates by stage, time in each stage, outcome breakdown and where applicants came from — rendered as clear tables (no charts needed), with date-range and per-job filters.
- Sellers can confirm a click-and-collect handover on the accessible site. A seller can type the short collection code the buyer shows them to mark an order as collected — the keyboard-friendly equivalent of scanning the buyer's QR code, no camera required.
- The accessible (simpler, screen-reader-first) version of the site now does almost everything the main site does. Every community area — your wallet, messages, events, groups, volunteering, organisations, jobs, the marketplace, courses, podcasts, blog, reviews, ideas/challenges, goals, achievements and leaderboards, member profiles, search, saved items and collections, and federation — now works on the accessible site, with the same actions you'd expect: posting, applying, joining, transferring time credits, leaving reviews, managing your own listings/courses/events, and more. Links were added throughout so these pages are easy to find.
- Identity Verification can now be switched on or off per community. Admins get a toggle for it under Settings → Module Configuration, just like every other feature. When it's turned off, the "Verify Identity" link is removed from the menu (top bar and mobile menu), the verification pages can't be opened, and members can't start a new verification; turn it back on and everything reappears where it was. It's on by default, so nothing changes unless an admin switches it off.

### Fixed

- A repeating event's "Expiry" and author details could disappear from its page. A formatting issue could blank out part of an event/listing detail page after an update; this is fixed.
- Donating time credits to another member, group invitations by email, poll CSV exports, and group messaging all had behind-the-scenes faults that could make them silently fail; all are fixed.
- The "Verified" badge in the top bar shows in green again. Once you've completed identity verification, the "Verified" indicator next to your menu is green, as intended. A styling change had quietly left it the same muted grey as everything else, so it no longer stood out.
- Recurring events now show their cover image on every date, not just one. When you create a repeating event with a photo, the photo now appears on all of its dates. Previously it only attached to a single date in the series (which showed up last in the list), leaving the rest with no image.

### Changed

- Public documentation is tidier and version-consistent. The README, public module engine reports, and open-source announcement collateral now identify Project NEXUS as v1.5.2; the stale federation integration report has been replaced with a concise pointer to the maintained federation manual and coverage documents; docs-public/README.md now separates current collateral from dated engine snapshots; accessible frontend docs now distinguish the public Beta track from legacy alpha route/code names and record govuk-frontend 6.3.0 as the latest verified stable; retired documentation links in tests/workflows now point to maintained guidance; docs hygiene rules now keep prompts, scratch plans, generated reports, PDFs, and private notes out of docs/.
- Documentation architecture is now explicit. Added maintained docs for documentation standards, API reference policy, test-layer meaning, security-scanner interpretation, and module-guide priorities; removed tracked root generated artifacts and updated stale public references to ignored local notes.
- The project documentation and GitHub presentation got a substantial overhaul. The README now carries status badges, a live-demo callout, a table of contents, and an architecture diagram; new SUPPORT.md, GOVERNANCE.md, GitHub issue templates, and a funding config were added; thirteen maintained module guides covering every major module (wallet & exchanges, members & GDPR, volunteering, notifications, search, admin, listings, messaging, events, groups, gamification, jobs, goals & impact), a new-contributor getting-started tutorial, and a real API getting-started guide were written from the live code; the federation, contributing, and module-map docs were corrected; the security-scanning and accessible-frontend guidance was expanded; and stale one-off engine reports and dated snapshots were archived out of the public repository.
- The Events page now shows one card per repeating series instead of every single date. A repeating event now appears once — as its next upcoming date — with a "Repeats weekly · N dates" label, so the page no longer fills up with the same event over and over. Open the event to see the full list of its upcoming dates.
- Clearer limit when choosing how many times an event repeats. The "number of occurrences" field now states the allowed range (2–52). Entering a number outside it shows a clear error, flags the field in red and jumps you straight to it, instead of the form silently doing nothing.
- Accessibility and visual polish across every page of the accessible site. A platform-wide pass to the GOV.UK Design System standard: status labels are now colour-coded by state (so an approved application looks different from a declined one at a glance), the keyboard now jumps straight to errors and confirmations, the current section is announced to screen readers, images reserve their space so the page doesn't jump as it loads, and the sign-out control and message-count badge are cleaner and fully keyboard-accessible.
- A clearer Polls page on the accessible site. Polls are now split into "Open polls" (which you can vote on) and "Closed polls" (results), with a short note explaining that you vote once and results stay hidden until a poll closes to keep things fair. Open polls show their closing date; closed polls show each option's share of the vote with the leading option and your own choice clearly flagged.
- A clearer, less crowded header on the accessible site. Your personal things now live together in a top-right "My account" area: a single "My account" link opens a hub for your wallet, messages, connections, matches, group exchanges, achievements, leaderboard, NEXUS score, profile and settings (with your unread-message count shown alongside). The main navigation bar is now a lean community bar — Dashboard, Feed, Listings, Members, Events, Volunteering and Explore — with Exchanges and Polls moved onto the Explore page alongside the other discovery features, so the bar stays uncluttered as more is added.
- Volunteering is far easier to find your way around on the accessible site. The Volunteering page now clearly separates the two things you might be doing there — taking part as a volunteer, and running an organisation. If you run an approved organisation, an obvious "Post an opportunity" button and a link to manage it now sit right at the top of the page (previously this was buried and genuinely hard to find). If you've registered an organisation that's still being reviewed, you'll see its approval status; and if you don't run one yet, there's a simple prompt to register one. Organisations also moved off the main menu bar onto the Explore page, with a "Browse organisations" link kept on the Volunteering page so it's still one click away.

### Added

- An Explore area on the accessible site: search, groups, goals, a skills directory and organisations. A new Explore link in the main menu gathers the community's discovery features in one tidy place: a global search across members, listings, events and groups; groups you can browse and join or leave; goals you can set and track with a progress bar; a skills directory to find members offering a particular skill; and an organisations directory where you can also register a new organisation for approval.
- Do more with your goals on the accessible site. Goals went from a simple list to a full feature: you can now edit a goal's name, target, deadline, check-in reminders and whether it's shared, or delete it behind a clear warning. Each goal page shows a progress timeline of what's happened so far (created, progress updates, milestones reached, completed). You can start a goal from a ready-made template — pick one, give it your own name if you like, and it's set up for you. And there's a new buddy system: offer to support another member's public goal to help keep them on track, and see all the goals you're buddying in one place. Private goals stay private — only the owner (and their buddy) can see them.
- Edit and delete your own messages on the accessible site. In a conversation you can now correct a message you sent — within 24 hours of sending it — or remove one, choosing whether to delete it just for yourself or for both people. Messages that have been changed are marked "Edited", and deleted ones show a short placeholder. This matches what the main app allows.
- Confirm your email, unsubscribe, and review members straight from the accessible site. The links in your verification and newsletter emails now open proper pages on the accessible site: one confirms your email address and sends you on to sign in; the other unsubscribes you from newsletters (you will still receive essential account and security emails). And the reviews page now lets you leave a star rating and a few words for someone directly from your "still to review" list, instead of sending you elsewhere.
- Event waitlists, event polls and recurring-series dates on the accessible site. When an event is full you can now join its waitlist and see your place in the queue (or leave it again). Events with an attached poll let you vote, with the running totals kept hidden until the poll closes so voting stays fair. And an event that is part of a repeating series now lists its other dates, with the one you are looking at clearly marked, so you can move between them.
- Volunteer certificates, shift waitlists and shift swaps on the accessible site. You can now download a certificate for the volunteering hours you have given; join or leave the waitlist for a shift that is already full; and request to swap one of your shifts with another volunteer, accepting or declining swap requests that others send you.
- Richer organisation pages on the accessible site. An organisation's page now shows its open volunteering opportunities — which you can apply to right there — alongside the reviews members have left it and a short summary of its impact: how many volunteers it has, the total hours given and its average rating.
- Choose how often you get an activity digest on the accessible site. Your notification settings now include an "activity digest" choice — off, as it happens, daily or monthly — so you control how often the community emails you a round-up, matching the option in the main app.
- Subscribe to the community blog on the accessible site. The blog now has a standard RSS feed, so you can follow new posts in any feed reader.
- Block a member on the accessible site. You can now block someone from their profile — once blocked, they can no longer see your profile or contact you, and any connection between you is removed. A new "Blocked members" page in your settings shows everyone you have blocked and lets you unblock them.
- Turn on authenticator-app sign-in on the accessible site. Your security settings now let you set up two-step verification with an authenticator app — scan a QR code (or type the key in by hand), confirm with a 6-digit code, and save your one-time backup codes in case you lose your phone. You can turn it off again at any time with your password, and you will get an email whenever it is switched on or off.
- Filter listings and events by distance on the accessible site. Both pages now have a "Distance" option — within 5, 10, 25 or 50 km — that uses the location saved on your profile to show only what is near you. No map or location pop-up needed; if you have not set your location yet, the page tells you how.
- React to posts, share and save them on the accessible feed. You can now react to a post or a comment (like, love or celebrate), share a post to your own feed, and save a post to read later — saved posts show up under the feed's "Saved" filter. Every post also has its own page now, so links to a post from a notification or email open the right place.
- Donate time credits and see your community fund on the accessible site. Your wallet now lets you donate credits to the community fund (or to another member), shows the community fund's balance, and lets you filter your transaction history (all, earned, spent), page through older entries, and download it as a CSV file.
- Group pages now show events and a group feed on the accessible site. A group's page now lists that group's upcoming events and — for members — shows the group's posts and lets you add your own, so each community group is a place to talk and not just a member list.
- Partner communities are now a real, browsable network on the accessible site. Federation went from a single read-only list to a full area: a hub with your network stats and an opt-in prompt, opt-in/opt-out and privacy settings you control, a page for each partner community, and the ability to browse members, listings and events across the communities yours is connected to.
- Connect, message and share hours with partner communities on the accessible site. The partner-communities area is now fully interactive: you can send and respond to connection requests with members of communities yours is linked to, exchange private messages across communities, and send some of your time credits to a member of a partner community. Everything respects the same privacy choices and safeguards as the main app — you only appear to, and can only reach, members who have opted in — and each alert is sent in the recipient's own language.
- A guided sign-up on the accessible site that helps keep everyone safe. New members now get a short step-by-step setup — add a photo and a few words about yourself, pick your interests, say what skills you can offer and what help you're looking for, answer any optional safeguarding questions your community asks, then confirm. The safeguarding step is fully private and opt-in (nothing is ticked for you, and you can choose "none of these apply"), and it's wired straight into the same protections the main app uses. You can change any of it later from your profile.
- A fuller notifications inbox on the accessible site — and the emails that go with your actions. Your notifications page now tags each update by kind (message, connection, event, credits, safeguarding and so on), marks an unread item read with one click, links straight through to whatever it's about, and lets you clear them all at once. And the things you do on the accessible site now send the same alerts the main app does: applying for an opportunity emails you a confirmation and alerts the employer; RSVPing, applying to volunteer, signing up for a shift or editing an event now notifies the organiser and everyone affected — each in the recipient's own language.
- A news & blog area on the accessible site, now in Explore. The community blog now appears in Explore, shows each post's publish date, links the author to their profile, and — for signed-in members — lets you read and post comments on an article. Posts also carry proper search-engine and social-share information.
- Edit and delete your own listings on the accessible site. A listing's owner now sees an Edit listing button on its page, opening a pre-filled form to change the title, description, category, hours, delivery method, location and photo — with the same field-by-field validation as creating one. The edit page also lets you delete the listing, behind a clear warning. Previously the accessible site could create listings but never change or remove them.
- Opportunities, ideas, resources and saved items on the accessible site. Four more areas, reached from Explore or your "My account": an Opportunities board listing the community's roles and volunteer openings — each tagged by type (volunteer, paid or time-credit) with its closing date — that you can open and apply to with an optional note; an Ideas area where you can read the community's challenges, submit your own idea and vote on others'; a Resources library of shared guides and materials you can search and download; and a Saved items list gathering everything you've bookmarked.
- Marketplace, courses, podcasts and more on the accessible site. When your community has them switched on, the accessible site now offers: a Marketplace to browse items for sale, swap or free (with photos, prices and a full item page); Courses you can browse and enrol on, paying with time credits where a course has a cost (with a clear message if you do not have enough); Podcasts with an episode list you can play in the page; a Coupons page of local merchant offers; a Premium page to subscribe and support your community; a Clubs directory; and a Partner communities page showing the communities yours is federated with. Each appears in Explore only when your community has the feature enabled.
- More of the platform on the accessible site: notifications, your activity, a reviews page, and Features/FAQ. A notifications inbox (in My account) lists your updates with an all/unread filter, mark-all-as-read and delete; an activity page summarises your hours given and received, connections, a month-by-month chart and recent activity; a reviews page gathers the reviews you've received and given plus any you still need to write; and plain-language Features and FAQ pages explain how the community works.
- Achievements, Leaderboard and NEXUS score on the accessible site. Three new pages, reached from your "My account" area: Achievements shows your level, experience and the badges you've earned (with a nudge towards the ones you're close to unlocking); the Leaderboard ranks members by a metric you choose — time credits, volunteer hours, badges and more — over all time, this month or this week, with your own place highlighted; and your NEXUS score shows your community reputation out of 1000, how it breaks down across engagement, quality, volunteering, activity, badges and impact, and tips on how to improve. All HTML-first and accessible.
- A polls page on the accessible site. A new Polls page (in the main navigation) lists the community's polls so you can vote and see results in one place, instead of only coming across them in the feed. While a poll is open the running totals stay hidden to keep voting fair; once it closes, each option shows its share of the vote with your own choice highlighted.
- Group exchanges on the accessible site. A new Group exchanges section (in the main navigation) lets a member organise an exchange of time between several people at once: create one with a title and a total number of hours, add people as either giving or receiving time, set how the hours are shared (equally or set per person), and see who has confirmed. Once everyone has confirmed, the organiser completes it and the time credits move between everyone automatically. Each person confirms their own part, and only the organiser can add or remove people, complete or cancel.
- See your matches on the accessible site. A new Matches page (in the main navigation) shows members whose offers and requests fit yours, ranked by how well they match, with the reasons for each match and a link to their listing — powered by the same matching engine as the main app. Previously the accessible site could save your match preferences but never showed you any matches.
- A "How timebanking works" guide on the accessible site. A plain-language page explaining the idea — give an hour, earn a credit, spend it on help from anyone — with the principle that everyone's hour is equal, and clear next steps. Linked from the community home page and open to everyone, including people not yet signed up.
- A connections inbox on the accessible site. A new Connections page (in your "My account" area) gathers everything in one place: requests waiting for your response (with Accept and Decline), the members you're already connected with (with Remove), and requests you've sent that are still pending (with Cancel) — each linking through to the member's profile. Previously you could only send a connection request from someone's profile, with nowhere to manage incoming ones.
- Type-ahead search for sending credits. The wallet's recipient search now offers suggestions as you type — each showing the member's location and how long they've been a member, so you can pick the right person (even two members called "Mary") without leaving the box. It's a progressive enhancement: with no JavaScript, the original search-and-choose list still works exactly as before.
- Tell apart members with the same name when sending credits. The wallet's recipient search now shows each person's location and how long they've been a member (e.g. "Cork · Member since March 2024") beneath their name, so two members called "Mary" are no longer indistinguishable. Works with no JavaScript.
- A time wallet on the accessible site. Signed-in members now have a Wallet page showing their time-credit balance, total hours earned and spent, and a full history of who they exchanged with and for what. You can also send credits to another member of your community: search for them by name, choose an amount, add an optional note, and send — with clear errors if you don't have enough credits or pick someone outside your community. Wallet now appears in the main navigation and on the home page.
- Manage your own events on the accessible site. An event's organiser can now edit it (with the same field-by-field validation as creating), cancel it with an optional reason (which notifies everyone who RSVP'd), or delete it — each with a clear confirmation step.
- A "For you" tab on the accessible volunteering page. Signed-in members now see volunteer shifts recommended for them — matched to their skills and availability, with a match score, the organisation, location, time and spaces left.
- Sort listings by Newest or Recommended on the accessible site. The listings page now has a "Sort by" control, with Recommended surfacing featured listings first.
- See who's going to an event on the accessible site, with clearer create errors. An event page now lists the people going and interested (with their photos), and the "Create an event" form now highlights each field that needs fixing with a message beside it, instead of one vague error.

### Fixed

- Maps are now OFF by default for every community — turn them on per-community when you want them. Previously maps defaulted to ON, so any community that hadn't been explicitly configured kept showing maps even after you'd disabled them. Only hOUR Timebank had ever been set to off in the database, which is exactly why it behaved differently from every other community. Maps are now uniformly off across the whole platform and only appear on a community when a super-admin explicitly switches them on for it — so no community silently differs from another.
- Maps can be turned on or off per community again, and the map provider is switchable. The admin "Maps & location" controls were locked, so the maps on/off switch only ever took effect on one community and nobody could change the map provider. Super-admins can now toggle maps for each community individually — turning it off reliably removes every map across listings, members, events, groups and the marketplace — and can choose a different map provider (Google, OpenStreetMap or Ordnance Survey) per community.
- Google maps now show their pins even when no Map ID is configured. Google's newer "advanced" map pins silently render nothing unless a Google Map ID is set, which made maps look empty or broken. Pins now fall back to classic markers when no Map ID is present, so locations always appear; and when a map genuinely can't load (for example a billing or key problem) a clear "map view is not available" message is shown instead of a blank gap.
- The accessible home page no longer mislabels sign-in-only sections as "not enabled". When you're signed out, sections that simply need you to log in (Dashboard, My Profile, Messages, Exchanges) now say "Sign in to use this" and link to the login page, instead of wrongly saying "This module is not enabled for this community". Sections the community has genuinely turned off still say so.
- Skill endorsements now work again across the whole platform. Endorsing a member's skill was silently failing with a server error (the endorsements table has no "updated at" column, but the code tried to write one). Endorsing and un-endorsing now work everywhere.

### Added

- Endorse a member's skills on the accessible site. A member's profile on the accessibility-first site now shows how many endorsements each of their skills has, and lets you endorse (or remove your endorsement of) any skill.
- The accessible community home page now shows live stats and the community's tagline. The landing page displays the community's own one-line tagline and live totals (members, hours exchanged, active listings and communities), matching the main site.
- A more visual accessible dashboard. The dashboard's recent-activity and recent-listings cards now show author photos and images, and the quick links now include Messages and Members.
- More detail on the accessible volunteering pages. Your applications now show the organiser's note when one was added on an approved or declined application, and your hours page now shows a progress bar towards your next round-number hours goal.
- Listing pages on the accessible site now show expiry and renewal details. A listing's page now tells you when it expires (or expired), and how many times it has been renewed, when that information applies.
- Manage your own feed posts and comments on the accessible site. You can now reply to a comment, edit or delete your own posts, and edit or delete your own comments — each with a plain confirmation step for deletions, and all working without JavaScript. Previously replies could be read but not written, and you could not change or remove anything you posted.
- Richer feed cards on the accessible site. Posts and comments in the feed now show the author's profile photo, and feed cards for events and volunteer opportunities now link through to their full page (previously only listings did, leaving other cards as dead ends).
- Friendlier messages on the accessible site. The Messages page now has a "Message a member" button (so you can start a brand-new conversation, not only reply to existing ones), shows each person's profile photo next to their conversation, and shows photos next to each message in a thread.
- Change language anywhere on the accessible site — and have it stick. Every page of the accessibility-first site now has a language chooser in the header offering all 11 supported languages, and your choice now actually takes effect and is remembered as you move between pages (previously changing language had no effect at all). Arabic now also displays right-to-left. The Messages link in the navigation shows a count of your unread messages.
- Clearer exchange pages on the accessible site. A time-bank exchange now explains its current status in plain English (for example, "Waiting for the service provider to accept or decline this request"), shows the star ratings and comments both members have left once it's completed, and notes who made each change in the activity timeline.
- Add a cover image when creating an event on the accessible site. The "Create an event" page now lets you upload an optional cover image (JPG, PNG, GIF or WEBP, up to 8MB), which appears at the top of the event page — matching the cover image the listing form already offered and the event page already displayed. Previously an event created from the accessible site could never have an image.
- Connect with other members on the accessible site. A member's profile on the accessibility-first site now has a "Connect" button. You can send a connection request, see when one is pending, cancel a request you sent, accept or decline a request someone sent you, and remove an existing connection — each with a clear confirmation message. Previously connections could not be managed from the accessible site at all.
- Vote in polls on the accessible feed. Polls in the feed on the accessibility-first site can now be voted on — pick an option and submit (no JavaScript needed). Once you've voted, or once a poll closes, you see the results, and you can't vote twice.
- Rate an exchange after it's completed on the accessible site. Once an exchange is finished, its page now invites you to leave a star rating (1–5) and an optional comment, and shows a thank-you once you've rated. If an exchange ends up disputed (because the two members confirmed different hours), the page now explains clearly what's happening.
- More context when requesting an exchange on the accessible site. The "Request an exchange" page now shows a fuller summary of the listing (including the time estimate and who posted it) and your current time-credit balance, so you can see what the exchange is likely to cost before you send the request.
- Message a member and see their badges on the accessible site. A member's profile on the accessible site now has a "Send message" button (so you can start a conversation directly, not only from a listing) and shows the badges that member has earned.
- A fuller accessible dashboard: time-bank balance, progress and upcoming events. The accessible dashboard now shows your time-credit balance (the core figure, previously missing entirely), your level, XP and progress towards the next level with a progress bar, the badges you've earned, and a list of upcoming community events — alongside the existing recent activity and quick links.
- Communities can now upload their own header logo. A community admin can upload a custom logo (PNG, JPEG, WebP, GIF or SVG, up to 2 MB) under Settings → Header Logo, and it replaces the default initials/name in the site header — on both the main app and the accessible site. You can provide a separate dark-mode version for viewers using dark themes (the main app swaps automatically; the accessible site, which has a dark header, uses the dark version when supplied), and remove either at any time to revert to the default. Uploaded SVGs are sanitised on the server so they are safe to display.
- Communities can now set their own header colour on the accessible site. A community admin can choose the background colour of the accessibility-first site's header — and the colour of the thin line beneath it — under Settings → Accessible header colour, with a live preview. Leave it unset to keep the standard black header with the blue line; pick a brand colour (for example a council's blue) to match your community. The header text automatically switches between white and dark to stay readable on whatever colour is chosen, and if you set a background but no line colour, the line takes the background colour so two near-but-different shades never clash.
- Change your email, password and language on the accessible site. The accessible "Profile settings" page gained a "Sign-in and security" section to change your email address (confirmed with your current password) and your password (with the same length, breach-reuse protections as the main site), plus a "Language" section to switch the interface to any of the 11 supported languages. Previously none of these could be changed from the accessible site.
- Two-factor authentication now works on the accessible site. If your account has two-factor authentication turned on, signing in now takes you to a page to enter your authenticator code (or a backup code) instead of stopping with an unusable message. Previously two-factor sign-in was a dead end on the accessible site.
- You can now reset a forgotten password from the accessible site. The accessible sign-in page now has a "Forgot your password?" link that takes you to a page where you can request a reset link by email, plus a "Choose a new password" page for setting it — with clear inline guidance (minimum length, breached-password and reuse checks) and a friendly message when a link has expired. Previously a locked-out member had no way to recover their account from the accessible site.
- You can now manage volunteer applications on the accessible site. The volunteering "My applications" tab now lets you filter by status (pending, approved, declined, withdrawn), page through a longer history, and withdraw a pending application — none of which was possible before.
- Volunteer hours breakdown and organisation logos on the accessible site. The volunteering hours page now shows your approved hours broken down by organisation and by month, and a volunteer opportunity now shows the organisation's logo.
- Sign up for and cancel volunteer shifts on the accessible site. If your application to an opportunity has been approved, each available shift now has a "Sign up for this shift" button (and a "Cancel signup" once you're on it), all without JavaScript.
- Record your accessibility needs for volunteering on the accessible site. A new "Manage your accessibility needs" page lets you tell organisers which categories of need apply (mobility, visual, hearing, cognitive, dietary, language, other), describe them and the adjustments that would help, and add an emergency contact — all controlled by you and removable at any time.
- You can now load older messages in a conversation on the accessible site. Long conversations previously only showed the most recent messages with no way to reach earlier ones; there's now a "Show older messages" control that works without JavaScript.
- You can now post a listing on the accessible site. The accessibility-first version of the platform gained a "Create a listing" page, so members can offer a skill or ask for help without switching to the main site (previously you could only browse and request exchanges). It's a clear, standard form — offer or request, title, description, category, estimated hours, how it's delivered (in person, remote, or either), location, and an optional photo — with proper inline error messages that link to the field that needs fixing, and it keeps what you typed if something needs correcting.
- Event photos and the join-online link now show on the accessible site. Events on the accessibility-first version of the platform now display their cover photo — a thumbnail in the events list and a large cover image on the event's own page (with a proper text description for screen-reader users, and nothing shown when there's no photo). Online events now also show their "Join online" link, and shared event links preview with the event's own photo.
- Listing photos now show on the accessible site. Listings on the accessibility-first version of the platform now display their photos — a thumbnail on each listing in the browse list, and a large cover photo plus a photo gallery on the listing's own page. Every photo has a proper text description for screen-reader users, and listings without a photo simply show no image rather than a broken one. The listing page also now shows the delivery method (in person, remote, or both), a "Featured" badge where relevant, the listing's status, any skills, and a richer "About the member" panel with the member's photo, rating, reviews and completed-exchange counts, and a link to their profile. Shared listing links now preview with the listing's own photo.
- You can now add a profile photo on the accessible site. The accessibility-first version of the platform gained a photo upload on the Edit-your-profile page — pick a JPG, PNG, GIF or WEBP and it's automatically cropped to a neat 400×400 square, with an option to remove it again. It uses the same image pipeline (and the same size and safety checks) as the main site.
- You can now attach a photo to a post on the accessible site. The accessible feed's "Write a post" box now takes an optional photo, with its own field to describe the image for people using a screen reader.
- Privacy and data controls on the accessible site (GDPR). The accessible Edit-your-profile page now has a "Your data and privacy" section: choose whether to receive newsletters, request a downloadable copy of your personal data, and delete your account through a clear, password-confirmed step. These use exactly the same data-request and erasure process as the main site.
- A full footer — and all the pages it links to — on the accessible site. The accessibility-first site now has a complete footer, organised into Platform, Support and Legal columns that mirror the links on the main site, along with the open-source licence notice and a link to the source code. Every page those links point to now exists as a clear, standard page: an About page (how it works, our values, live community statistics and credits), a Legal hub, Terms of service, Privacy policy, Cookie policy, Community guidelines and Acceptable use (showing the same community-managed wording as the main site, with a plain-English fallback when none has been published), a single Accessibility statement covering WCAG 2.2 AA, a Trust & safety page, a Help centre, a Knowledge base, and a Blog. Previously the accessible site had only a minimal footer and none of these pages.

### Changed

- The accessible version of the platform has moved from Alpha to Beta. Having been tested and rounded out, the accessibility-first site now shows a "Beta" label instead of "Alpha", and the remaining "alpha" wording has been removed.

### Fixed

- Clearer registration errors on the accessible site. When something is wrong while signing up — a mismatched password, an email that isn't accepted, a missing invite code, or unticked terms — the accessible registration form now highlights the specific field and shows the message next to it, as well as in the summary at the top, instead of only a single summary message.
- Safer sign-out on the accessible site. Signing out is now a proper protected button rather than a plain link, so it can't be triggered unintentionally.
- Clearer exchange and volunteering statuses on the accessible site. Status labels now always show readable text (never an internal code), and exchange statuses are colour-coded so their state is easier to scan at a glance.

---

## 1.5.2 - 2026-06-13

### Added

- The admin Impact Report can now calculate a real, defensible SROI ratio. Until now the page's "SROI Ratio" was simply the configured multiplier echoed back (×3.5 always showed "3.5:1") — not a return on investment at all. The page now has a proper Social Return on Investment section following the international SROI methodology used in the 2023 Timebank Ireland study: you enter your total investment and your verified outcomes (each valued with a financial proxy, e.g. from the HACT Social Value Bank), and the platform applies the standard deductions for deadweight, displacement and attribution, projects future years with drop-off and discounting, and divides by your investment. Every coefficient used is shown alongside the result so the figure is auditable. A one-click template pre-loads the four Timebank Ireland outcome categories with their calibrated proxy values. The calculation engine reproduces the published TBI study to the euro (€50,000 in → €803,184 of social value → 16.06:1), and that check is now a permanent automated test. The old hours-based figures remain, relabelled honestly as "Exchange Activity Value" with a "Social Value Multiplier".
- Automatic sign-out after a period of inactivity. Communities can now set an inactivity timeout, so a member is signed out automatically after a chosen idle period — useful on shared, public, or kiosk computers. It stays off unless a community turns it on.
- Recent passwords can no longer be reused. When you change or reset your password, the platform now remembers your recent passwords and won't let you set one you've used lately — nudging everyone toward a genuinely fresh, stronger password.
- UK address lookup and Ordnance Survey maps. Communities can now opt into Ordnance Survey for two things: precise UK address autocomplete (type a postcode or street and pick a UPRN-backed address) and OS Maps basemap tiles as an alternative to the default map provider. Both are configured per community, the OS key never reaches the browser, and each falls back gracefully when no key is set.
- More admin reporting tools. Admins can now export reports as Excel (.xlsx) files in addition to CSV, download a community-wide audit log as CSV, and find people faster with a new smart member-search panel.
- A "Report a problem" button on every page. Signed-in members get a floating reporter to send feedback or flag an issue without leaving the page they're on.
- Mobile app: more of the platform, natively. The mobile app gained native Polls and Connections workflows and advanced filters on the Exchanges screen, bringing it closer to full parity with the web app.

### Fixed

- Impact figures no longer count system credits as exchanged hours. Starting balances, admin credit grants, community-fund movements and credit gifts were all being counted as "hours exchanged" and monetised as social impact, across the Impact Report page, the CSV export, and community-health metrics (a new member's starting balance even counted them as an "active trader" and marked them "activated"). Only genuine completed service exchanges count now.
- The Impact Report no longer counts pending or cancelled exchanges. One of the page's two data sources included transactions in any state; only completed exchanges count now.
- Impact date filters now include the final day. Filtering "to" a date used to silently exclude everything after midnight of that day.
- The two customisable footer logos no longer bleed off the edge of the page. The community-set partner logo (left) and "Powered By" image (right) used a fixed size, so a wide logo could overflow its column and spill past the edge of the screen as the window narrowed. Both images now scale down to fit their column at any width.
- The community emergency-alert banner no longer logs errors when a check is interrupted. The banner checks for new emergency alerts every 30 seconds; if a check failed or was interrupted (for example while you were still signing in) it logged an error and could briefly clear the banner. A failed check now quietly keeps the alerts already on screen.
- The admin "active members" report shows real data again. Members' last-sign-in times stopped being recorded after a back-end migration, so reports of recently-active members came up empty. Sign-in times are recorded again (and backfilled from existing sessions), so activity reports are accurate.
- Guardian (parental) consent for young volunteers is now a complete, enforced feature. Members under 18 can no longer apply for a volunteering opportunity, sign up for a shift, or join a shift waitlist until a parent or guardian has approved. When an under-18 member tries, a friendly dialog asks for their guardian's name, email and relationship, and emails the guardian a secure approval link. The guardian lands on a dedicated approval page and confirms with one tap (the page deliberately requires that tap — automated email scanners that pre-open links can't accidentally grant consent). Once approved, the young person can volunteer immediately; admins see the consent (with dates and expiry warnings) on the existing Guardian Consents screen. Adults and members who haven't given a date of birth are completely unaffected. Fully translated across all 11 languages and verified end-to-end in the browser.

### Fixed

- Approving a new member now actually lets them sign in. On communities where new sign-ups need admin approval, clicking "Approve" sent the welcome email (with its time-credit bonus) but left the account itself locked — the member still saw "pending admin approval" every time they tried to sign in, with no hint anything was wrong. Approval (single or bulk) now fully unlocks the account, and re-approving anyone stuck in that half-approved state repairs them without sending duplicate welcome credits.
- The stray "Sign in with a passkey" Windows prompt is gone. Opening the sign-in page started a passkey request in the background that was never cleaned up — it outlived the page, stacked up again on every visit, and fired twice per page load. On some Windows machines this could surface the system passkey dialog out of nowhere, even for accounts with no passkey set up. The sign-in page now makes exactly one silent request per visit (passkey suggestions only ever appear in the email field's autofill dropdown), cancels it the moment you leave the page, and never opens a dialog unless you press the "Sign in with a passkey" button yourself.
- Email verification links no longer hang when many sign-ups are pending. Clicking the "verify your email" link could spin for over half a minute and time out whenever a community had a backlog of unverified registrations — the system was checking the link against every outstanding verification token one by one. It now finds the right token instantly, however many sign-ups are waiting. Existing links keep working.
- The "resend stuck activation emails" admin tool now works through the whole backlog. Running it repeatedly used to email the same oldest batch of people again and again and never reach anyone beyond the first batch. Each run now skips people who already received their email and moves on to the next group.
- The community name no longer vanishes from the web address on the Events page. Opening Events (or filtering it) silently rewrote the address from /your-community/events to just /events — so refreshing the page or sharing that link could land in the wrong community (the platform's default one) instead of yours. The address now keeps your community's name at all times.
- Screen readers now announce the reaction-filter tabs properly. The tab list in the "who reacted to this post" dialog was reading out a raw internal code instead of "Reaction type filter", in every language.
- Downloading a CV attached to a job application works again. Opening an applicant's CV (as the applicant, the job poster, or an admin) always failed with a server error — the download response was built in a way the system rejected at the very last step, so the error pages worked but the actual file never arrived. CVs now download correctly, byte-for-byte.
- Small images attached to feed posts no longer show a broken thumbnail. For images already small enough to not need a separate thumbnail, the post still pointed its thumbnail at a file that was never created — so feeds showed a broken image for every small picture. Small images now serve as their own thumbnail.
- Daily and monthly community digests now arrive on schedule. The civic digest's "have I already sent this recently?" check was slightly too strict, so daily digest readers were silently skipped every other day, and the monthly digest skipped entire months (every March, plus most months with 30 days). The timing check now has a sensible margin.
- A big newsletter send can no longer delay the day's other scheduled jobs. Sending a large newsletter used to occupy the scheduler until the whole send finished, which could silently skip that day's daily digests, reminders, and the midnight leaderboard snapshot. Newsletter sending now works in timed slices, finishing across the next minutes without blocking anything.
- Login-streak badges (Week Warrior, Monthly Dedication, etc.) are now actually awarded. The nightly badge job was looking up badge names that don't exist, so it had never handed out a single streak badge; it also only matched people on the exact milestone day, permanently skipping anyone it missed once. Both fixed — anyone whose streak already passed a milestone receives the badge on the next nightly run.
- Voice messages work again. Sending a voice message had been failing with an "upload failed" error for everyone since late March — the recording uploaded fine, but saving the message itself was rejected due to a missing database field. The field is now added and voice messages send, play, and notify correctly.
- Your match-notification preferences are now actually respected — and mutual-match alerts are back. Saved matching preferences (how often to be notified, opting out) were being read back as the defaults, and the "you have a mutual match" notification had been silently broken since March. Both fixed.
- AI assistant feedback and usage metrics work again. Giving a thumbs-up/down on an assistant reply failed behind the scenes (the button appeared to "not stick"), and the admin AI metrics page wouldn't load — the table recording assistant activity was never created in production. It's created automatically on the next release.
- Several admin actions that silently failed or corrupted records now work correctly: approving a flagged event no longer makes the event vanish from listings (it was saved with a broken status); approving an AI-proposed care tandem no longer creates an invisible pairing; the blog "bulk publish" button actually publishes; federation neighbourhoods can have communities added/removed and can be deleted; a failed transfer to a partner community is now recorded as "cancelled" rather than a corrupt blank status; and job application status history is recorded again (its viewing pages had also crashed, and the jobs-data erasure request it blocked now completes).
- Email polish across all languages. Membership-dues, federation-invitation and new-community welcome emails no longer show raw placeholder text like "{organization}" instead of the real name; names with apostrophes (O'Brien) no longer appear garbled in subject lines and info boxes; password-reset and verification emails no longer greet you with a doubled "Hi Hi John,,"; the "connection declined" email's button now links to the right website; time-credit emails say "hours" in your own language instead of always English; an admin-created account's emailed starting password can no longer be displayed corrupted; and a single malformed bounce report can no longer make the email-status feed double-count bounces.
- Repeating volunteer shifts now appear on the right days. Two scheduling faults meant a shift set to repeat "monthly" would instead be created every single day once its start date had passed, and a shift set to repeat "every two weeks" fired on the wrong weeks. Both now follow the schedule that was actually set up.
- Monthly repeating events no longer slip off their date. An event set to repeat monthly on the 29th, 30th or 31st used to drift to the 1st of the wrong month after a few repeats (and could skip a month entirely). The series now stays anchored to its day of the month, moving to the last day in shorter months.
- The monthly leaderboard season no longer disappears on its final day — and rewards can't be multiplied. On the last day of each month the "current season" looked missing, and every visit to the leaderboard quietly created a duplicate season behind the scenes; at month-end the nightly results job then handed out the season's prize XP once per duplicate. (This genuinely happened in March — top members received the season rewards six times over.) The season now stays visible to the end and duplicates can no longer be created.
- Partner-network credit transfers are now protected against double-processing. In the Credit Commons federation protocol (used to exchange credits with partner networks), a transfer confirmation or cancellation delivered twice at the same moment could move the credits twice; a repeated transfer proposal also piled up duplicate pending entries. All of these now detect the repeat and process the credits exactly once. No NEXUS community exchanges credits over this protocol yet, so no real balances were affected — fixed before first use.
- Card payments no longer get stuck "retrying" when a confirmation email can't be delivered. If a donor's, buyer's, or premium member's email address had previously bounced, the payment itself went through fine but the system treated the undeliverable confirmation email as a payment-processing failure — causing the payment provider to retry the same notification for up to three days and risking the whole payment-notification channel being suspended. Undeliverable confirmation emails are now simply logged; the payment records were always correct and are untouched.
- Profile changes now reach member search reliably. When a member joined, updated their profile, or left, the background task that updates the search index was being placed in a work queue that no worker was ever watching — so those updates silently piled up and search results could show stale or missing members until a full manual re-sync. The queue is now properly watched, and the stuck updates will be processed automatically on the next release.
- Account erasure now also removes identity and compliance copies. Following up on this week's erasure work: background-check records (DBS/Garda vetting references and uploaded documents), insurance certificates (policy numbers and certificate files), and identity-verification session results are now deleted when an account is erased — previously they survived because the database's automatic clean-up never triggers (accounts are anonymised rather than deleted). Safeguarding reports remain deliberately retained (legal duty).
- GDPR erasure messages to partner timebanks are no longer fire-and-forget. When a member of a federated community deletes their account, the "please erase this person's mirrored profile" message to each partner timebank used to be attempted exactly once — if the partner's server happened to be down, the request was silently lost. It now retries automatically (after 5 minutes, 30 minutes, then 2 hours) and raises a loud operator alert if it still can't get through.
- A marketplace order can no longer end up both refunded and paid out. If a refund and a payout release for the same escrowed order happened at the same moment (for example an admin refund racing the automatic release timer), the refund could overwrite the already-completed payout — leaving the buyer refunded and the seller paid for one order. Exactly one of the two outcomes now wins, and the loser is told the order has already moved on.
- Submitting the same review twice at once no longer counts it twice. A double-click (or a flaky mobile connection retrying) on the review submit button could create two identical reviews — double-counting the star rating and awarding the reviewer double points. The database now enforces one review per exchange, and the duplicate attempt gets the normal "already reviewed" message.
- Double-tapping "Apply" on a volunteering opportunity no longer creates two applications. Two identical applications submitted at the same instant could both go through, showing the volunteer twice in the organiser's list and taking up two spots on a shift. The duplicate is now rejected.
- Leaving partner timebanks no longer leave their content behind. When a federation partner is removed, all of the listings, members, events, groups and volunteering entries imported from that partner are now cleaned up, and imported volunteer opportunities are deactivated (kept for history, hidden from browsing). Message history and the credit ledger are deliberately retained.
- Single sign-on (SSO) engine — sign in with your organisation's account. Communities can now plug in any standards-based (OpenID Connect) identity provider — Microsoft Entra ID for councils and workplaces, Hivebrite, Google Workspace and others — as configuration, with no code change. Members of the connected organisation see a "Sign in with …" button on the login and registration screens and use their existing work account; no separate password to manage. Administrators get a new Admin → Single Sign-On page to add providers (with a Microsoft Entra ID preset), restrict sign-in to approved email domains (e.g. only @coventry.gov.uk addresses), choose whether new accounts are created automatically on first sign-in, and test the connection before enabling it. Security: standards-based flow (Authorization Code + PKCE), identity-provider signatures verified cryptographically, provider secrets stored encrypted and never shown again, and each community's SSO is completely isolated from every other community's. Communities without SSO configured see no change at all. Fully translated across all 11 languages.
- Permissions-Policy security header. API responses now also restrict which browser features embedded third-party content may use (camera, microphone and location are limited to the platform itself; payment and USB access are denied outright) — closing a gap in the security-header suite alongside the existing CSP, HSTS and frame protections.

### Changed

- Feed page polish — social-network-grade feel. A micro-interaction and consistency pass across the whole community feed. The Like heart and Bookmark icons now "pop" with a satisfying spring when tapped (respecting reduced-motion settings); the For You / Recent toggle clearly highlights the active mode; image carousels respond to lighter swipes and their arrows fade in smoothly (and now appear for keyboard users too); hover states across the stories bar, sidebar widgets, link previews, and quoted posts all transition smoothly instead of snapping. If stories fail to load you now get a quiet "Couldn't load stories — Retry" instead of a blank space, and a failed connection request properly rolls back the "Pending" button state. Also fixed two missing screen-reader labels (feed sidebar region and mobile composer) — all new text translated across the 11 languages.

### Added

- Podcasts module (Alpha) — community audio shows. A new self-contained, tenant-scoped podcasting module. Members can create shows and publish episodes — uploading hosted audio (with a live upload progress bar and clear, specific errors if a file is the wrong type, too large, or fails to save) or linking an external audio URL — with cover art, categories, visibility (public / members-only / private), transcripts, and chapters. Listeners get a built-in player with 15s-back / 30s-forward skip, variable speed, a draggable keyboard- and screen-reader-accessible progress bar, chapter jump-links, and a clear message when an episode's audio can't be loaded (instead of a silently broken player). Members can follow shows, react to episodes (the button now correctly reflects whether you've already reacted), download transcripts, and report episodes to moderators. A Podcast Studio lets members manage their own shows and episodes with a directory-readiness checklist, per-episode media status, and on-brand confirmation dialogs. Public shows expose an Apple/Spotify-compatible RSS feed. Tenant admins get a moderation queue (approve / reject / flag shows and episodes), a member-report queue (resolve / dismiss / escalate, with reasons shown in plain language), RSS feed validation, and listen analytics (completion rate, unique listeners, retention, client breakdown, top episodes). Privacy-preserving listen analytics, optional media scanning/processing hooks, and local-or-cloud media storage are configurable per tenant. The module ships marked "Alpha" and off by default — each community opts in. A single report can never hide a creator's episode on its own (it takes several independent reports, or a community switching moderation on, to auto-flag), so reporting can't be weaponised against a creator. Fully translated across all 11 languages.
- Mobile app now supports Light and Dark mode. The Timebank Global mobile app was previously locked to a dark theme. It now has a proper appearance setting — System (follow your phone), Light, or Dark — chosen in Settings and remembered between sessions. The whole app (backgrounds, cards, text, status bar, and navigation) switches instantly and stays consistent, with each community's brand colour preserved in every mode. Available in all 7 mobile languages.
- Mobile app feedback now feels native and on-brand. Across the entire mobile app, the old operating-system pop-up alerts — for "saved", "couldn't connect", form-validation messages, and "are you sure?" confirmations — have been replaced with the app's own branded toast notifications and confirmation dialogs (consistent styling, haptic feedback, and the community's colours) instead of generic grey system boxes. This spans every screen: wallet, messages, marketplace, groups, events, volunteering, jobs, settings, profile, federation, and more (≈359 prompts across 62 screens), with no change to the wording you see.
- Courses module (Alpha) — community learning. A new self-contained, tenant-scoped learning module: courses organised into sections and lessons (video, rich text, PDF, and external embeds), free and members-only enrollment, per-lesson progress tracking with automatic course completion, and auto-graded multiple-choice quizzes. Any member can author courses through a course builder by default (a tenant can restrict authoring to instructors/admins); admins get a moderation queue, instructor-grant management, categories, and tenant analytics. The module ships marked "Alpha" in module configuration and is off by default — each community opts in per tenant. An "Alpha" badge is shown on the member-facing Courses pages. Learners can discuss each lesson in threaded comments and leave star ratings + written reviews. Lessons support drip scheduling (release a set number of days after enrolment or on a fixed date), enforced server-side and shown as locked-with-unlock-date in the player. Instructors get a per-course analytics page (enrollment funnel, completion rate, average quiz score, and a per-lesson completion chart) and a grading queue for quiz attempts with short-answer/essay questions (set score, pass/fail, and feedback). Course completion awards gamification XP and a graduate badge and issues a printable completion certificate (download from My Learning). Learners get enrolment and completion notifications (in-app + completion email with a certificate link), each rendered in the recipient's preferred language. All courses are free to enrol in. Courses can be linked to community groups, and a group page surfaces its "Recommended courses". Courses support prerequisites (enrolment is blocked until the required courses are completed, shown on the course page) and cohorts (cohort-paced course groupings). Course content is indexed into the AI semantic-search embedding store (via a model observer), so the assistant and recommendations can surface relevant courses. Learning paths, feed celebration posts, and full keyword (Meilisearch) search-results integration are scaffolded for later phases. Fully translated across all 11 languages.
- AI assistant ships fully trained out of the box. Every new tenant is now auto-seeded with 38 comprehensive AI module docs covering the platform overview, timebanking philosophy, every module and feature (listings, wallet, messages, feed, dashboard, profile, notifications, settings, events, groups, volunteering, jobs, marketplace, blog, resources, polls, ideation, organisations, group exchanges, federation, gamification, goals, connections, reviews, AI chat, search, caring community, newsletter), account security, GDPR/privacy, accessibility, mobile/PWA, troubleshooting, and admin workflows. Each doc has 6–21 trigger keywords (including natural-language phrases like "how does it work" and "new here") and a body sized to fit the prompt-injection limit. All 12 existing tenants were backfilled (456 docs inserted). Tenant admins can still edit, disable, or add their own custom docs on top — the seed is idempotent and never overwrites customisations. Relevance ranking improved to score by keyword-hit count × match length and inject the top 4 most relevant docs per turn.
- Community Fund administration. New admin module under Timebanking for administering a shared community time-credit fund, with its own service, API endpoints, sidebar/breadcrumb navigation, a schema fix migration, and full translations in all 11 languages.
- Configurable "Powered By" footer branding and partner logo. Tenants can now show a "Powered By" slot in the footer (label, light/dark logo images, and a click-through URL) and a separate partner-logo slot with its own link, all configurable by the platform owner with upload endpoints. NEXUS branding ships as the default. The footer attribution panel was redesigned to accommodate this.
- Mobile app migrated to HeroUI Native v3 + NativeWind. The Expo/retired web-wrapper mobile app was rebuilt on HeroUI Native v3 with NativeWind across its UI primitives, auth screens, tab screens, and modal screens, with deep-link, image, offline-detection, and "More" menu fixes and updated EAS build configuration.
- Safeguarding staff are alerted the moment a report escalates or is assigned. In the Caring Community safeguarding workflow, a report that breached its review deadline used to escalate silently — no one was told, and staff only noticed by checking the dashboard. Now the assigned reviewer and everyone with safeguarding view permission get a bell, device push, and email (in their own language) when a report escalates (covering both the automatic SLA breach and a manual escalation), and the assigned reviewer is notified the instant a report is assigned to them. These alerts are staff-only and contain just the report's reference, severity, category and deadline — never the case details — and they never reach the person the report is about.
- Moderators are alerted when content is reported or auto-flagged. Reports on feed posts, social content, listings and marketplace items — and job postings automatically flagged as possible spam — used to be written to a queue with no alert, so moderators only found them by manually checking. Admins, brokers and coordinators now receive a bell, device push and email (each in their own language) the moment any of these arrive, so nothing sits unseen. The person who reported is never identified in the alert.
- Sellers and content owners are now told when moderation acts on them (transparency). Previously, if a marketplace listing was removed, a seller account was suspended, or a post/comment was taken down, the affected person often learned nothing — no reason, no way to contest. Now sellers are notified at each step of a marketplace report (under review → outcome → appeal result) and whenever an admin rejects a listing or suspends an account, and post/comment authors receive a clear email explaining that their content was removed and how to contest it. Every notice is in the recipient's own language, states what happened and how to appeal or contact support, and never reveals who reported them.
- Admins can now see device-push delivery health. A new "Device push" panel on the admin Email Deliverability page shows, for the selected time window, how many push notifications were delivered, partially delivered, or failed across web and mobile (FCM), the overall success rate, and the most recent failures with their reason — so a community can confirm push is actually reaching members' phones and browsers, not just being attempted. Backed by a new push delivery log; push send failures now also surface in error monitoring instead of being silently dropped.
- Job moderation decisions now arrive by email too. When an admin approves or rejects a job posting, the poster now also receives a durable email in their own language — for rejections, including the reason and how to edit and resubmit — in addition to the existing in-app bell and push notifications (which previously left no lasting record). Listing approvals now also send a device push for channel parity, and use the tenant-safe notification writer.
- Volunteer opportunities now have a "Share with partner communities" choice. Until now, every active volunteer opportunity was automatically shared with federated partner communities, with no way to keep one local. Organisers now choose per opportunity — a switch on the create form and on the opportunity page (owner only). Existing shared opportunities stay shared; new ones start private. This also fixed a subtle bug where opportunities imported from a partner could be re-broadcast back out to the network.
- Five admin tools that existed "under the hood" now have actual screens. The platform had working back-ends with no way to use them; admins now get: Help FAQ editing (write, reorder, publish/unpublish and delete the questions shown in the Help Centre), Search analytics (what members search for, trending queries, and searches that return nothing — a goldmine for spotting missing content), Donation refunds (see all donations and issue a Stripe refund with a clear are-you-sure step stating the amount), Group tags & collections (organise groups with tags, curated collections, and auto-assign rules), and Residency verification (review members' residency declarations and approve, or reject with a reason). All five are fully translated in the 11 languages.
- You can now see and delete the reviews you've written. The Reviews page has a new "Given" tab listing every review you've left for others — who it's about, your rating and comment — with the option to delete one (after a confirmation). Previously there was no way to see your own written reviews at all. Along the way, two glitches on the existing tab were fixed: the "load more" control on received reviews didn't work, and a failed delete showed nothing instead of an error message.
- Welcome credits now default to 5 everywhere. Communities that never configured a welcome balance behaved inconsistently: members approved by an admin received 5 credits, while members who joined a self-serve community received nothing. Both now default to 5 credits, matching the long-standing approval behaviour. Communities that don't want welcome credits can set the amount to 0.
- Security: all known vulnerable components updated. The daily security scan had been flagging outdated third-party components: the server container's operating-system packages (including the web server, where patched versions existed for a remote-code-execution and a denial-of-service issue) and one critical package in the mobile app. The container now installs all security patches every time it's built, the mobile package is updated, and the scan's reporting was fixed so findings always reach the GitHub Security tab.
- Welcome credits are now actually granted to new members. Communities could configure a starting time-credit balance for newcomers, but on self-serve communities it was never paid out — the setting did nothing. New members now receive it the moment their account becomes active (at email verification, or immediately for admin-created accounts; communities with admin approval already granted it at approval). Strong safeguards ensure nobody can ever receive it twice, even across the different signup routes, and the setting is honoured whichever admin page it was configured on.
- Accepting a municipal copilot proposal now actually publishes it. Previously, accepting an AI-polished communication proposal only recorded the decision — nothing went out, and the admin had to re-create the text by hand in another screen. Accepting now broadcasts the polished text as a community announcement (banner + push notification) in the same step, records which announcement it became, and offers a Publish retry button if the broadcast fails. Re-accepting can never publish twice.
- The platform now notices within minutes if background processing stops. The June outage went undetected for 5 days because every health indicator only checked that the queue manager was running — not that work was actually being done. Two new safeguards close that gap: a tiny "heartbeat" task is sent through the real queue every 5 minutes and an independent watchdog raises an alarm (error log + monitoring alert, at most one alert per 6 hours) if heartbeats stop coming back; and the container health status itself now requires a live worker process, not just the manager.

### Fixed

- Guardian (parental) consent for young volunteers now actually works. The consent system for under-18 volunteers was broken at every step without anyone noticing: when a parent clicked "give consent" in the email, the approval silently failed; withdrawing consent silently failed; the nightly job that expires year-old consents crashed every night (confirmed in production logs); and the admin "Guardian Consents" page always showed an empty list even when records existed. All four are fixed and covered by new tests that use the real database (the old tests used stand-ins, which is exactly how the wrong database column names slipped through). The admin page also no longer exposes the secret consent link — previously a community admin could have approved consent on a parent's behalf.
- Stopped a runaway email loop wasting resources every half hour. When a reminder email can never be delivered (the address has hard-bounced or marked us as spam), the volunteer shift-reminder system kept retrying the same dead addresses every 30 minutes, forever — in production it was attempting 36 impossible sends twice an hour, around the clock. Such addresses are now marked as handled once and never retried, while genuinely temporary email hiccups still retry as before.
- Quieter, healthier nightly maintenance. The nightly clean-up job logged a warning every single night while trying to tidy a database table that has never existed; it now checks first and skips silently.
- "Delete my account" now erases much more of your personal data. A deep audit found that account erasure — while already covering profile, messages, volunteering, connections and more — left several things behind: job application CVs and cover letters (including the CV files themselves), your stories, marketplace seller business details (address, VAT number, payment account link), delivery addresses and notes on marketplace orders, poll votes, personal goals and their check-in notes, course learning history, comments on feed posts, and voice-message recordings on disk. All of these are now deleted or scrubbed when an account is erased, and a permanent automatic test guards the full list so future features can't quietly fall out of it. Three categories are deliberately kept and were confirmed correct: safeguarding reports and vetting records (legal retention duties) and time-credit transaction amounts (the community ledger, with your name already anonymised).
- Event reminder emails now show the event time in your community's timezone. Event times are stored internally in universal time (UTC); the website and app already convert them back to your local clock, but the reminder emails printed the raw UTC time — so an Irish community's 7pm summer event read "6pm" in the email. Reminder emails now use the community's configured timezone setting.
- Event reminders no longer endlessly retry dead email addresses. The same retry-storm fix applied to volunteer shift reminders also applies to event reminders (it was attempting dozens of impossible sends per day in production) — and members with an undeliverable email address still get their in-app bell reminder.
- Mobile app: voice messages now play back. Recorded voice messages sent but showed a red "Failed" badge when you pressed play — the app was handing the audio player a server-relative path (e.g. /uploads/…) instead of a full web address, which the player can't load. Voice (and any other) media is now resolved to an absolute URL the same way images already were, so playback works — including for voice messages that were already sent.
- Mobile app: comment windows and every other slide-up panel now actually open. A deep timing flaw meant the component library could silently ignore the 'open' command in production builds — comment windows, apply forms and pickers fetched their content but never appeared on screen (in development builds, which run slower, it always worked — which is why it survived testing). The open command is now issued with a proper delay and re-asserted automatically, verified end-to-end on a real device build: the comment sheet opens first tap, comments post and appear.
- Mobile app: see who reacted, Facebook-style. Feed cards now show the familiar summary line — overlapping reaction emojis plus 'Anna and 3 others' — and tapping it opens a panel listing everyone who reacted, filterable by emoji, with each name linking to their profile.
- Mobile app: comments catch up with the web. You can now reply to comments, edit or delete your own (press and hold for the menu), and like comments — none of which the app offered before. Timestamps throughout the feed now appear in your chosen language, reaction and counter labels are properly translated in all 7 app languages, and failed likes/saves now tell you instead of silently undoing themselves.
- Mobile app: smoother feed scrolling. Feed cards no longer all re-render when one changes, loading the next page shows placeholder cards instead of a spinner, images recycle correctly during fast scrolling, and the next page starts loading earlier so you rarely hit the bottom.
- Mobile app: the Like button now responds to every single tap. A component-library quirk meant that adding long-press support to the Like button silently broke ordinary taps — a quick tap did nothing at all, while a long hold could register a stray like. The press handling was rebuilt (verified live on a device against the running API): one tap likes instantly, holding the button slides out the emoji picker while your finger is still down — just like Instagram — and releasing after the picker opens never fires an accidental like.
- Mobile app: emoji reactions arrive, and the Like button finally behaves. The feed's Like button now matches the web app: a quick tap likes (and stays highlighted — previously the highlight vanished the instant the server replied, because the app read a field the server never sent), and a long-press opens the full emoji picker (👍 ❤️ 😂 😮 😢 🎉 👏 ⏰) — the same eight reactions as the web, on every reactable feed card.
- Mobile app: slide-up panels open on the first tap. Comment sheets and other slide-up panels sometimes needed two or three taps to open — a quirk in the sheet library could fire a phantom "close" signal the moment a sheet was created, instantly cancelling it. Phantom closes are now filtered out everywhere, so every sheet opens first time.
- Account deletion now properly erases volunteering data. Deleting an account anonymised the member's profile but left their volunteering records untouched — vetting/credential records, wellbeing check-ins, accessibility needs (including emergency contacts), guardian consent details, training records, certificates and donor names all survived a GDPR erasure request. Deletion now removes those sensitive records outright and scrubs personal text and donor details from the records that must remain for organisation accounting (hours and donation amounts are kept, with no name attached).
- Volunteering: expense decision notifications now land on the right page. The "your expense was approved/rejected" email linked to a page that doesn't exist, silently dropping members on the volunteering home tab. It now opens the Expenses tab directly.
- Volunteering: the shift waitlist now actually works. Joining a waitlist looked fine, but the machinery behind it was never connected — when a spot opened up, nobody was told, and the "next in line" could never be moved onto the shift. Now, when someone cancels a shift signup (or an organisation declines a previously approved volunteer), the first person on the waitlist instantly gets a notification in their own language, sees a highlighted "Spot available — claim it" card on their Waitlist tab, and one tap signs them up (with a safety re-check so a claim can never overfill the shift). Unclaimed offers automatically pass to the next person after 48 hours, and leaving the queue while holding an offer hands it on immediately. Translated across all 11 languages.
- Volunteering: shift swaps can no longer be completed after the shift has already happened. A swap accepted (or admin-approved) late used to go through even if the shift had started, corrupting attendance records. It's now politely refused.
- Volunteering: admin CSV exports are now safe to open in Excel. Volunteer names or messages crafted to look like spreadsheet formulas can no longer execute when an admin opens an exported approvals or hours file.
- Volunteering: the log-hours form now stops impossible entries before submitting. The hours field accepts 0.25–24 in the form itself instead of letting the server reject it afterwards.
- Volunteering: cash and bank-transfer donations no longer vanish into a permanent "pending" state. Donating through the Donations tab without paying by card recorded the donation, but nothing could ever confirm it — it never counted toward the giving-day campaign total or donor count, and admins had no way to mark the money as received. Admins now get a "Mark completed" button on the Donation Refunds page for offline donations (cash, bank transfer, PayPal); confirming one adds it to the campaign total exactly once, even if clicked twice. Card payments are unaffected — they still confirm automatically. Translated across all 11 languages.
- Volunteering: a removed group-shift member could never be re-added. Removing someone from a group shift reservation and then adding them back always failed with a generic server error. Re-adding now works. The same fix closed two quieter problems: group member records were being saved under the wrong community, and two leaders adding members at the same moment could overfill the reserved slots — capacity is now checked atomically.
- Volunteering: shift waitlist positions could develop gaps when two people left at once. Queue reordering now locks each entry before renumbering, so positions stay contiguous.
- Volunteering: opportunity pages with many shifts loaded slower than needed. Spots-remaining counts for all shifts are now fetched in one query instead of one query per shift.
- Mobile app: typing anywhere now uses proper full-height composers. Starting a group discussion, requesting an exchange from a listing, reporting a listing, adding a skill, and creating a goal all squeezed your typing into tiny inline boxes that the keyboard covered. Each of these now opens a proper slide-up panel with room to write, and the keyboard never hides what you're typing.
- Volunteering: admins no longer see owner controls on everyone else's opportunities. A flag that was meant to mean "this is your post" actually meant "you have admin powers", so admins saw Edit buttons and an approve/decline applications panel on other people's volunteer opportunities — and their own "Apply" button silently refused to work on every post. Applying now responds instantly with clear feedback, applying to your own opportunity is politely refused with a proper message (in all 11 languages), and owner controls only appear on posts you created. Organisation admins still manage everything from the organisation dashboard.
- Organisations list no longer shows 0 members / 0 listings / 0 opportunities for everything. The server never sent the counts; it now calculates real opportunity, volunteer, hours and rating figures for each organisation — on web and mobile alike.
- Mobile app: "Read in app" on the Support & legal page looked dead. Tapping it actually worked, but the document appeared at the very top of the page — off-screen if you'd scrolled down. Documents now open in a slide-up reader you can't miss.
- Mobile app: profile tidy-up. The achievements section no longer arrives pre-expanded, and the two confusing full-width cards beneath it ("appreciations" and "collections" — both real features that also exist on the web) are now one compact, clearly-labelled pair of rows.
- Mobile app: live updates were silently broken — now fixed. The mobile app asked the server for its real-time connection details at an address that didn't exist, and the failure was swallowed silently — so new-message badges, live chat updates and other instant notifications never worked in the mobile app. The app now uses the correct address, and the server also answers at the old one, so phones with the current version installed start receiving live updates again as soon as the server is updated — no app-store update needed.
- Mobile app: your language choice now sticks. Picking a language in the mobile app's settings worked until you closed the app — on the next launch it silently reverted to your phone's language. The choice is now remembered. Dates and times throughout the app (56 places across 36 screens) also now follow the language you chose instead of the phone's region setting.
- Mobile app: buttons no longer turn invisible on light community colours. For communities with a light brand colour, selected filter chips, tag pickers, the floating "+" button icon and button loading spinners painted white-on-light — unreadable. Text and icons on brand-coloured buttons now automatically switch between black and white for proper contrast.
- Changing your password in-app now requires the same strength as everywhere else. Registration and the email reset flow required 12 characters, but the in-app "change password" screen (and its server check) only required 8 — a weaker back door. All three flows now require 12, and the mobile reset screen no longer accepts a password the server would reject anyway.
- Mobile app: small polish and safety fixes. Five screens that could show a blank page if something went wrong (edit profile, change password, image viewer, new message, quick create) now show a proper "something went wrong" recovery screen; a handful of unlabeled icon-only buttons (delete, image thumbnails, star ratings) are now announced correctly by screen readers; and the crash reporter now strips login credentials from anything it sends.
- Deleting a recurring event series now tells the people who'd signed up. Cancelling a series already notified attendees, but deleting it removed every future occurrence silently — people could show up to an event that no longer existed. Deletion now sends the same cancellation notice (bell, email and push, each in the recipient's own language) to everyone going, interested, invited or waitlisted on a future occurrence — exactly once per person, and never for occurrences that were already cancelled (those attendees were told at the time).
- Roughly 1,850 missing translations filled in across all 10 non-English languages. A series of recent features (support reports, volunteer alerts, guardian notifications, partner management, data retention, password history, UK address lookup, CRM admin, comment-reply emails, and more) had shipped with English-only text, so members using Irish, German, French, Italian, Portuguese, Spanish, Dutch, Polish, Japanese or Arabic saw English in those places. All are now properly translated (not machine-copied English). A new automated check blocks any future code change that adds English text without the other 10 languages — closing the gap that let these sit unnoticed for weeks.
- A review you delete can no longer be brought back by an admin. Previously, a review deleted by its author looked identical to one a moderator had rejected, so an admin working the moderation queue could accidentally restore it. Author-deleted reviews are now marked distinctly: they no longer appear in any moderation queue, and the restore/hide actions refuse them outright.
- Background job processing restored (queued work had been silently stuck since 6 June). A version mismatch between two framework components meant every background worker crashed the instant it started — while the system still reported itself "healthy". Anything handled by the background queue (federation syncing between communities and some queued notifications) quietly piled up instead of being processed; nothing was lost, and the backlog is worked off automatically once this fix is deployed. The mismatched component has been updated to the release that fixes the incompatibility.
- Joining a group twice by double-click is now impossible at the database level. A fast double-click on "Join" could create two membership records (double member counts, duplicate welcome messages). The database now enforces one membership per person per group — existing duplicates are cleaned up automatically — and the same hard guarantee was added for marketplace escrow records. The join action treats a lost race as "already a member" instead of an error.
- Marketplace payouts can no longer be released twice. A buyer clicking "confirm received" at the same moment the automatic timed release ran (or a double-click) could complete the same order twice — doubling the seller's recorded sales and revenue and sending duplicate payout notifications. Completion and escrow release are now claimed atomically; exactly one path wins.
- Volunteer-organisation wallets can no longer deadlock. A member depositing into an organisation at the same moment the organisation paid a volunteer could lock both records in opposite orders, failing one action with a server error. Both paths now lock in the same order.
- Transaction XP can't be double-awarded on a queue retry, and search results show listing category names again (a leftover legacy column was shadowing the real category link, so the name was always blank).
- Group exchange completion can no longer pay people twice — or short-change them. Two clicks of "Complete" in quick succession could run the whole credit distribution twice; completion is now claimed atomically so the second click is harmless. Separately, each participant's share was being rounded down to whole hours (three providers splitting 10 hours got 3+3+3 credited while the receiver paid 10 — a credit vanished; shares under one hour paid nothing at all): exact 2-decimal shares now flow through. Receivers also can no longer be driven into negative balance — completion fails cleanly if a receiver lacks the credits, matching every other payment path.
- Admin panel: around 70 more actions no longer claim success when the server refused. The same false-success flaw fixed in the member app last week was swept from the admin panel: safeguarding escalations, tenant provisioning approvals, push-campaign dispatch, paid analytics subscriptions, identity-provider credentials, compliance registers, group management, menu building, translation settings, and ~30 more screens now report failures honestly and stop discarding what you typed when a save fails.
- Duplicate-notification protection extended to nine more background senders. Job-alert fanouts, connection requests/acceptances, group joins, onboarding emails, safeguarding staff alerts, and two admin background jobs lacked the platform's standard guard against a queue retry re-sending every email; all now send at most once.
- Public knowledge-base article links no longer error. Opening an article by its web address failed every time due to a misnamed counter column (the in-app route was unaffected).
- Goal reminders respect privacy. A member could attach a reminder to someone else's private goal and the reminder email would reveal that goal's title; reminders are now limited to your own goals (or public ones).
- Deleted accounts no longer appear in member search, transfer recipients, or @mentions. With account deletion now working, anonymised "Deleted User" entries could surface in pickers — and credits sent to one would be unrecoverable. All people-pickers now exclude them.
- Help FAQ category filter no longer errors (wrong column name on a public endpoint).
- Recurring events: "only this event" edits now stick. Editing a single occurrence didn't detach it from the series, so a later "all future events" edit would silently overwrite it. Also, deleting a series is now all-or-nothing (a mid-way failure can't leave it half-deleted).
- Deleting your account works now — and properly anonymises your data. Closing an account failed with an error because it tried to set an account state the database doesn't allow, which also meant the personal-data anonymisation step never ran. Account deletion now completes: the account is deactivated, sign-in is blocked, and name/email/phone/location are anonymised with deletion timestamps recorded.
- Deleting your own review works now. It failed for the same kind of reason (an invalid state value); deleted reviews are now properly hidden everywhere.
- Marketplace bulk "deactivate" works now. It also wrote an invalid state; deactivated listings now return to draft (and can be re-activated).
- Super-admin "move user to another community" no longer reports failure after succeeding. The screen always showed an error even though the member had actually been moved — a confusing half-state. It now reports honestly (and the log no longer claims the member's content moved with them, which it never did).
- Onboarding settings changes take effect immediately. Saving onboarding configuration tried to clear the cache through a service name that doesn't exist, failed silently, and changes only appeared when the cache happened to expire.
- Cookie-consent changes are recorded in the audit trail again. Since April these were written to a table that had been removed (replaced by its correctly-named twin), so the GDPR consent history was silently lost.
- Also hardened in this pass: the general file-upload endpoint stored files under a folder named after the user's account number and ignored the requested category (now fixed); and the optional Vault secrets integration, whose client class didn't survive the Laravel migration, now fails softly to normal configuration instead of crashing if ever enabled.
- Group challenge rewards actually arrive now. Completing a group challenge wrote the XP reward into each member's activity history but never added it to their actual XP balance — so it couldn't be spent in the XP shop and didn't count on the leaderboard. Challenge rewards now go through the standard XP pipeline (balance, leaderboard, level-up check, live update), the same as every other XP award.
- Donation receipts now appear in the recipient's language. The wallet-history line for a credit donation was permanently stored in whatever language the donor was using. It's now stored in the recipient's language, since it's their wallet history that shows it.
- Deleting or cancelling a recurring event now covers the whole series. Previously, deleting a recurring event removed only the first one — up to 52 future occurrences stayed live with no way to remove them except one at a time — and cancelling it cancelled only the first occurrence, so people signed up for later dates were never told. Deleting now also removes all future occurrences (past ones are kept for attendance history), and cancelling now cancels every future occurrence and notifies everyone affected, with a clear warning in both dialogs that the whole series is included.
- Editing a recurring event now asks "only this event, or the whole series?" Edits to a series previously applied silently to just the one occurrence. Organisers now get a choice; series-wide edits update details like title, description, and location on every future occurrence (date/time changes deliberately stay per-event so the schedule can't be collapsed onto a single timestamp — a server-side guard now enforces this too).
- Around 200 messages that always appeared in English are now fully translated. Across volunteering (shifts, swaps, waitlists, group reservations), group conversations and chatrooms, private messages, sub-accounts, skills, the XP shop, and polls, the platform's responses — errors like "You are not signed up for the source shift" or "Not enough XP" — were written directly into the code in English and shown to every member regardless of their language. All of them now go through the translation system, with real translations (not English placeholders) in all 11 languages. Separately, ~190 "Loading" labels in the admin panel that screen readers announced in English are now translated too.
- Polish round from the June platform audit. Admin confirmation dialogs for deleting groups, group types, AI assistant docs, and bulk actions now use the platform's styled dialog instead of the browser's plain pop-up; "Total" count chips in the Polls, Ideation, and Algorithm admin pages show the actual number again; the volunteering opportunity search no longer fires a request on every keystroke; Caring Community transfer notifications can no longer be sent before the transfer is final; and a developer verification script broken since the Laravel migration was repaired, along with stale references in the README and contributor docs (React 19, removed animation library, decommissioned legacy admin) and missing example environment keys.
- Match emails greet you by name again — and no longer invent a match score. The hot-match, mutual-match, and digest emails always fell back to a generic greeting because the recipient's identity was never passed to the email builder. Mutual-match emails also asserted a fabricated "75% match" when no score had been computed — the score badge now only appears when a real score exists.
- Match and digest emails are now fully translated. Several English fragments leaked into every language: the "View" link and frequency word in the activity digest, "(Xkm away)" in hot-match alerts, the Offer/Request badge, day/week/fortnight period words, and fallback phrases like "a skill you need". All now render in the recipient's language (new translations added for all 11 languages), digest timestamps use localised month names, and the digest email finally carries the community's name and a proper footer like every other email.
- Accessible (GOV.UK-style) frontend hardening. Pages for disabled modules are no longer reachable by direct URL (feed, messages, member directory, and exchanges now respect each community's module settings, matching the main app); the registration page's terms/privacy links no longer point at a dead address on the accessible domain; posting to the feed is rate-limited like every other form; the page no longer pretends JavaScript is available before it loads (restoring the progressive-enhancement guarantee); the contact form's fallback subject is translated; and the feed's type-filter hint is now announced by screen readers.
- The app no longer claims success when the server said no. A platform-wide flaw meant that when the server rejected an action (for example confirming exchange hours, hiding a feed post, saving a marketplace coupon, deleting a goal, dismissing a match, or saving an item), the app often showed a green "success" message and updated the screen anyway — the change silently never happened and reappeared on reload. Around 40 such spots across exchanges, the feed, marketplace, goals, messages, notifications, wallet transfers, and volunteer applications now check the server's actual answer: real failures show a clear error message, optimistic changes are rolled back, and wizards no longer advance with nothing saved. Affected flows included all six exchange actions (accept/decline/start/complete/confirm-hours/cancel — credit-affecting), merchant onboarding steps, and coupon saving.
- Failed page loads no longer masquerade as empty pages. When loading search results, connections, listings, wallet transactions, exchanges, or the notifications flyout failed, the page showed a friendly "nothing here yet" empty state with no hint anything went wrong — and the exchanges page could get stuck on a permanent loading skeleton. These now show a proper error message with a retry option.
- Feed: a deleted poll no longer hammers the server. A poll card whose data could not be fetched (e.g. the poll was deleted) refetched in an infinite loop; it now shows the poll error state once.
- Messaging someone for the first time works from "Message" buttons. For members with no existing conversations, clicking "Message" on a listing, profile card, or seller page landed on an empty inbox and did nothing. It now opens the new-conversation screen as intended.
- Conversations: messages arrive without a refresh on communities without live updates. The fallback that checks for new messages every few seconds never started when opening an existing conversation, so incoming messages only appeared after switching tabs or refreshing. Also fixed: attaching a second file no longer breaks the first attachment's preview, edit/delete failures now show an error instead of nothing, and reaction counts no longer drift on unexpected server replies.
- Wallet: the "Load more" button works after using the filter tabs. Touching any transaction filter permanently disabled further pagination.
- Events: changing filters while older events were still loading could mix results from the old filter into the new list. Stale responses are now discarded; failures while loading more events show an error. Also, RSVPing no longer needlessly refetches the event-series list, and two redirects (profile login, conversation not-found) now keep you on your community's address.
- Matches: volunteering suggestions no longer link to a "page not found".
- Recurring events actually recur now. Ticking "recurring" when creating an event silently created a single, one-off event — the recurrence settings (weekly/monthly, days, end date/count) were sent to an endpoint that ignores them. Event creation now uses the recurrence-aware endpoint, which creates the series template plus all its occurrences.
- The platform-owner Billing Control panel works again. Every action on the super-admin billing dashboard (loading the tenant snapshot, assigning plans, pause/resume, grace periods, CSV export) called a malformed API address and failed. All actions now work, failures show a real error message instead of being silently ignored, and the CSV export goes through the authenticated download path.
- The activity-digest email frequency setting saves correctly now. Choosing how often you receive the digest email in Settings → Notifications always showed a save error (and never loaded your current choice) because the setting was sent to an address the server didn't recognise. It now loads and saves properly.
- Two admin CSV exports repaired. "Hours by category" and "Inactive members" report exports always failed with a validation error due to a report-type name mismatch between the export button and the server.
- The public municipality events calendar page loads now. The page existed and the server logic existed, but the API route connecting them was never registered, so the page always showed an error.
- Requesting a plan upgrade from the admin Billing page actually sends the request now. The "request upgrade" button posted without authentication, was always rejected, and still showed "Upgrade sent". It now sends authenticated, reports failure honestly (falling back to a pre-filled email), and the email subject is translated.
- XP Shop purchases work again. Buying an item with XP always failed with "Purchase failed" (and no XP was deducted) because the purchase record was written with the wrong column names for the purchases table. Purchases now record correctly.
- Scheduled group posts now actually publish. Two problems meant a group post scheduled for later never appeared: the background job that publishes due posts was never added to the platform's schedule, and discussion-type posts were written with a column the posts table doesn't have — which also quietly created an empty duplicate discussion shell on every attempt. Scheduled group posts (announcements and discussions, including recurring ones) now publish every five minutes as intended.
- Match suggestions learn from your activity again. Saving, dismissing, viewing, or contacting a match suggestion is meant to teach the matching engine your preferences, but every one of those signals was silently discarded due to a column mismatch in the match-history table. The engine now records them, so "Top matches" personalisation improves with use.
- Group recommendation feedback is recorded again. Clicks, joins, and dismissals on "Recommended groups" were silently dropped (the write was missing its community identifier), so the recommendations admin analytics always showed zero activity.
- Auto-translation now honours the community's default language. When translating member-written content, the platform looked up the community's default language in the wrong place and always fell back to auto-detection. It now reads the community's configured default correctly.
- Newly provisioned communities get their federation defaults. Communities created through the self-service application/approval flow were silently skipping all federation setup (wrong column names on all three federation tables), leaving them invisible to the federation network. They now get the same default-on federation features as communities created by an admin, and are auto-whitelisted by the approving reviewer.
- Federation activity logging and group achievements repaired. Incoming federation events from partner networks failed to write their audit-log entries, and the group achievements engine ("First Steps", "Community Builders", …) could never award or list achievements — both due to column mismatches. Both now write correctly.
- Cancelling an event works again. A code-placement slip in the recent security hardening pass put an attendee-roster privacy check inside the event-cancellation routine, where it crashed every cancel attempt (organisers and admins saw a generic error and the event stayed live, with no notifications sent). Cancellation now works as before, and the roster privacy check sits where it was meant to — limiting who can browse an event's attendee list to its organiser, admins, and fellow attendees.
- The accessible frontend's community-chooser page now shows the right feedback link. The "feedback" link in the footer of the accessible (GOV.UK-style) community chooser pointed at one specific community's contact form instead of the general platform feedback email address. Tenant pages keep their own contact form link.
- Leftover English text translated in the Italian, German, and French interfaces. An i18n sweep across all 11 languages found 14 strings still showing in English: the Italian Jobs module (loading message, close/reopen-vacancy confirmation dialogs, hiring-pipeline screen-reader labels, and the bias-audit job search) and Italian blog (loading messages and the category filter), plus the course grading form's "Feedback" label in German and "Score" label in French. All now display properly translated.
- The Members directory (and other pages) no longer break with "Something went wrong" after the app has been used across several updates. Over time, as new versions of the app shipped, the browser quietly kept a copy of every past version's translation files — these piled up until the browser's local storage filled completely, at which point even saving a tiny setting (like whether you prefer the Members list in grid or list view) would fail and take the whole page down with it. The app now clears out old, leftover translation copies on startup so storage stays tidy, treats a storage failure as harmless instead of crashing the page, and its built-in storage clean-up now correctly targets those translation caches when space runs low. As a safeguard, every place in the app that saves a setting to local storage now goes through this same self-healing path, so a full browser store can no longer crash any page — it quietly frees up space and carries on.
- New communities again launch with default categories, member attributes, and navigation menus — however they're created. A regression introduced during the Laravel migration meant newly created tenants were no longer seeded with their default member attributes (offer/request filters such as "Tools Provided", "Wheelchair Accessible", "References Available") or their default navigation menus (the main header menu and footer menu). New communities now start with the full default set again — eight categories, nine member attributes, and both navigation menus — so admins and members aren't faced with an empty filter list or blank navigation on day one. The defaults now come from a single shared seeder used by both the admin "create community" path and the self-service approval/provisioning path, which previously seeded neither (so communities created through approval still launched empty). The seeded navigation's auth/feature visibility rules are now also honoured by the server-side menu renderer (mobile and search-crawler views), not just the React app, so members-only and feature-gated links no longer show to signed-out visitors on those views. (The one Ireland-specific legacy attribute, "Garda Vetted", is seeded as the globally-neutral "Background Checked" in keeping with the platform being a worldwide product.)
- Jobs: a candidate can no longer be paid twice — or a single role filled twice — when an offer is accepted. For timebank jobs, two near-simultaneous "accept" clicks (or two finalists both accepting offers for the same one-position vacancy) could mint the time-credit reward twice and mark the role filled twice. Accepting an offer is now a single, atomic, all-or-nothing action: the credit is awarded exactly once, the vacancy is filled once, and any other outstanding offers for that role are automatically withdrawn. Interview self-scheduling was hardened the same way — two candidates can no longer grab the same interview slot.
- Jobs: hiring-team members can now use the tools they were given access to. People added to a vacancy's hiring team (and community/tenant admins) were wrongly blocked from viewing interviews, the team list, referral stats, AI candidate ranking, analytics, the audit trail, predictions, CSV export, and from posting scorecards or running bulk actions — only the original poster could. Access now consistently follows "owner, admin, or hiring-team manager" everywhere.
- Jobs: bulk candidate-status changes now behave exactly like single ones. Changing several applicants' status at once previously skipped the safeguards that single updates have: it could silently move an already-hired or withdrawn candidate backwards, wrote no history, and sent no notification. Bulk changes now respect finished states, record history, and notify each affected candidate in their own language.
- Jobs: "right to be forgotten" now clears all of a member's job data. A data-erasure request used to leave behind interview notes, offer details, reviewer scorecards, status-change history, referral links, and view history. Erasure now scrubs personal data across the whole jobs module, and a problem deleting one CV file no longer aborts the rest of the erasure.
- Jobs: offers and interviews can't be sent to the wrong person or a closed role. Employers can no longer send an offer or propose an interview to a candidate who has withdrawn or been rejected, or make a new offer on a role that's already filled.
- Jobs: the applicant count shown on community pages is now accurate. Some pages and admin email tools were reading job-application figures from the wrong (near-empty) table, so counts and candidate lookups could be wrong; they now read the live applications data.
- Jobs: abuse and runaway-cost protection. The AI job advisor, AI candidate ranking, hiring predictions, employer reviews, and the public job feeds (RSS/JSON/Indeed) are now rate-limited, and employer-review scores are validated, closing off ways to drive up AI costs, scrape data, or post malformed reviews. Internal error messages in the jobs module are now translated instead of occasionally showing English.
- Jobs: a more polished, safer, fully-translated interface. Closing or reopening a vacancy and declining an interview or offer now ask for confirmation first; choosing a CV file checks the file type immediately (not only on drag-and-drop); the My Applications page reliably shows interviews and offers as you page through; browse results no longer briefly flash stale matches when you change filters quickly; the AI chat panel can be closed with Escape and works with screen readers; share text and the offer "per month" label are translated; and assorted focus-ring, reduced-motion, and empty-state details were tidied across the module.
- Jobs: the employer rating control and CV downloads now work for everyone. The star-rating used to leave a review on an employer's brand page could only be set with a mouse — keyboard and screen-reader users could not rate at all; it is now a proper keyboard-operable, screen-reader-labelled control. And on the My Applications page, the "Download CV" button pointed at a private file path that returned nothing; it now downloads your CV through the authenticated endpoint, with a clear error message if it fails.
- Courses: clearer feedback and a safer paid-enrolment path. Marking a lesson complete now shows an error if it does not save (instead of silently doing nothing), and the instructor dashboard's empty state reads as guidance instead of a stray "My courses" heading. Paid (time-credit) enrolment was hardened internally: the charge reads the freshest course price under the row lock, and the enrolment notification is now sent after the wallet transaction commits (shorter lock hold, no change to the charge itself).
- Podcasts: stuck media uploads are now visible instead of retrying forever. If an episode's optional media-processing step kept failing, it would retry indefinitely while the episode sat silently in "pending"; it now retries a bounded number of times and is then marked "failed" (and logged) so admins can see and act on it.
- Mobile: saved/unsaved state on the Exchanges screen no longer goes stale. Tapping save/unsave applied an optimistic change that was never reconciled, so after a refresh or filter change a listing could keep showing the wrong saved state (for example if it was changed on another device). The optimistic state is now cleared once the server confirms it.
- Audit follow-ups: podcast audio seeking, a duplicate-certificate guard, and smoother mobile lists. Podcast episode audio now streams with correct HTTP range support, so seeking and scrubbing in the player are reliable. A database guard (plus a race-safe issue path) ensures a learner can never receive two certificates for the same course. And the mobile Exchanges list got virtualization tuning for smoother scrolling through long result sets.
- Volunteering: a volunteer can no longer be accidentally paid twice for the same hours. When an organisation approved logged hours (or a member self-logged hours with auto-pay enabled), two near-simultaneous approvals — or an accidental double-click — could pay the volunteer twice and debit the organisation's wallet twice for a single entry. Approval and payment are now strictly one-time per hours entry, guarded both in the application logic and by a database safeguard, so a duplicate payment can't happen no matter how the action is triggered.
- Volunteering: organisations are now told the truth when their wallet can't cover approved hours. With auto-pay on, approving hours always said "approved and paid" — even when the wallet was empty and the volunteer was not actually paid. The confirmation now accurately reflects whether the volunteer was paid or the wallet needs topping up, and the organisation's pending list and balance stay consistent.
- Volunteering: switching between two organisation dashboards no longer shows the wrong organisation's data. A coordinator who manages more than one organisation could briefly see one org's pending hours or wallet history under another org's name (and risk approving or paying against the wrong one). Each dashboard tab now always reflects the organisation you're viewing.
- Volunteering: a glitch in one tab can no longer blank the whole page, and a few smaller rough edges are fixed. An unexpected value from the server could crash the Emergency Alerts tab and take the entire Volunteering page down; every tab is now isolated so a problem in one shows a contained, retryable error instead of a blank screen. The wallet deposit limit shown in the form now matches what the server actually accepts (a clear message instead of a confusing rejection), the Safeguarding and Donations toggle buttons now visibly reflect their on/off state, Safeguarding no longer shows an empty list with no explanation when data fails to load, and bulk approve/decline on applications is protected against accidental double-submission.
- Volunteering: an organisation's private wallet balance is no longer exposed on its public page. The public organisation endpoint was including the internal wallet balance and auto-pay setting; these are now kept private to the organisation's own dashboard.
- Volunteering: federation and rewards reliability. Federated volunteering opportunities can no longer be re-sent in duplicate to partner communities if a background job is retried, and volunteers now reliably receive the correct XP for approved hours (a matching bug could silently skip the reward for some entries).
- Broker panel exchange history is now fully translated. The broker exchange-detail timeline labels ("Request created", "Provider confirmed hours", "Requester confirmed hours", "Status changed") were only ever shown in English for the ten non-English languages. They are now translated across German, French, Spanish, Italian, Portuguese, Dutch, Irish, Polish, Japanese and Arabic — restoring a fully-localized broker panel and unblocking the translation-completeness CI gate.
- Podcasts: scheduled episodes stay private until their publish time, and listen stats are more honest. A future-scheduled episode could be opened early via a direct link or the audio/listen route (it was correctly hidden from listings and the RSS feed, but those routes didn't enforce the embargo); the embargo is now applied everywhere, while show owners and admins still preview as before. Separately, an episode whose related show record was missing could return a server error instead of a clean "not found"; listen analytics now clamp the reported listening time to the episode's real length so completion/retention figures can't be inflated by a client; and the per-user show-limit check no longer loads every episode and chapter just to count shows.
- Scheduled podcast episodes now notify subscribers when they go live, not when they're queued. Scheduling an episode for a future time used to notify subscribers and post a feed card immediately — pointing people at an episode that stayed hidden from listings and the RSS feed until its scheduled time. A future-scheduled episode is now held back and released by a background task the moment its scheduled time arrives, which posts the feed activity and notifies subscribers exactly once (re-runs never duplicate). This completes the previously half-built scheduling feature.
- Partner time-credit notifications now arrive in your language. When an external partner integration credits time to your wallet, the in-app bell and push notification were rendered in the system default language rather than yours. They now render in the recipient's preferred language, matching the accompanying email.
- Landing-page builder no longer loses your place when you reorder or remove a block. In the admin landing-page builder, reordering or deleting an audience card, feature, step, or value could jump keyboard focus and in-progress edits to the wrong row, because the lists were tracked by position rather than identity. Each item now keeps a stable identity, so focus and field state stay with the right block. Saved content is unaffected.
- Smaller robustness and accessibility fixes. Disconnecting a social-login provider no longer surfaces a raw internal error message on failure (it now shows a clear, localized message and logs the detail server-side); the group branding colour-picker's hue slider now has a translated screen-reader label; a shared data-loading hook now ignores a slow earlier response after its input changes, preventing brief stale data on fast tab/filter switches; and a group team-chat screen now releases its real-time subscription when you leave it.
- Site search no longer fails with a server error. On communities using the fast search engine (Meilisearch), the global search bar and its type-ahead suggestions returned a 500 server error for every query — because the listings search index was queried with an "approved listings only" filter the index had never been told to allow, and the error was left unhandled. The index now permits that filter, and as a safety net any search-engine error now quietly falls back to a database search instead of failing the whole request, so search can never hard-fail this way again. (Operators: re-run the search-index sync after deploying so the live index picks up the new filterable attribute — php scripts/sync_search_index.php --all-tenants.)
- The "Leave a review" email link no longer hits a 404, and the Reviews "Pending" list works again. The review-request email's "Leave Review" button pointed at a page (/reviews/create) that didn't exist, so it landed on a Not Found page. That route now exists and opens the review form for the right person straight from the email — and it survives signing in first, so the link works even when you're logged out when you click it. Separately, the Reviews page "Pending" tab and the dashboard's pending-reviews card were being fed the wrong data (the reviews you'd received instead of the completed exchanges you still need to review), so they never listed the right items; they now correctly show each completed exchange awaiting your review, the person to review, and a working "Write review" button.
- Courses module hardening (Alpha follow-up). A round of safety and robustness fixes to the new Courses module: the course builder now shows an error and rolls back the on-screen change if a section/lesson delete, reorder, or rename fails to save (previously these failures were silent, leaving the displayed curriculum out of step with what was actually stored); quiz answer keys and explanations are now excluded from course data sent to the browser at the model level as defence-in-depth (the learner quiz view already omitted them); the star-rating control now has a proper accessible label for screen-reader users; a failed review submission now shows a review-specific error message instead of an unrelated "couldn't enrol" one; and course creation now sets author identity and moderation status strictly server-side so they can never be influenced by the submitted form. New labels translated across all 11 languages.
- Courses module polish (Alpha follow-up, round 2). Course counts — lessons, enrolments, and review counts — now pluralise correctly in every language (they previously used a hand-rolled scheme that left some languages, including Polish and Arabic, stuck in a single form regardless of the number); they now use proper per-language plural rules. A learner who has dropped a course can no longer post or overwrite its rating/review (only active or completed enrolments may review, so the public rating reflects genuine participants). And enrolling now ignores a cohort selection that doesn't belong to the course, instead of recording it, keeping cohort rosters and analytics clean.
- Instructors can now actually read what they're grading. The course quiz grading queue previously showed each learner's submission as a raw JSON blob (e.g. {"q12":["b"]}) with no question text — effectively unusable for grading short-answer and essay questions. It now lists each question's prompt with the learner's answer beneath it, mapping multiple-choice selections back to their option labels. The answer key is never exposed to this view. Two new labels translated across all 11 languages.
- Quiz attempt limits can no longer be bypassed by rapid resubmission. A quiz's "maximum attempts" cap was checked and then recorded in two separate steps, so two submissions sent at almost the same instant could both slip past the limit (a check-then-write race). The cap is now enforced atomically inside a row-locked transaction, so a learner can never exceed the configured number of attempts even with concurrent submissions.
- The Prerender Engine admin page no longer crashes on the Inventory and Analytics tabs. Opening the Inventory tab threw an error ("A slot prop is required") and the Analytics tab threw "Cannot convert undefined or null to object" — both white-screened the whole admin page via the error boundary. The Inventory crash was the row-selection checkboxes being misread as a data table's built-in selection control; the Analytics crash was the backend omitting two fields from its empty response when no crawler-traffic log exists yet (always the case before any bots have visited). Both tabs now load correctly, and the Analytics view is hardened so a partial response can never blank the page again.
- Device push notifications now reach every important alert. Many notifications — new messages, connection requests, likes, comments, replies, mentions, matches, achievements, job and goal updates, volunteering alerts, marketplace payouts, exchange and admin alerts and more — previously only appeared as an in-app bell or email and never arrived on your phone or browser as a push, because push delivery was incorrectly tied to the email-digest setting (which is off by default). Push is now its own channel, controlled solely by your push toggle, and fires for every notification type that warrants it on both web and mobile. It is de-duplicated, so a burst of activity on the same item (e.g. many likes on one post) can't flood your device.
- "Powered By" / partner-logo image removal and freshness in admin settings now work. Removing a "Powered By" light/dark image (the × button) is now persisted on Save instead of silently reverting with "no changes saved" — the image fields are tracked in the save diff and the remove button no longer pre-clears the baseline the diff compares against. Uploading or replacing a "Powered By" or partner-logo image now also busts the cached tenant bootstrap server-side, so the footer shows the new image immediately instead of the old one until the 10-minute cache expired. (Uploads already persisted to the database; removal had no working code path, and uploads left a stale bootstrap cache.)
- Footer branding now updates immediately after saving admin settings. Saving footer text, the "Powered By" label/URL, or the partner logo on the admin System Settings page now refreshes the live tenant context, so the footer reflects the new values right away instead of falling back to the default NEXUS branding until a hard page reload. (The backend already persisted the change and busted its cache; the SPA simply wasn't re-fetching its in-memory tenant bootstrap after the save.)

### Changed

- Module Configuration is now visible only to super-admins. The "Module Configuration" entry in the admin sidebar (under Platform Operations) — which toggles a community's core and feature modules — is now shown only to tenant super-admins (and platform/god admins). Regular admins no longer see it in the navigation.
- HeroUI v3 component-usage sweep across core pages. Audited the post-login pages — Feed, Explore, Listings, Exchanges, Group Exchanges (list + create wizard), Wallet, Messages, and Dashboard — to use HeroUI v3's dedicated components instead of hand-rolled equivalents. Filter, view-mode, and split-type selectors now use ToggleButtonGroup/RadioGroup/TagGroup; numeric fields use NumberField; member search uses SearchField; status pills use Chip; the listings load-more bar uses Progress; dividers use Separator. Also fixed several invalid interactive-element-inside-link cards (the listings save button, Explore "View Group"/"Vote Now" CTAs, and the feed composer's nested action buttons) for correct, accessible markup. No behavioural changes.
- HeroUI v3 component-usage sweep — round 2 (Members, Connections, Events, Groups, Volunteering). Continued the sweep across the remaining post-login modules. Quick-filter / view-mode / day-range / mood / RSVP selectors now use ToggleButtonGroup (or standalone ToggleButton) instead of Button+aria-pressed; the group branding picker uses the v3 ColorPicker (area + hue slider + hex field) instead of native <input type="color">; the wiki parent-page picker uses Select; remaining hand-rolled status pills use Chip; and several more interactive-element-inside-link cards/CTAs were converted to Button as={Link} for valid, accessible markup. No behavioural changes.
- HeroUI v3 component-usage sweep — round 7 (Help, About). Converted the remaining navigation CTAs on the Help Centre and public About page from <Link><Button> to Button as={Link} for valid, accessible markup. No behavioural changes.
- HeroUI v3 component-usage sweep — round 6 (Federation members/listings, Achievements). Federation member service-reach filter and listing type filter → ToggleButtonGroup (were Chip-as-button with manual role="button"/aria-pressed). Clickable cards on Federation listings and Achievements dropped their redundant manual role="button"/tabIndex/onKeyDown — GlassCard's onClick already makes the underlying v3 Card isPressable, which supplies button role + keyboard activation natively. No behavioural changes.
- HeroUI v3 component-usage sweep — round 5 (Notifications, Marketplace map search). Notifications all/unread filter → ToggleButtonGroup; settings CTA → Button as={Link}. Marketplace map/list view toggle → ToggleButtonGroup (the previous buttons conveyed active state only visually — no aria-pressed — so this also fixes a screen-reader gap); added a map.view_toggle aria-label across all 11 languages. No behavioural changes.
- HeroUI v3 component-usage sweep — round 4 (Profile, Settings, Goals, Polls, Ideation, Matches, Skills). Continued the sweep across more post-login pages. Profile/Matches/Settings navigation CTAs and back-links → Button as={Link} (removing invalid button-inside-link markup); the Settings theme picker, Goals view-switcher, Polls category filter, and the Skills per-category selector → ToggleButtonGroup; Goal deadline fields → DatePicker; the Ideation tag filter → TagGroup; the Polls ranked-result bar → Progress. No behavioural changes.
- HeroUI v3 component-usage sweep — round 3 (Resources, Onboarding, Jobs, Courses, Caring Community). Resources reorder/category controls → ToggleButton/ToggleButtonGroup; the onboarding interests/skills multi-select clouds → TagGroup; Jobs and Courses/Caring-Community navigation CTAs → Button as={Link} (removing invalid button-inside-link markup); the courses catalog and lesson-nav controls → SearchField and Button; remaining source/status pills → Chip. No behavioural changes.
- "Report a problem" is now logged-in only, and de-duplicated. The floating problem-reporter (bottom-right) no longer renders for anonymous visitors, and the duplicate "Report a problem" link in the desktop footer was removed since the floating reporter already covers desktop. This stops logged-out traffic from cluttering support reports / Sentry. (Submissions were already auth-gated server-side via requireAuth and rate-limited at 10/min; this closes the visible entry point too.)
- Proprietary brand names removed from the Caring Community module. All visible mentions of "KISS", "AGORIS/Agoris", "Age-Stiftung", and "Koordination und Innovation für Soziales" have been replaced with "Caring Community" throughout the frontend, admin panel, and all 11 language files. The Agoris node option was removed from the pilot inquiry form. Database table and column names, the agoris tenant slug, and internal service class names are unchanged.
- React upgraded from 18 to 19. Full production upgrade including React 19 concurrent features, updated type definitions (@types/react 19.x), and Vitest/testing-library compatibility fixes. All 223 usages of deprecated APIs resolved. Build, type-check, and smoke tests pass.
- framer-motion removed. As part of the React 19 modernization, the framer-motion dependency was dropped and replaced with a local CSS-transition-backed shim at @/lib/motion, repointing every animation import site. Smaller bundle and one fewer heavy dependency, with no visible change to animations.
- HeroUI v3 migration — complete. The frontend component library migration from v2 to v3 (@heroui/react) is finished; the v2 npm alias has been removed. All remaining components were migrated to v3 (Dropdown, Select, Accordion, Tabs, Modal, Drawer, Table, Badge, Switch, Checkbox, Radio, Tooltip, Skeleton, Slider, Popover, Pagination, DatePicker, ScrollShadow, ButtonGroup), the useDisclosure → useOverlayState hook adapter landed, the @heroui/styles internal dependency was removed, and a final wrapper/test/visual audit pass confirmed the app is clean on v3. (Supersedes the earlier "phases 1–5 of 10" status.)
- Admin panel is now fully translated in all 11 languages. The previous admin-English-only policy was reversed; admin UI strings now resolve through the same i18n system as the rest of the platform, and PHP locale namespaces were filled across all 10 non-English languages.
- Module Configuration cleaned up. Five unimplemented orphan modules (merchant coupons, member premium, AI agents, partner API, regional analytics) were removed from the admin Module Configuration screen so admins no longer see dead toggles. The Help Centre link was also removed from the utility navbar and a duplicate AGPL footer notice was removed.
- Caring Community marked as Alpha. The Caring Community module on the admin Module Configuration screen is now labelled "Caring Community Alpha" and shows an Alpha development-stage badge. Module cards support an optional stage (alpha/beta) marker, translated in all 11 languages.

### Accessibility

- Search boxes upgraded to HeroUI v3 SearchField across all browse/list pages. Filter inputs on Federation (listings, groups, events, members, messages), Organisations, Groups, Ideation, Marketplace (search, category, map), Messages (conversation list and in-conversation search), Talent search, Volunteering (opportunity detail and org applications), the group Files and Q&A tabs, and the Explore search entry now use the v3 SearchField primitive instead of a generic text input with a search icon — gaining a built-in clear (×) button, role="searchbox" semantics, and the "Search" key on mobile keyboards. (The shared SearchField wrapper now also forwards a plain className; inputs that need an autocomplete results dropdown were intentionally left on Input.)
- Marketplace category breadcrumb consolidated onto the shared Breadcrumbs component. The category page's hand-rolled inline <nav> breadcrumb now uses the same Breadcrumbs component as the rest of the app, gaining aria-current="page", consistent label truncation, and 44px touch targets.
- Platform-wide WCAG 2.1 AA audit (four rounds). A multi-round accessibility campaign brought the React frontend and admin panel toward WCAG 2.1 AA: colour-contrast fixes, semantic landmarks and role attributes (e.g. feed cards as article), ARIA labels on all Tabs and Input elements, accessible names on icon-only controls, aria-expanded on menus, aria-live/live regions for chat and search results, aria-busy on skeletons, keyboard support for sortable table-column headers, focus indicators, and usePageTitle on pages that were missing a browser/screen-reader title. Round 4 alone covered 24 pages and 10 admin modules. The accessible-frontend link was relabelled to reference "WCAG 2.2 AA" across all 11 languages.

### Security

- Member surnames are now hidden from non-admin viewers, platform-wide. Surnames were previously returned in all user-facing API responses. They are now gated behind an admin check at every exposure point (public profile, member directory listing, and member search), so non-admins see only the first name while admins continue to see full names.

### Fixed

- Admin "Inactive members" report no longer crashes on load. Visiting /admin/reports/inactive-members rendered the "Something went wrong" error boundary fallback with the message A slot prop is required. Valid slot names are "selection". The page wrapped a bare <Checkbox> inside a <TableColumn> for its "select all" affordance, but HeroUI v3 Table (React Aria Components) only permits checkboxes in the header when they use the built-in slot="selection" — i.e. when the Table itself is in selectionMode. Replaced the manual checkbox column and per-row checkbox cell with the Table's native multi-selection (selectionMode="multiple", selectedKeys/onSelectionChange bound to the existing selectedIds state, rows keyed by user_id). Also fixed a separate latent issue spotted in the same pass: the flag-type filter Select had an id="" "All types" option (React Aria Collections reject empty-string ids), now 'all' (omitted from the API request so backend behaviour is unchanged), and <TableColumn width={40}> (not a valid v3 prop) is gone with the column it sized. Reported via support NXR-260528-L6WQSE.
- HeroUI v3: profile hover-cards no longer flicker. Hovering an avatar on a feed card (and an @mention in post text) made the profile preview popover rapidly flash open and closed. Both are controlled HeroUI v3 Popovers (React Aria); their open state was being driven in a way that diverged from React Aria's internal trigger state — UserHoverCard delayed the close inside onOpenChange, and MentionRenderer flipped the open state instantly from raw mouse handlers — causing an open/close oscillation. Both now use a single hover-intent timer as the only opener and apply React Aria's close requests immediately, so the card opens and closes smoothly and still dismisses on Esc / click-outside.
- HeroUI v3: dropdown menus with selection now show a checkmark. Selectable dropdown menus (e.g. the language switcher, admin sidebar sections, feed filter menus) highlighted the chosen option internally but rendered no visible tick, because the shared Dropdown wrapper never emitted the v3 selection indicator. The indicator is now rendered for single/multiple-selection menus only — plain action menus are unchanged (no empty indicator gutter).
- Welcome credits now granted when admin approves via status change. Admins were approving members by editing their status to "active" (the user detail edit page) rather than using the dedicated Approve button. The generic update() endpoint set is_approved=1 but never called grantWelcomeCredits, so no starting balance was applied. Fix: detect the pending → active transition in update() and run the same credit-grant + welcome-email + in-app-notification flow as the dedicated /approve endpoint. Also fixed: grantWelcomeCredits was reading the welcome_credits tenant-settings key (which doesn't exist) instead of wallet.starting_balance (the key the admin Settings page actually writes). The code now reads wallet.starting_balance with a fallback chain so every tenant resolves the correct value. Backfilled 5 credits to the one user who had been approved but received nothing.
- Member activity reports now show real data. AuthController::login() never stamped last_login_at on successful login after the Laravel migration, so /admin/reports/members showed "No active members found" for all tenants. Fixed by adding DB::table('users')->update(['last_login_at' => now()]) immediately after token creation. A backfill migration approximates past login dates from personal_access_tokens.created_at for all active users so reports are immediately useful.
- Wallet transfer/donation UX and a 404. The wallet DonateModal exposed an unusable numeric "Recipient ID" field, now replaced with an avatar/member search picker. A missing GET /v2/wallet/config endpoint that caused the TransferModal to 404 every time it opened was added.
- Member profile tabs no longer collapse. A HeroUI v3 Tabs CSS issue hid all but the selected tab on member profiles; all tabs render correctly again.
- Dashboard and Explore layout fixes. Restored the dashboard quick-action tiles and stopped the Explore page tabs from wrapping onto a second line.
- Admin search box no longer triggers browser autofill. The browser was autofilling a saved admin email into the admin search field; this is now suppressed.
- Build & infrastructure reliability. Raised the Workbox precache size limit to 5 MB (large bundles were being skipped), cast GD image dimensions to integers and guarded against localStorage quota errors, unpinned the Redis PECL extension to fix the Docker build, added a queue watchdog cron to recover dead Horizon workers, and hardened container storage permissions.

---

## 1.5.1 - 2026-05-20

### Fixed

- Proximity regression guard tests added for listings, events, and members. Three new integration tests assert that a listing/event/member at Cork coordinates (~258 km from Dublin) is excluded from a 10 km Dublin-centred radius search. These would have caught every recurrence of the proximity filter being silently ignored. Also fixed: ListingService::countAll() proximity subquery had a binding-order bug — mergeBindings() placed WHERE-clause values before SELECT bindings, causing MariaDB to evaluate cos(radians('active')) as the latitude. Replaced with a raw DB::selectOne() query using explicit ordered bindings.
- Security: cross-tenant data access hardened in ExchangeService and MarketplaceListingService. ExchangeService::accept() and decline() refetched exchange records for notification dispatch without a tenant_id constraint — a defence-in-depth gap that could pass cross-tenant metadata to the notification system. Both refetches now include ->where('tenant_id', TenantContext::getId()), consistent with complete() which was already correct. MarketplaceListingService::saveListing() had no ownership check before creating a saved-listing record — a user on Tenant A could bookmark a listing from Tenant B by crafting a direct API call. A tenant guard via HasTenantScope is now applied before the firstOrCreate.
- Events proximity pagination returns correct results on Load More. EventService::getAll() applied distance ordering (ORDER BY distance_km ASC) when proximity was active, but still built the cursor from last_id and decoded it as WHERE id < cursor on subsequent requests — a keyset/sort mismatch that caused Load More to skip or repeat events. Proximity path now uses offset-based pagination (cursor format nearby:N) consistent with ListingService.getNearby().
- Proximity "Near me" filter broken end-to-end — five separate issues fixed across listings and events. Full audit revealed: (1) ListingService::getAll() received near_lat/near_lng/radius_km from the controller but never applied them — all listings returned regardless of distance (root cause of Dublin user seeing Cork listings at 2 km). Fixed by delegating to the existing getNearby() haversine query when coordinates are present. (2) ListingService::countAll() also ignored proximity, showing a wrong total count in the results badge; fixed with a subquery-based haversine count. (3) Both getAll() and countAll() were missing near_lat from the $hasFacetedFilters guard, so search + proximity queries incorrectly used Meilisearch totals. (4) ListingsController applied the personalisation re-ranker and smart-match ranker after proximity results, destroying the nearest-first order; both ranking passes now skip when proximity is active. (5) EventService::getAll() had the identical structural bug (proximity params ignored); fixed by applying the haversine filter inline, consistent with how VolunteerService already correctly handles it. Also fixed in the React UI: activeFilterCount now includes proximity so the "Filters" badge reflects it, and the "Clear filters" button now resets the proximity pill correctly using a remount key.
- GDPR: account deletion now scrubs the email_log audit trail and clears the user's row from email_suppression. The original email address is captured before the user record is anonymised so the platform-wide suppression cache (keyed on email, not user id) can be cleaned up. Recipient address in email_log is anonymised in place rather than deleted, preserving tenant-level aggregate deliverability metrics.
- Stuck notification_queue rows from before the daily-digest opt-in flip are now expired. Cleanup task now also marks status='pending' rows older than 7 days as failed so the digest cron doesn't send a member a "what happened in March" digest after the deploy. The 30-day cleanup also now sweeps failed rows along with sent (was only sent before).
- Dropped dead users.email_preferences JSON column. Never read, never written by application code (verified by grep); removed via guarded hasColumn migration so it's safe to re-run.
- DKIM / SPF / DMARC verified healthy on production — project-nexus.net has strict SPF (include:sendgrid.net -all), both s1._domainkey and s2._domainkey CNAMEs validly point at SendGrid, SendGrid reports the domain as valid=True. DMARC is at p=none (monitor-only); recommended to escalate to p=quarantine after 30 days of clean aggregate reports.

### Added

- Hierarchical domain inheritance for sub-tenants. Slug-only sub-tenants whose immediate parent has a custom domain are now accessible at parent.domain/child-slug (e.g. timebanking.uk/cardiff) in addition to app.project-nexus.ie/cardiff. The backend resolves the child tenant from the first path segment after locking on the parent's custom domain, and the bootstrap API returns a parent_domain field so the SPA automatically uses path-prefixed routing on the parent's domain. Email and notification links for these sub-tenants now correctly emit timebanking.uk/cardiff/... URLs (including queued/background jobs via a DB parent-domain lookup fallback). The sitemap layer is fully wired: timebanking.uk/sitemap.xml returns a sitemap index listing both the parent's and each sub-tenant's sitemap; /sitemap-{childslug}.xml generates URLs with the correct parentdomain/childslug/... base. The prerender pipeline (prerender:plan-routes and prerender-tenants.sh) prerenders sub-tenant pages at timebanking.uk/cardiff/... instead of app.project-nexus.ie/cardiff/.... SitemapService::generateForAppDomain() excludes sub-tenants that belong under a parent domain so they don't appear with wrong canonical URLs in the shared-host sitemap. Redis bootstrap cache is invalidated automatically on hierarchy moves and domain changes. Moving a tenant in the hierarchy tree is safe at any time — domain associations update immediately with no stale cached state. No DNS or nginx changes are required beyond pointing the parent domain at the platform.
- SEO: organization type, geo meta tags, and structured data enhancements. Tenant super-admins can now set a seo_organization_type (e.g. LocalBusiness, EducationalOrganization, NonprofitOrganization) via the admin tenant form, overriding the global Schema.org @type default. Geo meta tags (geo.region, geo.country, geo.placename, ICBM lat/long) are emitted in <head> when the tenant has location coordinates — helps Google/Bing assign geographic context and reduces multi-tenant duplicate-content risk. LocalBusiness tenants get an areaServed block in the org schema that maps service_area scope to Schema.org types (City, AdministrativeArea, Country, Place). Added @id anchor to org schema for cross-referencing. Lat/lng and service_area are now included in the bootstrap API contact payload.
- Admin panel: Registration Security card on /admin/settings/registration-policy. Front-and-centre status card for the per-tenant circuit breaker. Polls every 30s. Green chip when signups are flowing normally; red border + alarm-banner + one-click "Resume signups now" button when the breaker has tripped. Includes the live signup count vs threshold so admins can see "we're at 18 of 20 this hour" before the breaker actually fires. Additive — sits above the existing registration-policy form, doesn't touch any existing components.

### Added

- Existing SendGrid event webhook (POST /api/v2/webhooks/sendgrid/events) extended to populate email_log + email_suppression in real time. The webhook was already wired into NewsletterBounce and EmailMonitorService for legacy bounce / complaint tracking; now also updates the new deliverability tables: matches the row by recipient + sg_message_id prefix, advances status to delivered / bounced / failed (never regresses a terminal state), populates delivered_at / bounced_at / opened_at, upserts email_suppression on bounce / dropped / spamreport / unsubscribe. Adds support for open / click / unsubscribe event types that the legacy handler ignored. Uses the existing ECDSA verification via SENDGRID_WEBHOOK_VERIFICATION_KEY — no new env vars or routes. The Mailer captures the X-Message-Id header from SendGrid on send and writes it to email_log.provider_message_id so webhook events match back to log rows.
- Admin email deliverability dashboard at /admin/email-deliverability. Per-tenant headline metrics (delivered %, bounced %, status breakdown over 1/7/30/90 days), filterable email_log feed (recipient + status + date range), platform-wide suppression-list view with one-click remove (clears locally AND in SendGrid), and a per-user history endpoint. Operators can now answer "did Joe Bloggs get his welcome email?" with a click instead of a SSH session.
- Mobile push (FCM) fan-out from NotificationDispatcher. Previously the dispatcher's instant path only fired web push and silently skipped the retired web-wrapper mobile app — direct messages, connection requests, volunteer-application status, mentions etc. were invisible on mobile. Dispatcher now fans out web push + FCM push in parallel, failure-isolated so one provider's outage doesn't suppress the other. FCMPushService::sendToUser/sendToUsers also now honours notification_preferences.push_enabled so members can actually turn mobile push off (previously the preference existed but had no effect on FCM).
- emails:reconcile-transient-failures artisan command (every 15 min). Cross-checks recent email_log rows with status=failed against SendGrid's /v3/messages activity feed. If SendGrid actually accepted the send despite a transient 5xx on our side, the log row is repaired to delivered so the audit trail reflects reality. Genuine failures stay flagged.
- Per-recipient email rate limit. Redis-backed rolling-hour counter (default 30/hour/recipient, configurable via MAILER_PER_RECIPIENT_HOURLY_LIMIT, 0 disables). Catches runaway loops / buggy listeners that would otherwise flood a single member with dozens of emails. Logged as status=failed, error="per-recipient rate limit exceeded" so admins see what tripped the limit.
- expireOverdueJobs() + expireFeaturedJobs() re-implemented. Both methods were removed in an earlier refactor; the cron entries were throwing undefined method once per tenant per day. Now: featured jobs lose is_featured once featured_until is past; open jobs older than 180 days with no edit in the last 60 days are auto-closed. AchievementCampaignService::processRecurringCampaigns() is also re-implemented (ticks last_run_at for due recurring campaigns; award logic stubbed pending product decision on missed-runs). BrokerMessageVisibilityService::expireMonitoringBatch() stays no-op'd — the underlying schema (broker_monitoring table) does not exist on production.
- Integration test WelcomeEmailCrossTenantTest — asserts the welcome listener still works when TenantContext is pre-leaked from a tenant-2 job. Directly guards the original incident from regressing.
- Email observability: email_log audit table + email_suppression cache. Every Mailer::send() now writes a row capturing tenant, user, recipient, subject, status (queued/sent/failed/suppressed/bounced/delivered), provider message id, and error. Operators can finally answer "did Joe Bloggs get his welcome email?" without grepping log files. The companion email_suppression table is hydrated hourly by a new sendgrid:sync-suppressions artisan command that pulls SendGrid's bounce / block / invalid / spam-report lists; the Mailer checks suppression before every send and refuses to mail addresses SendGrid has already told us are dead (saves quota, protects sender reputation, surfaces invalid member emails to admins).
- One-click unsubscribe (Gmail/Yahoo Feb-2024 bulk-sender compliance). New NotificationUnsubscribeController plus /api/v2/notifications/unsubscribe route (GET for browser visits, POST for List-Unsubscribe-Post). Token format is HMAC-signed userId.tenantId.category.sig; categories map to notification preference keys (all/messages/connections/transactions/reviews/listings/digest/gamification/org/federation). The Mailer auto-attaches the header on every send by looking up the recipient in the current tenant — no caller changes needed for 30+ existing email-sending services. Confirmation page is locale-aware, tenant-branded, no-indexed.
- One-shot recovery: php artisan emails:resend-stuck-activations. Re-sends welcome/verification emails to members who registered while the earlier TenantContext leak / Mailer bypass bugs were live and never got their activation email. Defaults to --dry-run so you can sanity-check the recipient list. --since=60days, --tenant=N, --limit=200 flags for scoping. Reuses the canonical Mailer path so the email_log and suppression checks still apply.
- Notification settings: caring_smart_nudges and federation_notifications_enabled UI toggles. Both were backend-supported but had no UI control — members had no way to opt out. Now exposed in /settings → Notifications. federation_notifications_enabled is a column (not part of the JSON), so the GET/PUT endpoints were extended to read/write it alongside the rest of the preferences.
- Members can opt INTO the activity digest from notification settings. New "Activity digest frequency" selector in /settings → Notifications lets members pick off (default) / instant / daily / weekly. Backed by a new GET /api/v2/notifications/settings endpoint that returns the user's notification_settings.global row plus per-group and per-thread overrides; the existing POST endpoint upserts. Critical user-facing events (direct messages, connection requests/accepts, volunteer-application status, volunteer-hours approval) are forced to 'instant' regardless of this setting so disabling the digest never silences them.
- Inbound federation event ingestion wired up for review / connection / listing / community-event / member-updated. The federation webhook controller had been dispatching these 5 events for months with no listener registered, so the event(...) calls were dropped on the floor (the controller's local DB persistence still ran, so no data was lost). Five new listeners: HandleFederatedReviewReceived — notifies the local reviewee with an in-app bell + email (anonymous "Someone left you a 5-star review" so we don't dox the partner-side reviewer name). HandleFederatedConnectionReceived — notifies the local user of an inbound partner connection request / accept. HandleFederatedListingReceived, HandleFederatedCommunityEventReceived, HandleFederatedMemberUpdated — observability-only structured audit logs; persistence is already complete in the controller, and these inbound bulk-content events don't map to a specific local user to notify. Future extension points kept in the listener for search-index sync.
- In-app bell notifications for group chatroom messages. GroupChatroomMessagePosted had a Pusher broadcast for online members but no listener — members who weren't online missed the message entirely. New NotifyGroupChatroomMessage listener creates an in-app bell row for every active group member (excluding the sender and anyone who muted them), with a 5-minute dedup window so a burst of chat messages doesn't produce a wall of bell rows. No email — chat volume is too high to safely email every message; users who want email coverage of group chatter can opt into the daily digest.

### Changed

- Daily activity digest is now OFF by default. Members reported the previous daily-email default felt like spam. NotificationDispatcher::dispatch() and NotificationDispatcher::getFrequencySetting() and EventNotificationService::resolveFrequency() now fall back to 'off' when the member has no row in notification_settings, replacing the previous 'daily' fallback. The new opt-in selector in /settings → Notifications lets members turn it on if they want it. Six critical activity types still force 'instant' regardless: new_message, connection_request, connection_accepted, vol_application_approved, vol_application_declined, vol_hours_approved.

### Fixed

- Five notification-preference enforcement bugs. An audit of every email-sending listener found preferences silently ignored in 5 paths: NotificationDispatcher::sendReviewEmail did not check email_reviews — local + federated review emails ignored the pref. NotificationDispatcher::sendReviewRequestEmail checked the WRONG key (email_transactions instead of email_reviews). NotificationDispatcher::sendCreditEmail / sendCreditSentEmail had no defence-in-depth pref check (only the caller did, so any future caller would bypass). HandleFederatedReviewReceived did not honour federation_notifications_enabled (the per-user federation opt-out had no effect on federation review emails). CronJobRunner::processDigest sent the digest to every user with queued items regardless of their email_digest preference. All five now respect the matching preference; the digest path also marks the queued rows as sent when the pref is off so they don't pile up indefinitely.
- CI guard against Laravel Mail:: facade. Extended EmailMailerRoutingTest::test_no_mail_facade_usage_anywhere_in_app to scan every file under app/ (excluding app/Mail/ Mailable definitions and comments) for Mail::raw|Mail::to|Mail::send|Mail::queue|Mail::later|Mail::mailer. Any future regression that bypasses Mailer::forCurrentTenant() fails CI instead of silently dropping production emails. The platform .env has SendGrid configured and intentionally NO SMTP credentials — facade usage routes through Laravel's default SMTP mailer and silently drops the message.
- Structural defence: SerializesModels removed from all 30 events. Closes the deserialization trap permanently: even if a queued job's finally { reset(); } is missed for any reason, no Eloquent re-fetch happens at job-pickup time, so a stale TenantContext can no longer poison a model lookup. Event payloads are now snapshotted by PHP's default serializer with the full in-memory model state. Slightly larger queue payload for negligible savings before; effectively zero risk of the cross-tenant filter ever firing against a wrong-tenant id again.
- Newsletter template backfill: every tenant now has the starter newsletter templates. Re-engagement / nurture / onboarding starter templates were originally seeded for tenant 2 only via January-2026 migrations. Other tenants saw an empty admin "New newsletter" page and any code path looking up a template by category returned nothing. New idempotent migration copies every category='starter' row from tenant 2 into every active tenant; per-tenant edits made later are preserved (skip on (tenant_id, name, category) collision).
- Critical: daily / weekly digest emails were silently dropped for every user across every tenant due to a TenantContext leak inside the cron loop. CronJobRunner::processDigest() called User::findById($userId) BEFORE setting TenantContext::setById($user['tenant_id']) — so the Eloquent HasTenantScope filter applied a WHERE tenant_id = <previous-iteration's-tenant> clause and returned null for every user whose tenant didn't match the leaked context. Result: every user was logged as "Skipping User ID X (No email/Invalid)" — 31 skipped users on the most recent run, and 52 pending notifications stuck in notification_queue for 7 weeks (oldest from 2026-03-30). Fixed by calling TenantContext::reset() at the start of each iteration so findById runs with a clean baseline. The same pattern now also defends runSubTask() (resets before+after every cron sub-task) and forEachTenant() (resets between tenant iterations + final reset on exit) — closes the leak surface across the entire cron pipeline.
- Critical: BalanceAlertService::checkAllBalances() and ListingExpiryService::processAllTenants() were called statically every day but are instance methods. The cron threw "Non-static method ... cannot be called statically" 12 times per day for each (once per tenant), silently dropping every low-organisation-wallet alert email and every listing-expiry email across every tenant. Both now resolved via app(...) container resolution. Also no-op'd four other cron tasks that referenced services whose methods were removed in a refactor (JobVacancyService::expireOverdueJobs/expireFeaturedJobs, BrokerMessageVisibilityService::expireMonitoringBatch, AchievementCampaignService::processRecurringCampaigns) — these were spamming 12-48 "undefined method" errors per cron run; now print a single "skipped" line until the service replacements ship.
- Critical: four email-sending paths bypassed the platform Mailer and silently failed in production. Production .env configures SendGrid (SENDGRID_API_KEY set, FROM noreply@project-nexus.net) and does NOT configure SMTP (MAIL_USERNAME and MAIL_PASSWORD are intentionally unset). Four call sites used Laravel's Mail::to(...)->send(...) or Mail::raw(...) which routes through config('mail.default') (= smtp) — sends were attempted via an unconfigured SMTP server and dropped silently. Rerouted through \App\Core\Mailer::forCurrentTenant() so they now use SendGrid like everything else: (1) SafeguardingService.php:630 — critical safeguarding alerts were not being delivered; the SafeguardingCriticalMail Mailable is now rendered to HTML and sent via Mailer. (2) AdminBillingController.php:249 — billing-upgrade-request notifications to the platform owner. (3) GenerateMonthlyReports.php:127 — regional analytics report-ready emails. (4) AdminEmailController::test() — admin "send test email" now uses Mailer::forCurrentTenant() (matching testProvider()) so it respects per-tenant email_settings instead of only the platform .env.
- Critical follow-up: structural defence against the same trap firing during job deserialization. Per-listener finally { reset(); } only fires after handle() runs — a job that throws during its payload's deserialization (e.g. when SerializesModels::restoreModel() calls User::findOrFail() with a stale TenantContext and ModelNotFoundException blows up) never reaches that finally, so the stale context persists into the next job. Registered global Queue::before / Queue::after / Queue::failing hooks in AppServiceProvider::boot() that call TenantContext::reset() around every queued job. Queue::before fires BEFORE deserialization, guaranteeing every job starts with a clean null context regardless of what the previous job did. Combined with the per-listener finally pattern this is true defence-in-depth. Also removed SerializesModels from the UserRegistered event so its User model is snapshotted in-memory instead of re-fetched from the DB on dequeue — eliminates the deserialization round-trip for the most user-facing event (welcome / activation email).
- Critical: TenantContext static state leaked between Horizon queue jobs, silently dropping welcome emails, cron notifications, and federation pushes for all tenants except the first one processed by a worker. All 18 queued event listeners were missing TenantContext::reset() in a finally block. Horizon runs up to 4 long-lived worker processes; without the reset, static TenantContext state from one job leaked into the next job the same worker picked up. When Laravel's SerializesModels trait re-fetched the User Eloquent model during job deserialization, TenantScope applied a WHERE tenant_id = <stale> clause — causing ModelNotFoundException to silently swallow the job and drop the email. Now all queued listeners call TenantContext::reset() in finally, matching the pattern already used by NotifyAdminOfNewRegistration. Affected listeners: SendWelcomeNotification, SendOnboardingCompletionEmail, NotifyJobAlertSubscribers, UpdateWalletBalance, UpdateFeedOnListingCreated, CopyMessageForBrokerReview, and all 12 Push*ToFederated* federation listeners. Also fixed FederationInitialSyncJob (two setById calls, no reset) and RunAdminCronJob (delegates to CronJobRunner which calls setById across 7 code paths for multi-tenant cron processing).

### Security

- Registration form: multi-field honeypot. Three additional decoy inputs with realistic names (confirm_email, address_line_2, referral_code) sit alongside the existing website honeypot — all hidden via off-screen CSS positioning (catches more bots than display:none). Server silent-no-ops if ANY of the four come back non-empty. React form also checks all four refs client-side. Catches sophisticated bots that filter on the legacy website field name but can't tell which other inputs to skip.
- Email verification is now required for every tenant (current and future) and can only be disabled by God (platform super-admin). TenantSettingsService::requiresEmailVerification() now defaults to TRUE (fail-closed), reads the bare email_verification key (plus general.email_verification fallback for legacy rows), and the login gate calls requiresEmailVerification() consistently. A backfill migration (migrations/2026_05_16_enforce_email_verification_all_tenants.sql) writes email_verification=true to every existing tenant row and syncs tenant_registration_policies.require_email_verify=1. New tenant seeding was corrected from the orphaned general.email_verification key to the bare email_verification key. Both the Admin Settings and Registration Policy pages now lock the toggle with a "God only" chip for non-platform-super-admins. AdminConfigController::updateSettings() and RegistrationPolicyController::updatePolicy() enforce this server-side with a 403 for non-platform-super-admins.
- Admin approval and email verification toggles are now God-only in the admin UI. Both the "Require email verification" and "Admin approval required" switches on /admin/settings and /admin/settings/registration-policy are locked with a "God only" chip for tenant admins and tenant super-admins. The backend enforces this via requirePlatformSuperAdmin() — only platform super-admins (role god/super_admin or is_super_admin=true) may change these settings.
- Admin approval is now required for every tenant (current and future). TenantSettingsService::requiresAdminApproval() now defaults to TRUE (fail-closed) so any tenant without an explicit setting still enforces the gate. A backfill migration (migrations/2026_05_16_enforce_admin_approval_all_tenants.sql) writes admin_approval=true to every existing tenant row so the policy is explicit in the database. Tenant seeding (TenantHierarchyService::seedTenantDefaults) now writes the bare admin_approval key the reader actually checks — the previous general.admin_approval row was orphaned (reader never looked it up), which meant new tenants silently ran with admin approval disabled. Already-approved members are unaffected; only new registrations (and accounts still in status='pending') require an admin to approve them before login.
- Registration form: per-tenant hourly circuit breaker (default 20/h). Last-line containment when everything else fails. If a single tenant gets a flood of signups in one hour, account creation is automatically paused for that tenant for an hour and the next signup attempt returns HTTP 503 REGISTRATION_TENANT_PAUSED. Auto-resumes after 1h; tenant admin can clear manually via POST /api/v2/admin/registration/resume-signups. Status visible at GET /api/v2/admin/registration/breaker. Worst-case outcome: a tenant loses 1 hour of legitimate signups — much better than waking up to 10,000 fake accounts. Configurable via env REGISTRATION_TENANT_HOURLY_CAP (set to 0 to disable).
- Registration form: per-IP daily cap on successful signups (default 5/24h). Stacks on top of the existing 3/5min route throttle. The route throttle caps raw request volume; this caps how many accounts a single IP can actually create in a 24-hour window — closes the "patient bot grinding 1 signup every 6 minutes" hole that the short rate-window leaves wide open. Counter increments ONLY on successful registration, so a user typing wrong passwords doesn't burn quota. Configurable via env REGISTRATION_DAILY_CAP_PER_IP (set to 0 to disable). Returns new REGISTRATION_DAILY_LIMIT (HTTP 429) with retry-after.
- Registration form: MX-record check on the email domain. Rejects signups where the email domain has no MX record AND no A record — catches typos like user@gmial.com, made-up domains bots fall back to, and freshly-registered burner domains without mail wiring. Returns new EMAIL_DOMAIN_INVALID error code with a "check for typos" hint that doubles as a UX win. Results cached 24h (positive) / 1h (negative); fails open on DNS errors so an outage doesn't block legitimate users. RFC-reserved .invalid TLD rejected without a DNS round-trip.
- Registration form: disposable / throwaway email-domain blocklist. Rejects signups from ~200 known temp-email providers (mailinator, 10minutemail, guerrillamail, tempmail, yopmail, etc., plus their sub-domains). New DisposableEmailService loads the curated list from resources/security/disposable-email-domains.txt; refresh from the canonical upstream list via scripts/update-disposable-emails.sh. Returns the new EMAIL_DISPOSABLE error code with a clear "use a permanent email address" message. Kills the cheapest bot-signup path — no inbox to pay for = no per-account cost.
- Registration form: verified-location gate (anti-fraud). The location field on both forms is now hard-gated to require lat/lng coordinates that came back from the place-autocomplete API (Google Places or Nominatim). Free-text gibberish like "555" — the exact bypass a recent attacker used — is rejected server-side with a new LOCATION_NOT_VERIFIED error code. Null Island (lat=0,lng=0) is also rejected as the obvious signature of a default-zero coordinate. The bar is now: an attacker must call a Geocoding API themselves to forge believable coordinates. Both forms show inline guidance to "pick a suggestion from the list".
- Registration form: server-side enforcement closes three React-side-only bypasses. The React frontend has long sent terms_accepted, password_confirmation, and invite_code in the registration payload, but RegistrationService::register() ignored them — meaning a scripted submission could skip the terms checkbox, mismatch passwords, or register on an invite_only tenant with no code at all. All three are now enforced server-side with distinct error codes (TERMS_REQUIRED, PASSWORD_MISMATCH, INVITE_REQUIRED, INVITE_INVALID). Invite codes are validated against InviteCodeService and redeemed atomically after the user row is created; if the redeem races out (concurrent registration consumed the last use), the new account is marked rejected so the code stays the gating signal.
- Min-form-time bot gate is now server-enforced. Both the React form and the new Blade form send a form_started_at timestamp; the service silently no-ops (success-shaped response, like the honeypot) when the elapsed time is < 5 seconds. Previously the 5-second check only ran in the React UI and a scripted POST would skip it.

### Fixed

- Bulk user approval now sends welcome emails, in-app notifications, and grants welcome credits. Previously bulkApprove() silently flipped is_approved=1 with no further action — users approved in bulk received no welcome email, no in-app notification, and no welcome credits. Fixed: after each successful updateAdminFields() call the same grantWelcomeCredits() / sendApprovalWelcomeEmail() / sendApprovalInAppNotification() helpers that single-user approve() already calls are now called per user. grantWelcomeCredits() is idempotent, so double-approval is still safe.
- FederationInitialSyncJob: TenantContext always reset even when an exception is thrown. The previous fix added a bare TenantContext::reset() on the success path only, leaving the Horizon worker's static TenantContext stale if the audit-log write threw. Wrapped the entire handle() body in try/finally { TenantContext::reset(); } so cleanup is guaranteed on every exit path.
- Mailer::forCurrentTenant() now logs a warning when called without a TenantContext. Calling this method with no active tenant context silently fell back to platform SMTP credentials, making cross-tenant email delivery failures invisible. A Log::warning() with a 5-frame backtrace is now emitted so these cases appear in the Laravel log.
- All 18 queued listeners now have finally { TenantContext::reset(); } in handle(). The previous listener audit commit was orphaned on a branch that diverged from main and was never merged — the fixes existed in git history but not on disk. Re-applied to all 18 listeners: CopyMessageForBrokerReview, NotifyJobAlertSubscribers, PushCommunityEventToFederatedPartners, PushConnectionAcceptedToFederatedPartner, PushFederationDataRetraction, PushGroupMembershipToFederatedPartners, PushGroupRetractionToFederatedPartners, PushGroupToFederatedPartners, PushListingToFederatedPartners, PushMemberProfileUpdateToFederatedPartners, PushMessageToFederatedPartner, PushReviewToFederatedPartner, PushTransactionToFederatedPartner, PushVolunteerOpportunityToFederatedPartners, SendOnboardingCompletionEmail, SendWelcomeNotification, UpdateFeedOnListingCreated, UpdateWalletBalance. Verified by grep: zero ShouldQueue classes that call setById() are now missing a reset.
- Module Configuration: browser autofill permanently blocked on the search input. Chrome was storing the user's previously-typed value (the admin email address) as "search history" for the type="search" input and restoring it on every page load — including after the Refresh button, because the loading spinner was unmounting and remounting the input, triggering a fresh autofill injection cycle. Fixed by: (1) changing the input to type="text" so Chrome's search-history persistence doesn't apply, (2) setting autoComplete="new-password" which browsers actually honour (unlike "off"), and (3) rendering the loading spinner inline instead of replacing the whole page so the input is never unmounted.

### Changed

- Accessible (GovUK Alpha) registration form: feature parity with the React form. Added profile-type radios (individual / organisation), conditional organisation-name field, conditional invite-code field (shown only when the tenant's effective registration policy is invite_only), password-confirmation field with live-match indicator, mandatory terms-of-service + privacy-policy checkbox, and Google Places autocomplete on the location field (progressive enhancement — form works without JS). The newsletter checkbox is preserved. New register-enhancements.js handles all client-side interactions; the existing password-strength.js continues to provide live NIST-aligned length + HIBP breach feedback.
- GovUK Alpha storeRegister controller maps six new service error codes to distinct Blade page statuses so users see specific messages ("you must accept the terms" / "this invite code is invalid") instead of the generic "check the form and try again" fallback.
- React RegisterPage now sends form_started_at to the server so the min-form-time gate enforces on this path too.

### Removed

- Cloudflare Turnstile removed from login, password-reset, and registration forms (2026-05-16). Both the React SPA and the GovUK Alpha accessible Blade frontend. Member feedback found the widget too confusing and the false-positive rate unacceptable on account-recovery and sign-in flows. Turnstile is retained on contact forms where the cost of a small amount of user friction is acceptable as spam defence. Bot/brute-force defence on auth endpoints is now: the DB-backed per-email + per-IP brute-force limiter, route-level throttle (login 30/min, password-reset 5/15min, register 3/5min), the registration honeypot, the registration admin-approval gate, and the email-enumeration safety on the password-reset response. Removed TurnstileService injection from AuthController, PasswordResetController, and RegistrationService. Removed useTurnstile() and widget JSX from LoginPage, ForgotPasswordPage, RegisterPage (desktop + mobile mounts). Removed cf-turnstile divs and api.js loader from accessible-frontend/views/login.blade.php and register.blade.php. Dead turnstile_token request types and dead register-turnstile-failed / turnstile-failed Blade status branches dropped.

### Fixed

- Cloudflare Turnstile rollout UX + silent-failure regressions (emergency). Same-day hotfix to today's Turnstile/bot-defence rollout. Two valuable members reported real problems: one found the visible "Verify you are human" widget confusing and suspicious, another could not get a password reset email no matter how many times he tried. Widget is now invisible for legitimate users. Switched the Turnstile widget to appearance: 'interaction-only' (Cloudflare's silent-pass mode). The widget only renders visibly when Cloudflare actually decides a human challenge is needed — roughly 1% of legitimate sessions. The other 99% never see a widget at all. Forgot-password no longer silently swallows errors. The page previously caught every error and showed a fake "we've sent you an email" success message — including when a Turnstile failure or rate limit blocked the request. It now distinguishes Turnstile failures, rate-limit hits, and generic errors with distinct messages so users know to retry. Per-email reset rate limit raised from 3/hr to 10/hr. Legitimate users hitting the 3/hr ceiling silently got "we sent you an email" with no email ever sent. The cap now matches realistic usage; bots are still blocked by per-IP throttle (5/min) + Turnstile. The endpoint now returns a real 429 instead of fake success. Single-use Turnstile tokens are reset on every failed submit across login, register, forgot-password, and contact pages. Previously a failed validation locked the form because the consumed token couldn't be re-used until full page reload. Backend uses a dedicated TURNSTILE_FAILED error code (was wrongly reusing VALIDATION_REQUIRED_FIELD / VALIDATION_INVALID_FORMAT). All four API call sites updated. Registration controller now passes specific error codes through so the React UI can show "this password appears in known breaches" vs "an account already exists" vs "the security check failed" — instead of a single catch-all message. GovUK Alpha Blade flows get the same treatment. storeLogin and storeRegister now map API error codes to distinct page statuses (turnstile-failed, rate-limited, email-not-verified, account-suspended, register-duplicate, register-password-pwned, etc.) so the accessible frontend shows useful messages too. Diagnostic logging added to the password-reset flow: per-email rate hits, unknown-email reset requests, and successful email dispatches are now logged with masked email + IP. Distinguishes "wrong email" from "mailer broken" when investigating future complaints. New optional useTurnstile().status + useTurnstile().reset() for callers that need to react to widget load failures or reset after a failed submit.

### Added

- Prerender engine — Round 4: tests + retry + sitemap explorer. 11 new tests covering the Round 2+3 logic: circuit breaker trip + claim-suppression, per-tenant concurrency cap, route validation rejecting shell metacharacters, audit secret-redaction, health check transitions, snapshot integrity (ok/mismatch/missing), TTL-pattern specificity resolution, safeCachePath accepting route special characters, observer-storm coalescing to a tenant-wide row. Without these, one refactor breaks the safety net. Job retry button. Failed / partial / cancelled jobs now have a "Retry" button on the Jobs tab that clones their parameters into a new queued row. Original job is preserved for history. New audit row links the two via retried_from_job_id. Sitemap explorer. New Overview card lets you punch in a tenant slug and see the exact route list the engine plans to render — static floor (feature/module gated) + dynamic URLs from SitemapService (capped at 1,000). Answers "what does the engine think this tenant has?" without grepping logs. react-frontend/CLAUDE.md updated with the full Round 2+3+4 architecture so future contributors don't have to re-derive it from the code.
- Prerender engine — Round 3: defense in depth + operator superpowers. Scheduler liveness tracking. Every prerender scheduled task (detect-drift, auto-recache, reap-stale) now stamps a cache key on success. The health endpoint checks the age of each stamp against 2×/3× the expected interval and surfaces a yellow/red check if the Laravel scheduler has stopped firing — catches the "supervisord nexus-scheduler died" failure mode that would otherwise be silent. Webhook nonce one-time-use. HMAC /invalidate already had a 5-min timestamp window; now each (timestamp, signature) pair can only be used once. The nonce is keyed by sha256(ts:sig) and persisted for 600s. Replay attempts are bounced AND audited with outcome=denied, reason=webhook_replay for forensics. Snapshot integrity verification. The Playwright worker now writes a .sha256 sidecar next to every index.html it renders. The Inspect drawer shows an integrity: ok|missing|mismatch|unreadable chip — mismatch is highlighted in danger color and the tooltip shows the expected vs actual prefixes. Catches filesystem corruption, bit rot, and hand-edits that would otherwise look like a valid snapshot. CSV export for the three operator-facing tables: GET /api/v2/admin/prerender/export/{audit,inventory,jobs}.csv. Streamed, capped at 5,000 rows. "Export CSV" button on the History tab; the same URLs work for cron-scraped exports. TTL inspector card on the Overview tab. Type a route, see which config/prerender.php pattern owns it, what TTL it gets, and what other patterns also match (with their specificities). No more grepping config to understand the freshness policy.
- Prerender engine — Round 2: self-healing, audit, observability artefacts. Building on the P0/P1/P2 audit, the engine now self-recovers from worker outages and ships first-class ops artefacts. Per-tenant concurrency cap. claimNextJob now skips rows whose tenant already has a job in flight. Stops a single slow tenant homepage from starving the queue. Circuit breaker. Five failed jobs inside a 10-minute window auto-pauses the queue for 15 minutes. Saves CPU on a wedged host and gives operators time to investigate. Closes automatically on cooldown; can be reset manually via POST /api/v2/admin/prerender/reset-breaker or the new admin UI button. Health endpoint. GET /api/v2/admin/prerender/health returns a traffic-light JSON (green/yellow/red) with per-check details (cache filesystem, breaker, queue age, failure rate, stuck rows) and an actionable action string on every failing check. Rendered into a banner at the top of the admin module. Emergency "Reset stuck queue" button in the health banner. Requeues every claimed/running row older than 30 min AND clears the breaker — one click. Rate-limited (2/5min per user) and audited. Audit log. New prerender_audit_log table persists every mutating action (enqueue, cancel, purge, invalidate, auto_recache, detect_drift, purge_unexpected, reset_breaker, reset_queue) with actor, IP, UA, outcome, sanitised details. New History tab in the admin UI surfaces it with an action filter. Secrets are scrubbed before persistence (password/token/secret/api_key keys redacted). Per-user per-action rate limiting on every mutating endpoint. Denied attempts are themselves audited so abuse leaves a trail. New Prometheus metrics: nexus_prerender_breaker_tripped, nexus_prerender_breaker_until_seconds, nexus_prerender_queue_oldest_age_seconds, nexus_prerender_health_status (0/1/2 enum). Grafana dashboard committed at docs-public/observability/prerender-grafana-dashboard.json — health + breaker + coverage + queue age + outcomes + per-tenant missing-route bargauge. Prometheus alerting rules committed at docs-public/observability/prerender-alerts.yml — 7 alerts (4 critical, 3 warning) covering RED health, breaker, cache, queue jam, coverage, recent failures, asset invalidation. Operator runbook at docs-public/observability/prerender-runbook.md — alert-by-alert response steps + emergency procedures + forensics index. Jobs tab gains a PRIORITY column showing HIGH/NORMAL/LOW with a tooltip explaining the numeric value. The lifecycle was already priority-aware (claim order is priority ASC, queued_at ASC); now you can see it at a glance.

### Fixed

- Prerender engine — admin module audit, full P0→P2 sweep. Following the new admin panel's introduction, prerender jobs were piling up in queued state forever and tenant admins reported all action buttons greyed out. Full audit + 13 fixes: 🔴 P0 — Host cron for the job processor was never installed. scripts/prerender-job-processor.sh documented a * * * * * cron entry in its header but nothing in the repo actually wrote it to /etc/cron.d/. The in-container Laravel scheduler can run prerender:detect-drift and prerender:auto-recache, but the processor MUST run on the host because it calls docker exec. Result: every job — observer-triggered, drift-triggered, TTL, manual — sat queued forever, and observer-deleted snapshots were never regenerated. New phase: scripts/deploy/phases/install-prerender-cron.sh writes /etc/cron.d/nexus-prerender-processor idempotently on every deploy. 🔴 P0 — No stale-job reaper. If the worker was OOM-killed, a deploy SIGTERMed it mid-flight, or the host rebooted, the row stayed claimed/running forever, blocking dashboards and distorting metrics. New prerender:reap-stale artisan command (also installed in the host cron, runs every 5 minutes) plus scheduler registration in bootstrap/app.php. 🔴 P0 — Frontend "buttons greyed out" for tenant admins. PrerenderAdmin.tsx gated buttons on is_super_admin || is_god || role==='super_admin' while the backend's requireSuperAdmin also accepted is_tenant_super_admin. A tenant super-admin saw disabled buttons but could have called the API directly via curl — worst of both worlds, AND a cross-tenant operation surface a tenant admin shouldn't reach. Fixed by tightening the controller to requirePlatformSuperAdmin on every mutating endpoint (enqueue, purge, cancel, invalidate, auto-recache, detect-drift, purge-unexpected), hiding the sidebar entry from non-platform-super-admins, and adding an explicit read-only banner so anyone landing on the page understands why actions are disabled. Sign in as platform super-admin to drive the engine. P1 — Race in enqueueJob dedup. SELECT-then-INSERT outside a transaction let concurrent observer callbacks both insert. Wrapped in DB::transaction with lockForUpdate so MariaDB serializes them. Routes now also validated against the canonical regex inside enqueueJob — defence in depth for the host shell eval consumer. P1 — HMAC replay protection on /invalidate. Captured signatures were replayable indefinitely. Now requires X-Nexus-Timestamp header within ±300 s and signs "<ts>.<body>". P1 — safeCachePath regex too narrow. Omitted : @ ~ ( ) + , ; = ! $ * so inspecting any snapshot whose route contained those characters silently 404'd the drawer. Widened to match the canonical route regex; .. block + /index.html suffix check preserved. P1 — Observer storm backpressure. Bulk imports (e.g. seeding 5k blog posts) would enqueue 5k distinct queued rows because each post has a unique routes value. Per-tenant burst counter in a 60s cache window — over 50 invalidations/min collapses subsequent enqueues onto a single tenant-wide row. P2 — Overview tab double-fetched when realtime worked (Pusher reload + 30s poll). Poll now disabled when live === true. P2 — KPI grid layout was ragged on desktop (11 cards in grid-cols-2 md:grid-cols-4). Rebreakpointed grid-cols-2 sm:grid-cols-3 md:grid-cols-3 xl:grid-cols-4. P2 — inventory() unbounded scan. A misbehaving Playwright could write thousands of files into one host directory and hang the admin summary. Hard cap at 50k rows with a __truncated sentinel surfaced to the UI. P2 — URL state sync for the prerender admin tab + tenant filter. Refresh / back / forward now preserve view state (?tab=coverage&tenant=hour-timebank). P2 — .bot-access.jsonl logrotate. New install-prerender-logrotate.sh deploy phase writes /etc/logrotate.d/nexus-prerender-bot-access (daily, 14 days, compressed, copytruncate) so the bot-only access log doesn't grow unbounded.
- Cross-tenant login bug — app.project-nexus.ie/ no longer silently boots into a stale tenant. Logging into one community and arriving on another is fully resolved. Root cause. TenantContext had a storedSlug fallback that read nexus_tenant_slug from localStorage whenever a user had auth tokens. On app.project-nexus.ie/ (the platform root, no slug in the URL), this silently booted the SPA into whichever tenant the user had last visited — e.g. Agoris. The login page then saw a "resolved" tenant slug and hid the community chooser, letting users authenticate against the wrong community. Fix. TenantContext.tsx — removed the storedSlug fallback entirely. Effective tenant slug is now tenantSlug prop (from TenantShell, URL-derived) OR detectTenantFromUrl() only. This matches the 2026-05-08 policy already documented in TenantShell.tsx: URL is respected as typed; master tenant renders at /, tenant-scoped pages require the slug in the URL. Defence in depth. AuthContext.logout() now clears nexus_tenant_id and nexus_tenant_slug from localStorage (previously preserved as a UX nicety, which contributed to the leak). Cross-tab logout already did this; the same-tab logout path now matches.

### Changed

- Sales site (project-nexus.ie) — GA messaging and audit-driven fixes. Hero badge updated from "V1.5 Now Open Source — AGPL-3.0" to "V1.5 Generally Available · Open Source · AGPL-3.0" so the public marketing site matches the actual v1.5 GA status promoted in CHANGELOG.md. Broken Documentation link fixed. The Get Started panel linked to github.com/jasperfordesq-ai/nexus-v1/tree/main/docs (the repository's name at the time), which 404s — that path doesn't exist (the repo has docs-public/, not docs/). Repointed to the repo README anchor (#readme) with a sublabel referencing docs-public/. WCAG claim softened. "WCAG 2.1 AA — full accessibility compliance" was an unsupported blanket claim. Now reads "built to WCAG 2.1 AA targets with ongoing audit." Prerender.io reference removed from the SEO feature card. The platform is fully self-hosted on Playwright-rendered snapshots; the old "Prerender.io fallback" wording was stale. New copy describes the actual three-layer freshness model (observer + sitemap-drift + TTL) and HTTP status propagation. Sitemap lastmod bumped to 2026-05-14.
- README — v1.5 status promoted to Generally Available. The top-of-file blurb and the "Project Status" section both said "Release Candidate / in active production use while undergoing final pre-release validation." Updated both to "Generally Available, in active production use" with a pointer to the in-app /features page and CHANGELOG for per-module maturity. Historical RC entries in CHANGELOG.md and the v1.5.0-rc.1 release marker in .github/RELEASE_PROCESS.md are left untouched (historical record).
- Sales-site nginx — security headers hardened. Added Content-Security-Policy (allowing only Google Fonts and Ahrefs analytics, which are the only third-party origins the page actually loads), Strict-Transport-Security (max-age=31536000; includeSubDomains; preload), and Permissions-Policy (deny accelerometer/camera/geo/gyro/mic/payment/usb). Dropped the now-deprecated X-XSS-Protection header — modern browsers ignore it and CSP supersedes it. Headers repeated in the static-asset and HTML location blocks because nginx add_header is replace-not-merge.

### Fixed

- Admin panel — raw translation keys no longer leak. The Algorithm Settings and AI Settings pages (and 19 other admin pages, mostly in Caring Community) were rendering raw t() keys like algo.feed_label, advanced.provider_openai, admin.providers.title because their translation keys were never added to the locale files. Algorithm Settings and AI Settings — stripped useTranslation / t() entirely and inlined literal English (per the admin-is-English-only convention). 236 missing keys added to en/admin.json under admin.*, panel.*, billing.*, tenant_features.*, federation.*, groups.*, moderation.*, resources.*, super.*, and volunteering.*. Covers Care Providers, Loyalty Program, Warmth Pass, Hour Transfers, Municipality Feedback, Trust Tier, and the volunteer admin tooling. All 10 non-English locale files filled with English fallbacks; node scripts/check-i18n-drift.mjs now passes with 0 drift. All 2,552 admin-side t() calls now resolve.

### Changed

- Prerender engine — Round 5 (the full polish, "better than the big names"). Closes every remaining gap from both audits and adds three things no competitor ships. Three-layer freshness defence (the headline change). Stale public pages now have three independent mechanisms trying to keep them fresh: Observer hook (millisecond layer). Eloquent model observers for every public content type — Post, Listing, Event, JobVacancy, Group, MarketplaceListing, MarketplaceCategory, VolOpportunity, IdeationChallenge, Page (CMS), ResourceItem. On save/delete, the affected snapshot is deleted and a NORMAL-priority recache enqueued. Failures are logged, never thrown. Sitemap drift detector (minute layer). New prerender:detect-drift cron walks every tenant's sitemap, parses <lastmod>, compares against snapshot mtimes, enqueues HIGH-priority recaches for any drift. Catches code paths that bypass Eloquent (raw DB writes, migrations, queue jobs). 2-minute cadence; bounded fan-out. TTL auto-recache (hour/day floor). Existing Phase 2 cron, still the backstop for content that doesn't appear in either sitemap or model events. External invalidation webhook. POST /api/v2/admin/prerender/invalidate with Bearer token or HMAC signature. Lets headless CMS, marketing automation, or external integrations invalidate routes directly. Sets PRERENDER_WEBHOOK_TOKEN env var to enable. AI-friendly Markdown rendering. Worker now extracts a clean Markdown body (index.md) alongside the HTML snapshot. nginx detects AI crawlers (GPTBot, ClaudeBot, Perplexity, ByteSpider, Common Crawl, Google-Extended, Amazonbot, etc.) and serves the .md variant first via try_files. Falls back to HTML if markdown isn't available. No competitor (Prerender.io, Netlify, Cloudflare Pages) ships this — DataJelly was the only player doing it. Admin UI overhaul — six tabs, full polish. Overview — new Freshness automation card (one-click auto-recache + drift detect with dry-run / apply); new Wildcard cache purge form with pattern, tenant scope, dry-run, and auto-recache toggle. Inventory — adds HTTP status column, search/filter, status-code filter, bulk selection checkboxes, and bulk-recache button (groups selections by tenant, dispatches via the invalidate API). Inspect drawer — front-and-centre SEO score card (0-100 + A–F grade) with must-fix issues list and tips list. HTTP status chip. Reflects the new seo field on the inspect response. Coverage — new "Refresh all stale (N)" bulk button that enqueues per-tenant recaches for everything missing / stale / asset-broken in one click. Analytics — new tab. Bot traffic over 1d / 7d / 30d windows: KPIs (total hits, IP-verified %, spoofed count, unique URIs), hits-by-crawler + hits-by-status breakdowns, top-50 URIs table, recent-activity feed. Tests. New PrerenderServiceTest cases for purgePattern (glob single segment, ** recursive, host scoping, actual deletion), ttlForRoute (specificity + default fallback), seoScore (high / low grade scenarios), _status sidecar reading, priority promotion on duplicate enqueue, priority-ordered claim, and the JSONL crawler analytics aggregator. Docs. react-frontend/CLAUDE.md "Prerender Pipeline" section rewritten to describe the three-layer freshness model, priority lanes, status-code propagation, AI Markdown variant, and the six admin tabs.
- Prerender engine — Phase 4 (hardening). Crawler IP-range verification. New scripts/refresh-bot-ip-ranges.sh pulls Google/Bing/DuckDuckGo/Apple's published IP-range JSON feeds and ships them into the nginx container as a geo include. $nexus_bot_ip_verified is logged on every bot hit; analytics surface verified_hits and spoofed_by_crawler so admins can spot User-Agent spoofing without blocking (alternative crawlers / IPv6 transitions cause false positives if you block on verification alone). Designed for a weekly cron. Bot User-Agent refresh helper. scripts/refresh-bot-ua-list.sh diffs Matomo's actively-maintained bot regex list against the names we already cover and writes candidates to logs/bot-ua-suggestions.txt for human review. Stops the curated regex in nginx.bluegreen.conf from drifting into obsolescence. Viewport variant flag. Worker honours PRERENDER_VIEWPORT=mobile (414×896 + iPhone Safari UA) for tenants/routes that need a mobile-specific snapshot. nginx routing for the variant is a deferred follow-up — current platform is single-DOM responsive so the desktop snapshot serves both audiences correctly.
- Prerender engine — Phase 3 (visibility & SEO scoring). SEO score per snapshot (0–100, A–F grade). Synthesised from existing inspect() flags — title length, meta description length, canonical, OG completeness, h1 count, JSON-LD validity, asset issues, noscript fallback, body text volume. Surfaced as seo on the inspect API response with issues (must-fix) and tips (suggestions) arrays. Crawler analytics. nginx now writes a bot-only JSONL access log ($status, prerender override status, crawler label, verified flag, UA, IP, referer, bytes, request time) to the shared prerender volume. GET /api/v2/admin/prerender/analytics?since=ISO&limit=200 aggregates hits by status, crawler, host, top URIs, recent rows. Default window: 7 days. Manual auto-recache trigger. POST /api/v2/admin/prerender/auto-recache { apply: bool } runs one immediate pass of the freshness loop (dry-run by default) for operators who don't want to wait for the cron tick. Inventory/Coverage filter, bulk recache from Coverage tab, admin UI polish: backend supports ?tenant= filtering on inventory + the new analytics endpoint; frontend PrerenderAdmin.tsx polish deferred to a focused UI change.
- Prerender engine — Phase 2 (freshness automation). Snapshots now refresh themselves; deploy-time renders are no longer the only freshness mechanism. TTL rules per route pattern. New config/prerender.php maps route globs to max snapshot ages (homepage 6h, content index 6–24h, individual items 1–7d, static pages 30d). PrerenderService::ttlForRoute() resolves the most-specific pattern. Auto-recache cron. New prerender:auto-recache artisan command walks the deep inventory, identifies TTL-expired and content-drifted snapshots, and enqueues low-priority recache jobs grouped by tenant. Bounded by max_tenants_per_run / max_routes_per_tenant so a single tick can't flood the queue. Designed for a 15–30 min cron cadence. Content-change hooks. Model observers (Post, Listing, Event) now invalidate the affected snapshots (/blog, /blog/{slug}, /listings, /listings/{id}, /events, /events/{id}) on save/delete and auto-enqueue a low-priority recache. Failures are logged, never thrown — model writes never block on the prerender side-channel. The base PrerenderInvalidationObserver makes it a few lines to wire up additional content types. window.prerenderReady signal. Worker now waits for window.prerenderReady === true before snapshotting; falls back to the DOM-content heuristic when the signal is never set. initPrerenderReady() in main.tsx ensures the variable always exists; usePrerenderReady(isLoaded) is a one-line hook for data-driven routes to control snapshot timing.
- Prerender engine — Phase 1 (coverage & correctness). Lifts the engine from "render hardcoded routes on deploy" to "render every public URL Google can discover, with correct HTTP status codes." Addresses the highest-impact gaps from both prerender audits. Sitemap-driven URL discovery. New prerender:plan-routes artisan command unions the static-page floor (/, /about, …) with every URL SitemapService publishes — blog posts, listings, events, jobs, KB articles, marketplace listings/categories, CMS pages, organisations, ideation challenges. scripts/prerender-tenants.sh consumes the per-tenant plan; the hardcoded PUBLIC_ROUTES list remains as a fallback when the PHP container is unavailable. --no-sitemap flag and NEXUS_PRERENDER_NO_SITEMAP=1 env var disable it for emergencies. Closes the long-tail coverage gap flagged by both audits. HTTP status code propagation. Worker now extracts <meta name="prerender-status-code"> from rendered DOM and writes a _status sidecar next to index.html. Bash aggregates non-200 routes into /etc/nginx/prerender-status-overrides.list; nginx uses a map + error_page/return flow to serve 404/410/503 with the prerendered body. Soft-404s on community-not-found, deleted listings, and maintenance mode now emit the right status to crawlers. Validated with nginx -t before reload; reverts atomically if the new map is malformed. Inspect API and Inventory rows now expose http_status. Job priority lane. New priority TINYINT column on prerender_jobs (3 = high, 5 = normal, 7 = low). Claim ordering is (priority, queued_at, id) so auto-recache jobs can't starve urgent user-initiated runs. Enqueue API accepts an optional priority field; duplicate enqueues at a higher priority promote the existing queued row. Wildcard cache purge. POST /api/v2/admin/prerender/purge { pattern: "/blog/*" } removes matching snapshots (and _status sidecars). Supports * (single segment), ** (recursive), ? (single char), optional tenant_slug scoping, dry_run, and an optional recache flag that auto-enqueues a low-priority re-render. Dashboard summary now truthful. summary() was reporting content_stale_count and asset_invalid_count from a shallow inventory pass (deep=false), so the overview tab silently under-reported drift. Now uses the deep inventory under a 60-second cache.
- Partner Communities moved to the left column of the "More" mega menu, sitting directly under the Tools section. Previously placed beneath Impact in the right column, the federation submenu is now more discoverable to reflect its importance.

### Added

- In-app /changelog page rendering this file via react-markdown. The markdown source is copied from the repo root into react-frontend/public/changelog.md at prebuild/predev time by scripts/copy-changelog.mjs, so the in-app changelog is always in sync with the file in git. Footer Changelog link is now internal.
- Features link in the public Navbar and Mobile drawer (About section, alongside About / Blog / FAQ).
- nav.features and nav_desc.features translation keys in all 11 languages.

### Removed

- Dead dev_banner.* and dev_status.* translation keys swept from all 11 locale files (22 key blocks total). All code references were already gone when the platform moved to GA.
- "Dev Notice" amber button in the MobileDrawer bottom bar — redundant post-GA; Features is now reachable via the About accordion. FlaskConical icon import removed.

### Fixed

- Trust & Safety "Garda vetting" section made jurisdiction-neutral. This is a multi-tenant global platform; the Ireland-specific "Garda vetting" wording was inappropriate for tenants outside Ireland. Section retitled to "Background checks and vetting" and the body rewritten to cover background checks generally, mentioning Garda vetting (Ireland) and DBS (UK) as examples rather than the canonical regime. Applied across all 11 locale files.
- 🔴 Trust & Safety "Insurance and liability" section rewritten to match the actual platform-provider position in the Terms. Aligns the Trust & Safety page wording with the corrected Terms of Service Section 13 (see database/migrations/2026_04_15_000002_fix_terms_insurance_section.php, 2026-04-15): the organisation is a connection platform, not a service provider; members exchange services entirely at their own risk; members are solely responsible for ensuring they hold appropriate cover for any activities they undertake. Updated trust_safety.insurance_items across all 11 locale files and added a pointer to the Terms for the full liability and indemnity language.
- {{name}} literal placeholder rendered on the public Trust & Safety page. TrustSafetyPage.tsx was calling t(section.introKey) and t(\${section.itemsKey}.${i}`)without the{ name: branding.name }interpolation context, so strings like"By using {{name}} you agree to:"rendered with the raw{{name}}` placeholder visible. Both intros and list items now pass the tenant brand name. Title interpolation also added defensively.
- Build commit hash visible in the public footer. The footer was rendering __BUILD_COMMIT__ as a monospace string at the bottom of every page (and bleeding into Google snippets). The commit + build time are now exposed only as data-* attributes on a hidden element so the same diagnostics remain available via DOM inspection / Sentry tags without being part of the indexable page text.
- Blog post dates rendered with locale-dependent and ambiguous formatting. BlogPage.tsx was using bare toLocaleDateString() (no locale), so the same post showed as 12/9/2025 to some visitors and 9/12/2025 to others — unreadable for an Irish/UK audience. BlogPostPage.tsx was using toLocaleDateString(undefined, …) with the same issue. Both now pin to en-GB (12 September 2025).
- American "neighbors" in public marketing copy. Standardised to neighbours in the Stay Local landing card (public.json + CoreValuesSection.tsx fallback) and the "Local Hubs" mega-menu description in NavigationConfig.php, for consistency with the rest of the Irish/UK English copy.
- UnexpectedValueException: chmod(): Operation not permitted on every request that triggers a Laravel log write (Sentry NEXUS-PHP-7). The daily log channel in config/logging.php set 'permission' => 0664, which made Monolog call chmod() on the file on every write. When the existing day's log file is owned by a different user — e.g. left behind on a mounted volume from a prior container run — the chmod() fails and bubbles up as a 500. Removed the explicit permission so Monolog skips the chmod step entirely; new files are created with the default 0644 and existing files are left untouched.
- CHANGELOG.md cleaned up. Removed a block of fabricated legacy entries (a fake [2.0.0] - 2024-02-13, a duplicate [1.5.0] - 2024-02-12, and [1.4.0] through [1.0.0] with 2023–2024 dates) that were left over from a template — Project NEXUS development only began in mid-December 2025, so none of those releases ever existed. Also removed an incorrect "Hour Timebank (Crewkerne)" attribution (Crewkerne is an unrelated UK timebank) and the changelog's own contributors list, which conflicted with the canonical CONTRIBUTORS.md. Footer compare links pruned to the versions that actually exist (v1.5.0, v1.5.0-rc.1).

### Added

- Goals module: accountability and check-ins enhanced. New GoalInsightsPanel component surfaces trend analysis, streak tracking, milestone progress, and next-step recommendations on the goal detail page. GoalCheckinModal extended with cadence-aware prompts and partner-accountability nudges. Backend: GoalCheckinService and GoalProgressService rewritten to compute velocity, predict completion, and surface at-risk goals; new goal_insights and goal_accountability_partners columns added via migration. New GET /api/v2/goals/{id}/insights endpoint. Unit tests cover the insights panel.
- Security: users:purge-undeliverable artisan command. Retroactive cleanup for accounts registered with undeliverable email addresses (e.g. testing@example.com accounts created during the May 2026 cyber-attack). Re-runs the same DisposableEmailService + MxRecordValidator validators the registration form uses, restricted to email_verified_at IS NULL non-admin users. Defaults to --dry-run; --soft sets deleted_at, --hard issues a real DELETE. Scoped with --since=90days, --tenant=N, --limit=200.
- Registration: reserved-domain MX gap closed. MxRecordValidator previously only rejected .invalid TLD (RFC 6761) but passed example.com, example.net, example.org, *.test, *.example, *.localhost — all have real DNS records but are guaranteed undeliverable (RFC 2606/6761). Now rejects the full reserved-domain and reserved-TLD lists before any DNS round-trip.
- SocialInteractionPanel shared component. Likes, comments, shares, reactions, and poll voting are now handled by a single SocialInteractionPanel component used across the Feed, Blog, Events, Goal detail, and Group Discussion tab — replacing five separate per-page implementations. Backed by a new POST /api/v2/social/interactions endpoint. Tests cover the panel in all five contexts.
- Per-category From addresses on platform SendGrid. When the platform SENDGRID_API_KEY driver is active, outgoing emails now use purpose-specific From addresses on project-nexus.net instead of the single generic address: notifications@ (member alerts), newsletters@ (digests), messages@ (DMs, cross-community invites), noreply@ (password reset, verification, security), admin@ (moderation, ban, vetting), events@ (event reminders), safeguarding@ (staff alerts), billing@ (payments, marketplace, subscriptions). Mapping is derived from the existing EmailDispatchService audit category strings — no call-site changes needed. A default Reply-To of the platform owner address is attached to all platform SendGrid emails when the caller supplies none. Tenant-specific SMTP/Gmail/SendGrid accounts are unaffected.
- Password change email notifications. Users now receive a security notification at their current email address whenever their password is changed — whether self-initiated or by an admin. Wired through Mailer::forCurrentTenant() so it honours tenant email settings, suppression lists, and the email_log audit trail.
- Admin email deliverability dashboard polished. The /admin/email-deliverability dashboard (introduced in the email observability rollout) received a focused UX pass: clearer metric card labels, a time-range selector that persists in URL state, improved empty-state messaging, and a per-recipient search that highlights suppressed addresses. No new API endpoints — data comes from the existing email_log and email_suppression endpoints.

### Fixed

- Email delivery reliability — exhaustive multi-pass audit. Following the email observability rollout, a systematic audit of every email-sending path across the codebase identified and fixed ~80 reliability gaps spanning four themes: Tenant context leaks: ~20 service classes and listeners were not resetting or explicitly binding TenantContext before dispatch, so cron / queue jobs sent emails in the wrong tenant's locale or from the wrong sender. Affected paths: newsletter cron batches, federation notification listeners, Verein webhook handler, volunteering reminders, marketplace dispute handler, async notification queue. Missing send evidence guards: ~15 paths updated a state machine (registration token, activation token, billing reminder sent-at, digest status) before confirming the email was actually delivered, so a transient failure left the record in an "already sent" state with no email sent. Tokens are now only updated / marked as sent after Mailer::send() returns a provider message id. Deduplication gaps: duplicate event reminder bell rows, duplicate federated connection notifications, duplicate review notifications, duplicate event cancellation recipient lookups, and re-delivered newsletter queue rows. Each fixed with Cache::add() idempotency guards, unique index constraints, or atomic claiming. Broken delivery paths: federated message / connection / transaction delivery was silently no-op'd (missing tenant resolution on inbound payloads). Event reminders were blocked by a stale status=failed guard that prevented retries. Marketplace dispute notifications used the wrong tenant scope. Stripe webhook handler did not restore tenant context after processing. All repaired.
- All member-facing email links are now tenant- and domain-aware. Nine files were using config('app.frontend_url') or getFrontendUrl(path) with a silent path-discard bug, producing links that always pointed to the shared platform host or to the tenant homepage rather than the specific resource: AdminUsersController — admin-initiated password reset was also broken at the token layer: the controller was storing tokens hashed with bcrypt but PasswordResetController validates with hash('sha256'). Fixed both the hash algorithm and added tenant_id to the INSERT/DELETE so the token passes the ownership check. JobAlertEmailService and JobExpiryNotificationService — getFrontendUrl(path) silently discarded the path argument (method signature takes no params). Every job link pointed to the tenant homepage. GuardianConsentService — produced double-slug URLs like app.project-nexus.ie/hour-timebank/hour-timebank/... for path-based tenants. NewsletterService — unsubscribe and manage-preferences links always used the platform host. AppreciationReceived, VereinCrossInvitationReceived, CivicDigestMail — same config() pattern. SafeguardingService — admin alert linked to app.project-nexus.ie/admin/... even for custom-domain tenants. All nine now use TenantContext::getFrontendUrl() . TenantContext::getSlugPrefix().
- Browser geolocation obliterated — all proximity uses profile location. useProximity and useGeolocation hooks (both called navigator.geolocation) removed. No browser geolocation popup appears anywhere on the platform. The ProximityFilter shared component (Listings, Events, Volunteering, Marketplace map) now reads lat/lng from the authenticated user's profile via useAuth(), matching the pattern already used by the Members page.
- Listings location locked to user profile with automatic sync. Both listing creation forms (feed compose + full create/edit page) now show a disabled, pre-populated location field sourced from the user's profile. Users can no longer enter a custom listing location — the coordinates always reflect their profile. UserService::updateProfile() now propagates lat/lng changes to all non-deleted listings owned by the user. A backfill migration fills missing coordinates on existing listings from the owning user's profile.
- Proximity filter dropdown replaces pill buttons. The shared ProximityFilter component now offers a dropdown of 5 / 10 / 25 / 50 / 100 km radii (up from the 1 / 2 / 5 / 10 km pills), consistent with the Members page.
- Explore: trending posts sorted by velocity-weighted score (not raw engagement count). The trendingScore (velocity × 60% + volume × 40%) was already computed but then discarded — two sequential usort() calls left the final order sorted by raw engagement, so the same high-engagement posts always appeared regardless of recency. Replaced with a single sort on trendingScore so recently-active posts surface correctly.
- Explore: trending posts and popular listings windows widened from 90 → 365 days. The tight 90-day window returned near-empty results for early-stage communities. 365 days is appropriate because engagement-weighted ordering already surfaces the best content without an artificial hard cutoff.
- Explore: popular listings — collation crash fixed. A utf8mb4_unicode_ci vs utf8mb4_general_ci mismatch between listings.title and categories.name caused MariaDB ERROR 1267 on the title/category filter; the try/catch swallowed it silently, returning an empty list. Fixed by normalising the category name to unicode_ci via CONVERT.
- Docker: storage/logs permission denied on cross-user appends fixed (Sentry NEXUS-PHP-5, -6, -16). When artisan commands ran as root (image CMD, docker exec) before apache (www-data) did its first write, root created the day's log file with 0644 owner root — subsequent www-data writes threw UnexpectedValueException: Permission denied. Fixed in Dockerfile.bluegreen and Dockerfile.prod: container boot now sets umask 0002, applies setgid on all storage directories so new files inherit group www-data with group-write, and re-chowns after artisan optimize as defence-in-depth.
- Boot: URL::forceScheme('https') deferred to avoid null Request in console (NEXUS-PHP-17). Calling URL::forceScheme eagerly in AppServiceProvider::boot() resolved the url container binding immediately, which injected a null $request in non-HTTP contexts (queue workers, scheduler, artisan), throwing TypeError on every cron tick. Fixed with a resolving('url', ...) callback so forceScheme only runs when the url service is actually constructed inside an HTTP request.
- UI: transparent modals and popovers replaced with opaque surface token. 20 components including dropdowns, hover cards, command palettes, and sheet panels were using --glass-bg (rgba(255,255,255,0.05)) as their background — nearly invisible in dark mode, allowing background content to bleed through. All switched to --surface-dropdown (#16162a dark / #ffffff light), the correct opaque surface token for floating UI. UserHoverCard also has an !important override to win against HeroUI defaults.
- Listings: comments open inline on detail page. Previously, the comments section on a listing detail page required a separate tap/click to expand. Comments now render immediately below the listing content, consistent with blog posts and events.
- Feed: disable sharing for content you own. The share button on feed posts, listings, events, and blog posts is now hidden when the current user is the author — sharing your own content to your own feed was a no-op that cluttered the share count.
- Social: comment parity gaps closed. Several content types (Goals, Marketplace listings, Volunteer opportunities) were missing comment threading, nested replies, or the delete-own-comment permission. Brought to parity with Feed posts.
- Tenant: five correctness + one polish fix. (1) PrerenderPlanRoutes now excludes master tenant (id=1) from the parent-domain map so a misconfigured platform root can never pollute child routing. (2) prerender-tenants.sh re-validates FILTER_TENANT inside get_tenants() at the SQL use site. (3) moveTenant() fails safely on NULL path fields instead of using a fallback that could allow circular hierarchy moves. (4) TenantContext::getReservedPaths() adds 'platform' to sync with the TypeScript RESERVED_PATHS set. (5) SitemapController::tenant() replaces two sequential DB queries with a single JOIN. (6) tenant-routing.ts documents that slugs are lowercased at the DB layer.
- Deploy: post-deploy smoke tests hardened. WebAuthn / passkey smoke check now passes a real tenant slug (rather than hitting the platform root), matching how passkeys are actually challenged in production. Passkey smoke checks are also allowed during maintenance-mode windows so blue-green health checks don't fail because the platform is in maintenance. Local development health checks allowed through the post-deploy gate.
- Goals: modal polish and history label fixes. Check-in modal updated with clearer cadence copy. History panel label keys corrected (were displaying raw translation keys). Floating modal positioning fixed on small screens.
- Frontend: remaining TypeScript type errors and stale query patterns closed. Type-only pass over React pages — no behaviour changes.

### Changed

- Activity digest default changed from weekly to monthly. Feedback from early members found weekly digests too frequent for communities with moderate activity. Monthly is now the opt-in default for new users; existing preferences are unchanged. Critical instant-category events (DMs, connection requests, application status) are unaffected.
- Admin sidebar: navigation refined. Email Deliverability moved into the Settings group. Registration Security moved adjacent to Registration Policy. Prerender Engine entry restricted to platform super-admins only (was visible to tenant super-admins, who couldn't operate it). Ordering of secondary items tidied.
- SEO: detail page metadata enriched. Listing, event, job, blog post, and marketplace listing detail pages now emit og:updated_time, article:modified_time, and dateModified in JSON-LD. Listing and event pages also emit geo.placename when coordinates are present.
- SEO: account and settings pages marked noindex. /settings/*, /wallet/*, /notifications, /profile/edit, and similar authenticated-only pages now emit <meta name="robots" content="noindex">. Prevents personal account pages from appearing in search results.
- SEO: public SEO route coverage improved. Organisation profiles, ideation challenges, and caring-community hubs added to the prerender route plan and sitemap.
- SEO: crawl metadata coverage improved. <link rel="canonical"> and og:url now use the tenant's canonical domain (custom domain or slug-prefixed) rather than always app.project-nexus.ie. Duplicate-content risk reduced for tenants on custom domains.
- i18n: complete frontend translation fallback audit. All 52 React locale namespaces verified across all 11 languages (en, ga, de, fr, it, pt, es, nl, pl, ja, ar). ~400 keys that existed only in en/ were filled with English fallbacks in all other language files. node scripts/check-i18n-drift.mjs now reports zero drift. This was the source of several recurring CI failures.
- Admin: icon-only buttons given accessible labels. Toolbar icon buttons in the admin panel that had no visible text now carry aria-label attributes. Screen-reader and keyboard users can identify all admin actions.
- Newsletter heatmap contrast improved. Day/time engagement heatmap in the newsletter admin now uses a higher-contrast colour ramp for the top quartile, making peak send-time cells distinguishable in both light and dark mode.

---

## 1.5.0 - 2026-05-13

**Project NEXUS is now Generally Available.** After running as a release candidate since 2026-03-27, the v1.5 line — covering the full Laravel 12 migration, the React SPA frontend, federation, multi-tenant scoping hardening, the SEO overhaul, the email system rewrite, and the PWA update architecture — is promoted to GA. The platform as a whole is live and supported; newer modules may still ship with their own per-module maturity label.

### Changed

- Release marker promoted from RC → GA. RELEASE_STATUS.stageKey is now 'ga' with label "Generally Available (v1.5)". The amber "Release Candidate" footer strip is replaced with a calm GA strip linking to the new Features page and the public Changelog (this file, on GitHub).
- Footer Changelog link now points to CHANGELOG.md in the source repository — the canonical, public-facing version history.
- /development-status page replaced with /features — a public marketing-grade features inventory with honest per-module maturity chips (GA / Beta / Preview). The old /development-status URL 301s to /features so existing bookmarks survive. Federation is explicitly labelled Beta — Live with external partners, protocols still hardening to reflect reality: real partnerships exchange data daily while the wire protocols are still being hardened against edge cases.
- PWA update flow rewritten (2026-05-10). Replaced precache-shell + click-to-update workflow with NetworkFirst HTML + API stale-client gate. The HTML shell is no longer precached by the service worker; navigations are served NetworkFirst with a 3s timeout. Every API response carries X-Build: <sha>; the frontend interceptor force-redirects to /api/sw-reset if a build mismatch persists past a 10-minute grace window. Sentry events are now tagged with build_commit and build_time. Deploys propagate to users on their next navigation, with no UI prompt. See react-frontend/CLAUDE.md#pwa-update-architecture and the feedback_pwa_android_update.md memory file for the full architecture.

### Removed

- react-frontend/public/sw-rescue.js — service worker rescue shim that force-navigated clients via client.navigate(). Made redundant by NetworkFirst.
- /clear-site-data nginx route. Older SWs intercepted it and served the precached SPA shell, making it useless for actually-stuck users. /api/sw-reset does the same job and bypasses every SW we've ever shipped via the universal /^\/api\// denylist.
- "Update to the latest version" link in the mobile drawer (and the nav.update_app translation key in all 11 languages, the triggerSoftAppUpdate helper). With NetworkFirst + the API gate, no user will ever need a manual force-update button.

### Added

- Public SECURITY.md vulnerability disclosure policy.
- Public CODE_OF_CONDUCT.md community participation expectations.
- Dependabot coverage for Composer, npm, Docker, and GitHub Actions.
- Dependency Review workflow for pull request dependency changes.
- Tag-driven GitHub Release workflow and release process documentation.
- Request ID middleware that returns X-Request-Id and shares request, tenant, and user context with application logs.
- Comprehensive documentation suite API Endpoints V2 reference (80+ endpoints documented) React Component Library documentation (40+ components) Developer Guide for extending the platform User guides for Smart Matching and Reviews System

### Changed

- README now documents the public repository topology, visible quality gates, security process, and release process.
- README now clarifies that native mobile packaging is separate from the default public Docker workflow.

---

## 1.5.0-rc.1 - 2026-03-27

This release candidate covers nearly all development from 2026-01-18 to present. It represents the full maturation of the V1.5 line: a complete React SPA frontend, a full Laravel 12 migration, WebAuthn passkey support, expanded i18n, federation, social features, and comprehensive security hardening.

### Added

#### Laravel 12 Migration (Completed 2026-03-21)

- Laravel 12.54 is now the sole HTTP handler — all 1,218 routes wired to Laravel controllers
- All 223 services converted to native Eloquent implementations (zero stubs remain)
- 5 Event Listeners fully implemented: NewUserRegistered, ExchangeCompleted, ListingCreated, MessageSent, VolunteerHoursLogged
- Full 386-table baseline migration (artisan migrate works from scratch)
- Laravel scheduler replaces custom cron runner for all 25 scheduled tasks
- Nexus\ namespace fully eliminated — 100% App\ namespace throughout
- Dead legacy code deleted: 192 src/Services and src/Models files, 73 legacy framework files, all legacy PHP frontend controllers and views (civicone, modern, starter themes)
- Maintenance mode system: two-layer (file + database) with scripts/maintenance.sh and automatic deploy integration

#### React Frontend (Primary UI)

- Full React 18 + TypeScript + HeroUI + Tailwind CSS 4 SPA replacing all PHP-rendered user-facing views
- retired web-wrapper-based native mobile app (iOS/Android) from the same React codebase
- React Native Expo mobile app with separate test suite (mobile/)
- 108 admin panel pages with 100% parity to legacy PHP admin
- Super Admin panel for cross-tenant management
- Universal Compose Hub with feature-gated tabs (listing, event, group, poll, post)
- PostDetailPage with direct post links and auto-open comments
- Explore / For You page with 7-source recommendation algorithm
- PWA service worker with auto-reload on stale chunks and update banner
- Google Maps integration (replacing Mapbox) with marker clustering and near-me filters
- Sales site at project-nexus.ie (separate container)
- Component refactors: ConversationPage, GroupDetailPage, SettingsPage split into sub-components

#### Authentication & Security

- WebAuthn / passkeys authentication (react-frontend/src/lib/webauthn.ts, BiometricSettings.tsx)
- TOTP two-factor authentication with trusted device support
- Registration policy engine: email verification gate, admin approval gate, invite codes, waitlist mode
- Identity verification module with per-tenant provider credential management (AES-256-GCM)
- Mandatory profile photo + bio enforcement on onboarding
- 7-layer regression prevention system (pre-commit → pre-push → CI → PR → Zod → local → deploy)
- Redis-based rate limiting on all API endpoints
- CSRF protection on all write operations and forms
- Sentry error tracking integrated in PHP and React
- Dependabot CVE alerts resolved; rollup, dompurify, serialize-javascript, tar, basic-ftp patched

#### Internationalisation (i18n)

- 7 languages: English, Irish (Gaeilge), German, French, Italian, Portuguese, Spanish
- All languages enabled for every tenant; tenant default language overrides browser detection
- 33 i18n namespace files per language (~4,571 keys each) covering all modules
- Language switcher on unauthenticated navbar and auth pages
- Translation drift detection added to CI and pre-push hook
- PHP admin i18n groundwork (English-only for now)

#### Federation

- Federation API V1 live: Neighborhoods, Credit Agreements, External Partners
- Partner detail page at /federation/partners/:id
- Federation connections route and FederationConnection type
- All federation features enabled by default for new tenants
- Federation gating uses dedicated tables (federation_system_control, federation_tenant_whitelist, federation_tenant_features)

#### Social Features

- Post reactions (emoji) on feed items and comments
- User presence indicators (online/away/offline) with heartbeat
- Link previews for shared URLs
- Media carousel with lightbox and thumbnail navigation
- @mention system with batch resolution and banned-user guards
- Stories feature with 30-story limit, audience controls, and IDOR prevention
- Video player with accessibility (focus restore, aria-live counter)
- Explore page with category chips, infinite scroll For You feed, and trending content
- Group feed tab and listing social features via shared social module
- Profile aggregated activity feed

#### Jobs & Volunteering Modules

- Enterprise-grade Jobs module: job templates, hiring teams, inline interview/offer response, salary display, bias audit, candidate moderation, talent search
- Volunteering module expansion: 7 new services, 5 React tabs, QR check-in, shift management, recurring shifts, expense tracking, certificates
- Organisation registration and opportunity posting UI
- Volunteer notification dispatch on application events

#### Polls, Ideation & Other Modules

- Polls module: create, vote, and results pages
- Ideation Challenges module: create campaigns, submit ideas, favourites, tags, cover images, draft saving, "turn ideas into teams" conversion
- 96 additional features across 18 modules implemented in the 2026-03-01 build sprint

#### Algorithms & Search

- Meilisearch integration with SQL fallback for listings search; index synced on create/update/delete
- EdgeRank feed algorithm upgraded to 15-signal pipeline with full CTR tracking
- Collaborative Filtering (CollaborativeFilteringService) for personalised recommendations
- OpenAI embedding-based matching (EmbeddingService)
- FeedRankingService with geo-decay, context-aware mode, and configurable signals
- GroupRecommendationEngine with cold-start handling
- Rubix ML, Wilson Score, and Bayesian average for member and listing ranking
- Cross-Module Matching Service with debug panel in admin
- User–User CF boost, dismissed listings suppression, skill proficiency in matching
- Batch geocode script (scripts/batch_geocode_users.php) for backfilling user coordinates
- OpenAPI 3.0 specification for V2 API added to repo

#### Onboarding

- Admin-configurable onboarding module (5 phases): backend config, admin UI, dynamic frontend steps, safeguarding step, listing creation modes (draft/review/active)
- Broker dashboard integration with safeguarding presets
- Atomic /complete transaction wrapping full onboarding flow

#### CRM & Admin

- CRM module: member notes, coordinator tasks, onboarding funnel, CRM webhook dispatches for volunteering events
- Newsletter admin: full parity with legacy PHP admin, stats improvements, activity page, SendGrid provider, per-tenant email config
- Tenant CRUD with full parity to legacy PHP admin including super admin role
- Registration policy admin UI with explanations for all modes
- 6 new admin management pages, algorithm settings page, Match Debug Panel
- Tenant super admin role; tenant lifecycle hardening

#### Email & Notifications

- SendGrid email provider with per-tenant configuration and SPF/DMARC deliverability fixes
- Email notifications for events, groups, endorsements, wallet credits received, reviews received
- All notification links made fully tenant-aware
- Fix for 404 dead links across all email notification types
- Nightly DB backup cron

#### Infrastructure & DevOps

- Git-based production deployment replacing file upload
- scripts/safe-deploy.sh with full/quick/rollback/status modes; automated migrations on deploy
- Docker production images protected from dev-image contamination
- Cloudflare cache purge automated in deploy scripts
- scripts/maintenance.sh for atomic two-layer maintenance mode toggle
- Migrations tracked in git; all legacy SQL migrations committed to migrations/
- Ahrefs Web Analytics on sales site and React app
- PHP memory_limit raised to 4G for PHPUnit; 8G for production containers
- .gitattributes enforcing LF line endings on shell scripts

#### Testing & Quality

- PHPStan level 3 added (warning-only; 123 pre-existing errors baseline)
- ESLint 9 flat config with 929-warning baseline
- 4,504+ PHPUnit tests (0 errors, 0 failures at point of Laravel migration merge)
- 118 Eloquent model factories added; 64 service test suites; 88 coverage-gap test files
- Vitest test suite for React with 71 WebAuthn tests, 66 ComposeHub tests, 367 social tests
- React Native Expo mobile test suite with auth, hooks, and screen tests
- E2E tests migrated fully to React frontend (Playwright)
- Lighthouse CI added for performance regression prevention
- Vitest Axe accessibility testing integrated in CI
- API contract test stage added to CI pipeline
- Translation drift detection in CI and pre-push hook

### Changed

- Primary frontend is now React SPA only — all PHP-rendered user pages removed
- PHP admin legacy views remain only at /admin-legacy/ and /super-admin/
- Routes split from monolithic routes.php (2,487 lines) into 14 domain-specific partials
- Tenant routing: /:tenantSlug URL prefix with 42 reserved paths and tenantPath() helper
- Login is fully tenant-URL-aware; super admin can access any tenant
- Maps provider migrated from Mapbox to Google Places / Google Maps API
- Feed algorithm: default mode is Recent, EdgeRank as alternative; unified feed_activity table
- Compose Hub: Post tab removed; Listing set as default tab
- Navbar redesigned with mega menu, utility bar, command palette, and intelligent collapsing
- More dropdown reorganised with Partner Communities collapsible and Activity dividers
- avatar column renamed avatar_url across the entire codebase (4 affected files)
- Irish-specific phone and location validation removed globally; international E.164 throughout
- CORS wildcards replaced with per-origin validation
- routes.php and all controllers now under app/ namespace exclusively

### Fixed

- Cross-tenant IDOR in Group::findById() — missing tenant_id scope (security audit 2026-03-09)
- AdminContentApiController menu_items DELETE/UPDATE lacked embedded tenant check (security audit 2026-03-09)
- WalletFeatures fatal error and Exchanges config regression
- Pusher auth 401 on login page; Pusher unsubscribe against closing WebSocket; Pusher 405 in production
- Feed load-more returning duplicate items from cursor pagination
- Balance alert emails spamming all users instead of the target user
- register() function not granting welcome credits on no-approval tenants
- Blog infinite re-render loop (cursor in useCallback deps)
- Avatar uploads: DB update silently failing, double /api/ URL prefix, file permission bug in production
- FeedRankingService::getConfig() visibility (private → public)
- Legal document GET routes moved outside auth:sanctum — were silently returning generic defaults
- Service worker auto-reloading during message composition
- PWA icons corrected; stale chunk auto-reload on deploy for both Chrome and Firefox error patterns
- AbortController race conditions resolved across 83 pages
- estimated_hours column PDOException on listings creation
- created_by column reference on jobs page (should be user_id)
- image_url → image column on feed_posts table
- Sanctum cross-tenant auth bypass
- GDPR column names (type, location) fixed across multiple endpoints
- Cookie consent Bearer-token-aware auth; returns 200 when no record found
- Onboarding redirect loop resolved using onboarding_completed flag as sole source of truth
- CMS page cascade delete for menu items
- Custom domain tenant resolution: path no longer mistaken for slug
- Presence heartbeat 429 rate limiting
- Broken avatar URLs from stale domain references after legacy frontend removal
- Duplicate comment reactions route removed
- longitude field: standardised to lon (not lng) across nearby endpoint calls

### Security

- Critical: Cross-tenant IDOR in Group::findById() — fixed 2026-03-09 (see audit-history.md)
- Critical: AdminContentApiController DELETE/UPDATE lacking tenant check — fixed 2026-03-09
- Critical: God mode privilege escalation vulnerabilities fixed
- Critical: Open redirect vulnerabilities removed from login scripts
- Critical: SQL injection protections hardened across 50+ files
- Critical: Hardcoded production DB credentials removed from tracked files
- Critical: Pusher fallback key removed from NotificationsContext
- High: XSS vulnerabilities in view files fixed; DOMPurify and serialize-javascript patched
- High: CORS wildcards replaced with origin validation
- High: Rate limiting added to auth endpoints; login relaxed to 10 attempts/5 min
- High: Registration policy gates enforced on all entry points (not just registration)
- High: Super admin cross-tenant access control hardened
- High: Tenant isolation gaps in events, groups, messages, exchanges hardened
- High: 2FA enforced for all admin users
- High: AES-256-GCM encryption for per-tenant identity provider credentials
- Medium: nosemgrep annotations added for Semgrep false positives
- 18 tenant isolation regression tests added; admin security regression gate script added
- SPDX/AGPL-3.0-or-later headers on 100% of source files (1,230/1,230 files verified)

### Removed

- All legacy PHP frontend themes: civicone, modern (user-facing), starter — fully deleted
- 229 dead PHP frontend routes and 42 legacy frontend controllers
- 192 src/Services and src/Models files replaced by native Laravel equivalents
- Legacy Database:: class replaced everywhere by Laravel DB facade
- Nexus\ namespace entirely eliminated from the codebase
- 73 dead legacy framework files and all legacy ob_start delegation patterns

---

## Project history

Project NEXUS development began in mid-December 2025. The 1.5 line was developed throughout early 2026 and entered release-candidate status on 2026-03-27 before being promoted to General Availability on 2026-05-13. There are no earlier public releases — anything tagged before 1.5.0-rc.1 was internal development against the legacy PHP codebase and is not separately versioned here.

For the people behind the project, see [CONTRIBUTORS.md](CONTRIBUTORS.md) — the canonical attribution file.

---

## Support

- Issues: https://github.com/jasperfordesq-ai/Project-NEXUS/issues
- Documentation: /docs directory
- Security reports: see SECURITY.md

---
