1.8.0
Released 2026-09-09
Added
A job application sent from the phone can carry a CV.
POST /v2/jobs/{id}/applyhas 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,
useApikept 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 asisLoading ?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.tsfails 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 failed refresh was completely silent. When a screen already had rows,
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-avpauses 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 inapp.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.
endorseSkillsat inmobile/lib/api/endorsements.tswith 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 sentskill_idalone, andEndorsementService::endorserequiresskill_nameand 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, thoughPOST /v2/appreciationshas 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.tshad 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 screenmobile/app/(modals)/group-discussion.tsxshows the opening post, the replies and a composer;getGroupDiscussionThreadandpostGroupDiscussionMessagejoin the client. Stored HTML goes throughtoPlainTextrather 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.tsis now the single table of screen → required feature/module;components/withRouteGate.tsxwraps every screen's default export and shows "Not available here" with a way back;app/(tabs)/_layout.tsxhides 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.tsfails when a screen underapp/(modals)orapp/(tabs)has neither a requirement nor a written reason for having none, when a listed screen does not callwithRouteGatewith its own name, or when a requirement names a switch the platform does not have;lib/navigation/routeRequirements.test.tsproves the deep-link store and the screen gate agree for every gated route. Still a client-side courtesy, not authorisation: the API does not enforcecoursesorpodcasts(recorded 2026-09-06, owner decision).
- Prevention:
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
changeLanguagein 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
nulland 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-exportnow serves the same download (same handler, rate limit and audit row;expo-file-systemcan only stream a GET to disk) and the app hands the archive to the share sheet. Pinned by two new cases inMemberDataExportTest.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/donatereplays 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. NewWalletDonateIdempotencyTest(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.tsxdeclared its four options inline in JSX whileQuickCreateMenu.tsx(the phone tab-bar sheet) held a separate seven-item array, andmobile/app/(modals)/quick-create.tsxa 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/registerrequires bothvolunteeringandorganisations, whilelistings/createis 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 requiresdirect_messagingon top of themessagesmodule, 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=postopens the compose hub on the post tab,/polls?create=1expands the create-poll form (the shape the native app already used), and/messages?compose=1opens 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 onPollsPagefor the flag in both directions.tsc --noEmitandeslint srcclean; the fullcheck:i18nsuite 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.
- 🔴 Every gate matches the gate on the route it links to. Two were easy to get wrong and are pinned by tests:
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 recordedout-of-scope — "authoring workspace", which made a browser hand-off look like the correct answer. Sixteen React routes move fromout-of-scopetonative(153 → 169 native claims).- Course grading and analytics are now native (
(modals)/course-grading,(modals)/course-analytics), ported fromCourseGradingPage/CourseAnalyticsPageand reached from the instructor dashboard. Field names were taken fromCourseQuizService::pendingReviewForCourseandCourseController::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,buildWebUrland 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 thesupport.docs.*subtree with it. Help now readsGET /v2/help/faqs; about, contact and trust-and-safety readGET /v2/public-page-content/{pageKey}(the key istrust-safety, not the web pathtrust-and-safety); the four legal documents open the existing nativelegal-documentscreen. Community guidelines and acceptable use are added — both are validlegal_documents.document_typevalues 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/contactand, 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-previewthen/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 onpermissions.manage_agenda, which mirrorsEventPolicy::manage()exactly — deliberately notpermissions.edit, which also requires publication ≠pending_reviewand 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 toexchange-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, matchingTrendingHashtagson the web. - Deep links to the new builders were landing on "not found".
/courses/instructor/newwas swallowed by the/courses/:idarm —instructorread as a course id — and/podcasts/studioby/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") andexchanges.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.tsasserts every "+" Create option resolves to a screen file on disk and that the file contains noLinkingorbuildWebUrlat 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.tsfails 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.openURLis 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 --noEmitclean,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-targetsneeds a running emulator and was not run.
- Course grading and analytics are now native (
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/xmldomunder 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.jsonclaims everyhttps://app.project-nexus.ie/*URL withautoVerifyand nopathPrefix, so any web page can hand a member an arbitrary link intoredirectSystemPath. 🔴 It cannot be patched from here, and the obvious remedies are both wrong. npm's proposed fix isexpo-router@5.1.11— a downgrade from the installed 6.0.24. Anoverridespin to the patcheddecode-uri-component@0.5.0fails too: that release is ESM-only ("type": "module") and its consumerquery-string@7is 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-routermajor, out of scope for a fix release and tracked separately. In the meantimeredirectSystemPathcaps 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_DEFAULTSsetscoursesandpodcaststofalse, andCourseControllerandPodcastControllereach carry a doc comment stating the module is "gated by the per-tenantcoursesfeature" — but nofeature:middleware is applied to their route blocks (only the/v2/{...}/groupslinking routes carryfeature:groups) and neither controller checksTenantContext::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 inmobile/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.tsxasLinking.openURLhand-offs, becausemobile/parity-map.jsonrecordedcourses/instructor,courses/instructor/new,courses/instructor/:id/editandpodcasts/studioasout-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 recordednative.- 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) fromInstructorDashboardPage;(modals)/new-course(create, then edit in place with the curriculum builder unlocked, publish control, cohort list whenenrollment_type === 'cohort') fromCreateCoursePage;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) fromPodcastStudioPage. - API clients extended, not invented.
mobile/lib/api/courses.tsgains 16 functions andmobile/lib/api/podcasts.tsthe studio half; every endpoint was taken fromroutes/api.phpand every field name from the PHP services and controllers.npm run api:checkverifies all 494 mobile endpoints against Laravel's route inventory — 0 missing.getAuthoredPodcasts()returns themetaalongside 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'saudiomultipart 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.tsapplies the tenant's own ceiling and MIME allow-list before the upload starts, and deliberately lets an unknown orapplication/octet-streamtype through, because Android content providers routinely report one for an ordinary.m4athe server would accept. - 🔴 New
lib/api/uploadWithProgress.ts, becauseapi.upload()physically cannot do this. The shared client isfetch-based, and React Native'sfetchemits 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 usesXMLHttpRequestdirectly for real progress and a working cancel. Auth and tenant headers are resolved only from trusted storage andContent-Typeis left unset so React Native writes its own multipart boundary — the same rulesclient.tsenforces, and a test asserts each. A cancelled upload rejects with codeUPLOAD_ABORTEDso the studio can keep the form and the chosen file for a retry instead of reporting a failure.createPodcastEpisodeWithAudio()deliberately omitsaudio_urlwhen 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 --noEmitandeslint .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). ThequickCreate.opensOnWebsitestring and the wholeopensOnWebsitehand-off mechanism are removed rather than left unused, and a regression test presses every option on the menu and asserts none of them reachesLinking.openURL. - Deliberately still web-only: the instructor grading and analytics pages (
courses/instructor/:id/grading,:id/analytics), which are review tools rather than authoring, remainout-of-scopeinparity-map.json. The native instructor dashboard therefore offers no buttons to them, rather than offering buttons that lead nowhere.
- New native screens, ported feature-for-feature from React:
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 in745673e0a) 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 endpointGET /v2/admin/config/native-app/install-stats(AdminConfigController::getNativeAppInstallStats, backed byApp\Services\NativeAppInstallStatsService) and a newNativeAppInstallStatscomponent 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 bytenant_id; the cross-tenantplatformblock — every community's totals, a per-community breakdown and a recent list naming members of communities the caller does not administer — is returned only whenusers.is_godis set, and isnullotherwise.is_super_adminandis_tenant_super_adminalone deliberately do not unlock it: a hub-tenant super admin is confined to its own subtree bySuperPanelAccessand 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 carriesdisclaimer_key: push_registrations_not_store_installsand 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 anauthoritative_blockcheck, so the admin health banner and thenexus_prerender_health_statusPrometheus 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 outlivesprerender.authoritative_block_alert_seconds— with anactionstring 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
coursesandpodcastswere both on: the menu showed neither, so both features looked as if they did not exist. Auditingmobile/app/(modals)/quick-create.tsxagainst 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-audiencestill 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 callLinking.openURLwith the slug-prefixed URL and neverrouter.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
coursesandpodcastswere bothtrue. 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 inNavbar.tsxand the data-drivenQuickCreateMenubehind 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:coursesandpodcastsdefault tofalseplatform-wide and are off or unset on 9 of 13 communities, and their routes carryFeatureGate … 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 rawpurple-600gradient stop, which the design-token gate (react-frontend/scripts/check-design-tokens.mjs, run inprebuild) forbids because brand hues must come from tokens; that failed Lighthouse CI and the React build, and it now usesfuchsia-600like 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 existingQrCodeImagecomponent 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 inreact-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 becausescripts/check-irish-translation-safety.mjsblocks 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.tsregenerated.@xmldom/xmldomis 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/xmldomsat in the mobile production tree carrying GHSA-6gmq-8vp8-gcm6 —@expo/plist@0.4.9 → 0.8.13andplist@3.1.1 → 0.9.10. Both parents already allowed the patched releases (^0.8.8and^0.9.10), sonpm update @xmldom/xmldommoved 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 remainingdecode-uri-componentadvisory is now listed inscripts/check-production-audit.mjsrather 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: ifMAX_DEEP_LINK_LENGTHand its guard leavemobile/app/+native-intent.ts,audit:productionfails 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, noquery-string@7.1.4, and every@react-navigation/core7.x up to 7.21.13 still depends onquery-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(bothexpo.versionandexpo.android.versionCode, which the earlier note omitted),mobile/store-listing/apple/en-GB.json,config/mobile.php, and the gitignoredmobile/android/app/build.gradle(versionNameplus theplayVersionCodedefault). The seventh ismobile/package-lock.json, whose own rootversionhad 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'sminimum_versionis 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, andMobileVersionGateRegistrationTest— the PHP test that is the only guard onconfig/mobile.phpand which CI skips on a mobile-only change. 🔴audit:productionstill 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 beingexpo-router'sdecode-uri-componentandquery-string, which do ship in the app and whose only fix is a semver-majorexpo-routerupgrade — 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.jsonandmobile/app.json, andverify:releaseasserts they agree. 🔴 It also lives inmobile/android/app/build.gradle, which is gitignored Expo prebuild output and therefore machine-local: that file hardcodesversionNameas a literal and derives its version code from a-PplayVersionCodeGradle property whose default was2— five below the live release — so a local bundle built on an existingandroid/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 fromapp.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.nexusread back out of the merged release manifest, the signing certificate's SHA-256 confirmed asF5: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, andSYSTEM_ALERT_WINDOWabsent 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
AlphaBadgecomponent could only ever say "Alpha"; it is replaced byModuleStageBadge, 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 fromalphatobeta, and Podcasts — promoted out of alpha on 2026-07-03 to no label at all, which reads as generally available — is now explicitlybeta. The chip text reuses theBetawording 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_TIMEOUTis 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.logfrom the artefact showed Metro reachable and mid-build at25.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 turnedmainred —Documentation, Version, and Changelog Hygieneis blocking and CI counts as release-relevant, exactly asAGENTS.mdsays. Recorded rather than quietly fixed, because the localpreflight.mjschangelog 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.3contradicts itself: its Swift-generated header forward-declaresSTPPaymentStatusasNSIntegerwhile its hand-writtenStripeSwiftInterop.hdeclares the same type asNSUInteger. Earlier compilers tolerated the conflicting redeclaration; Xcode 26 treats it as an error and the build fails insidenode_modules. GitHub'smacos-latestimage 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 --checkreports 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 tomacos-latestand 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 (profileios-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/accessibilityLabeloutright — 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.ymlbuilds a debug APK, boots an API-34 AVD, serves the bundle from Metro and runs the newmobile/.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. Amainthat 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 becausescripts/predeploy-ci-verify.mjsreadsci.ymlandplatform-contracts.ymlonly, so nothing here can trip the unknown-job rule or hold a deploy hostage while the job earns trust. Promoting it intoci.ymllater means adding it toREQUIRED_JOBSin 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, andcompose.ci.ymlpublishes 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 whymobile/scripts/screenshots.mjsstays 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 viaworkflow_dispatchbefore 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-smokeruns on a macOS runner:expo prebuild, CocoaPods, an unsignedxcodebuild -sdk iphonesimulatorbuild, 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.useUnsavedChangesGuardis built onusePreventRemoveprecisely because abeforeRemovelistener 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 whileverify:ios-releaseis 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 fromscripts/ci-smoke-suites.txtthat the blocking smoke step had just run, and measured them against the whole-codebase thresholds inreact-frontend/vitest.config.ts(statements/lines 55, branches/functions 50). Fourteen suites cover 4.47% ofsrc/, so the step failed on every run since it was written;continue-on-error: trueswallowed the failure, and thePost Coverage Report as PR Commentstep it fed said in its own text that it carried no coverage data. Verified rather than assumed: the step printed the identicalCoverage 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 fromvitest.config.tstoo — they were only ever evaluated by the deleted step, and leaving them would move the same trap onto any developer runningnpm run test:coverageover 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 inmobile/(coverage-baseline.json+scripts/check-coverage-ratchet.mjs), placed onreact-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 thatreact-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'sneeds: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_availableasserted caching, butfetchPreview()runsOutboundUrlGuard::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 containergethostbyname('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;domainis 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 fetchit answerswww-authenticate: Basic realm="GitHub", and git responds by asking for a username on a terminal nobody is attached to — soscripts/deploy.shstopped 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 undersudo env GIT_TERMINAL_PROMPT=0, so the same refusal exits 128 immediately and the&&chain stops beforegit reset --hard— and the script reports what happened, states plainly that production is unchanged, and exits 1. 🔴 The variable is passed throughsudo envdeliberately: sudo's defaultenv_resetstrips it, so exporting it on the server (/etc/environment, a shell profile) would never reachsudo git, which is why this is a change to the deploy command rather than to production configuration. Measured while diagnosing:git cloneof the same public URL succeeded whilegit fetchfailed 5/5 including inside a freshly cloned repo, and a control clone of the unrelated publicgit/gitalso 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) andaccessible-awid.timebank.global(web-uk) were created as Plesk subdomains of thetimebank.globalsubscription and bound to the sharedcloudflare-origin-tbg-20260809origin 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 fromuk.timebank.global, the accessible pair fromaccessible-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 pinnedpairc-goodman.comto blue for months. Verified live: the tenant-aware PWA manifest returns "AWID Timebank" on the React host,/versionon the accessible host returnsnexus-webukat the active release, the accessible host serves the community slug-less instead of the community chooser, both redirect HTTP to HTTPS, andapi.project-nexus.ieechoes the React origin back inAccess-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 toscripts/uptime-targets.jsonat 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.mjsmatchedinsert,update,updateOrInsertandinsertGetIdbut notupsert, soAchievementUnlockablesServicewriting a non-existenttenant_idcolumn 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 checkwhere()/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 --autotakes 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--autoalongside an explicit--major/--minor/--patch. Separately,bash scripts/deploy.shnow 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|| trueand 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.9was followed by1.6.0for no reason connected to the release. Both1.6.1and1.6.2shipped an### Addedsubsection — 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:semverenforces 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 thesemverpackage — npm's reference implementation, now a declared dependency — because hand-rolled string comparison gets1.6.9 < 1.6.10right and1.8.0-rc.1 < 1.8.0wrong. 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.mjsderives 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, offmain, 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.mjslocates 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, usingreact-frontend/package.jsonfor the releases predatingVERSION(introduced at 1.5.2).1.5.0-rc.1is 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 onAuthContextprove 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.tsrather 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::registerends its validation with$validator->errors()->first()and the controller passesnullas 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 inmobile/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
onConfirmcallback, 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.
- 🔴 Two, not thirty-three. A heuristic scan listed 33 candidates and could not see inside an
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.tsfails 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 own401 || 403 || 404list 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'sfetchreports 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.
- Root cause: the send used the shared upload path, which is built on
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
CreateOrganisationRequestfirst 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-receiptandgroup-inviteeach 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.
- 🔴 Two of the tests covering this were asserting the fault.
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.
- Root cause: four of the five wrote their loader as
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
tenantSlugrequest option for this. A caller-suppliedX-Tenant-Slugheader is still ignored, deliberately, so this is the one explicit way to ask about a community other than the selected one.
- The API client gained a
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_ALLOWEDand 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:
usePaginatedApiexposed onlyerror— the server's translated sentence — while its siblinguseApihad exposederrorStatusanderrorCodefor 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
SessionTimeoutWarningcountdown 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 downReact 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.ymldecides which changes re-run which jobs, and itsphparea did not listmobile/config/**,mobile/scripts/audit-native-push-producers.phpormobile/app.json— all three are read byNativePushProducerInventoryTestandMobileVersionGateRegistrationTest. Proven on 2026-09-08: the inventory test failed, the fix was committed tomobile/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. Notescripts/**does not covermobile/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 ascontracts/**(2026-08-03) andphpstan.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
/feedafter 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')inrouteRequirements), 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/feedunconditionally, so pressing it silently bounced the member to the dashboard; it now names the dashboard when there is no feed, using thehome.cta_dashboardstring 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.tsxwalks the landing journey against the realFeatureGate(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/feeddefault was already pinned byLoginPage.test.tsx. Two cases added toHeroSection.test.tsxand two tomobile/app/(tabs)/home.test.tsx, each verified to fail with the fix reverted. The mobile suite'sbeforeEachnow resets the tenant mock:jest.clearAllMocks()clears calls but not return values, so amockReturnValue(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
!feedUnavailablerather thanhasModule('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
/dashboardand answers/feedwith a translated 403 page; the API already refuses the feed routes behindmodule:feed; the web dashboard's Recent Activity card and its/v2/feedrequest 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
organisationsas well as onvolunteering. Organisations is a volunteering surface — the page table in the contributor guide gates it onvolunteering, 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 separateorganisationsflag lost the section with no way to tell why. Now gated onvolunteeringalone, 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.tsxmockeduseConfirmasconfirm: (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) || 0turns 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.
useDebouncealready existed and neither used it (F/F-14).A deposit into an organisation wallet could be taken twice.
VolunteerController::walletDeposithas read anIdempotency-Keysince 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 sharedmobile/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.tsxstripped 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 sharedparseDecimalInput.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 toreceiver_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
profilenamespace, which has noappreciationsblock; the keys live inmembers, and there is nofallbackNS. 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, whileuser_badges.awarded_atisNOT NULL DEFAULT current_timestamp(). And "Locked badges" read 0 for every member for ever: it was "all badges minus earned badges", but/gamification/badgesreturns rows fromuser_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 onlyitem_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.roleagainst four names, butsuper_admin,tenant_adminandcoordinatorare never written to that column — they are boolean flags — so only the literaladmincould ever match.GET /v2/users/mealready computes the answer and the app was discarding it;is_adminis now on the mobileUsertype.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
onErroron 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.mdbacklog item 8 and the pass is recorded inmobile/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.replaceran 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'spendinganswer 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".useApinow exposeserrorStatus/errorCodeso 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
joinablefederated group saved it aslisted. 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.
- Money and credits. A time-credit transfer committed on ONE tap, and a deep link (
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-queueruns/opt/nexus-php/scripts/watchdog-queue.shdirectly, 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-phpis a git checkout withcore.filemode=true, so every deploy wrote it back non-executable; the script's own install notes told the operator tochmod +xby 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 ofdocker-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, andqueue:verify-livenesscould 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-onDockerfile Drift Detectionjob 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'sbash scripts/...convention instead of flipping four more exec bits. - Verified: the gate fails on the pre-fix tree naming
scripts/watchdog-queue.sh:16and passes after, with 7 cron invocations checked across 102 shell scripts;bash -nclean 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.shrestores it immediately in the meantime, and this change makes that survive the next deploy.
- Prevention: new BLOCKING gate
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
descriptionasLabel: valuelines, and the label is the member's own translated word. A listing created in French storesExpérience: Professionnel / certifié; the edit screen looked forExperience: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 intomobile/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.tsxandedit-exchange.tsxhad 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 paragraphcame back asFirst 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 asLabel: valueand 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.
useUnsavedChangesGuardtook oneisBusyflag 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 passedsaving || 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.isSavingkeeps the screen protected and changes the question from "discard your changes?" to "your changes are still being saved — wait, or leave anyway?"; onlyhasSavedstands the guard down.navigation.dispatchis 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.replaceto 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 showListingPartialSaveNotice, 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 — nevercreateExchangeorupdateExchange.mobile/lib/exchanges/listingExtras.tsattempts 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::transferfell 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 clientidempotency_keyfor 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::donatehas 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.
setRootBottomInsetwas a ratchet that could only grow, to defend against Android modal routes reportingbottom: 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, inuseBottomInset, 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 onecatchand 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 raisessessionRestoreFailed, and the newSessionRestoreGateshows 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.iedeep link opened the browser instead of the app. Found while closing out F05.app.jsonhas declaredautoVerify: truefor the host all along, but the file Android actually fetches —react-frontend/public/.well-known/assetlinks.json— named the packagecom.nexus.timebank(not this app; the package isie.project.nexus) and carried a literalREPLACE_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, andverify:releasenow fails on a placeholder, a package that disagrees withapp.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::getGroupedNotificationsnever 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 anactor_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_countreports 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.
NotificationServicehas 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'sNotificationtype declarescategoryas 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 elevencategory.*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,volunteeringandmarketplaceare added, along with the missing types inlistings,safeguardingandsystem.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 nowpointerEvents="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 throughexpo-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
ConnectionsControllerreturns a cursor andhas_more; the screen called it once, with no cursor, and mapped that single page inside aScrollView. 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 ontousePaginatedApi, with a next-page control on every tab.has_moreis 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.csvto 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 deadmap.pinCountstrings 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-mapsis 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.
useApiandusePaginatedApiboth retry once when an error is "transient", defined as a retryable status, or not anApiResponseErrorat all. Butlib/api/client.tswraps every network failure and every timeout asApiResponseError(0, …), which satisfies neither arm: it is anApiResponseError, and0was 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.0is 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 bareErrorpassed 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.
useUnsavedChangesGuardusednavigation.addListener('beforeRemove')pluspreventDefault(). Expo Router'sStackresolves to native-stack, where React Navigation documents that approach as unreliable and recommendsusePreventRemoveinstead — 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 usesusePreventRemove. 🔴edit-profile.tsxwas 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 sharedlib/test/unsavedGuardHarnessso 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
RealtimeContextthat 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.
AppTopBarregistered aBackHandlerlistener returningtrue— 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.tsxthroughlib/api/courses.tstoCourseProgressService::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 renderedbodyandtranscriptand 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 withwatch_percent: 100hardcoded, 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.tsxrenders each type properly:videoplays throughexpo-av(already a dependency, so this reaches members over the air),pdfandembedhand off to the phone's own viewer/browser,quizopens the new quiz journey,textkeeps 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. - 🔴
pdfandembedare deliberately NOT rendered inline, and the reason is release mechanics, not laziness. React Native has no built-in web view andreact-native-webviewis 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. Anembed_urlis 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 establishedLinking.openURLcase alongside attachments and meeting links. If inline rendering is wanted later, WebView plus a store build is the change. - New
mobile/components/courses/LessonQuiz.tsxis the whole quiz journey: questions fetched fromGET /v2/courses/quizzes/{quizId}(deliberately re-fetched rather than read off the lesson, becauseCourseQuizService::forLearneris 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 toPOST /v2/courses/quizzes/{quizId}/attempt, and an honest result. A score is withheld whileneeds_reviewis true — a percentage beside "submitted for review" reads as the final mark. A refusal shows the server's own words:MAX_ATTEMPTS_REACHEDis final, and "please try again" would be a lie. watch_percentis 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.tsapplies the same http/https-only rule as the web client and the API before any instructor-typed URL reaches a player orLinking.openURL. On a phonefile:andcontent: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.
- New
A drip-locked course lesson offered a completion button the server was certain to refuse. Audit finding F04.
CourseEnrollmentController::progresshas always returned per-lessonavailabilityandunlock_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 theLESSON_LOCKEDerror 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.tskeeps 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/logoutsuccess branch — butAuthContext.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 callssetSession, 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)andclearApiSession()— 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 reportsunreachableand applies nothing — deliberatelyunreachablerather thanrejected, so nobody is signed out and no offline check-in queue is purged for it. Six new client tests assert on theAuthorizationheader, which is the only thing that decides who the server thinks is asking, and two newAuthContexttests 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.
BiometricLockGatemakes 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 stayedlockedand 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 calledlogout()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
AbortControllerand 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 ownTIMEOUTS.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 classifiedunreachable, neverrejected: 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 anAbortSignaland that firing it yieldsunreachablewith 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_REQUIREDrefusal 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()inapp/(modals)/podcast-studio.tsxcalledtoLocaleString()with no argument, soIntlfell back to the device's locale rather than the language the member chose in the app. Fixed to passdateLocale()from@/lib/utils/dateLocale, the same helper the other 94 formatter calls inmobile/already use. Caught byscripts/check-date-locale.mjsin 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-apphas had a route and a breadcrumb since the legacy admin was retired, but no entry inAdminSidebar.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;systemis 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 existingbreadcrumbs.native_applabel, already translated in all eleven locales, so the menu entry and the breadcrumb cannot drift apart; the newsearch_keywords.native_appsynonyms 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()concatenatedfirst_name/last_namedirectly. An ORGANISATION account (profile_type = 'organisation') keeps its trading name inorganization_nameand 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 throughApp\Support\UserDisplayName::resolve()— the single source of truth for a display name — and both selects feeding it now readprofile_typeandorganization_name; the username fallback and the never-show-a-bare-row-id contract are unchanged. Caught byscripts/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.tsxcalledtoLocaleString()with no locale in three places andIntl.DateTimeFormat(i18n.language)in a fourth, so counts and dates rendered in the browser's language andi18n.languageomitted the region. All four now takegetFormattingLocale()from@/lib/helpers, matching every other admin surface. Caught bycheck:locale-formatting, which runs in the Reactprebuildand had turned Lighthouse CI red. (3) The same commit added/v2/admin/settings/native-app-install-statstoroutes/api.phpwithout refreshingmobile/docs/generated/laravel-api-route-inventory.json, so the mobile API-consumer ledger reportedUNVERIFIED - the route inventory is staleand verified nothing at all — correctly refusing to pass vacuously, and failing the Android Native Release Gate. Regenerated withnpm run api:routes: 2,240 distinct API paths, 463 mobile endpoints verified, 0 missing.npm run check:i18nwas red on a clean tree: 28 duplicate JSON keys across every mobile locale.scripts/check-i18n-json-integrity.mjs(part of thecheck:i18naggregate) reported four keys defined twice in the same object, in all sevenmobile/locales/languages —detail.files.downloadFailedanddetail.files.sharingUnavailableingroups.json,connections.removeinmembers.json, andcertificates.sharingUnavailableinvolunteering.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.parsekeeps 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.jsonkeeps the generic "Could not download the file." / "This device cannot open downloaded files." andvolunteering.jsonkeeps "This device cannot open downloaded files.", discarding the unreachable file- and certificate-specific wordings;connections.removewas 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-integrityand the fullcheck:i18naggregate now exit 0;mobileis green at 392 suites / 2,921 tests. No user-visible string changed. Note for later:certificates.downloadFailedinvolunteering.jsonis referenced by no source file (volunteering.tsxusescertificates.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-driftstands aside whenever a global (tenant_id IS NULL) prerender job is stillqueued/claimed/runningor 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 satqueuedback to back from roughly 2026-08-11 to 2026-09-06 — the host job processor could not claim anything, becausephp artisanfailed to boot inside the container (bootstrap/cache/routes-v7.phpwas missing), which polluted the claim output and madescripts/prerender-job-processor.shlogFATAL: unsafe routes in claim outputevery minute. So every two minutes the sweep hit the guard, printedauthoritative_global_job_exists, exited0, and thereby refreshedprerender:sched:prerender-detect-drift:last_ok_atvia the scheduler'sonSuccesshook inbootstrap/app.php. The scheduler-liveness check inPrerenderService::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 blockstaleonce it outlivesprerender.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 exit1— so theonSuccessstamp stops being refreshed and scheduler liveness goes red on its own within ~6 minutes. Deliberately carved out: arunningjob 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 inPrerenderDetectDriftTestcover 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 — calledPrerenderService::enqueueJob()inside the per-tenant loop with no guard.enqueueJob()re-validates every route token and throwsInvalidArgumentExceptionwhen 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-appwas added toSitemapService::getStaticPageUrls()on 2026-08-12 but not toPrerenderService::ALWAYS_PUBLIC_ROUTES, and nothing compared the two hand-maintained lists. Three changes:/install-appis added to the route plan (it is a public, unauthenticated React route inPublicAppRoutes.tsx, so it should be snapshotted); the sweep now askstenantRouteCanBePrerendered()before enqueueing, passing the tenant target it already loaded so the check costs no extra query, and reports what it dropped in the per-tenantunavailable_routes/unavailable_samplefields and inskippedasroutes_not_in_tenant_plan— counted inplanning_errors, never silently discarded, and a malformed route still reachesenqueueJob()and still throws. This matches howprerender:auto-recachehas always filtered. Regression tests: the sweep continues past a tenant whoseenqueueJobthrows exactly as production's does and still enqueues the following tenant, unavailable routes are reported alongside a tenant's renderable ones, andSitemapServiceTestnow 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_testemulator 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 toNumber()and rejected1,5as invalid or silently sentnull. Every one now goes through the sharedparseDecimalInput. All non-integer numbers interpolated into a translation are now locale-formatted centrally (alwaysFormatinmobile/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) useformatDecimal. - 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}andbackgroundColor: selected ? primary : …had escaped both existing guard tests, andFormActionFooterpainted its disabled buttontheme.borderwhile 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, andcomponents/accentIconColour.test.tsandaccentOverride.test.tsnow 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_DRIFTmachine code was the message a member would see (now a translated sentence with the code incode, 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; thebg-content2/bg-default-200classes 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;NativePressableacceptshitSlop; theFormActionFooteron 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, andexpo-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 abackgroundColoron 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% andcomponents/verificationto 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 becausejest-setup.tsmocks that component away for every suite, so a file testing it must unmock it first.
- Money. A comma-decimal price or offer in the marketplace (
The Changelog page showed members the app's own HTML source instead of the release notes. The page asks the server for
/changelog.mdand 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-colournginx.confstill carries thetry_files $urithe blue/green config dropped, which is why the page worked before and broke silently after. Every existing test mocksfetch, 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.tsxcarried 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 — acatchthat 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 againstdescribeApiErrorrather 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.tsxran its navigation block after thecatchas well as after success, so a failed or timed-out save flashed its error and then left the form (router.back()orreplaceto 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 onlydatafromuseApi, 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; thebeforeRemovepattern from edit-profile is lifted intolib/hooks/useUnsavedChangesGuard.tsand applied to both (Back, Cancel, Android back and iOS swipe). F06 —Number('1,5')is NaN andparseFloat('1,5')is 1, so comma-locale members were refused or, worse, silently mis-booked; newlib/utils/decimal.ts(parseDecimalInput,formatDecimal) is used by the exchange forms, group exchanges, exchange confirmation, credit transfer, and the wallet/More balances. F07 —useApiandusePaginatedApiset the English literal 'An unexpected error occurred.' after a failed retry, which a dozen screens display verbatim; both now translatecommon:errors.genericat the moment they set it, aslib/api/client.tsalready did. F08 — the sharedButtonreplaced its label with a bare spinner while loading; the label now stays beside the spinner andaccessibilityState.busyis exposed. F09 — the Messages tab badge sprang regardless of the OS reduce-motion setting and used hard-coded white text on the tenant colour; newuseReducedMotion()skips the spring, the text usescontrastText(), and the badge carries a translated "{{count}} unread messages" label. F12 (partial) —store-listing/apple/en-GB.jsonsaid 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 innew-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 aSafeAreaView's padding, so the wrapper that looked like protection was not; and insidepresentation: 'modal'routesuseSafeAreaInsets().bottomis 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 theirSafeAreaViewat all. The eleven form screens onFormActionFooterwere already right because it floored the hook with the inset the root layout records (mobile/lib/ui/rootInsets.ts). That floor is nowuseBottomInset()in the same module — the one value any pinned control must clear — andFormActionFooter,BottomSheetandCommentSheetcall 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: everyabsolute … bottom-*element, every footer rendered after a ScrollView, everyKeyboardAvoidingViewscreen withoutFormActionFooter, every rawinsets.bottomread, 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 thenexus_testemulator 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.tsand.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. Newmobile/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 optionaltenantSlug, 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_versionis 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 throughmobile/did not surface it;npm run check:versiondoes not cover it either, and its own comment says so. The single guard isMobileVersionGateRegistrationTest::test_the_advertised_current_version_matches_the_manifest, which compares it againstmobile/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_versionis 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 plainViewwith no press handler at all, and ignoredcontent_typeentirely. 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, becauseurlis member-supplied content echoed back by the API andLinking.openURLwill hand any scheme to whatever app claims it.content_typewas also missing from mobile's link-preview type, which is why the card could not tell a video from an article;embed_htmlis deliberately still absent from that type, since nothing in the app could render it. Five regression tests inmobile/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
hrefnever produced a preview.LinkPreviewService::extractUrls()calledstrip_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 ajavascript:ormailto:href can never reachfetchPreview. Entities are decoded after stripping, never before: decoding first would turn an escaped<a href=…into a real tag and hand an attacker an extra candidate. 🔴 The failure was invisible —createPostV2catches 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.
&survived extraction, so the stored address held a literal&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
imgin all three sections resolved to the identical eightlh3.googleusercontent.comURLs, 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 file01-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:checkpassed 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 thenexus_tablet_7andnexus_tablet_10emulators (1200×1920 and 2560×1600 raw, converted bystore: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.
TenantHierarchyServicereturns 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 74errorreturns 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 inlang/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 nowapi.super_slug_in_usewith a:slugvalue rather than a sentence assembled in PHP. Separately, sevencatchblocks 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'sTenantFormshows the server's reason again as a result, guarded at both ends:ApiErrorLocalisationTestfails if a literal'error' =>string returns to that service, and theTenantFormregression 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 thattenantToggleHubstill setsmax_depthtomax(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_readis the real per-message flag, and reading a conversation clears it —MessagesController::showcallsMessageService::markAsReadon every thread fetch. The bell rows innotificationsthatNotifyMessageReceivedwrites for the same message (typenew_message, link/messages/{senderId}) were never touched, so they accumulated permanently./v2/notifications/countsreports itsmessagescategory 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 unreadnew_messagerows, which is the phantom badge exactly. Reading a conversation now also clears that conversation's message-type bell rows, scoped to the member's ownuser_idand the conversation link;federation_messageis 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
TenantFormdiscarded 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 inTenantHierarchyService(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.mjsblocks that pattern asserver-message-bypass, correctly, and it failed CI on0d23d3ad7. Those refusals now go through__('api.*')with keys translated into all eleven locales (see the entry below),ApiErrorLocalisationTestlists 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 byGET /v2/admin/super/tenants?hub=1without checking whether that hub can still take a child.max_depthis an absolute level cap, not a relative allowance —TenantHierarchyService::createTenantrefuses a child whose own depth would exceed the parent'smax_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 withmax_depth2, 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 thattenantToggleHubsetsmax_depthtomax(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-storefails outright on an iOS build with no keychain-sharing entitlement ("A required entitlement isn't present") while writingnexus_tenant_slug— the community the member is browsing — which is the symptom theset()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 arecovered_in_memoryflag 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 deliberatesetFingerprint(), which is what keeps one unresolved condition as one Sentry issue instead of a fresh one nightly. Those two legs were not independent: production'sLOG_STACKcontains thesentrychannel, 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, taggedlogger=productionwith alog_contextextra) and NEXUS-PHP-66 (the capture leg) in the same second. The same doubling applied toBackupVerify,OverdueGdprRequestCheck,SloCheckandStuckStripeWebhookCheck. The sharper cost isscripts/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 throughApp\Support\Sentry\OperatorLog::withoutSentry(), which logs to the configured stack minus that one channel, so local visibility indocker logsand the daily file is unchanged and the fingerprinted capture is the only thing that reaches Sentry.SafeguardingPolicyHealthCheckwas also missing from the explicit list inAlarmSentryFingerprintTest, 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 callsLog::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.
GeocodingServicecached 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 recurringGeocoding exceptionin 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
AchievementUnlockablesServicefiltereduser_active_unlockableson atenant_idcolumn 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, souser_idalready 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()readseason_rankings.season_xp; the points are stored inscoreand noseason_xpcolumn 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 fromscoreand still published asseason_xp, which is the key this method promises — previously the stored-ranking branch and the fallback branch returned different shapes, so a caller readingseason_xpgot nothing even when the query worked.The regional-analytics partner dashboard returned a server error whenever demographics were enabled.
computeDemographicReport()selected agendercolumn fromusers. 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_bucketsstays 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.
CommentObservermaintained acomments_countcolumn onfeed_poststhat 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 inSocialController,AdminFeedControllerandPostAnalyticsControllerand 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 byfeed_hidden.post_id; that table identifies an item bytarget_typeandtarget_id, as the live batch path alongside it already does correctly.MatchingService::getStats()readscore,is_mutualanddistancefrommatch_cache, which storesmatch_score,distance_kmandmatch_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 selectedpost_hashtags.tag, which lives inhashtags.tag, and joinedfeed_activitywhen the foreign key points atfeed_posts— and nothing called them, because the working implementation isFeedSocialService. 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/logbuilt its log message by embedding the client's whole payload, the JavaScriptstackincluded. Those stack frames are absolute paths carrying a per-install simulator and bundle UUID, and Sentry groups a plainLog::errorby 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 keynexus_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-typedphp -revents 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 thelog_contextextra. 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
#applicationsbefore 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, sovolunteer_emergencyappears 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
Upcomingtab 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 exactnative_hrefreview 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#applicationsdestination 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
33376091426then 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 messagetext tap with a public messages URL, but exact-source run33329914433and a local emulator reproduction proved that Android's custom-scheme launcher interpreted both public URL forms as a conversation identifier and renderedInvalid conversation. The guarded harness route is nownexus:///(modals)/new-message; the complete four-effect flow passed locally through real message persistence, volunteering, event RSVP and marketplace save, then exact-source run33332261410completed 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
NameandDescriptionlabels 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 run33324976225then 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 realfeatured_image,reading_timeand 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 run33328237409clean-built the app, passed its four-page tour and OCR, and produced four independently inspected opaque native screenshots. Android rerun33328312721passed 12/13 flows but exposed an ambiguous text tap where twoNew messagecontrols 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
GETendpoint 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--jsonwas 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
33320137825passed all twelve journeys, seven effect assertions and its inspected screenshot artifact; unsigned iOS run33319960815rebuilt, 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 -rcommand 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 everyPDOExceptionby its shared stack shape, so an unknown-column typo and a stray%splaceholder 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 taggedinvocation:cli-evaland 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 tophp -r,php -aor 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
33286909272at exact mobile source58654079a, 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.ymlnow truncates the notes at a line boundary and appends a pointer to the complete section inCHANGELOG.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
33268853140rendered 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 run33270768040proved 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. Run33271244590then 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. Run33271690460showed 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. Run33273775980then 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. Run33275173679proved 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. Run33276612778then 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. Run33277812745reached and captured Feed and Listings before iOS acknowledged but did not deliver the foreground Messages URL. Run33278248859exposed a stale-hierarchy race after accepting the Messages sheet, and run33278642889proved that locating Apple's SpringBoard confirmation button by text can itself spend 69 seconds serializing the system hierarchy and kill XCTest. Run33279028208finally 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-bundledmobile/scripts/are now excluded from the native app fingerprint and the just-built exact-source cache is migrated once to the stable key. Run33280416707then 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. Run33280917178completed 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
33281688048authenticated the protected Partner Demo account and captured Feed, Listings, Messages and Events from commitfc496aedddd6793835f49d80a6c7abc4ffbf7771on 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
33282022295passed 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. Run33283006194then showed Android's own “System UI isn't responding” sheet covering a correctly rendered tenant picker in every independent flow despite the emulator'shide_error_dialogssetting; 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. Run33284698299passed all ten independent journeys and produced eight current images before exposing a real foreground-routing defect:nexus://listings/createwas treated as listing IDcreateand 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 run33267129079proved the Release app authenticated and loaded its feed APIs, but Maestro's XCUITest host lost the main accessibility window during the login route swap. Retry33268251338captured Feed and Listings before the same host killed itself while scanning the long, module-dependent profile hub. Run33268900439then proved a separate app defect: the foreground link parser ignored a custom URL's host, so canonical links such asnexus://messagesworked 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, run33269363809reached 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 ownsimctlnavigates 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 run33267127839passed 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-uripicked up four new high-severity advisories during the day and is upgraded rather than suppressed. The mobile tree's existingoverridespin 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.6and 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 throughexpo-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.jsonis only correct when there is nothing to upgrade to. 🔴 The failing CI step audits five trees in onebash -eblock, so aborting atmobilemeantweb-ukwas 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.4would 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 commit0d23d3ad7, which was the first run after the advisories were published — the same check passed on5837ea12efour hours earlier. The twoimage-sizeexceptions 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/commonmarkmoves 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 theon*event-handler filter in the Attributes extension. It reaches us transitively throughlaravel/framework(^2.8.1), so a single-package update was enough.browserslistmoves 4.28.1 → 4.28.8 in the mobile tree via a newoverridesentry, closing CVE-2026-73088 (prototype pollution) and CVE-2026-73089 (unbounded memory growth); it arrives transitively through Babel, Expo's Metro config andcore-js-compat, all of which accept^4. Neither needed an entry in.npm-audit-exceptions.jsonor.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 twoimage-sizeexceptions it also reports are the pre-existing, documented ones and are unaffected.