Skip to main content

Give feedback

Back to all releases
Changelog

1.7.0

Released 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.


Back to all releases