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.tsdrives the unchanged React frontend against each backend — switched byVITE_BACKEND_TARGETonly, 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-relationshipsand/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-ukhadcaring_communityswitched 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 Laravelgovuk_alpha_caringcatalogue 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_communitystays 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 disposablenexus_webuk_e2estack 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.shprovisions 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-ukaccessible 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.
__()readslang/<locale>/<ns>.jsonbefore the.phploader, yet no gate ever counted those files — 49,223 values (63.6% of the non-English JSON catalogue, four fifths of them inadmin.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, andscripts/translate-php-lang-json-gaps.mjsexists to work it down.Accessibility is now machine-enforced in the React frontend instead of resting on reviewer discipline.
eslint-plugin-jsx-a11yis 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 onlycheck-nested-interactive.mjs. Triage of the 78 initial findings fixed real defects — reaction menu items advertisedaria-pressedonrole="menuitem", which is invalid ARIA and told assistive tech nothing about the active reaction (nowrole="menuitemradio"witharia-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 ineslint.config.js:no-autofocus(44 sites, all dialog/overlay focus management that WAI-ARIA APG requires) andmedia-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.tsxand__tests__/Foo.test.tsxin the same folder: there are 103 such pairs, every one a separately written suite rather than a copy, andSavedSearcheshad 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 adate-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
ad2029ba7fc1473cc8c7816a3c9344650a851597as 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.jsonis 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 … MODIFYstops the deploy. But the check only matched the wordMODIFY; 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 outALGORITHM=INSTANTis 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 withERROR 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 demandINSTANT, so it proves nothing), andDROP TABLE. Seven contracts pin both directions inscripts/test/test-migration-safety-gate.sh, andAGENTS.mdnow 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 thatdeploy.shcannot 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, soIntlfalls back to the language's default one — the United States for English — and 17 August 2026 rendered as8/17/2026across 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.regionsetting, else the tenant's contact country, else the platform defaultIE) via a new provider-freeregionStore, 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 read30 sec ago/3 days agorather than30s ago/3d ago, because those single-letter abbreviations exist only in US English locale data — Irish and British English have no compact form at anyIntlstyle.
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 unwiredDeliverableService::create/::addCommentandDeliverableController::addComment(the service and the controller wrote different phantom column names for the same field — proof neither ever ran),AdminListingsService::approve/::reject(superseded byListingModerationService),ExchangeService::decline,GroupSSOService::findOrCreateSSOUser,LeaderboardSeasonService::endSeason,SkillTaxonomyService::addSkill(the routed path isaddUserSkill),AdminContentController's threecontent_reportsmethods,AdminSettingsController::features/::toggleFeature(real toggling isTenantFeatureConfig+tenants.features),CronJobService::run/::getHistory(real monitoring isCronJobRunner), andAuthService::refreshToken(real tokens areTokenServiceJWTs). Together with the comment-moderation fix below, the shrink-only schema gate baseline drops from 17 tracked problems to 1. Thirteen staleroutes/*.txtcopy-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 againstccl_tenant_caregiver_recipient_status_unique) whenever aninactiverow 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, andRemoveLinkAsyncdid 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 inCaringCommunityCaregiverControllerUnitTests.Fixed: the "Link a care receiver" form could not be completed at all, because its member search never displayed a single result.
/v2/users/searchanswers{ data: { items: [...] } }andapi.get()unwraps exactly one level, so the page received an object where it expected an array; reading.lengthon it producedundefined, 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 mockdata: [ ... ], 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.mjsaudits 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 newercheck-php-lang-json-untranslated.mjsand 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.phpgate no longer audits entries with no byte-identical occurrence in anylang/**/*.phpfile (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.EnsureIsAdminreturned the bare strings "Authentication required" and "Admin access required", while its siblingEnsureIsBrokerOrAdminhad 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 scopedadmin-i18n-ignoremarker naming the controller checked and the evidence, so the blockingcheck-admin-ui-literalsgate is back to zero violations. That gate had been failing onmainunnoticed 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.tsasserted a hardcodedv1.6.2, soreleaseStatus.tsand its test could be bumped together while the rootVERSIONfile was left behind, with the frontend suite none the wiser. The test now readsVERSIONas the single source of truth and also rejects a stale second version in the label, andcheck-version-consistency.mjsenforces 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::applyDecisionwrotecomments.is_hidden, a column thecommentstable 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 samedeleted_attest every read path already applies. It had been triaged as unreachable because the search looked for->applyDecision(, which aprivate staticmethod called asself::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 samenode/usernameidentity 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-requestinvoked 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 legacyPOST /listings/deleteroute, 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 testexecuted 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_DElocale 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 toisoFormatfixed month-name translation while silently locking English readers into month-first order. Separately, sixteen services and endpoints held hardcoded'M j, Y'-style patterns, whichdate()renders identically in every language. Dates now resolve through the newApp\I18n\FormattingLocale, which combines the recipient's language with the community's region (general.regionsetting, else the tenant'scountry_code, else the platform defaultIE) — 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
Intlformatters no longer follow the visitor's browser. The build-time locale gate only understood method calls, so ~40new Intl.DateTimeFormat/Intl.NumberFormatconstructions escaped it: some passed no locale at all (following the browser or OS rather than the language chosen in the app), others passed a barei18n.language. All now use the app's formatting locale, and the gate coversIntlconstructors too, rejects a barei18n.languageas a locale, and accepts a localconstbound 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 alocale-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
TimeHelpertest 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
IdentityVerificationEventServicecalledDB::statement(...)->fetchAll()/->fetchColumn(), butDB::statement()returns a bool rather than a statement object, so each read crashed with "Call to a member function fetchColumn() on bool". Reads now useDB::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 onGET /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.