Skip to main content

Give feedback

Back to all releases
Changelog

1.6.2

Released 2026-08-28

Added

  • Registering a volunteering organisation now tells somebody — at both ends. Registering one used to notify nobody at all: no email and no in-app notice to the admins who alone can approve it, and nothing to the person who registered when a decision was finally made. Two organisations sat waiting unseen, one for seven weeks. Admins are now emailed and notified when an organisation is registered, and the registrant is emailed and notified when it is approved, declined, suspended or reinstated. Admins can now actually decline one — the endpoint previously accepted only active and suspended, so there was no way to say no — and a decline or suspension can carry a reason, which is passed on in the message the member receives. The admin dashboard gained a "pending organisations" alert so a queue cannot build up unnoticed again; that gap was a known parity gap recorded in the dashboard's own notes. The admin audience deliberately excludes brokers and coordinators, who are refused the approval endpoint, and is selected by admin flag as well as role so that flag-only admins are not skipped. All new member-facing copy is translated into all eleven languages, and all five new email types are registered in the email audit matrix so their delivery is tracked like every other transactional email.

  • The native iOS release path now has an evidence-backed App Store submission pack. Apple store copy, privacy and age-rating worksheets, reviewer notes, screenshot rules, owner/legal decisions and a release-candidate freeze record now sit alongside automated iOS release checks and EAS build/submit wrappers. Paid promotional pushes require a separate default-off preference, while ordinary native pushes now use generic lock-screen wording and replace sensitive payload detail with one generic authenticated notification- centre link; the in-app notification retains the useful detail. The protected Partner Demo review account has also been re-verified against production without placing its credentials in Git. A signed device build, App Store Connect record and real-iPhone/TestFlight proof remain gated on active Apple enrolment and explicit owner approval. Production EAS builds now refuse a dirty mobile tree and embed the exact Git source commit in their temporary build config.

  • The GDPR audit trail now names what actually happened, in all eleven languages. Every action the platform records — consent given, data exported, the six member rights requests, processing started, request assigned, note added, export generated, breach updated and data-protection-authority notification — plus the data-export record type had no wording at all, so the admin audit list read "Unknown action" for all 280 recorded entries, 256 of them consent records. All seventeen labels are added and translated, with Irish hand-written rather than machine-filled. Six neighbouring Irish labels are corrected at the same time, including one that rendered withdrawn consent as "baptised permission" and two that called a data breach a physical break.

  • Mobile quality gates now cover more real failure modes without creating a build. The Irish catalogue's remaining 107 English-identical phrases are translated or narrowly allowlisted as product/unit invariants; the API response-contract audit supplies a discovered real listing target to both comment endpoint families; the accessibility-tree crawler force-stops between routes and adds Connections, Activity, Endorsements, Reviews and Skills; and the screenshot tour accepts its asynchronous tenant logo only after two consecutive login frames meet the existing pixel budget. Irish catalogue integrity and the contract changes are locally verified. Phone, 7-inch portrait and 10-inch landscape emulator captures now reproduce with zero changed pixels when repeated, and the capture tool requires an explicit device when more than one emulator is attached. A live public-entry accessibility-tree pass found and removed one more informational chip exposed as a tiny button; the login and community-picker layouts are also capped to a readable width on landscape tablets. Coherent authentication, endorsements, Explore, notification-group, saved-search, messaging attachment/voice, home activity/poll, gamification, goals, group-exchange, ideation, nearby-marketplace, federation, event, quick-create, residual terminology, group collaboration, group-wiki, group-task, group-analytics, group-detail/marketplace and group-create/edit translation batches reduce the guarded English-identical baseline by 2,387 entries, from 4,531 to 2,144; the large groups namespace is now clear in all seven shipped locales. Profile matches, reviews and marketplace navigation reduce the baseline by a further 158 entries, for a total reduction of 2,545 entries and a current baseline of 1,986. Profile destination descriptions reduce it by another 161 entries, for a total reduction of 2,706 entries. Profile help, resource, policy and safety navigation reduces it by another 85 entries, for a total reduction of 2,791 entries and a current baseline of 1,740. Reviewed About, Contact and Terms summaries reduce it by another 110 entries, for a total reduction of 2,901 entries and a current baseline of 1,630; the summaries continue to identify the published web documents as canonical. Privacy, cookies/storage, accessibility and trust/safety summaries complete the profile namespace in all seven shipped locales. Account hints, blocked-member management and data-export journeys bring the total reduction to 3,196 entries. Language, translation and feed-order preferences bring the total reduction to 3,281 entries. Linked/delegated account access brings the total reduction to 3,391 entries. Identity verification status, fee, secure-payment and failure states complete the settings namespace in all seven shipped locales, bringing the total reduction to 3,502 entries. Volunteer shift, certificate, expense and campaign- donation journeys bring the total reduction to 3,682 entries. Volunteer organisation dashboards, approval, application and hours-review journeys bring the total reduction to 3,805 entries. Organisation wallet/settings and shift-swap journeys complete the volunteering namespace in all seven shipped locales, bringing the total reduction to 3,939 entries. Job-owner analytics bring the total reduction to 4,089 entries and the baseline to 442. Hiring-pipeline, application-history and job-alert journeys complete the jobs namespace in all seven shipped locales, bringing the total reduction to 4,247 entries and the baseline to 284; every remaining entry is in the separately modified members catalogues. Member profile, trust, listing, achievement, collection and public- appreciation journeys complete those catalogues, reducing all 4,531 guarded entries to zero across the six non-English shipped locales. This is catalogue integrity and a reviewed AI translation pass, not native-speaker certification. The emulator touch-target auditor now reports partially visible controls clipped by a scroll viewport separately, so a full-size card at the bottom edge is not misreported as an 8dp target while genuine undersized controls still fail the audit. The screenshot gate also treats its unpinned launch-state capture as inspection-only: first-install, remembered signed-out and authenticated launches legitimately open different screens. Protected TalkBack routes and the source fixes remain explicitly pending the next permitted build and authenticated device session.

  • Apple App Store preparation now has a separate, fail-closed release path. A full Apple-source readiness audit now orders everything that can be completed before enrollment, the immediate post-enrollment credential work, signed-iPhone certification, TestFlight and App Review. Device notification permission is no longer requested merely by signing in: members explicitly enable it in Settings, while paid promotional notifications have a separate default-off opt-in and in-app opt-out. Campaign recipient selection now fails closed unless that consent is exactly true. Expo ticket IDs are followed by delayed receipt checks so invalid device tokens are removed, notification taps now survive cold launch and accept every link key emitted by backend producers, logout can unregister a stored token even after OS permission is revoked, and group-chat lock screens no longer contain private message previews. The audit conservatively declares developer marketing purposes and Advertising = Yes while paid radius-targeted campaigns exist, and records the unresolved conflict with the published promise not to monetise user data through advertising instead of silently rewriting legal copy. Camera rationale is narrowed to the marketplace QR scanner actually used by the native app, and release checks protect the consent-sensitive privacy declarations and maintained readiness matrix. The Expo app declares localized Face ID use in all seven native locales and records its standard exempt-encryption status; its 1024-pixel store icon is now encoded without an unused alpha channel; dedicated EAS preview, production and TestFlight commands are documented alongside an iOS configuration gate that remains red until the real App Store Connect numeric app ID is available. The maintained Apple handoff keeps enrollment, signing, APNs, universal links, privacy inspection, cloud builds and real-iPhone testing explicitly open rather than treating shared Android source as proof of an iOS app. The handoff also records the initial Apple policy assessment for first-party login, physical-goods Stripe payments, user-generated content controls, account deletion, privacy declarations and iPhone-only artwork. A machine-checked English (UK) store record, official EAS Metadata mapping, conservative App Privacy worksheet, current Apple age-rating worksheet, real-iPhone screenshot storyboard and credential-free reviewer notes now prepare the App Store Connect fields while keeping manual release mandatory. The generated app privacy manifest now conservatively declares Project NEXUS data collection and linkage with tracking disabled, keeps Stripe-held card details distinct from purchase history, and explicitly aggregates third-party SDK manifests. The native member profile now also exposes the existing tenant-scoped Block member control—with confirmation, translated copy, connection removal and a reversible Settings entry—so Apple Guideline 1.2's abusive user blocking requirement is a real member journey rather than an inaccurate review-note claim. The production and preview EAS environments now also carry the public Stripe publishable key, and native PaymentSheet initialization passes the registered nexus scheme separately from its complete return URL so iOS 3-D Secure and bank redirects can return to the app correctly. A fail-closed AASA generator is ready to publish Universal Links from the genuine Apple Team ID while excluding staff consoles and browser-owned authentication callbacks; the release gate refuses a placeholder or missing association file. Expo's generated iOS metadata is also narrowed to the permissions the app really exercises: foreground location only, with no Always/background-location declaration and no unused photo-library write permission. The EAS upload wrapper now strips the Android native tree from iOS archives and excludes stale .cxx products and local screenshot evidence on every platform, preventing hundreds of megabytes of irrelevant machine output from being sent to the cloud builder. The iOS deployment target is pinned to Expo SDK 54's supported minimum of 15.1, and an unnecessary static-framework override is removed to avoid the documented React Native 0.81 / Stripe 0.50.3 header conflict. A production-like unsigned iOS Simulator profile now provides real macOS compile evidence before Apple enrollment, without pretending that a simulator .app is a signed device or TestFlight build. Its first EAS run completed successfully; inspection of the final app verified the bundle/version, iOS 15.1 target, URL schemes, narrowed permissions, seven native locales and 21 aggregated app/Stripe/Sentry privacy manifests, with the artifact identity, hash and remaining signed-device limits preserved in the Apple build record. The authoritative journey ledger now records iOS as PARTIAL rather than OPEN: a real Apple-toolchain compile was attempted and succeeded, but the app has not yet run on an iPhone, so the signed-device journey remains deliberately uncredited beyond that step. TestFlight submission is now fail-closed around an explicitly named EAS build instead of “latest”: it verifies the finished production profile, store distribution, non-simulator target, bundle identifier and app version, then still requires an owner-approval flag before starting the upload.

  • Google Play upload-key recovery is now independently backed up and tested. The current production JKS and credential metadata have a byte-verified unencrypted offline copy plus matching password-protected, header-encrypted offline and cloud archives, each accompanied by password-free recovery instructions. No secret or recovery artefact is committed. Mobile lint now runs ESLint directly without invoking Expo, scopes Jest/CommonJS exceptions to test and configuration files, and passes with zero warnings instead of 529.

  • Google Play tablet artwork is now captured from genuine Android tablet emulators and checked automatically. The mobile release tooling prepares a curated four-screen set for both 7-inch (1080x1920) and 10-inch (2560x1440) listings without cropping or stretching the app, and the Play asset validator now enforces Google's current four-image tablet minimum and checks phone and tablet counts, dimensions, colour mode and the exact 9:16 or 16:9 listing ratio.

  • Google Play compliance pages have been added to the maintained React frontend. Public, tenant-aware /account-deletion and /child-safety routes explain the app's deletion process, retention boundaries, child-safety standards, reporting routes and operator identity. The legal hub and footer link to both, and their contact actions open a correctly pre-filled support form. Both pages are published in all eleven languages: the 66 new keys were translated into the other ten locales by hand, because the public translation endpoint refused the batch and no translation API key is configured. The operator's registered name, charity number, address, package id, the literal DELETE confirmation word, the CSAE/CSAM/NCMEC terms and the published contact address are held identical in every locale, and the two addresses are registered as reviewed invariants so the Irish audit and the gap ratchet do not read a deliberately unchanged mailbox as missing work.

  • The Play submission handoff now uses the official Timebank Global domain. The prepared privacy, account-deletion, child-safety and support URLs point to timebank.global, the Advertising ID answer is grounded in the built Android manifest, and misleading no-payment/no-tracking store claims are replaced with accurate Stripe marketplace and Sentry disclosure wording.

  • Store screenshots — two full sets of eight, taken from the real app. Not mockups: the release build, running against the live Partner Demo community, captured at full phone resolution with a tidied status bar. Light and dark, so you can pick whichever suits the listing. Getting there meant tidying the demo community itself, and it is worth knowing what changed in case a partner walkthrough relied on the old wording. Every listing title had begun "Timebank request:" — which the badge beside it already says — and several described testing the software rather than offering anything ("sanity-check the listings demo flow"). All twenty are rewritten as ordinary offers and requests set across West Cork; six requests became offers, because a board of nineteen requests and one offer reads as a queue of people wanting things rather than a timebank; thirty-one members who lived at "Data room" or "Volunteer desk" now live in real towns; and one listing whose owner was not a member of the community at all has been removed. The originals are archived and every field can be put back.

  • Everything that could be prepared for the Google Play Store, ahead of your account being verified. A new page, mobile/docs/PLAY_SUBMISSION.md, holds the drafted store description, the Data Safety answers worked out from the code rather than guessed, the content-rating answers, the exact commands for each step, and the two decisions only you can make — the app's signing key, and the Google service account. It also names the order to do things in once clearance arrives.

  • The store artwork Play insists on. The app's own icon is 1024 pixels square, which Play rejects — it wants exactly 512 — and the banner across the top of a listing did not exist at all. Both now do, and both are generated from source (npm run store:assets in mobile/), so anyone can change them rather than being stuck with an image nobody can edit. The banner is a developer's draft: it says the right things in the right colour, and a designer would still do better.

  • Google Play assets and production dependencies now have automated release gates. npm run store:assets:check validates the exact icon and feature graphic formats plus every phone screenshot's dimensions, aspect ratio and alpha channel. npm run audit:production fails for any new dependency advisory while explicitly tracking the two unpatched, build-time image-size findings inherited through Metro. The Play handoff now also contains ready-to-enter support, reviewer-access, Data Safety and content-rating answers.

  • You can now delete your account from inside the phone app. The website's settings page has had this since before the app existed; the app had nothing — no button, no screen, no request. It is also a firm Google Play rule that an app which lets people create an account must let them delete it in the app, so this was a blocker for publishing as well as a gap a member would notice. Settings → Account → Delete account. It uses the same server request the website uses, so what actually happens to the data is unchanged: type DELETE, enter your password, and your profile, listings and sent messages go, while time-credit records stay in the community's accounts with your name removed. The screen says all of that in plain words before the button will work, in all seven of the app's languages. Fixing it uncovered a quieter fault: the app's own code for "delete" requests threw away any information attached to them. Nothing had needed it before, so nothing had noticed. Without the fix, the password could only have been sent in the web address itself, where it would be written into server logs.

Fixed

  • Posts written on the website no longer show their formatting code, and short posts no longer get a "Read more" they do not need. A member reported her own post opening in the phone app as <p class="mb-1 leading-relaxed text-[var(--text-primary)]"><span>So I had…. The feed card was fixed for the phone on 2026-08-24; this completes the same fault everywhere else it reached. On the website, the small card shown when someone quotes another post rendered the stored HTML as literal text — the one place on the web that shows post content without going through FeedContentRenderer. It now shows the words, using a new shared htmlToPlainText helper that keeps the paragraph and line breaks stripHtmlToText collapses, and decodes the entities it leaves encoded. On the server, the feed preview counted raw characters towards its 500-character budget, markup included: six short paragraphs from the web composer carry about 450 characters of tag before a single word is counted, so a three-sentence post was flagged as truncated and given a "Read more". The preview is now measured on the visible text, cut on a word boundary, never cut inside a tag, and closes whatever tags the cut left open — a half-written <p class="mb-1 lead has no closing bracket, so no tag-stripper can match it and the fragment reached the screen. The appended ellipsis now sits inside the final paragraph instead of after it. Separately, the phone's feed card spent one of its four preview lines on the blank line between two paragraphs, and React Native drew its own "there is more" ellipsis onto that empty line — the stray "..." floating under a card, which looked like left-over markup and was not. The preview closes those gaps; the post's own page keeps its paragraph breaks. Every new test was checked against the unfixed code first: four earlier drafts passed with the fix reverted, because a substring match still matches when the tag soup is on screen.

  • The three feed pages that had no automated safety net now have one. The tests for the hashtag feed, the hashtag discovery page and the single-post page had been switched off because they were failing, so nothing was checking those three screens. All three failed for the same reason, and it was the tests that were wrong, not the pages: each was looking for the wrong kind of control. The "back to feed" control is a link, and the tests demanded a button; the hashtag search box is a search box, and the tests demanded a plain text box. Neither page was changed. Two checks that could never have failed were also replaced with real ones, so the search test now proves a search actually happens. All 31 tests pass, and the count of switched-off test files drops from 49 to 46.

  • A failed role deletion now explains itself. The admin role list discarded the reason the API gave and always showed the same generic failure, so an admin was never told why a delete was refused. It now shows the server's own translated reason and falls back to the generic message only when none is sent, matching the twenty other admin screens that already did this.

  • The platform's own release checks disagreed with the iOS build they were checking. Preparing the App Store submission correctly dropped the permission that asks to save photos to your library — the app never saves photos, and Apple rejects an app that asks for something it does not use — but one check still insisted the permission be present while two others insisted it be gone. The same submission added an iPhone-simulator build, which a second check mistook for a build that could never be sent a fix, because it recognised the exempt kind of build by its name rather than by what it is. The privacy policy also gained new App Store disclosures and a new date without its check being told. All three now agree with what actually ships. Separately, the Enterprise role screen's delete message is now marked as already-translated text, so the translation check stops reporting it as untranslated English.

  • A connection request from a member with no first name arrived blank. The notification read " sent you a connection request" instead of naming anyone. The line meant to fall back to "Someone" chained through the account's display name, and that display name is now always a string — empty rather than absent for a nameless account — so the fallback could never be reached. Empty now counts as missing. The same line also preferred the first name unconditionally, which for an organisation account is the contact person's name, so an organisation now sends its own name and a person still gets the informal first name. Alongside it, twelve tests were repaired that had quietly stopped testing what they claimed: they set a member's display name directly, but the platform recalculates that column from the first and last name on every save, so the name each test chose was replaced by a randomly generated one. One of the twelve was the spreadsheet-export safety check for group analytics, whose hostile filename payload never reached the exported file — the guard itself was working and is now genuinely exercised. Two others had been passing only because they reproduced a known misspelling ("organization" for "organisation") that stopped the organisation branch ever running.

  • An organisation account now shows its organisation name everywhere, instead of sometimes showing the contact person's own name. A member who signs up (or later switches) to an organisation in profile settings stores the trading name separately from the person's first and last name. The person's name was leaking through into the members directory, group creators, the explore lists, the feed sidebar, shared and quoted feed posts, the gamification profile card, the leaderboard and the AI assistant's memory. There were three underlying causes, all fixed: the stored display-name column was written from the person's name when an account was created, was never written at all by self-registration, and was never recalculated when a member switched their profile to an organisation. A migration repairs existing accounts. The decision about what an account is called now lives in exactly one place on each side (App\Support\UserDisplayName and resolveUserDisplayName()), roughly 700 hand-rolled name assemblies across the API and the React app were routed through it, and a new blocking check (npm run check:display-name, ceiling zero) stops them coming back. Two services also compared the profile type against the American spelling, which never matched, so their organisation branch had never once run.

  • The admin Enterprise pages no longer go blank after an account is deleted. A GDPR audit entry recorded for a deleted account carries no entity type — by design, because the record it would point at is gone. The Enterprise dashboard and the GDPR audit log both assumed that value was always present and crashed the entire page to the error boundary when it was not, even though the API had answered normally. Both now fall back to their existing "Unknown entity" label via a single shared helper, the affected columns are typed as nullable so the assumption cannot be reintroduced silently, and regression tests cover the null value at both the helper and the rendered-page level. Reported from production as support report NXR-260827-ND1UJA; three communities were affected.

  • Native refresh controls and comment reactions now report real outcomes. Explore and Exchange Detail pull-to-refresh indicators follow the underlying request instead of fixed 650 ms and 1.2 second timers; Exchange Detail keeps its existing content visible during a refresh. A failed comment reaction now restores authoritative state and shows the translated server or fallback explanation instead of relying on error haptics alone.

  • Native wallet and listing feedback now reflects the real request outcome. Wallet pull-to-refresh remains active until balance, transaction, community-fund and pending-credit requests have all settled instead of stopping after a fixed delay. A failed listing save or unsave still rolls back its optimistic state and now also shows the translated server or fallback explanation instead of relying on error haptics alone.

  • Native mobile refreshes and first-install community selection now fail safely. Older paginated responses can no longer overwrite a newer refresh or block a newer filter request across feed, listings and messages; a community that cannot load is no longer remembered; signed-in and signed-out picker failures now remain visible with an explanation; and wallet pending transactions plus the authoritative notification unread total refresh with their respective screens. Each regression was reproduced by a failing test before repair.

  • Mobile organisation deposits now reconcile both sides of the movement. Funding a volunteering organisation writes the organisation ledger and the member-facing wallet transaction in the same database transaction, with idempotency coverage preventing a replay from duplicating either entry.

  • Mobile startup and accessibility guards cover more real failure modes. A removed remembered community now clears the stale selection and returns the installation to the neutral picker, while goals, organisations and settings use the non-interactive status-chip wrapper so informational labels are not exposed to assistive technology as undersized buttons. Response-contract tests now pin the nested settings, linked-account, export and activity payloads, and the two actionable production hook-lint warnings have been removed.

  • The maintained mobile release status now describes the app that actually ships. Rubric M1 is reconciled to a machine-checked 708/1000 local candidate (with 629 retained as the committed/CI-backed floor) from the current 140-row journey ledger, live Google Play distribution, Sentry, policy pages, tablet evidence and release gates. The roadmap and Play handoff no longer claim that identity verification, signing, listing assets, Sentry or distribution are missing, and one risk-ordered pre-build backlog replaces their contradictory historical task lists.

  • Fresh mobile installations now start with an unselected community picker. A community is remembered only after the member chooses it, then signed-out returning members go directly to that community's login while authenticated members continue directly to the app. Existing stored community selections are preserved. Android shipping-manifest validation also ignores generated debug manifests while continuing to inspect generated release manifests.

  • Accessible tenant routing now reserves the two Google Play compliance paths. /account-deletion and /child-safety can no longer be mistaken for child tenant slugs on parent domains, keeping Web UK aligned with Laravel routing.

  • The public Google Play child-safety standard now names only reporting controls that the native app actually provides. The eleven published locales direct members to report a post, listing, exchange or marketplace item, removing unsupported claims about profile, message and event report buttons while preserving the separate public child-safety form.

  • Legal-document administration now honours its selected notification audience. Choosing “all active members” previously sent only to members who had not accepted the selected version, while reporting success as though the wider audience had been used. The API now validates and applies the chosen audience, and every newly created legal version is forced to begin as an editable draft so a crafted request cannot create an unpublished version that cannot be edited through the administration screen. Notifications and compliance figures now exclude administrators, matching their deliberate acceptance-gate exemption; the pending-member total is exact across multiple documents rather than an average; only the current published version can be notified; notification timestamps are recorded; historical versions cannot be republished as an accidental rollback; and registration records only documents whose configured acceptance point is registration. “All members” may still receive the neutral in-app update, but members who already accepted that exact version are no longer sent a misleading action-required email.

  • Self-service account deletion no longer leaves an untracked personal-data export on disk. A direct deletion now erases the account without first generating a ZIP that had no GDPR-request record and therefore no expiry path. DPO-managed deletions may still create a request-linked seven-day export, and cleanup now removes historical orphan export files after the same period.

  • The mobile-only EAS wrapper can now resolve Expo configuration plugins before creating a Play build. Its isolated upload context correctly omitted node_modules, but EAS evaluates expo-router and the other plugins locally before creating the archive, so the production AAB command stopped immediately. The context now uses an upload-excluded local dependency junction, guarded by a regression test, while the remote build remains mobile-only. The wrapper also materializes the app-version runtime policy for the native EAS context, and EAS now owns version-code auto-incrementing remotely so failed isolated builds cannot repeatedly reuse the same Play version code. Finally, the Android post-install hook now copies and validates the existing EAS Firebase file secret into the native location before Gradle runs, instead of failing at processReleaseGoogleServices. The repaired path produced and locally verified a signed production AAB on 2026-08-26. After rotating the upload credential, a quota-free local Gradle path produced and verified 1.2.0 / version-code 5 against the new EAS-default certificate; generated credentials, AAB and APK artifacts are ignored so they cannot be committed accidentally.

  • The Google Play phone screenshots now meet the upload rules without losing any app content. The original 1080×2400 captures exceeded Play's 2:1 aspect limit and contained an alpha channel. All sixteen are now opaque 24-bit 1080×1920 PNGs, proportionally fitted on colour-matched side gutters with no cropping or stretching.

  • Reaction details now open from the feed-item detail screen. The card was updating a reactor-sheet state that the screen never rendered, so tapping the reaction summary did nothing. The sheet is now present and covered by a focused open-and-close regression test; related production hook dependency warnings in feed, events, marketplace and volunteering flows are also resolved.

  • ASP.NET message media now stays playable after a conversation is reloaded. Voice-message thread reads now retain the voice marker and duration, and both voice and ordinary attachment URLs use the participant-authorized private-media routes instead of owner-only generic file links.

  • The Google Play feature graphic now includes the complete app icon. Its renderer previously replaced a placeholder mentioned in an HTML comment instead of the image source, leaving a blue tile with a broken-image fragment. The renderer now targets the image source directly and refuses to capture the banner until the embedded icon has decoded successfully.

  • A goal deadline that isn't a real date is no longer thrown away silently. Setting a goal due on 31 February said "Your goal has been created" and created a goal with no deadline at all — the date the member typed simply vanished, with nothing said. The check that catches impossible dates was already there and already working; the goals page was ignoring its answer. It now says "Enter a real date", the same way every other part of the site already did.

  • The accessible site blamed members for things their community had simply switched off. Opening a page for a module the community does not use — courses, the marketplace, podcasts and others — said "You do not have permission to view this page" and "this page is not available to your account… contact your community organisers if you think this is wrong". Both are untrue: nothing is wrong with the member's account, and there is nothing for organisers to fix. It now says "Not available — this module is not enabled for this community", in the reader's own language. Genuine permission refusals, such as an ordinary member opening an organiser-only page, are unchanged.

  • New members on the accessible site could not finish setting up their account, and were not told why. On the last step of the six-step setup, pressing "Finish and go to my dashboard" said only "Something went wrong. Please try again." Trying again could never work. What was actually missing was a profile photo, which is required — the server said so clearly, and the page was looking for that answer in the wrong place. The member is now taken back to the photo step and told plainly that a photo is needed. This mattered more than it looks: a member whose setup is unfinished does not appear in the member directory at all, so they could not be found by anyone.

  • The setup wizard's messages were in English for everyone. All six of its error and confirmation messages had been translated into all eleven languages already; the page was showing hardcoded English instead. It now uses the translations — which matters most here, since this is the very first thing a new member does.

  • The accessible site told families a carer could read a supported member's messages. They could not, and the person was never asked. On the Linked accounts page, "View their messages" sat in a list of tick boxes under a Save button. Ticking it did nothing at all — that switch has granted nothing for years — but the page made it look like a carer's message access was something the carer could just turn on. The real arrangement was built some time ago and is the opposite: the carer has to ask, the supported member has to agree in their own "Waiting for your approval" page, every time the carer looks it is recorded with a reason they had to state, and the supported member can stop it in one press. All of that existed, in all eleven languages, and simply was never put on the page. It is there now, and the misleading tick box is gone.

  • And the carer's message view could never open, even with permission properly given. Three separate faults, each of which alone would have stopped it. The reason a carer has to state before looking was sent in a way that cannot carry Japanese, Arabic or Polish at all, and cannot carry anybody's typed note in any language — so the request was never made, and the carer was told the permission "may have been withdrawn" when nothing of the sort had happened. Nothing reached the record either, on a feature whose whole point is that every look is recorded. Separately, the page could not work out the supported member's name and treated that as "this isn't your person", and once open it labelled every conversation and every message "Unknown member" and showed a raw :name placeholder where a name should be. The same sending fault was present in the main app and is fixed there too.

  • "You have 1 connections" — the accessible site's My network page no longer gets its grammar wrong. With exactly one connection, or one request waiting, the summary line read "You have 1 connections, 1 requests waiting for your reply…". It could not be fixed by making the words plural-aware, because that one sentence carries three separate counts and the translation system can only adjust for one. The three counts are now shown as plain labels with their numbers beside them — right in all eleven languages, and no new wording for anyone to translate.

  • The accessible site's Notifications page now tells you what happened. Two silences, both found by using the page. Marking a single notification as read said nothing at all — every other button there (mark all as read, delete, delete all, mark a group read) confirms itself, and the confirmation wording for this one had already been written and translated into all eleven languages. It had simply never been switched on. Worse, when any of these actions failed, the page reloaded looking exactly the same and said nothing — so a failed "mark all as read" was indistinguishable from a successful one. Failures now say so, in the reader's own language rather than in English. This matters most for people using a screen reader, who get no visual cue that a page changed.

  • Everyone reading the app in English was seeing American dates. A listing posted on 17 August showed as "8/17/2026" — month first — on a platform built for Ireland and the UK. The app deliberately follows the language you pick in settings rather than your phone's, so that switching to Spanish gives you Spanish dates; the flaw was that a language on its own carries no country, and English on its own means America. Dates now take the language you chose and the country your phone is in, so an Irish phone shows 17/8/2026 and Spanish still reads in Spanish.

  • A listing whose author could not be found showed a bare question mark. Where the member's name and photo belong, the app printed "?" twice — which reads as the app being broken rather than the person being gone. The server had been sending a proper answer all along and the app was discarding it. It now shows that, or "Unknown member" if there is nothing at all. Both of these were found by using the real app on a phone to take store screenshots — neither would have been caught by a test, because both look entirely normal until you read them.

  • Members' surnames were being handed out by the wallet's recipient search, and are not any more. Everywhere else on the platform a surname is private to everyone except a community's admins — the member directory and profile pages both hide it. The box you type into when sending someone time credits did not, so anyone could have collected every surname in their community two letters at a time. It now returns the first name only, exactly like the rest of the platform. So that you can still tell two members called Mary apart before sending credits, each result now shows the member's username beside their first name, and the send button names them both — sending credits is not something you can undo, so the list must never be ambiguous. Searching by surname still finds the right person; the surname is simply not read back to you. Admins see surnames as before.

  • Crash reporting for the phone app is now set up, and proven to work. There was nowhere for the app's crashes to go: the website and the backend each have their own place in the error-reporting service, and the app had none. One was created, a test report was sent to it and read back to confirm it arrived, and the nightly error summary now covers it. Release builds also upload the file that turns compressed code back into readable line numbers — without it, crash reports arrive as gibberish, and every build profile had that switched off, including the one for the Play Store. Locally built releases carry crash reporting automatically now, and say so plainly when they cannot. Cloud builds are wired up too, now that you're signed in: the app built for the Play Store and the one on the download page both report crashes, and both send the file that makes those reports readable. The download-page build matters most there — it reaches people before anything on the store does.

  • A password-reset link from one community could be used while the browser was pointed at a different one. The link itself was always tied to the community that sent it, and the new password was only ever written to the right account — but the server did not check the two agreed before doing the work. It now refuses that combination outright, before touching the password or signing anyone out, and the unused link stays valid so the member can still finish the reset in the right place. A test covers it.

  • The app was asking Android for permission to draw over other apps. Nothing in it draws over anything — the permission comes from the development tooling and should never have been in a release build. Google Play lists every permission an app asks for, and that one invites questions. It is now blocked, and the proof is in the built file rather than in the setting: the app was rebuilt and the permission is gone from the manifest that actually ships.

  • A release build could not be produced at all without silently switching off crash-report uploading. Building one the ordinary way fails outright, because the crash-reporting tool has no project configured to upload to. That is why every build profile switches it off — including the one for the Play Store, which means a released app would have sent crash reports nobody could read. What's needed to fix it properly is written down: one Sentry project and two settings, all on your side.

Changed

  • Gamification badges and level-ups no longer appear as posts in the feed. Earning a badge or reaching a new level used to publish a full-width celebratory card into the community feed — an oversized icon, a coloured gradient panel and, on the web, a confetti burst. On an active community that was a large share of the screen, and it pushed real member content down. The feed now carries member content only, on all three clients: the React web app, the accessible (GOV.UK-based) frontend, and the mobile app.

    Everything else about gamification is unchanged. Badges and levels are still awarded, XP is still counted, level bonuses still apply, and members are still told about a new badge or level by in-app notification, push and email. Badges remain visible on the achievements page and on member profiles, and the admin gamification tools are untouched.

    Removal happens in four places so no client can bring the cards back: the service stops recording the activity row, the feed query excludes the two types (which also hides the rows already in the database), the feed filter cannot be asked for them by name, and each client both filters its list and refuses to draw a milestone card handed to it by a stale or cached response.

  • The .NET edition now handles forgotten passwords and staying signed in the same way the live site does. This is the second edition of the server, the one some public-sector buyers require; it is not in use anywhere. Four things were wrong. The reset email was sent and forgotten about, so a member could be told "check your inbox" when nothing had been sent — the link is now only created once the mail server has accepted the message, which also means a failed send no longer cancels a link the member already has. The link pointed at a web address that does not exist on the real site. Two reset attempts, or two "stay signed in" attempts, arriving at the same moment could both succeed; only one can now, and the loser gets the same harmless retry the site already handles. And the reset form's own fields were named differently from the ones the website sends. Reset and refresh credentials are also refused when they are presented through a different community, before any valid session or link is consumed. Fifteen tests cover the two journeys end to end. No production code or configuration is affected.

  • Identity verification is hidden in the phone app for now. Google requires apps to use Google's own payment system for anything bought inside them, and the app was charging a card through Stripe for ID verification. Getting that wrong gets an app removed after it launches, not rejected before, so the option is switched off for the first release rather than argued about. Anyone already verified still sees their verified status; the screen now says the check can't be started in the app, and the button that sent people to the website to pay is gone — sending people elsewhere to buy is a separate rule, broken on its own. It is one setting to switch back on, and the code underneath is untouched and still tested. Two things deliberately left alone: buying second-hand items from other members still works, because Google's rule doesn't apply to physical things people post or hand over; and donating time credits was never money in the first place.

  • Every sign-up form now says the same thing about age: Project NEXUS is for adults, 18 and over. The web app's form had always said "and I am 18 years of age or older". The phone app's said nothing about age in any of its seven languages, and the accessible frontend's said nothing in any of its eleven. So the platform was describing itself two ways at once, and the phone app is the one going to Google Play, where the audience has to be declared and has to match what the app tells people. All three forms now carry the declaration, hand-translated in every language each one offers, and a new check (node scripts/check-age-declaration.mjs, blocking in CI) fails if any of them ever stops saying it. Nothing could see the disagreement before: every key was present in every language, so both translation gates were green while the three sentences said different things.

  • The public features page no longer advertises something the platform does not offer. It described "consent flows for members under 18", which cannot be true of a platform whose sign-up requires being 18 or older. The guardian-consent capability is real and is unchanged; it is now described for what it is — an optional, staff-mediated consent record for communities running supervised activity with young people, switched off unless a community turns it on — with the adults-only position stated alongside it, in all eleven languages. docs/PRODUCT-AUDIENCE.md records the position, what the code enforces versus what a member declares, and the gaps still open (social sign-up makes no age statement; two communities' terms documents contain no age clause).

  • Two translation checks that existed but were not being run are now blocking in CI. The phone app's untranslated-value ratchet was written yesterday and wired to nothing, so nothing would have noticed English creeping back into the other six languages. It now runs in the mobile job, alongside the new sign-up age check.

  • The app icon was being cropped on real Android phones. Android cuts the launcher icon into whatever shape the phone uses — a circle on most — and only guarantees the middle two thirds of the artwork survives. Ours filled the whole square, with the four dots right at the edges, so those dots and the outer ring were sliced off. It looked correct on the emulator because that launcher trims less, which is why it took a real phone to notice. The same artwork now sits inside the safe area with the blue supplied behind it, so the whole design shows on any phone shape. It appears slightly smaller — that is the space Android reserves, not a change to the design. A test now measures the icon and fails if artwork strays outside the safe area again.

  • The listings page now spends its screen on listings. The block at the top — title, description, result count, search, type tabs, Near me, Filters and sort — was not part of the list: it sat above it as a fixed panel that could never scroll away, leaving the list less than half the screen and about one and a half listings visible at a time. Only the search box is pinned now; everything else scrolls with the list, which is what the app's five other list screens already did. Measured on a phone: the fixed area dropped from 331 to 48 points, the space for listings went from 48% to 79% of the screen, and you now see two and a half listings instead of one and a half. Nothing was removed — every control is still there, one scroll up. The line "Search by skill, category, place, or member" was dropped, because the search box directly below it says the same thing.

Fixed

  • Three more Tier 1 React journeys now have reproducible dual-backend certification coverage. The paired runner starts a genuinely empty direct-message relationship, proves the exact first message from both members' fresh thread views, drives a connection request through recipient acceptance and both members' fresh accepted lists, and changes then restores a settings tagline across fresh sign-ins. The work closed ASP.NET's v2 member-search and connection-list contract gaps, added explicit tenant boundaries to connection acceptance, and made the disposable Laravel relationship actors approval-complete.

  • The ASP.NET do-nothing endpoint baseline now records the gain the sign-out and onboarding work actually made. Implementing the tenant-aware maps configuration and onboarding endpoints removed two more placeholder routes, taking the inventory from 550 routes/317 methods to 548/315. The shrink-only ratchet is enforced in both directions, so the improvement itself failed the Platform contracts build until the baseline was lowered to match; aspnet-backend/scripts/noop-stubs-baseline.json now carries the new figures and the checker matches on all ten metrics.

  • Sign-out and first-time onboarding now have complete dual-backend journey evidence. The unchanged React client registers a disposable member, completes the mandatory photo and bio profile steps plus each tenant's configured optional steps, reaches the dashboard, and proves onboarding_completed=true after a fresh reload on both ASP.NET and Laravel. The same paired browser run signs out through the real user menu, verifies the client clears access, refresh and tenant state, replays both copied credentials, and proves protected navigation cannot resurrect the session. The journey exposed a shared security gap: logout revoked refresh credentials but a current access JWT remained usable until expiry. Both backends now issue uniquely identifiable access tokens and denylist the presented token on logout; focused ASP.NET and Laravel regressions cover the server-side guarantee.

  • Seventeen core ASP.NET member journeys now have full same-run certification instead of ASP.NET-only proof. The paired React smoke now creates and reloads its own listing, event, feed post, comment, transfer, RSVP, message and signup/legal-acceptance evidence; drives real listing/event/feed filters; verifies dashboard destinations, wallet history, exact member/profile identities and persisted theme state; and advances only generated disposable registrations through verification, approval and onboarding before proving the first-sign-in legal gate in a fresh browser context. One comprehensive run completed 22/22 steps as MATCH against ASP.NET and the Laravel control, with no failures or skips. The same certification gate exposed and closed the tenant-aware maps configuration stub and documented the already-tested recurrence capability projection, shrinking the no-op inventory from 553 routes/319 methods to 550/317. This promotes sign-up, verification, legal acceptance, sign-in, dashboard, feed browse/filter/create/comment, listings browse, event discovery/RSVP, existing-thread messaging, wallet history, directory browse, profiles and theme persistence from PROVEN to CERTIFIED; the banked score floor remains unchanged until the batched push is green.

  • No git safety check had been running in this repository, including the scan that stops passwords and keys being committed to a public repo. Found while explaining why a broken test reached main. The mobile app's tooling sets core.hooksPath for the whole repository whenever packages are installed under mobile/, and git then ignores the project's own checks completely. The file it pointed at instead said mobile pre-commit hook disabled and did nothing. Both of the project's gates were therefore silently off: the credential scan, on a public repository, and the check that runs a commit's own PHP tests. Nothing reported this, because doing nothing and passing cleanly look identical. That mobile hook now hands control to the project's real checks rather than overriding them, so the gates survive the next package install instead of being switched off by it. The installer no longer claims both gates are live without looking: it detects the override, confirms the checks are genuinely reachable, and refuses with instructions when they are not. Line-ending rules now cover both hook locations, which the previous pattern missed because it only matched the repository root. Verified by attempting real commits: one carrying a fake access key and one carrying a failing test were both blocked, and neither commit was created. No evidence any secret was actually committed while the scan was off; that has not been audited and is tracked separately.

  • A post longer than a few lines could not be read in full in the mobile app, and posts written on the website appeared as raw code. Reported by a member with a screenshot: her post opened showing <p class="mb-1 leading-relaxed… and tapping "Read more" did nothing. She was right on both counts, and the first one was a server fault, not an app one. A post's own page was being built by the same code that builds the feed list, so it inherited the list's 500-character preview: an 872-character post came back as 503 characters. There was no request any part of the platform could make that returned the rest of it. The post page now returns the whole post; the feed list still sends a preview, deliberately, because it carries twenty posts at a time. In the app, the post page was also still clipping the text to four lines and offering a "Read more" button that pointed at the page you were already on — so it was greyed out and led nowhere. It now shows the whole post and drops the button. And posts written on the website are stored with formatting markup, which the feed was printing literally; it is now shown as words, with the paragraph breaks kept. Six other screens in the app already did this — the feed, the first screen anyone sees, was the one that did not.

  • ASP.NET listing search and filters now narrow the catalogue instead of silently returning every listing. The endpoint now honours the React app's search text, offer/request type, category, estimated-hours range, delivery mode, posting age, coordinate requirement and distance radius. Nearby results include their computed distance only when requested. A paired browser journey proves exact search and type changes through the unchanged React UI against both ASP.NET and Laravel, with database-backed coverage for the remaining filter combinations.

  • The mobile app's screenshot check now watches twice as many screens — and one screen it was already watching turned out to be unreliable. The check photographs screens and compares them with approved copies, so an accidental layout change is caught. It covered three screens; it now covers six in the light theme and four in dark, adding settings, the support page and an empty create-listing form — all chosen because nothing on them moves or changes with the date. Each new screen was photographed twice and the two photographs compared before being trusted. That same check showed the sign-in screen differs by about 1% between two identical runs, because the community logo is fetched and sometimes arrives after the photograph. It has been taken out of the comparison and the reason written down, so the check can't fail on a run where nothing changed.

  • 33 things across the mobile app were too small to be offered as tappable, and none of them should have been tappable at all. A new tool measures every tappable thing on 24 screens against the accessibility minimum, using the screen's real density. Everything it flagged turned out to be the same thing: a small label — "5 members", "Not ID verified", "Closes Sep 22, 2026", "2 votes" — that the design library quietly renders as a button. Fourteen screens and cards now use the wrapper that leaves labels as labels, so they are no longer offered as targets at all. Re-measured from a clean start: every flagged screen is now clear. The tool's own first results were wrong, which is worth saying: it measured whichever screen happened to be showing, so on a slow screen it reported the previous one's contents. It now proves which screen it is looking at before measuring, and says "unverified" instead of guessing.

  • ASP.NET listing-creation certification now proves the persisted result. The paired React smoke reloads each newly created listing and requires the submitted title on both ASP.NET and the Laravel control, so a redirect or invented success response can no longer pass journey 1.19.

  • Five things the app was saying to blind members that it shouldn't have been. Found by turning on Android's screen reader and reading back everything the app announces, then comparing it with what a person would expect to hear. None of it was visible in a screenshot. Every post in the feed was read out twice — once as a summary that stopped mid-sentence after 100 characters, then again in full — and the author's name was said twice inside the second reading. Every profile picture in the app announced itself as "Avatar", and in the member directory each row also read out a lone letter (the initial shown when someone has no photo). The round button on the home screen announced itself as "Action button", which says nothing about what it does; it now says "New exchange". And every search box announced its magnifying glass as "Search icon". All fixed and checked on a device. The home screen went from 25 spoken stops to 22 clear ones; a member row now reads "View E2E's profile, Not ID verified, 5 given, 8 total" instead of six stops including a stray letter. Nothing looks any different.

  • ASP.NET listing edits and deletes now follow the production Laravel journey. The React edit form's listing type, category, coordinates, estimated hours, available-hours cap and delivery mode are persisted and returned after a reload; skill-tag replacement is owner-authorized; bodyless deletes return the expected success status; and an unrelated same-community member can no longer edit, retag or delete somebody else's listing. A database migration adds durable storage for the available-hours cap and delivery mode.

  • The wallet no longer offers a "Pending" filter that can never show anything. In a community that isn't connected to another platform, nothing ever puts credits into a pending state — so tapping Pending always answered "No matching transactions". It now appears only when there really is something pending. The summary still says "No pending credits", so nothing is hidden; the difference is that a member is no longer invited to tap a control that leads nowhere.

  • Volunteers can now ask to swap a shift — which was impossible anywhere on the platform until today. Answering a swap request worked; asking for one did not exist, on the app or the website, and the website's own help text pointed at a page that had never been built. The obstacle was that the request had to name the volunteer you wanted to swap with, and nothing tells you who is on which shift — showing that is a privacy decision nobody had taken. It turns out the decision wasn't needed. On the app you now pick the shift you'd rather do, and the platform asks whoever is on it without telling you who they are: "We will ask whoever is on it — you will not see their name unless they agree." Nothing new is revealed, because the number of people signed up per shift has always been shown. If several people are on the shift you want, it asks the one who hasn't already been asked, so two people aren't left queuing behind the same volunteer. Walked end to end on a device: asked for a swap, the other volunteer was notified, they accepted, and both shifts genuinely changed hands. The website still has no equivalent screen.

  • On the mobile app, "No matches yet" was hiding the real reason, which was usually fixable in ten seconds. The server already explains an empty match list — no area on your profile, matching switched off, or nothing posted yet — and the app was throwing that explanation away. So a member with no location was told "no matches yet", which reads as "nobody suits you", when the truth was "we can't look until you tell us roughly where you are". Matching is built on what is near you, so without an area the app genuinely cannot suggest anything local. The screen now says which of the three reasons applies, in all seven languages, and offers the button that fixes it. Two mistakes it deliberately avoids: it never guesses a reason the server didn't give, and it doesn't blame your location when you have matches but happen to be looking at an empty tab. Measured on a device while fixing it: with an area on the profile the member gets a real listing match at 51%; with the area removed, that match disappears completely.

  • Volunteers were told to check their details when they were actually waiting for the organisation. Logging volunteering hours needs an approved application first. When it wasn't approved, the page said "Your hours could not be logged. Check the details and try again" — so a volunteer would keep re-reading a date and an hours figure that were both perfectly correct, with no way to discover the real reason. It now says they can log hours once the organisation has approved them, with separate wording for an unapproved application and an unapproved organisation. New messages in all eleven languages.

Notes

  • The volunteering journey now works end to end, and has been walked. Previously it could not be checked at all because the test community had no volunteering data. Seeded through the pages themselves and walked: registering an organisation (which correctly waits for approval, and explains that clearly); an administrator approving it; posting an opportunity (an empty form refused with a proper error summary); another member finding it by browsing and by search; applying once, after which the apply form is correctly withdrawn so nobody applies twice; the organisation seeing and approving the application; logging hours, which are held as pending; and the organisation approving those hours, at which point the time credits arrive — two hours became two credits, and nothing arrived before approval. The volunteers roster then shows the member as approved with their total hours.

  • Saving a listing showed up on one page and nowhere else. Pressing Save changed the button to "Unsave", but the listing list you came from still showed no "Saved" label. Two different features were sharing the word "Saved": the button records a favourite, while the list was checking a separate "saved items" store that the button never writes to. The list now reads the same thing the button writes, so the two agree. Checked by saving and un-saving and watching both pages follow along.

  • People who had never asked to join a group were told an admin was reviewing their request. Opening a group's discussions as a non-member showed "Your request to join is waiting for an admin to approve it" — which could easily stop someone from joining at all, since they would assume they already had. It now says "Join this group to see its discussions", and a genuinely pending request still gets the pending message. New wording in all eleven languages.

  • Asking to join a group that needs approval said "You have joined the group". The same page then said the request was waiting for an admin, so one screen said both. The server is explicit that the request is pending; that answer was being thrown away. It now says the request has been sent, and open groups still say you have joined.

Notes

  • Volunteering could not be walked. The test community has no volunteering opportunities, so there was nothing to open or apply for. The listing page, its search and its empty states are correct, but applying, shifts and hours remain unchecked. Recorded so this is not mistaken for a clean result.

  • Member profiles were walked and are correct. Search with a working empty state; connect (which correctly becomes "Request sent" and offers to cancel); reviews (empty refused, real review appears); sending credits from the profile (a bad amount refused, one credit moved exactly one); and blocking, which hides the profile and is reversible from the blocked-members list.

  • 872 phrases in the mobile app now really are in the reader's language, and the rest can no longer be missed. The app has always had every phrase present in every language, which is why every existing check said the translations were complete — but 5,671 of those phrases still held the English sentence, about one string in nine, in German, Spanish, French, Italian and Portuguese. (Irish was nearly done.) Nothing could see it: the checks compared the list of phrases, not the phrases themselves — the same blind spot that once hid 99,139 untranslated values on the website. 872 have been filled from the website's own reviewed translations, matched phrase by phrase, which is better than machine translation and cost nothing. The remaining 4,648 need a paid translation service: the free one now refuses this machine outright. The gap is now counted and held: a new check reports how much English is left in each language and fails if it grows — and also fails if it shrinks without the new, lower number being written down, so an improvement can't be quietly spent later.

  • On the mobile app, a link that pointed at a particular part of a page landed on the wrong part — and the reason turned out to be different from what had been written down twice before. Found by printing what the screen was actually given, after two earlier fixes had failed to change anything. Opening a link like "volunteering, donations" from a closed app produced two copies of the page: the right one, opened on the right section, and a second, plain copy on top of it. The app deliberately re-follows a link once it knows who is signed in — that matters for someone who starts signed out and has to sign in first — and the code that re-followed it was dropping everything after the "?" in the address. The right page was underneath the whole time. So this was never only about sections of a page: anything a link carried was being lost — a filter, a category, "start a new one". That is fixed at the source, for all forty places that navigate this way, and a link can no longer smuggle in a different record id than the one in the address itself. Confirmed on a device from a closed app. Two other things were tidied in the same pass: two screens now notice a link that arrives while they are already open, and the guard that was supposed to protect this behaviour was rewritten — it had been checking for the names of the old code rather than for the behaviour, so it stayed green through the whole fault.

  • On the mobile app, recording a donation to a fundraising campaign appeared to do nothing. Walked on a device. The donation was accepted, but the form simply emptied: the campaign still said "€0.00 raised", the donor count stayed at zero, and the only trace was a line far below the form. None of that was wrong — a donation recorded this way is a pledge, and it only counts once someone confirms the money arrived — but from the member's side it read as a failure. It now says what happened, in all seven languages: "Pledge recorded — your donation has been recorded and will count towards the campaign once it is confirmed."

  • The message inbox contradicted itself about unread messages. The summary at the top of the page and the badge on each conversation row show the same words, "1 unread message", but came from different places — one cached for 15 seconds, one live. They disagreed in both directions: after reading a message the summary kept claiming it was unread, and a message that had just arrived showed on its row while the summary said nothing at all. Reading a conversation is what marks it read, so the page now clears the cached count at that moment, and the inbox reads its own figure fresh. Verified against the server at each step.

  • Pressing "Translate" told members to try again when translation was not available at all. If no translation provider is configured, the service reported the same "translation failed, please try again" as a genuine provider error — advice that could never work, a permanent server error in monitoring, and, in the React app, an auto-translate loop that retried every message on every cycle. The API now answers with a distinct "not available" result, and members are told plainly that message translation is not available on this community. New wording added in all eleven languages.

  • Starting a group conversation offered you yourself as a member to add, then refused to create the group. Taking that offer listed you twice — once as the administrator, once as a nameless "Community member" — and counted towards the page's own "at least two other members" rule, so the page said the group could be created and the attempt then failed with an unexplained "We could not create the group". You are the administrator by definition, so you no longer appear in the member search, and a hand-edited web address naming you is ignored rather than producing the same dead end.

  • Nothing was found wrong in the wallet. Walked end to end for completeness: eight different bad transfers were each refused with their own specific reason, a real transfer of 3 credits moved exactly 3 (102 → 99 and 23 → 26, nothing created or lost), both members' histories and balances updated, the CSV export contained the transaction, and re-submitting the same page did not send the credits twice. Recorded here so it is not re-audited.

  • On the mobile app, group exchanges never appeared in the list, and the split of hours was shown as raw database field names. Found by creating a real group exchange on a device — a screen that had only ever been looked at before. The exchange was created successfully and the app opened it, but the list it came from still said "No group exchanges found", and it stayed empty on every visit. The app was looking for the list in the wrong place in the server's answer, so an organiser could never see anything they had set up; the wording of the empty screen made that read as "you have none". On the exchange's own page, the section that shows how the hours are divided printed lines like "To member #role: provider hours" — the names of the fields in the server's answer, not the answer itself. The app expected a different shape entirely: a grid of transfers between members, which the platform has never had. It now shows one line per person, with their name and their share: "E2E UserB — 4 hours / Provider". Both were invisible to the tests because both test fixtures had been written from the app's own mistaken idea of the server's answer rather than from the answer. Both are rewritten from real responses, and each fix was checked by breaking it again and watching the tests fail. Verified on a device.

  • On the mobile app, changing community while signed in locked you out of your own community. Found by walking it on a device. An account belongs to one community, and its sign-in is not valid anywhere else — so the moment a member picked a different community from the picker, every request was refused: their profile, the community's own branding, their notification count and the feed all came back with "Token tenant does not match requested tenant". The home screen kept the previous content on display, so it still looked signed in and working, and the only control offered was a Retry button that could never succeed. Closing the app and reopening it did not clear it. The way back was blocked too. The community picker is built from the public list of communities, but the app was sending the sign-in token with that request, so it was refused as well and the screen said "Could not load communities" — the one screen that could have put the member back where they belonged was the one screen that would not load. Both are fixed. The picker now explains what a switch means before anything changes — "Your account is with Hour Timebank. To use Agoris Caring Community you need to sign in there, so you will be signed out of Hour Timebank now" — and signs the member out for them, in the order that lets the sign-out reach the server. Choosing the community you are already in does nothing, as before. The list of communities is now fetched without the token, so the picker always loads. Proved on a device from the broken state, and covered by tests that were each checked by breaking the fix and watching them fail.

  • Four languages titled the cookie settings page with the word for the biscuit. German said "Kekse", Portuguese "Biscoitos", Spanish "Galletas" and Dutch "Koekjes" — the food, not the browser kind. Spotted while checking a German page during other work. Every other string in the same section already said "cookies", so the title was the odd one out in each language, and the same slip appeared on the React privacy page's navigation link. All eight now say "Cookies", which is the standard term in all four languages. Irish ("Fianáin"), Japanese, Polish and Arabic were already correct and are untouched.

  • On the accessible frontend, creating an event was a dead end, and an organiser could not reach their own event's tools. Found by walking the events journey in a browser; no static sweep across seven audits could see it. An event is created as a draft, the page said "Success — your event has been created", and there was no way to publish it — so it stayed invisible to members for ever. The same root cause hid the check-in page, attendee management, broadcasts and lifecycle history: all of those controls are gated on the event's permission set, which exists only on the canonical (v2) events contract, and this app never negotiated it. Measured before and after: an organiser went from 3 links and no publish control to 7 links and a working publish control, and create → publish → visible to another member now completes entirely within the accessible frontend. The permission set is read in a separate call on purpose. Opting the event detail read itself into v2 was measured and rejected: on the same event it drops 43 fields, 21 of which this app uses, including all ten venue accessibility fields (step-free access, hearing loop, quiet space, accessible toilet, parking, seating, transit details, assistance contact, notes), and changes location from a string to an object. Losing accessibility information on the accessible frontend to gain a publish button is not a trade worth making; a proper v2 migration is separate work.

  • Event door staff were told the wrong thing when check-in was refused. The attendance endpoints answer HTTP 409 for five different situations, and the accessible frontend showed one message for all of them: "This attendance record changed elsewhere. The roster has been refreshed; review it before trying again." Confirmed live — checking someone in before the window opened produced exactly that, sending staff to re-read a roster that was perfectly fine. Four of the five now say what actually happened and what to do: check-in has not opened yet; check-in has closed; this person has no confirmed place; this event is not published. The original wording is kept for the one case it describes, a genuine concurrent edit. Applies to both the signed-code and roster-pick paths, with copy hand-translated into all eleven languages.

  • The ASP.NET edition now supports the complete event create, edit, and owner-management journey used by the React app. Event writes persist the consumed schedule, timezone, location, capacity, remote-attendance, video, and venue-accessibility fields; return the canonical event contract instead of the former flat compatibility DTO; enforce tenant and organizer/admin/group-manager authorization; and expose the owner capabilities that make the management workspace reachable. A repeatable browser control now creates, reloads, edits, and manages an event through the unchanged React UI against both ASP.NET and Laravel, backed by focused persistence and authorization tests.

  • A silent mail server could hold a member's request open for a minute, and make a completed action look like it failed. Found by walking the create-a- listing journey in a browser rather than reading code — none of the seven static sweeps could see it. Some emails are sent synchronously inside member-facing writes: creating a listing sends the confirmation before replying. App\Core\Mailer is a hand-rolled SMTP client, so it connected with a 30-second timeout and then read every reply with no timeout at all, inheriting php.ini's default_socket_timeout — measured at 60.1 seconds per read, and a send performs several. Reproduced end to end: with the mail host unreachable, creating a listing took 9.6s, the accessible frontend gave up at its 15s budget, the member saw a failure — and the listing had been created. Anyone retrying gets a duplicate. Both waits are now bounded by the existing SMTP_TIMEOUT setting, which this mailer had simply never read, and a timed-out reply now aborts the send instead of being mistaken for a valid response — so one stall ends the attempt rather than costing a timeout per read. The default drops from 30s to 5s (a healthy relay connects in well under 100ms). Pinned by tests that bind a real socket which accepts and then stays silent; with the fix removed they measure 60.1s and fail. 🔴 Worth knowing: MAIL_MAILER=array does not disable this mailer — it opens its own socket and ignores Laravel's mail layer. Using it as a control to rule mail out is misleading, and did mislead during this investigation. 🔴 Honest scope: this is a latent fragility with a demonstrated failure mode, not a proven live production fault — production's relay presumably answers quickly, and that was not measured. The remaining ~4s of a listing create is still the synchronous email and is unaddressed; queueing it is the real fix and carries its own risks (the queue crash-loops without Redis, and a slow queued listener sends duplicates).

  • Thirteen screens that had only ever been glanced at are now actually used. Events, event details, groups, group tabs, the members directory, member profiles, polls, jobs, job details, marketplace browsing, marketplace listings, listings, listing details and your balance — each opened on a real phone, with real data, and something done on it: searching the directory narrowed five members to one, tapping a job showed its two applications, the group discussion tab listed a real topic.

  • And one of them was quietly broken: the resources screen never refreshed itself. A file added while the app was open left the screen saying "Nothing found" — and it wasn't even asking the server. It had looked once, when the community had no files, and never looked again; you had to close and reopen the app to see anything. The pull-down-to-refresh handle is what makes that nasty: the screen looks like it's checking, so "nothing here" reads as the truth. It now re-checks whenever you come back to it, proved by adding a file with the app running and seeing it appear. Three example files were added along the way, since the section was completely empty.

  • The notification log could not answer "was a push actually sent?" — now it can. When a push found nobody to send to, the system wrote nothing at all, so "we never tried" and "we tried and nobody had a device registered" looked identical in the records. The code's own notes said that was exactly what production showed. It now records that case explicitly, which is the difference between "nobody has installed the app yet" and "notifications are broken".

  • A correction to something I told you was broken. The note on push notifications said a real message produced no notification at all. Re-testing today: the notification is fine — what was missing was the background worker that processes queued jobs, which does not run in the local test setup. Thirty-three jobs were waiting. Run the worker and the notification appears immediately, correctly worded and pointing at the right conversation. The earlier finding is withdrawn, and the test setup now warns when jobs are piling up so nobody mistakes that silence for a fault again.

  • What is still genuinely unproven: a push arriving on a real phone. No device can register for notifications in the local setup at all, and the two ways to fix that both need something only you can provide — either the app's identifier on your Expo account, or Firebase credentials. It is now written down as a decision to make rather than an open mystery.

  • When something goes wrong, the app now tells you what the server actually said. In 165 places across 53 screens it threw the explanation away and showed a generic line instead. The example that started this: logging volunteer hours failed and the app said only "Could not log these hours", while the server had answered "You have already logged hours for this organization and date". The member learns nothing, tries again, and fails again — with the answer sitting there unused.

  • All 165 now pass the reason on, with limits. The server's wording is only shown when it is fit for a member: never for an internal server fault, never for anything long or that looks like a web page, and never for the refusals the app answers by taking you to the right screen instead. Checked on a real phone against a real refusal: the message read "A problem cannot be reported for this exchange right now" where it used to say "Please try again". The helper doing that filtering had no test of its own despite standing in front of every one of these messages; it now has eight.

  • And a check that stops it coming back, which names the file and line of any screen that reports a failure without passing the reason on. It is deliberately narrow: a quiet failure that tells the member nothing is left alone, because a background refresh that fails should not raise an alarm at anybody.

  • Six places where the app and the server disagreed about what a response contains — found by measuring, not guessing. The app asks the server 494 questions and only 78 of them were checked in any way. The other 416 fail quietly: a field the app expects and the server never sends simply arrives empty, so a screen shows blanks or falls over. That is what took the Matches screen down yesterday. The checking tool now finds ids for itself instead of using a fixed list, which nearly doubled what it can actually inspect — 58 questions checked before, 115 now — and the marketplace went from 11 of 32 to 19 of 32 once I also created the missing test data (an offer, a saved search, a collection, a pickup slot).

  • The one a member would have noticed: a group page never named the person who runs it. The app was looking for an "admin" field the server does not send, so the whole "Group admin" card silently disappeared from every group. It now uses the group's creator, which the server does send, and there is a test that fails if the card vanishes again.

  • The other five were the app promising itself data that never arrives. Achievement badges, the gamification profile, member surnames, organisation logos and volunteering opportunities all declared fields the server does not send. The screens survived on defensive coding, but the app's own description of the data was wrong — which is how the next change breaks something. All five now describe what actually comes back, with the reason written beside each one. Two more "mismatches" turned out to be the tool's fault, not the app's, and it now recognises that case instead of crying wolf.

  • The nightly phone tests were failing for a reason that had nothing to do with the app, and all nine now pass. Six of the nine were red — two for days, four as of this morning — and every one of them failed for the same silly reason: a yellow developer warning bar sits along the bottom of the screen, right on top of the tab bar, so a tap meant for "More" hit the warning bar instead. Every test that taps a tab then failed the next check. Meanwhile the ordinary test suite was green and so was the whole build pipeline, which is exactly why nobody caught it.

  • The warning bar was there because of a real gap in the community colour themes. One colour variable was missing from the generated per-community themes, and the app complains about that on every launch. The generator now carries every variable across on its own, so the next one added cannot go missing. The developer warning bar is also switched off for the test build — a test suite that any unrelated warning can break cannot tell you anything about the app.

Added

  • A course video lesson can now carry a transcript, so someone who cannot hear or watch it is not simply shut out. Found by the accessible-frontend audit (2026-08-23): a video lesson offered no text alternative at all, which is a WCAG 1.2 failure, and there was nowhere in the schema to put one. Instructors now get a Transcript box on video and embedded lessons in both the React builder and the accessible frontend, and learners get it under the player — in a disclosure, so a long transcript does not bury the rest of the lesson. A new course_lessons.transcript column carries it, following the existing podcast_episodes.transcript and messages.transcript precedents. Pinned by a round-trip test through create, update and read (a field that is accepted and silently dropped is worse than no field, because the instructor believes they provided one), and a render test proving markup inside a transcript is shown as text rather than executed. 🔴 Deliberately NOT included: transcript_language, which both precedents have. Theirs supports automatic transcription, which detects a language; this one is typed by the instructor in the lesson's own language, so the column would have no consumer. 🔴 This is a transcript, not captions. Real captions need a subtitle file displayed over the video as it plays, which needs an upload and storage path the schema still has no room for. The React player also stopped emitting an empty <track kind="captions">, which declared a caption file that does not exist — worse than declaring none.

  • The ASP.NET edition's wallet-transfer journey is now certified and banked. The unchanged React application selected a real recipient and transferred one credit against both backends in the same controlled run. ASP.NET moved the sender and recipient by exactly -1/+1, persisted the transaction in wallet history, traversed no known do-nothing endpoint, and the same checks passed against Laravel. Green CI at the exact evidence SHA raises the fixed R5 floor from 309 to 310 and the certified count from 11 to 12 of 250.

  • You can now write a post to your community from your phone. Until today the app could read every post a community wrote and never add one — there was no composer, no way in, and nothing in the app that called the server, even though the server has accepted posts all along and the website has had a composer for a long time. There is now a "What's on your mind?" row at the top of your feed, and a matching entry in the Create menu. Write, press Post, and the post opens so you can see it; go back and it is at the top of your feed. Two deliberate limits: no photo and no poll yet — those are separate jobs on the server side and each deserves its own proper test rather than being bolted on. Communities that have switched their feed off do not see any of it.

  • You can now report a problem with an exchange — for the first time anywhere on the platform. Coordinators have always had a tool for settling a disputed exchange, but nothing could raise one: the only way an exchange became disputed was an automatic check when the two people entered different hours. So if someone never turned up, or the help was not what was agreed, there was no way to say so about that exchange. Now either person can, while the work is under way or waiting to be confirmed: pick what went wrong, add a note, and a coordinator is told. On the phone today; the website still has no way in, and that is written down.

  • Found and fixed while testing it: the other person would have been told the wrong thing. Because the new report and the old automatic hours check shared the same notification, someone reporting "nobody turned up" made the other member read "conflicting hour confirmations". Telling someone their hours are in dispute when that is not what was said is a false statement about their own exchange. The wording now depends on what actually happened, and the automatic case keeps its precise message. There is a test that fails if that ever merges back together.

  • What the reporting screen deliberately does not do. It does not open a safeguarding case, and it says so plainly rather than leaving someone in danger thinking it has. Only the two people in an exchange can report on it. It is not available before the work starts (either side can simply cancel) or after it is finished (the credits have moved, and only a coordinator can undo that).

  • You can now open the app with your fingerprint. Sign in with your password once, turn on "Unlock with fingerprint or face" in Settings, and after that the phone's own fingerprint, face or PIN is what lets you back in. Walked on a test phone with a real enrolled fingerprint: the prompt appears before anything else on screen, the fingerprint opens the app, and cancelling leaves a clear "Locked" screen with an Unlock button.

  • Two things about it worth knowing. First, it protects the session already on your phone — it is not the same as the passkeys the website offers, which the server checks. Bringing those to the phone needs decisions only you can make (the app's signing certificate, and a file published on the API's domain), so it is written down and not started. Second, there is always a "Sign out" button on the lock screen: if a phone's fingerprint reader stops working, nobody is shut out of their own account. Turning the feature on requires passing the fingerprint check first, for the same reason.

  • The phone app now has a size limit it cannot quietly exceed, and we know for the first time how long it takes to start. Nothing in this project had ever measured either. The app's code now weighs 14.1 MB, and the build fails if that grows past 15.5 MB — so it can still grow with new features, but only on purpose. I proved the check can actually fail, and that it says "could not measure" rather than "fine" when something stops it running.

  • Start-up: about one second is spent in the app's own code. Measured three times on the test phone. Two things worth knowing. The number everyone quotes — the one the standard Android tool gives, about 1.3 seconds — is the splash screen appearing, not the app being ready, so it flatters us. And the honest figure for what a member actually waits on a real installed app still isn't measured: that needs the crash-and-performance service switched on, which is one of the things only you can do. I've written down what is and isn't measured rather than rounding it up.

  • Loading more of your feed as you scroll now has a test behind it, and was checked properly for the first time. It had never been exercised on a phone because the local test community only had twenty things in its feed — not enough to have a second page. Now there are forty-four, and scrolling to the bottom fetched the second page, then the third, and finished with "You've reached the end" without repeating anything. One thing worth knowing came out of it: the server can answer a request for twenty items with eighteen and still say there are more. An app that took a short page as the end would strand people part-way down their own feed; ours does not, and there is now a test that keeps it that way.

  • Your feed now notices when you have written something. The feed deliberately does not reload every time you open it, because that would cost a request on the app's busiest screen. But it did mean a post you had just written was missing when you came back to the list — which reads as a post that was never saved. It now reloads on return, and only when something was actually written.

  • You can now open a time credit to see what it was. Tapping a line in your wallet history opens it: who it was with, what it was for, when, what kind of exchange, and your balance afterwards where the platform has it. Before this the rows did nothing at all — and the server has been able to answer this question all along, with nothing anywhere asking it, in the app or on the website.

  • Every achievement you had earned was shown as locked. Ten of them, behind padlocks, on the Achievements screen. The app was looking for three different "has this been earned" markers and the server sends none of them — it records the date the badge was awarded instead. Found by the new check that compares what the app expects with what the server sends, which is exactly the kind of silent mismatch it was built for. Worth noting the achievements screen had no entry at all on the 140-item work list, though it is a whole part of the app; it has one now.

  • A check that the phone app and the server still agree with each other. Three of today's faults came from the same place: the app assumed the server would answer in a particular shape, nobody had ever checked, and the test alongside it was written from the app's assumption rather than the server's answer. There is now a check that takes nineteen real answers from the server — fifteen questions and four actions — and runs each one through the app's own code. Proof it works: delete the field that caused this morning's check-in fault and the check goes red. Worth knowing that the first version of it only covered questions, not actions, and would not have caught that fault — actions are the dangerous half, because by the time the app complains the server has already done the thing.

  • And it says what it does not cover. Five lists were empty when the answers were captured, so the check proves nothing about the items inside them, and it names all five rather than quietly implying it covers everything.

  • The wider picture from that audit, which is the part worth your attention. The app asks the server 494 different questions. Only 78 of them are checked in any way; 416 are not checked at all. The unchecked ones fail silently — fields come back empty and the screen either shows blanks or falls over, which is exactly what happened to Matches today. The biggest unchecked areas are the marketplace (89), groups (40), volunteering (34), jobs (31), federation (28) and exchanges (20). That is the largest pool of unexamined risk in the app and the obvious next piece of work.

  • web-uk: the last native date input is converted to the GOV.UK three-field pattern, and its browser-only min guard is replaced by a server-side check that a poll's closing date is in the future. min is advisory: anything posting the form directly could always create a poll that was already closed. A new poll_create.expires_past_error is hand-written in all eleven languages and both PHP lang gates pass with zero values added to the untranslated count.

  • web-uk: the 120 recorded GET API contracts are now replayed against a live Laravel on every push. npm run api:verify runs as a step of the Web UK authenticated accessibility job, which boots a real Laravel against a synthetic-only database. Its synthetic-accounts guard reached the database through docker exec, which cannot work on a CI runner; it now accepts the client command via WEBUK_CONTRACT_MYSQL_CMD instead. There is deliberately no flag that disables the guard, and it was verified to still refuse when fed a client reporting non-synthetic accounts.

  • web-uk: a missing bearer token on a helper is now a failing check. The consumer ledger classifies each helper guest/optional/required; the contract sweep learns from the live API whether an anonymous call is refused. Crossing the two detects the /organisations defect class. It has zero subjects today, so scripts/ledger-token-crosscheck.js is pinned by tests rather than trusted to be alive.

  • web-uk: every literal translation key in a template is checked to resolve. translate() returns the key itself when nothing matches, so a mistyped key renders as raw text to the member without any error. 7,839 complete keys scanned, none unresolved; concatenated keys are explicitly out of scope.

  • web-uk: the event moderation queue is pinned as a faithful window onto Laravel — it must not add its own filter, must send the moderator's token, and must not re-sort, de-duplicate or drop rows. The order assertion was mutation-checked against a deliberately sorting route.

  • web-uk: scripts/locale-invariants.js classifies English-identical locale values. npm run locales:audit now reports the raw count and the count actually needing a translator side by side.

Fixed

  • Depositing credits into an organisation wallet can no longer move them twice. The deposit locked the rows it touched, which prevents over-spend but not duplicate intent: a double-click or a network retry of an affordable amount created two real movements, both looking entirely legitimate in the audit trail. The personal wallet transfer and donate have carried an idempotency key for months — the organisation deposit was the outlier, and the accessible frontend's data-prevent-double-click was the only thing standing in the way, which does nothing without JavaScript. VolOrgWalletService::depositFromUser() now takes an optional idempotency key and uses the same guard as WalletService::transfer: an explicit client key over a 24-hour window, a 120-second content fingerprint as the fallback so an accidental double-click is caught even from a client that sends no key, the fingerprint bound to the content in both branches so a client that wrongly reuses one key for a different amount still gets two deposits, a replay of the original outcome rather than a second debit, the claim released when the deposit was refused so a corrected retry is not blocked by our own guard, and fail-open on any cache trouble. VolunteerController::orgWalletDeposit accepts the key from an Idempotency-Key header or the body; web-uk renders a fresh one per page and refuses to forward a stubby one. Proven by disabling the guard and watching the duplicate tests go red. 🔴 One existing test was silently testing the guard rather than pagination: test_get_transactions_returns_paginated_items made three identical keyless deposits, which now correctly collapse to one, so its fixture uses distinct amounts. 🔴 Recorded, not fixed: lang/*/svc_notifications_2.json is untranslated English in all eleven locales. The one key added here is properly translated rather than adding to that debt.

  • The accessible site no longer says "Page not found" about members who are really there. A member profile that the server declines to show has four quite different reasons, and every one of them landed on the same blank "Page not found" page: the person genuinely is not there; they have not finished setting up their profile; they show their profile only to their connections; or one of you has blocked the other. That last case was worse still — it produced a server-error page. Each now gets its own page, in all eleven languages, and the two that are not errors keep the site's normal header, navigation and footer instead of stranding you on a bare error document. This mattered more than it sounds: on one live community, 235 of its 260 active members were being reported as "Page not found" purely because they had never finished the sign-up wizard.

  • The API now says when a profile is withheld rather than missing. GET /api/v2/users/{id} returns the new PROFILE_PRIVATE code for a profile restricted by its owner's privacy setting, mirroring the existing PROFILE_INCOMPLETE. Both privacy branches previously returned no code at all, so every client had to guess, and all of them guessed "not found". The change is additive — still HTTP 404, so a restricted profile is still not confirmed to exist by status code alone, and a client that does not know the code behaves exactly as before.

  • A refused profile lookup no longer contaminates the next one. UserService::getPublicProfile() never reset its static error bag, unlike every sibling operation in that service. Because the controller reads the first recorded error, any process serving more than one lookup — a queue worker, a test run, Octane — reported every later refusal with the first one's reason, so a member who did not exist could be reported as having an incomplete profile.

  • ASP.NET RSVP certification now uses a real Laravel attendee control. The controlled React smoke signs the disposable fixture's second member into the event owned by its first member, asserts the saved relationship after reload, understands the confirmed-status chip, and restores the starting RSVP state.

  • ASP.NET event RSVPs now survive the page reload. The self-service action returns the canonical relationship, metrics and RSVP counts that the unchanged React client validates before refreshing; going and interested states are persisted for the signed-in member and reappear on event detail.

  • web-uk: around two dozen forms stopped discarding what the member typed. A field marked in error was re-rendered EMPTY after the failure redirect, so the member lost their work — worst case a group announcement, where someone could write a long notice, be told "Enter content", and find the box wiped. Group announcements (create and edit), the blog/review/resource/listing/feed/ ideation/goal comment forms, the account-deletion reason, insurance details, volunteering wellbeing/donations/expenses/safeguarding, group-exchange creation, saved collections and appreciations now stash the submission and replay it, via shared src/lib/form-replay.js. A password is deliberately never stashed. Two further defects surfaced on the way: volunteering training expiry read a field the GOV.UK date input never posts, so every training record saved as never expiring; and the appreciation form hardcoded checked on the public box, so a deliberate "keep this private" flipped back to public on every failure.

  • web-uk: eight create/edit forms sent every error to one hardcoded field. A member who left the price blank was told "Enter a price", the summary link jumped to Title, and nothing on the page marked Price. Marketplace listings and coupons, seller onboarding, podcasts, federation transfers, organisations, polls and the event registration form now link each error to its own field and mark that field; page-level API failures are no longer pushed into the error-summary list as link-less items. Nine more templates signalled validation through a status string and so never got the browser tab's "Error: " prefix. The resource library also never showed whether you had already liked something — the list rows were requesting reaction fields the API does not return, so the count was always zero.

  • web-uk: 61 back links moved out of the main landmark. The skip link targets #main-content, so those pages handed a keyboard user "Back" as the first thing after skipping. 154 templates were already correct, so this was drift; tests/back-link-placement-contract.test.js now pins it. "Load more" controls also never underlined on hover — 17 block-mode pagination links were missing the modifier govuk-frontend adds itself, while the 14 labelled prev/next links are deliberately left alone.

  • web-uk audit #7 — parity drift left by a week of backend fixes. The wallet manage page summed pending-in and pending-out into a single "Pending" figure that corresponds to nothing (the same fault was fixed in react-frontend and mobile; web-uk was missed); marketplace orders paid in time credits printed the cash total, so a 2-credit order read "€0.00"; voice messages sent from web-uk carried no duration, so Laravel stored every clip at its 1-second floor and other clients rendered "0:00" (now measured client-side by public/js/voice-duration.js, with no-JS behaviour unchanged); a moderated community's seller was told their listing was "published" while only they could see it, and meta.notice from the create endpoint was discarded; the listing detail page never rendered the ?status=… its own handlers redirect with, so no create/save/report confirmation was ever shown; a failed event cover-image upload was swallowed and reported as a clean save; creating a recurring series confirmed nothing; and the community fund rendered a live donate form over a permanent zero when the tenant's wallet module was off (enabled: false was dropped). Laravel side: the page-1 federation overlay merges status = completed rows and was still running for type=pending, interleaving settled credits into a pending-only list.

  • web-uk: silent successes, unreachable pages and lost input across eleven routes. Event invitation campaigns rendered their success sentence inside a red error summary (the template's success whitelist had outgrown the route file); /jobs sent an offset that JobVacanciesController::index ignores, so "Next" re-served page 1 for ever and every vacancy past the first page was unreachable (now cursor-paginated like its sibling pages); deleting an ideation challenge fed the confirmation token into the API's status filter and emptied the list; four optional date fields discarded readDate().error, creating never-expiring polls, invites, coupons and vacancies from a mistyped date; and message attachments collapsed every problem into one vague failure while translated "too many"/"wrong type" messages sat unreachable (limits mirrored from MessageAttachmentUploader, not invented).

  • web-uk: error summaries no longer race the screen reader. 49 hand-rolled summaries carried role="alert" on the element initAll() focuses — reproducing the race govuk-frontend fixed upstream by nesting the alert in a child container — while 180 others already used the corrected form. Summaries reporting a transient load failure additionally no longer steal focus on page load (data-disable-auto-focus). Pinned repo-wide by tests/error-summary-alert-contract.test.js.

  • web-uk: route-built English stopped leaking into all eleven languages. Course levels/costs/lesson types, ideation attachment types and badge rarity/tier/type rendered raw English enum words while their translated twins sat unused; job alerts built their entire description by English concatenation; percent suffixes, decimal points, am/pm with a fixed day-month-year order, a currency-symbol prefix map that puts before the amount in German, byte units and ", " list glue were all locale-blind. Now Intl-formatted throughout, guarded by tests/route-label-localization-contract.test.js. Two corrupted values fixed at source: the Japanese feedback mailto: had raw multi-byte characters in its percent-encoded query, and five locales had localised example.com into real registrable domains.

  • web-uk: GOV.UK component conformance. The event create/edit forms showed the same error under every field — the templates used a three-argument selectattr, which nunjucks silently treats as a truthiness filter — and date-field error links targeted a wrapper <div> that cannot take focus instead of the day input. 27 warning texts announced "There is a problem" or "Important" where the icon means "Warning"; the marketplace and event registration status banners were valid as neither a notification banner nor an error summary (no coloured band, no body); a federation error box had a title and no message; marketplace coupon and slot tables had no row headers and an action column headed "View" over "Edit" links; three navigation lists used a class that does not exist in the design system and rendered as stacked links; 19 decorative live-region attributes sat on static text; and five dead utility classes dropped their styling silently — one of them losing line breaks in members' own registration answers.

  • web-uk: keyboard and screen-reader fixes. The submit-button loading state set disabled, which drops focus to <body> and suppresses the new label's announcement (GDS says not to disable submit buttons; double submission is now blocked by a form guard instead). The review star rating reversed its row in CSS so arrow keys moved focus visually backwards. A duplicated aria-describedby silently detached the password-strength live region on register and reset. Language-switcher options and machine-translated event text now carry lang (and dir for Arabic). Repeated card-list actions, onboarding's five identical "Change" links and the announcement actions now name the item they act on; decorative images no longer repeat the adjacent heading; the voice-message player has an accessible name; and tightly packed link rows meet the 24px target-size minimum rather than relying on the spacing exception.

  • The two web-uk accessibility gates now pass in CI, where they had never actually run. The Web UK authenticated accessibility job was only added in this cycle, so its first real execution surfaced two faults that a local run could not: the keyboard/focus gate still asserted role="alert" on the error-summary root that the same cycle deliberately moved to a nested child, and the Arabic/Irish poll gates walk from the polls index to a poll detail page without any poll existing — E2ETestDataSeeder seeds none, and the gate had only ever passed against a developer database that happened to hold polls. The stale assertion is replaced by one pinning the corrected placement (verified to go red when the old form is reintroduced), and the job now seeds the polls the gates need as an explicit precondition.

  • Changing how the Web UK jobs are set up now re-runs them. The webuk filter in .github/ci-paths.yml watched web-uk/** but not platform-contracts.yml — the file that defines the Web UK jobs — so the commit that added the poll fixture woke no Web UK job at all and Platform contracts reported success with all three SKIPPED. The omission was deliberate and documented, on the stated grounds that these filters "appear in no REQUIRED_JOBS entry"; that ceased to be true on 2026-08-17, when the three Web UK … jobs were added to REQUIRED_JOBS because web-uk is the production accessible frontend serving three live hostnames. webuk now watches the workflow and ci-paths.yml itself, both workflow triggers accept ci-paths.yml, and the stale reasoning is corrected in place rather than silently contradicted. aspnet is deliberately unchanged — it is genuinely outside the deploy path, so the original cost argument still holds there, and a CI-config edit wakes the Web UK jobs, not the ~92-runner-minute .NET suite.

  • That poll gate then proved nothing, and now runs. Seeding one standard poll cleared the failure but the gate walks on to a RANKED ballot, and its test.skip for "no ranked-choice poll" marks the whole test skipped retroactively — discarding the assertions that had already passed. The job went green while the Arabic poll family was never checked. It now seeds an open standard poll AND an open ranked poll, each with two options, idempotent per poll_type. The remaining skips in that job are the declared ones (ACCESSIBILITY_ORG_ID=none, ACCESSIBILITY_GOAL_ID=none), which state their absence rather than inferring it from whatever the database happens to hold.

  • ASP.NET global React bootstrap calls now return tenant/member state instead of plausible empty payloads. Public menus include persisted published pages, OAuth discovery requires both the global switch and the tenant's provider allowlist, algorithm labels expose the four areas the React client consumes, and identity status reflects the member's latest verification session and badge. The no-op ratchet falls from 561 routes / 325 methods to 553 / 319; cryptographic CSRF generation and live realtime configuration are now explicit, tested defensible cases rather than silently broadening the scanner heuristic.

  • The ASP.NET React journey smoke no longer mistakes its own selector and confirmation failures for backend results. Credit transfer now selects the intended member inside the accessible search-results group, asserts both wallet legs and records every method/path touched by each journey for the no-op gate. RSVP accepts the product's real confirmation dialog, records its mutation, and client-reported response-contract drift now fails the owning step. Registration tokens are redacted from console evidence.

  • web-uk tests: three poll fixtures posted a hardcoded expires_at: 2026-08-01, a date that had been in the past since 2026-08-02. They encoded a stale literal as "a future closing date" and would have reddened the build for whoever added a past-date guard. They now compute a date relative to today.

Changed

  • The accessible site's footer now says the software is open source, rather than "free". The bottom of every accessible page read "Project NEXUS is free software licensed under AGPL-3.0-or-later." It now reads "Project NEXUS is built in the open. The software is open source under AGPL-3.0-or-later." The licence is still named, which is what the licence itself asks for. Six of the ten translations had rendered "free" as free of charge (gratis, gratuito, saor in aisce, フリー, مجاني) rather than free as in freedom, so all ten are rewritten too, with the Irish hand-written.

  • The Matches screen crashed for everybody. Matches is where the platform suggests people, listings and opportunities to you — and on the phone it did not open at all. It fell over immediately, because the server describes a match using one set of names and the app was reading a completely different set: the app asked for "source type" and the server calls it "module", and so on for the id, the reasons and the person. Nothing in the app checked, so nothing reported it. Fixed by translating the server's answer into what the screen expects, in one place, and the app now also copes with a kind of match it has never seen rather than falling over. Both suggestions now show properly, with their score and the reason for them.

  • Your listed skills were barely affecting your suggestions at all. This is close to the heart of what a timebank does. Skills are stored as you typed them — "gardening", "cleaning", "plumbing" — while listing text is chopped into word stems: "garden", "clean", "plumb". The two were then compared for exact equality, so they almost never agreed. Measured with the real code: gardening never matched a gardening listing, cleaning never matched cleaning, plumbing never matched plumbing, and any two-word skill such as "dog walking" could never match anything at all. Both sides now go through the same word processing, and a skill you rated yourself expert at still counts for more than a beginner one.

  • Honest about the limits of that second fix. It is proved at the level of the calculation — three tests, two of which fail without it — but I could not show it changing a suggestion end to end on real data, because your own listing text usually already contains the same words as your skills, which hides the fault in the easiest cases to set up. So the mechanism is fixed and demonstrated; the visible improvement is not yet measured. Recorded that way rather than claimed.

  • And a third test written from the wrong source. The test for the matches screen used the app's own invented field names as its sample data, so it passed happily while the screen crashed on the real thing. That is now the third such case today. It uses a real server response.

  • Offline event check-in has never worked, on any phone — and now it does. This is the feature that lets an organiser keep checking people in when the venue has no signal. Walked it properly for the first time: authorised the phone as a staff device, put it into aeroplane mode, entered someone's code, watched it queue, killed the app and reopened it to prove the queue survives, then reconnected and synced. The attendance was recorded correctly at the end. Before today it fell over at the very first step: authorising the phone worked on the server but the phone told the organiser "That offline check-in action could not be completed" and carried on saying no devices were authorised. The cause was two characters. The app stores the key that encrypts the offline queue under a name containing colons, and the phone's secure storage flatly refuses any name with a colon in it — so the write failed silently, the read came back empty, and the whole thing gave up. Every other stored name in the app uses underscores, which is why nothing else was affected. There was a second name with the same fault, found immediately by the new check that now refuses any name the secure storage would reject.

  • And the tests were pinning the bug rather than catching it. They asserted the exact broken names, copied from the code — so they passed while the feature could not work at all. Same lesson as the check-in fault earlier today: a test that agrees with the code cannot tell you the code disagrees with reality. Fixed, and the guard now checks the shape of every stored name instead of repeating it.

  • One honest limitation: I could not test restarting the app while still offline, because the development build fetches its own code over the network and cannot start without it. The restart was done after reconnecting but before syncing, which is what proves the queued action had really been written to the phone's storage.

  • The first screen-reader test the phone app has ever had — and it found three things. Someone using the app by ear, with the screen reader turned on, was being read the raw symbol code of every little picture before the actual words: every tab, every filter button. It sounds like gibberish and it happened before every single label. Second, every small informational badge — "1 conversation", "0 unread", "3 results", "No pending credits" — was announced as a button they could press, which then does nothing; the message list alone went from claiming 17 buttons to 12 once that was corrected. Third, the "save this post" button on the feed had no name at all, which only became visible once the symbol noise stopped hiding it. All three fixed, and re-measured on the phone: every control now has a proper name and no symbols are read out.

  • And the first check that buttons are big enough to hit. Measured properly this time, using the phone's real screen density — an earlier guess would have made everything look better than it was. Nine controls were 20 points tall where the accessibility standard asks for at least 24: the five filter buttons on the very first screen, and four on the exchanges screen. Fixed, and re-measured to none. Being honest about what this does not cover: five screens out of roughly 137, and a large group of buttons sit at 40 points — above the minimum but below what Android itself recommends. Both recorded.

  • Also worth writing down: the tool for reading what a screen reader sees was believed not to work on this app, and that belief had blocked this work. It does work — it just needs the screen reader switched on first. That is now written into the testing notes.

  • The wallet added credits coming in to credits going out and showed you the total. With 7 hours due to arrive and 4 hours due to leave, the wallet said "11 pending" — a number you have nowhere, and with no direction, sitting right next to "Earned +3h" and "Spent −5h" which do say which way the credits are going. It now reads "7 coming in, 4 going out", and the card beside it reads "+7h −4h". The website said the same wrong thing and is fixed too.

  • And when you tapped "Pending" to see what those hours were, the wallet said there were none. The list of transactions was fetched in a way that could only ever return finished ones, so the Pending tab was empty for everybody, in the app and on the website, while the card above it claimed hours were pending. The tab now asks the server for them properly. The rest of the history deliberately still shows only finished transactions, because the earned and spent figures are worked out from that list and a pending amount must never be counted as if it had happened.

  • Every row in your transaction history was cut off. The description and the amount were not drawn at all — a row showed a circle with initials, a name and a date. The cause was the same one behind the notification cards last week: the whole row sat inside a button, and a button limits its own height. Fixed, and there is now a check that stops this spreading — it counts the eight remaining places built the same way and refuses to let that number grow.

  • Worth knowing about pending credits generally: the only thing on the platform that creates one is a transfer from another installation of the platform, which is switched off and has never been connected to anybody. So in every real community these figures are always zero, while the wallet gives them a badge, a card and a tab. Not a fault, but it is three pieces of furniture for something nobody can see.

  • Every job advert said "Posted by" and then showed nobody. Walked applying for a job on the phone. The card naming who posted the advert was blank — a heading with an empty space under it — because the code that fetches a single job never looked up the person who posted it. It was a fault in the shared part of the platform, so the website's job page had it too, and both are now fixed. The job lists were always fine, which is why nobody noticed: only the detail page was blank. Worth recording how nearly this went wrong: there are two near-identical copies of that fetch, and the fix landed on the copy the website and app do not use. It looked correct and changed nothing. The test now checks both.

  • Applying for a job takes nine and a half seconds, and could tell you it failed when it had not. Measured. The reason is that the request sends two emails — one to you, one to the employer — before it answers, and it only had fifteen seconds before the app gives up. Your application is saved in the first second, so if the app does give up, the employer already has your application while you have been told it failed — and trying again is refused as a duplicate. This is the same trap as signing up, fixed the same way for now: this one request is given more time, and if there is still no answer it says something true — your application may already have been sent, check before trying again. The proper fix is to stop sending those emails inside the request, which has not been done and is worth doing.

  • Checking someone in at an event worked, and the app said it had failed. Walked on a phone as the organiser of a live event. Tapping "Check in" recorded the attendance properly — the database has the row, and the person's RSVP moved to "attended" — but the screen showed a red "Attendance not updated" and the list still said "Not checked in". The obvious thing to do next is tap again, and that genuinely fails, because the person is already marked as attended. So the organiser is left believing the app is broken while it has actually done the job. The cause was one field: the server has always sent back a note about whether attendance earns time credits, and the app was set up to reject any reply containing anything it did not already expect. Fixed, and checked in and out again on the phone from start to finish. Two things worth recording. The existing tests stayed green through all of this, because their sample reply was written from what the app expected rather than from what the server really sends — the new tests use the real thing. And the app now always re-reads the list after a failure, so an organiser can never be left looking at something that is not true. There are 141 places in the events part of the app set up to reject unexpected replies the same way, and nothing checks them against the server; that is recorded as a risk rather than fixed today.

  • Volunteer shifts: signing up for a second one quietly cancelled your first. Walked on a phone. A volunteer can only hold one shift per opportunity — that is how the server is built — but nothing on screen said so. Every shift looked the same, including the one you had just joined, so tapping "Sign up for shift" on another date silently dropped you from the first while the app said "Shift joined. You have signed up for this shift." Checked against the database: the volunteer was moved off Monday onto Wednesday with no warning. The shift you are on now shows a green "Confirmed" mark and a "Cancel shift" button, and joining a different one asks first, naming the date you would lose. Also worth recording: nothing anywhere on the platform can create a shift — there is no screen and no route in either the phone app or the website. Shifts only appear when the nightly job turns a repeating pattern into dates, and creating that pattern produces nothing until the job next runs, so an organiser sees an empty list and reasonably thinks it failed.

  • Both the phone app and the website described a shift-swap request backwards. When somebody asks to swap shifts with you, the card showing the two dates had them the wrong way round: it labelled their shift as "your shift" and your own as the one being proposed. That is the one card carrying Accept and Reject, so a volunteer checking their diary could easily turn down a swap that suited them perfectly. Walked with two accounts on two phones: the request, the accept, and both volunteers genuinely changing places were all checked in the database. Fixed in both the app and the website, each with its own test. One more thing found and recorded rather than fixed: there is no way to ask for a swap anywhere on the platform. Both can only answer requests, and the website's own empty state points members at a page that does not exist. Building it needs a decision from you first, because it means showing volunteers who else is on which shift.

  • Correction: the phone app has no fingerprint or face sign-in at all. The work list said this had "never been attempted on a device", which reads as though the feature exists and simply had not been tested. It does not exist — there is no code for it anywhere in the app, and no library. The website has it and the server is ready for it, so this is a gap between the two, and it is now recorded honestly as missing rather than untested. Whether to build it is your call. Worth knowing it can be tested once built: the test phone here supports it.

  • The "please accept the updated terms" gate now works cleanly on the phone. Walked it by removing a member's acceptance and then trying to do something: the app showed the terms, the version, a link to read them in full, and a choice between accepting and signing out. Accepting was recorded properly and the half-typed message survived. One thing fixed: alongside that screen the app also flashed a red "Message failed to send. Tap to retry." Nothing had failed — the send was held back until the terms were accepted — and retrying could never have worked. Two contradictory explanations of the same moment is worse than one, so the misleading one is gone. Worth knowing how this gate is built: it guards doing things, not reading them, so somebody who has not accepted can still read the app normally and is stopped at the point of acting.

  • The "you must update" lever has been fired for the first time, and it works. This is the one safety mechanism that cannot be added later — once a copy of the app is on someone's phone, it either already knows how to lock itself out or it never will. It had been built but never actually triggered. Triggered now: the server refused the app's version, and the app replaced itself with an undismissable screen offering the download, with no way past it. One real fault found by firing it: the small print read "Latest version 1.2.0 · you have 1.2.0" — on a screen refusing to let the member continue. That sentence tells someone the block is a mistake and leaves them nothing to do about it. It now shows the version genuinely required, and says nothing at all rather than something contradictory. What still remains before a release is the other half: the update it demands has to be genuinely downloadable.

  • Resetting a forgotten password works on the phone, both halves of it. Walked end to end: asking for a reset link, then following that link into the app and setting a new password. Checked afterwards that the new password signs in, the old one is refused, and the link cannot be used twice. Nothing needed fixing. Two things worth recording for whoever works on this next: the confirmation screen deliberately says "if an account exists with that email address", so it cannot be used to find out who is a member; and the reset link is only created after the email is accepted for sending, on purpose, so an email outage can never quietly cancel a link somebody is already holding.

  • Signing up on the phone created the account and then told you it had failed. This is the worst thing found today, because it is the very first thing anyone does. Registration checks that your email domain can actually receive mail and that your password has not appeared in a known breach — both of which reach out to the internet — so it often takes longer to answer than the app was willing to wait. The account was created; the app said "Request timed out. Please check your connection." Anyone who believed that and tried again was told their address was already taken, and would reasonably conclude the platform was broken. Registration now waits three times as long, and if it still gets no answer it says something honest: your account may already have been created, try signing in first.

  • And the error you did get was off the top of the screen. The sign-up form is longer than a phone screen, and its failure message appears at the very top — so someone who has just pressed "Create account" at the bottom sees nothing happen at all. The message now scrolls into view. Walked on a phone with a deliberately undeliverable email address, which is how both faults were found.

  • The "app looks dead on launch" problem was already fixed — nobody had checked. It was recorded in August as the worst open fault on the phone: a member whose sign-in had gone stale saw a bare spinner and the app hammering the server in a loop, which looks exactly like a broken app. It was reproduced deliberately today by revoking that member's sign-in on the server and launching: the app goes straight to the sign-in screen and says "Your session has expired. Please log in again." — one request, no loop. The repair had landed in the meantime and the record still said broken. Also confirmed by test that a flaky connection does not sign anyone out or wipe their queued event check-ins, which is the half that matters most.

  • Every voice message ever sent on the platform was recorded as one second long. Send a voice note from the phone or the website and the server stored its length as 1 second, so the recipient saw "0:00" whatever they had actually been sent — a 38-second message included. The cause was a single argument: the code that saves a voice message passed a hard-coded zero instead of the length, and the save then applied a "minimum one second" rule to it. Fixed on both sides — the app now sends the length it already measured and shows you, and the server now reads it. Checked on a phone: a two-second recording is now stored as two seconds. The website has the same missing half and is recorded for a separate fix.

  • New: a hand-off document for the phone app, at mobile/docs/MOBILE_HANDOFF.md. It states the goal, what has actually been proved on a device, the 30 journeys still to walk, the traps that have cost real time, and — separately marked as my own view rather than agreed plan — eight things I think the plan is missing. The most important is that there is no agreed definition of "ready", so the document proposes one and asks you to accept or change it. Two others worth your attention: a change to shared server code can break the phone app without any phone-app check running on it — which is exactly what happened with the voice-message bug above — and the test data on this machine has now drifted enough that repeatable automated runs need a reset script before that work can start.

  • You can now message the other person about an exchange from the phone. There was no way to: the exchange screen showed the status, the hours, who confirmed what and the full history, and offered no route to the person on the other side of it. Someone needing to say "I'm running twenty minutes late" had to leave the exchange, find the member and start a conversation from scratch. There is now a "Message {name}" button, shown while the exchange is live and dropped once it is finished — the same rule the website uses. Walked on a phone: it opened the existing conversation with the right member and the message was sent.

  • Searching listings, filtering them by offer or request, and taking a listing down were all walked on a phone and all work. Searching narrowed three listings to one; the Offer tab narrowed to the two offers; deleting a listing behind its confirmation removed it from the directory and the count fell. No changes were needed to any of them — the value here is that they are now proven rather than assumed, and each is pinned by a test so they cannot quietly break.

  • Notifications on the phone were being cut in half, and the unread number was wrong. Every notification card was cropped: the heading ("Marketplace order", "Ideation idea submitted"), the little category label and the "1h ago" timestamp were not shown at all, and the message itself was sliced through the middle of a word with no "…". The cause was the whole card sitting inside a button, and a button limits its own height. The header also said "10 unread" when 26 were genuinely unread, because it counted only the notifications it had loaded rather than asking the server — the correct number had been available the whole time and nothing was using it. Both fixed and checked against the database on a phone.

  • Job alerts can now be reached and created. Tapping a job-alerts link always landed on the Browse tab, because the screen never read the part of the link that names the tab. Worse, once you were on the Alerts tab, the alert you created was drawn below the bottom of the screen with nothing to scroll — you could not see it, pause it or delete it. Both fixed, and an alert was created and read back from the database.

  • A whole family of "content you cannot reach" bugs is now closed. The cause of the job-alerts one is a quirk we already knew about: the styling shorthand used for "fill the screen" silently does nothing on one particular container, so those screens size themselves to their content and anything past the bottom edge is unreachable. 86 places across 56 screens were given the real instruction, matching the 97 screens that already had it, and the check that watches for this is now set to zero tolerance instead of the old allowance of 115. A side effect worth noting: the open-source licence line at the bottom of Settings was previously cut off and now shows in full. Three screens were re-checked on a phone and the whole test suite stayed green.

  • Sell an item, then buy it — both now work on the phone, and a seller is no longer told their brand-new listing does not exist. The worst thing found in this sweep: communities have marketplace moderation switched on by default, so a listing you publish waits for a moderator before anyone else can see it. That part is correct. What was wrong is that both the phone app and the website then took the seller straight to a page that said "Listing not found. This item may have been sold, removed, or moved." — about the item they had just created, seconds earlier. The seller had no way to tell whether their listing existed. A seller can now always open their own listing whatever its moderation state; nobody else can, which was already the intended rule and is still enforced. The phone app also now passes on the message the server was already sending: that the listing is waiting for a moderator. This fix repairs the website as well, since the fault was in the shared API.

  • A marketplace purchase paid with time credits no longer says it cost €0.00. Buying with time credits leaves the cash total at zero, and the orders list printed that cash total — so a member who had just spent two credits saw "€0.00" against the order. It now says "2 time credits". Walked with two accounts on two phones: one member listed an item, the other bought it, the credits moved (25 to 23, and 86 to 88 the other way), and the order appeared as paid. One thing recorded and not fixed: the Checkout panel shows a heading with nothing under it when there are fewer than two ways to pay.

  • Community idea challenges now work end to end on the phone, and a single tap no longer wipes the page. Walked with two accounts: one member created a challenge ("How should we spend the tool library budget"), both members submitted an idea to it, and the second member voted on the first member's idea — all four actions checked in the database afterwards. One real fault fixed: tapping Vote, or submitting an idea, replaced the entire challenge with a loading spinner for several seconds before rebuilding it, because the screen could not tell a refresh from a first load. The page now stays put while it updates, and the button says "Submitting..." instead.

  • Members can now create a poll and vote in one on the phone, and the vote counter has stopped saying "1 votes". Both were walked on two phones with two different accounts: one member created a poll with two options, both voted, and both votes were checked in the database afterwards. Three real faults turned up on the way. The poll card showed a result that was not a result. The platform deliberately keeps the running tally private while a poll is open, so nobody's vote is swayed by what others chose — only the person who created the poll sees the split. The phone app did not know that, so it printed a small chip with no number in it (just the word "votes") and, once you had voted, drew two bars both reading 0%. It now says "Vote to see results" before you vote and "Results revealed when poll closes" after — the same words the website has always used — and it shows how many people took part when the server does tell it that. The question appeared twice, once as the card's heading and again immediately underneath. And the count was ungrammatical in every language: with one vote it read "1 votes".

  • Every "one of something" in the phone app now reads correctly, in all seven languages. 129 labels that count things — votes, posts, members, applications, minutes, results, reviews, spots left — had no singular wording at all, so a single item was always described in the plural: "1 votes", "1 members", "1 spots left". Someone had started this work and added the plural halves only. 903 singular phrases were written across English, Irish, German, French, Italian, Portuguese and Spanish, and a new check now fails the build if a new counting label ships without one, so it cannot drift back. Two smaller repairs came with it: three labels had lost their accents entirely ("postail" for "postáil", "Beitrage" for "Beiträge", "publicacoes" for "publicações"), and the Irish word for coupon uses was blank. 43 labels are deliberately exempt and listed by name — those are ones where the number is not counting a thing, like "3 left" or "All (3)".

  • Experimental ASP.NET backend: the first ten member journeys on the accessible site are now certified, and two of the three faults that were blocking them could not have been caught by any comparison we run. Development-only; the live platform is unaffected. "Certified" means the journey was driven through the site's own forms against .NET and the identical run passed against the PHP platform side by side, so a difference in test data can never be mistaken for a broken backend. Ten now clear that bar — signing in, posting to the feed, creating a listing, replying to an event invitation, sending a message, transferring credits, applying to volunteer, joining a group, leaving a review, and changing a setting that sticks — run twice, both engines, nothing excused. Joining a group was broken for every group. The server sent "are you in this group?" beside the group instead of inside it, so the page never saw it and offered "Join" even to a group's own owner, whose join was then refused. Every piece of that answer was individually correct and both engines replied "fine", so nothing that compares answers could have found it — only opening the page did. Joining a private group was also refused outright, where the PHP platform creates a request awaiting approval; a "you can join" signal the server will not honour is worse than none, so both were fixed together. Leaving a review could not work at all. The address that saves a review did nothing while replying "saved", so members were told their review had been left over a page that stayed empty. And the form was built without the recipient, because that field was simply absent from the reply — an absence, which a comparison of what two replies have in common cannot see. Both fixed, with the review now genuinely stored and the same anti-abuse rules the PHP platform applies. Two more were never faults. Applying to volunteer and joining a group could not be checked against the PHP platform because its test data gave the test account the only opportunity and the only group; recorded as test-data gaps rather than blamed on a backend, and now fixed — along with the discovery that the test-data file could not be run twice at all, dying on a database constraint and silently seeding nothing after that point.

  • Experimental ASP.NET backend: a review can now be attached to the exchange it is about, and a stricter-than-intended rule that silently blocked honest reviews is gone. Development-only. The reviews table had no link to the transaction being reviewed, so the rule "one review per exchange" could not be expressed and the rule actually enforced was "one review per person, ever" — meaning two members who completed a second exchange together were refused a second review by the database, with no message any screen could show. The link now exists, the correct rule is enforced, and the old one is removed. Also added: a review attached to an exchange must be between the two people who took part in it, which is what stops someone fabricating reviews for exchanges they were never in. Proved by replaying the change on a throwaway database both from empty and from one already holding reviews, checking existing rows survived, and confirming the new rule accepts a second exchange with the same person while still refusing a duplicate for the same one.

  • The ASP.NET plan was audited by three independent passes and it found errors in the documents written the day before, including in the score itself. All corrected. The score published yesterday as 355 was arithmetically wrong — the journey list's summary claimed 20 proved items where its own rows held 19, and one row carried a status that was not in the list's own vocabulary at all, so the published formula matched no reading of the table. Three documents said "eight categories" above a nine-row table, including the instruction telling every future report to list eight. And yesterday's headline promise was false: it said the new score "cannot go down because we looked harder", when under its own rules adding a newly discovered journey diluted its section and did exactly that. The audit also confirmed the opposite of what was feared about the code: the ASP.NET backend is real, roughly nine in ten of its endpoints do genuine work, and its money-handling code is production-grade with proper database locking and tests that fire five simultaneous transfers to prove no overdraft.

  • The ASP.NET scope is now everything, including the mobile app, and the measuring frame has been re-cut one final time to match: 270/1000. Owner decision. The mobile app (331 server addresses, about 138,000 lines) was in no plan at all and is now a tracked section of the work; the admin surface grew from 25 tracked journeys to 72, because 514 admin addresses were being represented by 25 items and public-sector buyers evaluate the admin panel. The work list grew from 130 journeys to 250. That is why the number moved, and it is the only one of the four re-cuts caused by a deliberate decision rather than a measurement correction. Three mechanisms now make another re-cut impossible, and all three are enforced by the build rather than promised in prose: every section of the list carries spare pre-counted slots, so a journey discovered later fills a slot instead of lengthening the list; a recorded floor means a published total can never fall, with any demotion recorded honestly in the list while the headline waits for the next net gain; and the score is recomputed from the list on every run, with the build failing if the two disagree. That last check was proved to fail correctly against twelve deliberate errors — including the exact arithmetic fault that produced yesterday's 355 — before being trusted.

  • The claim that the ASP.NET database had no backup was overstated, and the correction had been sitting unread in the repository for five days. A document written on 16 August — whose own index says "read before repeating the no-backup line" — records a restore-tested copy taken off the server on 10 August: 265 of 265 tables, 49,958 rows, verified by actually restoring it into a throwaway database. The container has been switched off since that date, so the copy is current. The older claim ("no successful backup since 8 March, nothing to restore from") was repeated in six places yesterday without that document being read. What genuinely remains: the scheduled backup job is still broken, the final two and a half hours before shutdown exist in only one copy, and the container must not be restarted. Corrected in all six places, and in the source document too, which still asked an owner question that has since been answered.

  • Two ASP.NET measurements were also overstated in the platform's favour and are corrected downward. Background tasks were reported as 26 of 69; Laravel's true scheduled surface is about 117, because one of those 69 fans out into 49 more, so the honest figure is 26 of 117. And push notifications are worse than recorded: the code uses both a Google delivery address and a login method that were switched off in July 2024, so native push cannot function at all rather than merely being outdated. Two others were overstated in the opposite direction and are corrected upward: search indexing has a working administrator rebuild (only automatic updating is missing), and Stripe payment webhooks are properly handled on two paths with correct signature verification — only one unused alias is not, and it refuses honestly.

  • The ASP.NET edition's goal has been rewritten, because how it was written down was making the job far bigger than it needed to be. Three faults, all in documentation rather than code, all now corrected in the documents themselves. First, every agent guide called the work optional — a decision record from 15 August described ASP.NET as "an optional future alternative" and said "do not promise that ASP.NET will be deployed", and that wording had spread into the main agent guide, the frontend portability guide, the documentation policy, the public README and the ASP.NET workstream's own guide. An optional project gets no scoped delivery plan, so nobody was ever authorised to shrink the goal. Second, nothing recorded why the work exists: a segment of public-sector buyers require a .NET application stack as a condition of procurement, so without this edition those contracts cannot be bid. That is now a formal decision record. Third, the goal was measured by comparing whole API responses, and because Laravel often returns raw database rows, copying internal columns no screen reads — down to a category's password-reset token — counted as required work. The target is now journey equivalence at the boundaries clients actually consume, with fields no client reads explicitly out of scope. A question put to the owner on 19 August about exactly this had gone unanswered, and the work continued under the strict reading by default.

  • The ASP.NET readiness score is now 355/1000, replacing 653/1000, and nothing regressed. The rubric changed and the two totals are not comparable — a rule now enforced in the documentation policy. The old rubric asked how much of Laravel's API surface had a .NET counterpart that looked right, and deducted points simply for surface that had not been measured yet, so auditing more carefully lowered the score while the software improved. That is why it had already fallen from 712 to 598 in August. The new rubric asks how much of the product has been proved to work on .NET, and is computed mechanically from a new finite list of 130 enumerated user journeys rather than from roughly 2,650 API endpoints. A journey list can be scheduled, split between agents and finished; an endpoint count cannot. The new score can only rise by making the product work, and it cannot fall because someone looked harder.

  • A finite work list now exists for the ASP.NET edition, with a status for every one of the 130 journeys and an explicit distinction between "runs against .NET" and "proved to match Laravel". That distinction exposed a real gap: the main app's automated browser test drives 37 steps against .NET but never runs the same steps against Laravel in the same pass, so nothing is fully certified yet — 21 journeys are proved to work but not proved to match. Adding that comparison arm is a half-day change to the test with no product risk, unlocks up to 21 journeys at once, and is now first in the queue. Also recorded: the rules that stop parallel agents colliding (migrations serialise, the shared test scripts conflict, one controller owns one verb), and six decisions that only the owner can make.

  • Stale instructions removed from the ASP.NET workstream's agent guide. It still told agents to treat a July pause handoff as the resume point, five weeks after the pause was lifted, and still described the deleted Blade accessible frontend as the source of truth for the accessible site's routes, layout, forms and workflows — telling agents to port patterns from code that was deleted on 14 August. The public README's ASP.NET figures were five weeks out of date (254 controllers, 165 migrations, 3,386 tests, a score from a paused workstream); they now read 279, 184, ~3,774 and the current rubric.

Added

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

  • Accessible frontend: three things the system could always do, but no one could reach. Members can now create an event for a group (the button never existed, so an event could never be attached to a group). Anyone reading a notification can now "Mark as read and view" in one step instead of two. Sellers renewing a marketplace listing can now choose 30, 60 or 90 days, where before every renewal silently took a default. All three come with hand-written translations in all 11 languages.

Fixed

  • Changing the picture on an event did not work. Not "sometimes" — it failed for every event that had a publication status set, on the live site, for everyone. A member hit it on 18 August and simply got an error back. The cause was one line inside the check that decides whether somebody is allowed to change an event's image. It compared the event's publication status by converting it to text, but that value stopped being plain text at some point and became a structured value, and converting one of those crashes outright. So the check died before it ever got as far as saying yes or no. Fixed, and two tests now cover both answers: a published event must be editable, and an event still awaiting review must still be refused — so the fix cannot quietly turn into "always allow". Worth knowing this is the whole of it, not the first one found: the same pattern appears in twenty other places in the events code, and every one was checked. All twenty are safe, because they read the value by a different route that does not do the conversion. This was the only broken one.

  • Mobile app: the phone was throwing away its saved copy of the community's settings every single time, so it had to ask the server on every launch. The app keeps a local copy of your community's name, colours and which parts of the platform are switched on, so it can show you something immediately while it checks for changes in the background. That copy was being filed under a name the phone's secure storage refuses to accept — one wrong punctuation mark — so both saving it and reading it back failed silently. Nothing showed an error, because a failed read looks exactly like "nothing saved yet". Two consequences: every launch waited on the network before it could show you your own community, and a launch with no signal fell back to showing no community at all — no branding, and every section of the app switched off. Fixed, and there is nothing to migrate because the old name never successfully saved anything. A test now drives the app with a deliberately awkward community name and refuses any storage name the phone would reject.

  • Our own test runs were writing fake crashes into the live error log. The phone app reports crashes to the server as well as to the crash-reporting service, which is deliberate — it is the only route that works today. But there was nothing stopping that from happening during a test run, and one test crashes the app on purpose to prove the crash screen works. So every time that test ran — on this machine and on the build server — it filed real crash reports against the live site. 77 of them in three days, and the error log had started flagging one group as getting worse. The practical harm is that they bury the real reports this nightly check exists to find. Test runs can no longer reach the server; the one test that is genuinely about the reporting itself still checks it, against a stand-in. Verified by running the whole phone test suite: 309 files, 2,116 tests, all passing.

  • Both of the safety checks that run when you make a commit were switched off on the development machine, and one of them had been off for twelve days on a repository that is public. Two separate faults with the same root cause. The first: the check that refuses to commit a password, private key or API key was restored to the project on 10 August, but installing it copies the file into a hidden folder — and nobody re-ran the installer, so the copy stayed on the 4 August version, which did not contain the credential check at all. The commit that fixed the lost check did not fix the installed check. The second: the gate that runs any test files you are committing looked for PHP on the Windows machine itself, which deliberately does not have a working PHP setup, so it printed "skipping" and waved every commit through. This is the gate the project's own guide calls the one gate that must never be bypassed, and it had never once run here. Both are fixed. Installing now creates a small pointer to the real file instead of copying it, so it can never fall behind again. The test gate now runs the tests inside Docker, which is where this project's PHP tests are supposed to run, and takes about ten seconds. And when it genuinely cannot check something — Docker not running, for instance — it now stops the commit rather than reporting success, because "I could not check" quietly reading as "this is fine" is exactly how it stayed broken. Proven rather than assumed: five deliberate checks, confirming the credential scan blocks a planted key, does not complain about ordinary text, that a passing test is allowed through, that a failing test is stopped, and that an unavailable Docker stops the commit instead of skipping.

  • Mobile app: you can now hide, mute and report things in the feed — none of which was possible from a phone before, and reporting is a safety feature. The "…" menu on a post offered Share, Save and View post: nothing at all about the content itself. The website has had hide, "not interested" and mute since its current feed was built, and the platform has always had a report route that alerts the community's moderators — the phone simply never called any of them. Now the menu offers Not interested, Hide this, Mute (only on someone else's post, and it names them) and Report, which asks for a reason from a short list and tells the moderators. Walked on a phone and checked in the database: a report was filed against a post, and a hide was recorded. Available in all seven of the app's languages, and covered by nine tests, each checked by deliberately re-breaking it. Two smaller fixes came with it: every label in that menu was clipped through the middle of the letters (all of them, on every menu in the app), and hiding a post while viewing it on its own page used to leave an empty page behind — it now takes you back. Mobile readiness moves 503 → 513 out of 1000.

  • Mobile app: posting a listing, an event or a group left you sitting on the filled-in form with no confirmation — a trap that invites posting the same thing twice. The post itself always worked; the screen simply never moved on. Now all three land you on the thing you just created, whether you arrived at the form from inside the app or from a link. 🔴 Also walked and checked in the database along the way: posting a request (as opposed to an offer) — the button correctly says "Post request", and leaving out a category is refused inline with "Please choose a category"; your transaction history, with its earned/spent filters and totals matching the ledger; giving to the community fund; and trying to send more credits than you have, which is refused before anything is sent with "You do not have enough time credits for this amount". One gap recorded rather than fixed: tapping a transaction opens nothing, because no transaction detail screen exists — and the website does not have one either, so that is a decision for you rather than a mobile fault. Mobile readiness moves 496 → 503 out of 1000.

  • The community fund showed nothing to anybody, on every community, since it was built — and donations were landing in it the whole time. Found by using the mobile wallet: a member donated one hour, the hour left their balance, the fund recorded it correctly in its own account, and every screen reported the fund as empty and switched off. The cause is a one-word mistake in the platform's own permission check: the wallet is registered as a module, and the code asked whether it was a feature. Those are two separate lists; the wallet is not in the feature list, so the answer was "no" for every community on the platform and could never have been anything else. Six addresses were dead this way — the fund's balance, its history, paying in, paying out, its own donate route, and the wallet's transaction categories. Nothing was ever lost: the money was always correctly recorded, it simply could not be seen. Now fixed and confirmed on a phone: the fund reads 1 hour with the donor named in its history. Two tests were added, both checked by deliberately re-breaking the fix — one of which asserts the contents of the answer, because the test that already existed asserted only that the address replied, and it passed happily for as long as the fund was invisible.

  • 🔴 Experimental ASP.NET backend: a private message could be written into a group conversation. Development-only; the live platform is unaffected and no real member's message was involved. Sending a direct message found the conversation by matching the two people in it, but never checked whether the row it found was a group conversation — the same two columns are reused for group rows, and the database's uniqueness rule only covers direct ones. Where several group rows existed for the same pair, a private message was written onto a group conversation while the screen read back from a different, empty row. So the message looked as though it had vanished; it had not, it was in the wrong place. Fixed in five places, and 186 message and conversation tests pass. Found by teaching the accessible site's automated test to actually submit a form — no amount of comparing responses would have shown it, because both backends answered "sent".

  • Experimental ASP.NET backend: seven of the nine "get something done" journeys on the accessible site now work, measured end to end. Development-only. The accessible site's automated test could open pages but had never submitted a single form, so every journey that changes something was untested. It now fills in and posts the site's own forms, on both backends in the same run, and checks the result rather than the reply: the post appears on a fresh page load, the new listing renders at its own address, an RSVP survives a reload, the message is in the thread, the balance falls by exactly the amount transferred and the note appears in the history, the volunteering application shows as pending, and a changed setting reads back. Two are genuinely broken and both causes are pinned down: joining a group fails for every group — the backend reports your membership alongside the group rather than inside it, so the site never sees it and offers "Join" even to the group's own owner, whose join then fails — and leaving a review cannot work, because the address that saves a review does nothing at all and the form is built without the recipient's identity, so it submits empty. Neither is fixed yet; both are recorded with the exact line to change. Worth noting: the test now stays red while a known fault is open, rather than reporting a clean run.

  • The count of do-nothing endpoints was too small, and is now 562 instead of 316. Development-only backend; nothing changed in the product. This is the tally of addresses that answer "success" while performing no work, and it is the number several plans and estimates lean on. It was missing more than it found, in four ways. Most of it: 177 addresses are handled by five catch-all methods that fall through to a shared "write it down and read it back" store — a request is recorded in an audit table and answered "recorded only". Because that touches the database, the counter read it as real work. A method carrying six addresses counted as one, so a single fix could look like six. Addresses written in an alternative form were resolved into paths no client can ever call, which is why five social-login addresses were unfindable — and why they were previously reported to you as "missing" when they existed all along. And, least comfortably, the counter was fooled by the wording of the fake replies themselves: it looked for certain words to decide whether code does real work, and matched them inside the field names of the invented responses, so three genuinely empty addresses were excusing themselves with their own output. The tally is now four separate categories, each of which must shrink and cannot grow silently, and raising it at all requires a written reason recorded in the file. Every part of it was deliberately broken first to confirm it fails, including an attempt to smuggle a real fault onto the "legitimately empty" list, which is caught.

  • Mobile app: three more journeys walked — turning down a request, and putting an event on the calendar for someone else to say they are coming — and a pop-up panel that would not go away is fixed. Declining a help request now has a walked path (the requester's note is shown before you decide), and an event was created, published behind a clear confirmation of what publishing does, and then marked "going" by the second member, which moved the card to "1 going". 🔴 The panel fix is a consequence of the earlier one: now that pop-up panels actually open, one was caught still sitting on top of a completely unrelated screen after tapping a link — panels are drawn above the whole app, so they did not disappear when the screen beneath them changed. They now close when you leave the screen that opened them, and a test proves it by deliberately re-breaking it. Also worth recording: two more of today's near-misses were column-name guesses in the database, not real faults — an event's date is stored in a different column from the one that looked obvious, and so is an RSVP. Both would have been reported as "the data was not saved". Mobile readiness moves 491 → 496 out of 1000.

  • Mobile app: six more journeys walked on real phones — sending credits, connecting with a member, and creating, joining and posting in a group — and every text field in the app was found to be the wrong size. Each step was confirmed in the database, not taken from the screen: one credit sent member-to-member (86 → 85 hours for the sender, 26 → 27 for the receiver), a connection request sent from one phone and accepted on the other, and a new group created, joined from the second phone, and its first discussion posted. 🔴 Three defects found and fixed along the way. First, every text field in the app was sized to the words inside it rather than to the space available — the "send credits" recipient search and the "start a discussion" title both appeared as small pills, awkward to tap and showing almost nothing. It looked for a while like a problem with the box around the field; painting that box bright red on a phone proved the box was already full width and the field inside it was not. One fix in the shared field component repairs every form in the app. Second, a member looking at a connection request they had just received saw the literal text "connections.status.pending" — a missing translation showing its own internal name. Third, a request that had not been accepted was labelled "Connected 22 Aug 2026", which is exactly the thing that had not happened. Both are now covered by tests that were checked by deliberately re-breaking them. Also recorded, not fixed: the wallet's send-credits panel does not move the field you are typing into above the keyboard. Mobile readiness moves 475 → 491 out of 1000.

  • Mobile app: member-to-member messaging is now proved to work on real phones, both ways. Walked on two phones side by side: one member sent a message, the other's phone showed 1 unread, opening the conversation marked it read, and the reply came back. Each step was confirmed in the database rather than taken from the screen. Nothing needed fixing — this journey worked; it had simply never been checked, which is not the same thing. Automated tests already cover the sending path and the unread badge, so all three steps count as fully certified. 🔴 One note for anyone working here next: the screen called "chat" in the code is the AI assistant, not member messaging; member conversations are a different screen.

  • Mobile app: a member can now complete a time exchange from their phone — and half of that journey did not exist until today. This is the transaction a timebank is for: one member asks for another's help, the helper accepts, the work happens, both confirm the hours, and time credits change hands. The app could send the request and then do nothing at all with it — no accepting, no declining, no starting, no marking it done, no confirming the hours, and no screen anywhere that listed your own exchanges. The person being asked had exactly one route in, a notification, and tapping it opened the wrong screen and said "Listing not found", because the app used one word for two different things: a listing you browse, and an exchange between two people. Built and walked on two phones side by side: the request was sent, accepted, started, marked done, and confirmed by both members, and one time credit moved — the helper went from 85 to 86 hours, the person helped from 27 to 26 — with the entry appearing in both members' own wallet histories. Every step was checked in the database, not just on screen. Available in all seven of the app's languages. 🔴 Two things found while doing it, both worth knowing. First, the formal exchange process is switched off by default for a community: with it off the button reads "Request this service" and opens a message conversation instead, which is correct behaviour and not a fault — but it means a community must turn the process on before members can use it. Second, a screen showing something two people share can go stale: after one member confirmed on their phone, the other's already-open screen still showed the old state until it was reopened. The two new screens now re-read whenever you come back to them; no other screen in the app does this, which is recorded as a known gap rather than quietly swept across sixty screens. Still not walked: declining, cancelling, and messaging someone about an exchange. The mobile readiness score moves 455 → 468 out of 1000.

  • Mobile app: bottom sheets open again, so comments, card menus and every other pop-up panel work — and a comment written from the phone was found in the database. These are the panels that slide up from the bottom of the screen. Every one of them had been unusable across sixteen screens, which took commenting, replying, the "…" menu on a post and the reactor list out of service. The cause was our own workaround, not the panel library. A previous repair opened the panel and then, a fifth of a second later, briefly closed and reopened it — a trick meant to survive a timing problem. Closing it, even for one frame, made the library conclude the member had swiped the panel away, so it closed for real. Removing that trick fixed all sixteen screens at once: no library was changed, no screen was rewritten, forty lines were deleted. 🔴 Why it went unfound for six days, and why four earlier attempts failed. The panel was opening. It slid into view and closed itself in about a third of a second, so every screenshot taken a second or two after the tap showed nothing — which read as a dead button rather than a panel that had come and gone. Capturing frames immediately after the tap caught it mid-slide, and that one measurement overturned the diagnosis. Verified on an emulator: the card menu opens and stays open in three of three attempts (and closes in three of three when the trick is put back), a comment was typed, sent and confirmed in the database, and a form panel inside a pop-up screen opens with its keyboard. Not yet confirmed on the owner's own phone: the fix needs a new app build. A guard test now fails if the trick returns — and it is honestly labelled as checking the code rather than the behaviour, because the behavioural version of that test cannot fail: the test framework collapses the offending sequence, so it reported the fix and the fault identically.

  • Mobile app: commenting, replying and "who reacted" are now proved to work from the phone, not just proved to open. With the panels fixed (above), the social journeys behind them were walked on an emulator and checked in the database rather than on screen: a comment written from the app, then a threaded reply to it recorded against the right parent comment, then a reaction added and the "who reacted" list opened showing the right member. The post's own comment count moved from one to two on the card without a refresh. Two of these four are now covered by automated tests as well; the "who reacted" panel is not — it is only ever stubbed out in tests, never actually rendered by one — and that is recorded as the next gap rather than glossed over. The mobile readiness score moves 408 → 455 out of 1000, all of it from journeys measured today.

  • Experimental ASP.NET backend: a member can now complete an exchange and the credits actually move — the first journey proved working end to end on the new backend. Development-only; the live platform is unaffected. An exchange is the transaction a timebank exists for: one member requests another's help, the provider accepts, the work happens, both confirm the hours, and time credits change hands. Driven through the app's own screens against the new backend, with the real backend running the identical journey alongside in the same pass: the requester went 15.5 to 14.5 hours and the provider from −1 to 0, and the same journey on the real backend moved 95 to 94 and 30 to 31. The screen was not trusted — the database was read directly afterwards to confirm the exchange was marked complete, both confirmations recorded at 1.00 hours, and a single transaction row moving those credits between the right two people. 36 of the 37 automated steps now behave identically on both backends, with nothing failing only on the new one. 🔴 Worth knowing: not one of the three things blocking this was a fault in the backend. Two were gaps in the test data — one member had completed the sign-up steps and accepted the terms while the others had not, which left the second member's pages rendering a menu and a footer with no content at all: no error, no failed request, nothing in the log, 13 form fields for one member and none for another on the same address. The third was the measuring tool: it could not read a minus sign, so a provider correctly credited from −2 to −1 was reported as "credits moved by the wrong amount" — the tool was accusing the backend of a bug that did not exist.

  • Experimental ASP.NET backend: a full count of the staff surface, and it is worse than the running total said. Development-only. Every one of the 1,119 administrator addresses on the real backend was catalogued and compared: 257 of the 1,008 that the admin panel actually calls — one in four — either do nothing or do not exist. Worst areas measured: the AI-agent screens (10 of 10 do nothing), legal documents (15 of 16), volunteering administration (34 of 52), and groups, enterprise and newsletters all between 56 and 60 per cent. 🔴 The count we have been tracking sees only a fraction of the problem. There are three ways one of these addresses can do nothing, and our counter recognises one of them: 108 are plainly empty, but 138 fall through to a shared "write it down and read it back" store — a request is recorded in an audit table and answered "recorded only", and a later read replays it. Nothing is moderated, sent, applied or deleted. Because it writes to the database, our counter reads it as real work. A further six return a fixed answer behind a genuine permission check. On top of that, spot-reading 52 of the addresses still classified as working found three more that are not, suggesting roughly one in fifteen of the remainder is also hollow. All of it is now catalogued per address so the work can be scheduled from evidence instead of guesswork, and the least trustworthy figure is flagged as such: the Caring Community screens report 155 addresses with none broken, which is exactly the family where "the address exists" has historically meant least.

  • The accessible site has nineteen administrator functions that lead nowhere. Not a fault anyone can hit — none of them is called from anywhere in the site — but every one points at an address the real backend does not publish, so a maintainer reading that file would reasonably conclude the accessible site has a full administrator surface. It has six addresses. Recorded rather than deleted, since removing unused code is a separate decision.

  • Experimental ASP.NET backend: the exchange page could not be displayed at all, so the exchange feature was unusable even though paying for an exchange worked. Development-only; the live platform is unaffected. This is the clearest example yet of why a working server is not a working product. The money side had been finished and proved the day before — two members confirm the hours, the credits move, the ledger balances. None of it could be reached, because the app could not draw the exchange screen. The server was sending the right exchange, under the wrong names: it called the two people "initiator" and "listing owner" where the app looks for "requester" and "provider", it called the hours "agreed" where the app looks for "proposed", and it wrapped nothing in the envelope the app unwraps. The app then tried to format an hours figure that was not there, threw an error, and showed "exchange not found" — while the server reported complete success. Every action button was hidden for the same reason: with the two names missing, the app could not work out that you were the person being asked. The statuses were in a private vocabulary too. The server said requested, inprogress and pendingconfirmation; the app knows pending_provider, in_progress and pending_confirmation, and an unrecognised status does not mislabel a chip — it blanks the whole page. All of it is now translated at the boundary, in one place, with a check that fails the build if a new status is ever added without being taught the app's word for it. The stored values are untouched, so the settlement work from the day before is unaffected.

  • Experimental ASP.NET backend: the "Request Exchange" button was missing from every listing in every community. Development-only. The listing page asks the server "does this member already have a live exchange on this listing?" The server answered a different question — "may they start one?" — in a shape the app reads as "yes, one already exists". So on every listing the button was replaced by a link to a non-existent exchange, and the status badge beside it crashed the listing page outright. The exchange feature therefore had no entry point at all. The server now answers the question that was asked, and answers "none" when there is none, which is what lets a member start one.

  • Experimental ASP.NET backend: three smaller exchange faults found in the same pass. Development-only. The hours a member typed into the request form were being thrown away and replaced with the listing's estimate — silently, with a plausible number in the reply. The status tabs on the exchanges list did nothing: the server did not recognise the word the app sends for "active", and rather than saying so it ignored the filter, so the Active tab listed completed and cancelled exchanges as well; it now refuses a filter it cannot honour instead of quietly returning everything. And on the dashboard, "your turn to confirm the hours" could never appear, because the one status that produces it was missing from the query behind the card. Two gaps are recorded rather than fixed: preparation time has nowhere to be stored on this backend, so it is accepted and dropped and always reads back empty; and there is no record of an exchange's history, so its timeline is rebuilt from the timestamps that do exist and is shorter than the real platform's. Both need a database change, which is a different piece of work.

  • 🔴 A real fault in the live platform, found while doing the above. On a completed exchange the app displays who left each rating by reading a nested "rater" object. The live platform does not send one — it sends the rater's first and last name as separate flat fields — so a completed exchange that carries a rating crashes the exchange page on the live platform as well. Nothing was changed in the app or in the live platform to hide this; the ASP.NET backend now sends both shapes, and the live-platform fault is flagged for a decision rather than quietly worked around.

  • Experimental ASP.NET backend: you could not tell who you were about to send time credits to. Development-only; the live platform is unaffected. The recipient picker in the transfer screen showed a first name and nothing else, so two members called Maya were indistinguishable on the very screen that asks you to confirm an irreversible transfer. The cause was not the transfer screen: a piece of middleware rewrites every response the backend sends to an ordinary member, blanking surnames and cutting full names down to the first word. The real backend does have a surname-privacy rule, but it applies it at exactly four places, all of them browsing screens — public profiles, member search, the member directory, and public event organisers. The transfer picker is deliberately not one of them, because confirming a financial counterparty is not browsing. The two search addresses are now exempt, with the four real places recorded alongside so the next reader sees the rule rather than guessing. Nothing was removed and the browsing screens still hide surnames — proven by a companion test that fails if the exemption ever widens. 🔴 Two larger findings recorded, not yet fixed: organisation names are being cut at the first space by the same middleware, so "Bristol Community Trust" displays as "Bristol" (the real backend explicitly exempts organisations); and 74 files in the main app read the surname field, composing a full name in 78 places, so every one of those is a candidate for the same unreadable-name problem. The proper repair is to invert the middleware so it only applies at the four real places — deliberately not attempted yet, because getting that wrong exposes surnames the platform currently hides.

  • Experimental ASP.NET backend: sign-up accepted throwaway email addresses and known-breached passwords that the real backend refuses. Development-only. Three checks existed on the real backend and none on this one: whether the address's domain can actually receive mail, whether it belongs to a known disposable-email provider (a 283-domain list, now copied across with a test that fails if the two copies ever drift apart), and whether the password appears in public breach data. All three now refuse in exactly the same way the real backend does — same status, same error code, same wording, and in the same order, so a throwaway address that already exists reports "disposable" rather than "already registered", as it should. Neither website needed changing; both already understood these refusals, and the translations existed in all eleven languages. 71 tests, including a deliberate check that a perfectly good sign-up is still accepted — without which a check that refused everybody would have passed every other test.

  • 🔴 A real fault found in the live platform while doing the above, and it is worth your attention. The real backend's email check is documented to let a sign-up through if the domain lookup cannot be completed. It does not. The function it relies on gives the same answer for "this domain has no mail server" and "the lookup did not answer", so the safety valve effectively never opens — meaning during a domain-lookup outage the live platform would refuse every new registration, across every community, until the outage cleared. The platform's own source code notes the ambiguity. The ASP.NET version distinguishes the two cases and lets the member through when the lookup is merely unreachable, recording a warning that the address was accepted unchecked. That is a deliberate difference between the two backends, and it is flagged for an owner decision rather than settled quietly: match the live behaviour, keep the new behaviour, or fix the live platform too.

  • 🔴 Experimental ASP.NET backend: the exchange — the whole point of a timebank — could not be started, could not be accepted, and cannot yet be paid. Development-only; the live platform is unaffected, and in production this journey runs on Laravel where it works. Four separate faults, found by driving the real app rather than by reading code: 1. A member could not even reach the request form. One small settings answer from the server was a placeholder that returned four values nothing reads and left out the four that everything reads. The app treats a missing answer as "switched off", so it politely showed "exchanges are not enabled for this community" on every screen and hid the Request Exchange button on every listing. Nothing looked broken: every request succeeded, no error appeared anywhere, and the automated response comparison called it a difference in field names. The whole feature was simply invisible. 2. Accepting a request answered the wrong person's question. The app sends "accept" one way and the backend was only listening the other way, so the request landed on an administrators-only placeholder that does nothing — and told the ordinary member who owned the listing "admin access required". The working code for accepting was sitting right there, unreachable. 3. Three actions let anybody change anybody's exchange. Confirm, decline and start each wrote a new status with no check that you were even involved. Demonstrated live before removing them: an ordinary member reopened a finished, already-paid exchange in one request. All three now go through the one place that checks who you are and whether the change is allowed. 4. Ratings on an exchange were always empty, and rating one crashed. The list was being read from the wrong table — by exchange number against a table of listing reviews — so it returned a real, plausible-looking review belonging to somebody else. And two pieces of code claimed the same address for rating, which makes the server return an internal error that a browser reports as a security-policy problem, sending anyone investigating in the wrong direction. What is still not fixed, and why it is not a quick patch: credits do not move. Paying an exchange requires both people to confirm the hours and agree, and this backend has nowhere to record one person's confirmation — the storage for it does not exist. The code correctly refuses rather than pretending, and now says so honestly instead of returning a fake success. Adding that storage is a database change, which is deliberately done by one session at a time and was not this one's to make. It is the named next step. Fourteen new tests pin all of the above, the count of do-nothing endpoints fell from 317 to 316, and the app's automated browser walk now drives the full exchange between two members — asserting that both balances actually moved by the right amount, not that a status label changed. On the Laravel comparison arm that walk moves credits correctly end to end. One honest gap: the fixes are proved by tests and a clean build, but were not run through the browser against the shared development server, because two other sessions were using it at the time. Also recorded, because each cost time and will cost it again: the tool that answers "is there a do-nothing endpoint on this journey?" said CLEAN for two endpoints that were doing nothing — it missed them because one method carried eight unrelated addresses, a shape its scanner does not recognise, now written down inside the tool. The first attempt at the ratings fix returned the right rows one level too shallow, which shows an empty list at HTTP 200 with the correct data in the payload. The new browser walk reported a failure that was my own test's fault, not the product's: it read the page once after a reload, got nothing because the page was still starting up, and said the accept had not worked — the database said it had. It now retries the read and distinguishes "blank page" from "wrong status". And two of these long comparison runs started close together exhausts the Windows machine's network sockets, which produced nine comparison failures in a row that were purely the host running out of room.

  • 🔴 Experimental ASP.NET backend: an exchange can now actually be paid — the credits move. The journey is still not finished, and the reason has changed. Development-only; the live platform is unaffected, and in production this runs on Laravel where it works. The entry above this one ends by saying credits cannot move because there was nowhere to record one person's confirmation. That storage now exists, and this is what it took. Paying an exchange is a two-person agreement, not a button. The live platform records each side separately — when the requester confirmed and how many hours they said, and the same again for the provider — and moves credits only when both have answered and their two figures are within a quarter of an hour of each other. If they agree exactly, that figure is paid. If they are close, the midpoint is paid. If they are further apart than a quarter of an hour, the exchange is marked disputed for a human to look at and nothing is paid. A single database change adds those five pieces of storage, copied field for field from the live platform so the two cannot drift, and the payment itself now goes through the same money-handling code the group-exchange feature already uses: one database transaction, both members locked in a fixed order so two simultaneous requests cannot interleave, and the balance worked out by adding up the ledger rather than trusted from a stored number. What is proved, and how. Ten new tests against a real PostgreSQL database, because the locking cannot be tested without one. They read the ledger back rather than trusting the server's reply: two people agreeing moves exactly two hours from one to the other, as one ledger row with both sides on it; agreeing within tolerance pays the midpoint, and the amount paid is the same number as the figure recorded; disagreeing beyond tolerance pays nothing; one person's confirmation alone pays nothing; someone who is not involved gets "not found" and changes nothing; asking twice after payment is refused and does not pay twice; two confirmations arriving at the same instant settle exactly once — run five times through a starting gate so the two really do collide; and an exchange the payer cannot afford is refused with a clear message while the other person's confirmation survives. The step the app calls "mark complete" now does what the live platform's does — hands over to the confirmation stage and moves no money — and only the person who did the work may press it. 🔴 Still not finished, and this is a different fault from the one it replaces: the app cannot display the exchange page against this backend at all. It asks for the exchange, then immediately reads a field called proposed_hours. This backend calls the same thing agreed_hours, calls the two people initiator and listing_owner where the app expects requester_id and provider_id, wraps nothing where the app expects a wrapper, and spells the statuses requested and inprogress where the app expects pending_provider and in_progress. So the app reads a missing field, throws, and shows "exchange not found" — and every action button stays hidden because it cannot work out which side you are on. This was invisible until now because the journey died earlier, at the payment. Translating that one answer into the shape the app already reads is the named next step, and it is an addition on the backend only; nothing in the app changes. The work list entry for this journey stays marked broken, deliberately, and its recorded cause has been rewritten to the new one. No score moved. Calling it proved would claim the app drives it, which is measurably false.

  • Experimental ASP.NET backend: two endpoints told members their legal data requests were being handled while doing nothing at all. Development-only; the live platform is unaffected, and in production these journeys run on Laravel where they genuinely work. A member asking for their account to be erased received "queued"; a member asking for a copy of their data received "pending". Neither queued nor recorded anything — the request was silently discarded. Both now refuse honestly with a "not implemented" response, logged as an error on the server, modelled on the pattern this backend already uses correctly for unhandled payment webhooks. Two details worth recording: the accessible site was already rejecting the fake success, because it insists on a confirmation field the pretend answer never carried, so this makes the backend honest about a failure the member already saw; and a third endpoint of the same kind (recording cookie/consent choices) was found in the same file and is now tracked rather than quietly left. Four tests pin the refusal, and the count of do-nothing endpoints fell from 319 to 317.

  • The check that guards every published score enforced nothing, while a comment inside it claimed the opposite. Two markers recording the ASP.NET score and the size of its work list were both declared "no cross-check", so the score was never compared against its own table, the work-list total was never compared against the list, and a comment stated the total "cannot drift silently" when nothing was reading it. It now recomputes each section's score from the individual journey rows, rejects any status outside the list's own vocabulary, checks the totals add up, counts the database migration files on disk against the number the documents claim, and refuses to let a published score fall below its recorded floor. It also scans every maintained document for superseded scores presented as current — which immediately found 28, including the public README and the accessible-frontend status page still publishing a five-week-old figure. All 28 corrected. Every new check was deliberately broken first to confirm it fails, and one rule I specified turned out to be too loose — it would have exempted any document that merely mentioned a retired sibling, including a fully current one — so it was tightened to require the document to declare itself historical.

  • Experimental ASP.NET backend: the main app's browser test could not fail, and now it can — plus it finally runs the same walk against Laravel in the same pass. Development-only; the live platform is unaffected. The automated browser walk of the React app against the new backend has 37 steps, and every one of them was being thrown away: a failure printed the word FAILED and the test still finished reporting success, so nothing it checked could ever stop anything. Every step now records its own result, the run prints a table, writes a machine-readable record of the run, and ends with a real pass/fail. Proven deliberately broken before being trusted: pointed at an address with nothing behind it, 36 of 37 steps failed and the test correctly ended in failure. A step that genuinely could not be measured — a form that never opened, a search that returned nothing to click — is now reported as SKIPPED and never counted as a pass. The bigger change is the comparison arm. The test can now start two copies of the unchanged app itself, one wired to the new backend and one to a throwaway Laravel, run the identical 37 steps against both, and label each step: both worked, both failed (so the environment is suspect, not the new backend), failed only on the new backend (a real defect to chase), or failed only on Laravel (the comparison itself is at fault). Only the third kind fails the run, so a shared environment problem can no longer be misreported as an ASP.NET defect. First full comparison run: 31 of 37 steps matched, and not one step failed on ASP.NET only — the new backend passed 35 of 37 with zero failures. The six non-matches are all on the Laravel comparison side: its throwaway test data holds only one community, so its sign-in screen offers nothing to choose and the sign-up step cannot complete, and four steps were skipped on one side or the other. That is a test-data gap to close, not a product fault, and the run says so in those words rather than reporting a clean pass.

  • Mobile app documentation rebuilt so the next session starts where this one finished. The old readiness document had grown by adding a new dated section each time something was found, until the top of the file described a different app from the bottom — and it scored the code carefully while barely scoring the product, which is how the app reached 302 green test files with three unreachable controls on the first screen a member sees. It is now four documents, each with one job: a short status page that is the only place a score is stated; a work list of 140 numbered journeys with a fixed total, so progress cannot be faked by quietly adding scope; a plan in ordered phases, each ending in a measurable change rather than a description of effort; and a harness guide recording exactly how to walk a journey on two phones, including every trap that cost time. The old document is kept, marked historical, for its measurements. 🔴 The score is now checked by machine, the same way the ASP.NET one is: inflating the headline without changing the table fails, and promoting a journey without updating the totals fails. I proved both by trying them. The app currently scores 408 out of 1000 — well-built code around a product that is largely unproven, with the honest reasons listed.

  • 🔴 Mobile app: pop-up panels do not open, and that blocks a lot of the app. Found while walking the journeys in the order a member meets them. Tapping "Comment" on a feed post fetches the comments from the server — confirmed in the server's own log — and then nothing appears on screen. The "…" menu on a card behaves the same way. Three taps in a row, still nothing. These sliding panels are used by sixteen screens: comments, the card menu, who reacted to something, chat, exchange details, goals, group pages, job details, reviews and five marketplace screens. Anything behind one of them currently cannot be reached. Not fixed yet, and deliberately so — the cause is in a third-party component that has already been repaired four times for this exact symptom, and a proper fix means changing something shared by sixteen screens, which needs its own careful session rather than the tail end of a walk. I ruled out the obvious causes one by one: animations are on, the app's set-up is correct, the library version has not moved, it fails identically without any of my changes, and it is not the known "tap it two or three times" flakiness. You can settle one thing for me in seconds: open the app on your phone, tap "Comment" under any feed post, and tell me whether a panel slides up.

  • Mobile app: you cannot write a post to the community feed from the phone. The Create button offers eight things — a listing, an item for sale, a message, an event, a poll, a challenge, a group, a goal — but not a plain post. The website has a proper composer and the server accepts posts; the app simply has no way to send one. Nothing was built, because that is a new feature and your decision. Worth knowing why no check caught it: our app-versus-website comparison works by matching pages, and the website's composer is a panel inside a page rather than a page of its own, so it was invisible to the comparison.

  • Mobile app: the volunteering journey now works end to end, and the money adds up. Continuing the two-phone walk: one member logged hours, the organiser approved them, and the credits moved correctly — the volunteer gained 2 hours and the organisation was charged 2, each with a proper entry in its own record. A certificate generated with a verification code, an expense went from the phone to admin approval and back, and a wallet top-up landed correctly. Fixed on the way: the expense amount box was too small to show a figure like "12.50" (a width instruction was being applied to the wrong part of the field — the third time this week the same kind of mistake has hidden in plain sight); a failed action threw away the reason — the server said "you have already logged hours for this organisation and date" and the app said only "could not log these hours"; and the organisation wallet told organisers the opposite of the truth, claiming approved hours "will need manual payment" when they are in fact paid immediately. 🔴 That last one turned out to be worse than wrong wording: the auto-pay switch beside it called an address on the server that does not exist, so it could only ever fail, and it controlled nothing. The website had already removed the same switch. The phone app now matches, which also clears the last remaining mismatch between the app and the server — 402 of 402 addresses now verified.

  • Mobile app: an organiser's own balance can drop with nothing to explain it. Found in the same walk and not yet fixed, because it touches money and deserves care rather than speed. When an organiser puts credits into their organisation's wallet, the credits leave their account correctly and the organisation records receiving them — but the organiser's own transaction history shows nothing. Measured: a balance went from 90 to 85 while the personal history still listed a single unrelated entry. Nothing is lost or invented, and an administrator can trace it, but a member looking at their own statement cannot. This is now the top outstanding item.

  • Mobile app: four buttons and links that led nowhere, found by running two phones against each other. Two emulators side by side, signed in as two different members, put through the whole volunteering journey: one registers an organisation, publishes an opportunity, the other finds it and applies, the first approves the application. That worked end to end. What did not: the volunteering page offered "Create opportunity" to everyone, and the form it opens cannot be submitted unless you run an approved organisation — so anyone else could fill the whole thing in and find the button dead. And three links into the app opened the right screen and then said "not found": a link to an organisation's dashboard (for an organisation the member owns), a link to a marketplace category, and a link to a blog post. Each was handing the screen the right information under the wrong name. All four fixed. 🔴 Two things worth recording: tapping around inside the app could never have found the link faults, because in-app navigation always used the right name — only an incoming link was broken. And one of our own tests was holding the blog fault in place, asserting the wrong name while looking like protection.

  • Mobile app: the screen where you choose your community showed no community names. Every row was a single letter and an arrow — you could not tell one community from another except by remembering the order. This is the screen people use to pick which community they sign into. It was broken on every size of phone, not just narrow ones, and it had never been noticed. Now fixed, and checked on two phone sizes: full-width rows with the community name and a line of guidance underneath.

  • Mobile app: the "Save" button was cut off on nine different forms. Anywhere the app asks you to fill something in — editing your profile, changing your password, creating an event, a group, a job, a listing or a volunteering opportunity — the bar at the bottom put the wording and both buttons on one line, and the wording won. On a narrower phone the heading had shrunk to three letters and "Save changes" ran off the edge of the screen. 🔴 Worth recording: my first repair only applied to narrow phones. Looking closely at the wide phone showed the same button clipped there too, so the repair would have looked right while leaving most phones broken. The buttons now sit below the wording and arrange themselves to fit, with no assumption about screen size at all. The jobs page tabs were also unreadable on a narrower phone — "My A…", "My Po…" — and now sit as two rows of two with their full names.

  • Mobile app: the community list could go blank and stay blank for five minutes. Separate from the above, and on the server side. The sign-in screen asks the server which communities exist; the server keeps the answer for five minutes to stay quick. It was keeping an empty answer just as readily as a real one — so a single query that came back with nothing would show "No communities found" to everyone, and every request after it would repeat that answer rather than checking again. Nothing would appear in any error log; the platform would simply look empty. Found live on our development server, where the list was empty while the database held four communities. Now an empty answer is never kept.

  • Mobile app: two buttons fitted the test phone and fell off a real one. Found straight after the fix below, by opening the same app on two different phone sizes instead of one. On the wide screen we normally test on, everything looked right. On a narrower phone — a very common size, and quite possibly the size of yours — the picture was different: of the eight reactions, only six could be reached, with nothing on screen to suggest the other two existed, and the "save" button had been pushed off the edge of the card and disappeared altogether. Now fixed: on a narrow phone the reactions arrange themselves as two rows of four so all eight are visible at full size, and the buttons under a post drop their word labels to stay on one tidy line. 🔴 Two things worth recording. Nothing we run automatically could have caught this — our picture-comparison check photographs three screens at one fixed size, and our other tests have no concept of screen width at all. And hiding a button's word label quietly removes the name a screen reader announces, so both buttons were given a proper spoken name; without that, this repair would have made the app worse for anyone who listens to it while looking perfect to everyone else.

  • Mobile app: the feed was offering three buttons on cards that had nothing behind them, which is why reactions "wouldn't save". Reported from a real phone. All three complaints were true and none was what it looked like. The heart appeared on every card — including the "badge earned" and "level up" cards, which are notifications rather than something anyone wrote. There is nothing there to react to, and the server refuses it, so tapping the heart could only ever fail: the icon flipped, the save failed, it flipped back. Because most of the feed is those cards, it read as "the app can't save my reactions" — while on real posts and listings it always worked. The eight emoji reactions were also invisible: they exist (👍 ❤️ 😂 😮 😢 🎉 👏 ⏰) but only a long press reached them, with nothing to hint at it, so the app genuinely looked like it had one reaction. And "View post" on those same cards always led to "Not found" — there is no post behind a badge. Fixed: those cards now show only Share, real content keeps its heart plus a small hint that more reactions are there, and the dead link is gone. Checked on a phone emulator: a reaction now saves and survives closing and reopening the app. 🔴 Worth noting — the website had already found and fixed the same reaction bug months ago; the phone app simply never received the fix.

  • Experimental ASP.NET backend: a brand-new member's complete first day is now proven, and session expiry was genuinely tested. Development-only; the live platform is unaffected. The automated browser walk now takes a fresh registration all the way through: sign-up form, email verification, first sign-in, the legal-terms acceptance screen (which appeared exactly as it should), acceptance, and arrival in the app. Separately, the backend was run with tokens that genuinely expire after one minute to answer "what happens when a session runs out?" — the honest finding is that the app sends you back to the sign-in page rather than quietly renewing in that situation, which is how the app itself is written and would behave the same against Laravel; the renewal machinery itself is proven working by the accessible site's sessions. One test refinement remains parked pending approval, since it needs a one-line change to the main app's code.

  • Experimental ASP.NET backend: the accessible site's signed-in pages are now proven too. Development-only; the live platform is unaffected. The accessible site's automated test now actually signs in — through the real login form with all its protections — on both the ASP.NET copy and the Laravel control, and confirms all eight member pages (dashboard, listings, events, feed, groups, volunteering, explore, knowledge base) render properly against the new backend: 19 of 20 page checks identical or rendering correctly, the twentieth being the already-explained test-data difference. Getting the scripted login working surfaced two traps now written into the test for good: the login page issues its security token twice and only the second counts, and the form silently requires a community field whose absence produces a message that reads like a wrong password and isn't.

  • Experimental ASP.NET backend: saving your cookie choices always failed, and looked like something else entirely. Development-only; the live platform is unaffected. Every attempt to save cookie preferences from the browser failed with what looked like a cross-origin security block. The truth was stranger: two pieces of code both claimed the same address, so the server crashed on every save, and the crash response happened to be missing the header browsers use to tell the two sites apart — so the browser blamed the wrong thing. One owner remains, and two quieter faults fixed in the same pass: the reply format now matches Laravel's exactly, and the save was reading a differently-named field than the app sends, so the "preferences" choice was being silently recorded as OFF no matter what the member picked.

  • Experimental ASP.NET backend: voice messages can finally be played back, and three of the four missing doors are now built. Development-only; the live platform is unaffected. A member could SEND a voice message against this backend but nobody could ever LISTEN to one — the fetch route simply didn't exist; the same was true of message attachments and of downloading your own volunteering certificates. All three now work, with the same privacy protections Laravel applies (no caching of private audio anywhere, strict content-type rules, and downloads only for people actually in the conversation — anyone else is told the message doesn't exist rather than that it's forbidden, so the response can't be used to probe who talks to whom). Certificates also re-check the allowed-document-type list on the way OUT, not just on the way in, so a file that predates the rules can't become retrievable. The fourth missing door — marking event attendance by scanning a signed pass — is honestly recorded as blocked: it needs the whole signed-pass system this backend doesn't have yet, and a shortcut version would accept any pass, which is worse than no door.

  • Experimental ASP.NET backend: polls never reached the feed, and two more contract repairs. Development-only; the live platform is unaffected. Creating a poll never announced it to the community feed — twelve polls existed in the development data and not one had ever appeared — so poll cards, and the voting data behind them, simply couldn't show up. Creating a poll now publishes it exactly as Laravel does, and poll cards carry their full voting data, including the fairness rule that hides running totals from everyone except the poll's creator until it closes (so early results can't sway later voters). Separately, the feed sidebar was returning raw internal database records for a "trending hashtags" list that nothing displays — harmless today, but one routine code change away from leaking internal data into a member-facing response — so it now sends exactly what Laravel sends and nothing more. And the feed's offers-versus-requests narrowing now works, with a test that runs against a real database to prove the query actually executes.

  • Experimental ASP.NET backend: three fixes found by actually walking the pages. Development-only; the live platform is unaffected. First, the events list ignored everything the app asks for — it now honours the requested page size, the upcoming/past/everything choice (so the dashboard's "Upcoming events" no longer shows last month's events), the group filter (so a group's events tab shows that group's events, not everyone's), and gives the same clear validation errors Laravel gives for nonsense values. Second, every help-page FAQ silently vanished on BOTH websites, because this backend sent them in a flat list while everything expects them grouped by topic — now grouped identically to Laravel. Third, the accessible site finally has a real, repeatable test against this backend: a committed script that starts both copies itself (so the wiring can never be mis-typed again), compares twelve public pages against the Laravel-backed control, and records the result — 11 of 12 identical, with the twelfth explained to root cause (test-data difference, not a fault) and documented so it can only ever be removed by fixing it. The main app's browser test now walks 17 steps including the help page, with no errors.

  • Experimental ASP.NET backend: the dashboard crashed, because of an earlier change of ours. Development-only; the live platform is unaffected. Events can be described in two ways — an older, simpler format and a newer, richer one — and each screen asks for the one it understands. Recent work taught this backend the newer format but not how to answer a screen that asked for the older one, so it always replied in the new format. The dashboard, the group page and the clubs panel all ask for the older format; the dashboard in particular could not cope and blanked out completely with an error. It now answers each screen in the format it asked for, and says which one it used. Worth recording how this was found: the automated response comparison rated events as fully fixed the entire time, because it only ever asked in the newer format — it took actually opening the pages in a browser to see the dashboard was broken.

  • Experimental ASP.NET backend: the news feed sent 33 fewer pieces of information per item than the app expects, and could not be scrolled. Development-only work; the live platform is unaffected. Each feed item now carries everything the app reads — whether a post was shortened, how many times it was shared or saved, who reacted to it, and the details specific to each kind of item (an event's date, a review's rating, a badge's name). Four of those are now real figures read from the database rather than blanks. Separately, the feed's "load more as you scroll" was broken in a way that looked fine: the app asks for the next batch using a marker the backend never sent back, so scrolling silently re-loaded the first batch for ever. The backend now sends that marker and honours the page size the app asks for. The feed's filter tabs were also doing nothing at all — picking "Events" or "Listings" returned the same unfiltered feed, because the backend was not reading the filter the app sends. They now work. Two gaps remain and are recorded: posts with several photos show only one, because this backend has nowhere to store the extra ones yet; and the "For you" ordering is the same as "Recent", because there is no ranking service on this backend.

Changed

  • Experimental ASP.NET backend: the score has been re-measured from scratch and is now 653 of 1,000 (up from 598). Development-only; the live platform is unaffected. Months of real work had never been formally counted, so the number sat still while the backend improved. Every measurement was re-run today at a fixed code version with all automated checks green on GitHub: the rise banks the feed/events/listings work, the first-ever measurement of saving actions, the browser proof that a member can sign in and actually use the app against this backend, and the removal of one deduction that had been counting a frontend's translation files against the backend by mistake (its honest replacement, covering the backend's real translation gaps, is smaller). From here the score is re-banked every time a batch of work is proven, instead of once a quarter.

  • Experimental ASP.NET backend: the paperwork has been completely reorganised. Development-only; the live platform is unaffected. The main status document had grown to nearly 3,000 lines, 81% of it old diary entries, and three different numbers each claimed to be "the current score". It is now a ~170-line page holding only what is true today; the diary moved intact into dated archive files; a new plain-English ROADMAP explains where the project stands without any jargon; and a sweep corrected every stale claim found by audit — references to a frontend deleted a week ago still listed as a "source of truth" in four documents, a "development is paused" fence from July that was lifted in August, links to a retired status page in nine places, and a mislabelled measurement that had been counting a frontend's translation files against the backend's score.

  • Accessible frontend: the audit list is finished. Listing and event cards are now one big click target (tap anywhere on the card) with a single keyboard stop, instead of two links to the same place. Personal pages — your inbox, your notifications, your orders, your applications, 27 in all — now say "Nothing here yet" instead of the misleading "No results found" when nothing was searched; translated by hand into all 11 languages, with a test that pins which wording each kind of page uses. Feed posts show one row of respond buttons instead of two stacked rows. And a sweep of all 325 pages against the test environment found zero server errors and one real blemish — the achievements page displayed the literal word "undefined" above its title — which is now impossible by construction, on every page, with a test.

Security

  • Patched a newly published vulnerability in a shared code library (nanoid). Two advisories against the "nanoid" identifier-generator were published and caught by the automated security scan on push. Only one copy in the project was genuinely affected (the root tooling tree, on 3.3.12 — patched versions start at 3.3.16); it is now on 3.3.18 like every other copy. The scanner also flags already-patched 3.3.16+ copies because the public vulnerability database over-matches the whole 3.3 line; those exact false positives are suppressed with version-scoped rules that still fail the scan if a genuinely vulnerable copy ever reappears.

  • Patched a newly published vulnerability in a shared code library (fflate). An advisory against "fflate", a compression library, was published overnight and caught by the automated security scan on push — the code had not changed, the vulnerability database had. Nothing here chose fflate: it arrives indirectly, underneath the PDF generator used by the admin impact report, so the fix is a patch-level move from 0.8.2 to 0.8.3 that the PDF generator already permits, and it touched one line of the lockfile and nothing else. Verified by rebuilding the frontend and running the impact-report tests. Two things worth recording: the public npm advisory database does not list this problem at all, only the scanner does; and the advisory is scored 6.6 while the scan is configured to fail at 7.0 and above, so the gate is stricter in practice than it reads.

Fixed

  • Listings — the worst remaining page on the experimental backend — now returns everything the app expects. Development-only backend. It was 50 pieces of information short of the real backend, 42 of them things the app's own code refers to, including the category, the author, the photo, the estimated hours, and whether you have saved it. Now nothing is missing.

    🔴 Where this backend genuinely has nowhere to store something, it says so rather than guessing. About fifteen fields have no home here — a price, availability, save and contact counts, the author's rating. Each is now reported as explicitly empty rather than absent, so the app gets a definite answer instead of nothing; and none is invented by guessing from a neighbouring value, which is the mistake that produced fabricated titles and dates earlier in this work. Two such guesses were caught by the compiler while writing it.

  • A shared piece of pagination was nearly changed for every page when only one page needed it. The real backend sends different pagination details depending on the page — listings gets a page number and totals, events and groups get neither. Changing the shared code would have made two pages report details the real backend never sends. It is now scoped to the one page that needs it, with the measurement written beside it.

  • Mobile app: there is now an undo button for sending out a fix, and the app tells people when a fix is waiting. Sending a fix out had careful safety checks; taking one back had none — the one command you reach for while something is actively broken was the only one that could be pointed at the wrong group of users unchecked. There is now a proper wrapper for it, and an automatic check that every group of users you can send a fix to can also have it taken back. 🔴 Deliberately, taking a fix back does not require a tidy workspace, unlike sending one out: it sends nothing from your machine, and whoever is running it is in the middle of an emergency, possibly with a half-written fix open. It does still make you name the group of users twice, so a rollback meant for testers cannot land on everyone. Separately: the app was already downloading fixes quietly in the background and only using them whenever someone happened to fully close and reopen it — so a fix could sit unused on a phone for days. It now offers "Update ready — restart now", in all seven of its languages. That prompt is deliberately dismissable, unlike the "you must update" screen: a blocking nag for an optional restart is how people learn to ignore the one that matters.

  • Mobile app: two screens showed a header above a completely empty page, and the cause turned out to affect a whole class of screens. The rewards/leaderboard screen and the Goals screen both did it. Neither was a data problem — the information was there and there was no error. The cause: a styling instruction that every screen in the app writes on its outermost element does nothing at all, because that element comes from a third-party component the styling system does not recognise. 112 files carry that dead instruction. Most survive it, which is why it went unnoticed — a screen only breaks when something inside it needs the outer element to have a height, and then that something collapses to nothing. Both screens now render properly, and 19 in total were fixed. I checked three of the flagged screens on a phone before and after: the broken one now works, and the two that already worked are pixel-for-pixel unchanged. The ~93 remaining files keep the dead instruction on purpose — rewriting them risks breaking layouts that currently look right, for no visible gain — and a new test blocks any new screen from arriving blank. 🔴 It also solves an older puzzle recorded in the code as unexplained: content on the "link not opened" screen that once rendered at zero size had the same cause.

  • Mobile app: the certificate the app trusts had already changed, and nothing noticed. The app pins itself to your server's security certificate so a fake server cannot impersonate it. That certificate is replaced automatically every 90 days — and the app was still pinned to the previous one, about five weeks out of date, with every automated check green. The app kept working only because a second, backup pin was in place, doing exactly the job it was added for. But that left one pin standing with no spare, which is the state that stops an app connecting at all if the certificate authority changes something. Fixed by pinning two long-lived certificates instead of the short-lived one — the short-lived pin was adding no protection while guaranteeing it went stale four times a year. There is now a check that compares what the app trusts against what your server actually presents; run against yesterday's settings it reports the exact problem. This also closes the early-October deadline I flagged: it is done, and the next review is mid-2027.

  • Mobile app: if it crashes on someone's phone, you will now know — and this needed nothing from you. Crash reporting was switched off in every single build, so the app's 13 different kinds of internal warning all went nowhere: crashes, failed sign-in storage, an unexpected server reply, a security check, and ten different "the server has changed shape" detectors. Two details made it worse. The warning telling you crash reporting was off was itself hidden in exactly the builds that ship. And one of those reports was sending the whole web address of any link the app could not open — which for a password-reset link means the reset code itself would have been sent to an outside company. Every report now goes to your own server as well, where it is recorded as an error and picked up by the nightly problem-report round-up you already have. Creating a proper crash-reporting account is still worth doing (it adds grouping and readable crash traces), and that remains yours to do — but you are no longer blind while it waits.

  • Mobile app: you can now require people to update, and it is proven working on a phone. This was the one thing on the whole list that could not be added later: a copy of the app already on someone's phone can only be told "you must update" if that copy already knows how to ask. Both halves now exist. The server refuses any app version below a minimum you control — and that minimum can be raised in an emergency without a new release. The app, on being refused, replaces its whole screen with "Time to update" and a button, in all 7 of its languages. Tested end to end on a phone emulator: the server refused, the screen appeared, the website was completely unaffected, and putting the minimum back restored normal service. It also cannot lock people out by accident — an app that does not say its version, a garbled version, or a missing setting all mean "carry on", and the "what version do I need?" address is deliberately never blocked, so "please update" can never become a dead end.

  • Events now work against the experimental backend, where previously the app discarded every single one. Development-only backend. The app checks each event against the structure it expects and throws away anything that does not match — and the experimental backend was returning an older, flatter shape, so the events list was permanently empty and attending an event was impossible. The structure the real backend builds has now been reproduced faithfully. Problems reported by the app's own checker went from 60 to zero, five events appear where none did, and attending an event works.

    🔴 It needed three separate fixes, each hidden behind the last. Fixing the list left the individual event page failing — an event could be listed but not opened. Fixing that left the attendee list failing — the attend button appeared but the people behind it did not. Each screen is checked separately, so "the list works" was not "events work".

  • A flaw in our own comparison tool had been distorting the events picture the whole time. The real backend can serve two versions of its event data and picks based on what the app asks for. Our comparison tool never asked — so it was handed the old version while the app receives the new one. Every events measurement ever produced compared the experimental backend against a shape the app never sees, including the figure that justified a whole piece of planned work. One line fixed it, and those addresses went from the worst in the set to matching. Nothing about either backend changed.

  • Mobile app: a failed load now offers a way out, and the "Create" tab no longer shows a blank white screen. There was no shared failure state anywhere in the app — the toolkit had a loading state and an empty state but nothing for "this did not load", so every screen improvised and only 19 of 43 large screens offered a retry. Your own profile screen had no failure handling at all: a failed load left you looking at a permanent skeleton with no way out but to force-quit. There is now one shared failure panel, translated into all 7 languages the mobile app carries, adopted on the profile screen first. Separately: eight buttons on the orders screen were permanently greyed-out button-shapes that were never meant to be tappable (they are labels), 15 hard-coded colour fallbacks that could never be reached were removed, and the Create tab drew literally nothing while it redirected. The Create tab and the redirect were both confirmed working on a phone emulator.

  • Mobile app: the rewards and leaderboard screen could show a blank page with no explanation, and the reason turned out not to be the one that was obvious. Found by opening a leaderboard link on a phone emulator: a title bar above an entirely empty page, unchanged for 45 seconds. Two genuine faults were found and fixed — the screen never checked whether any of its eight pieces of data had failed to load (so a real failure had no way at all to reach you, not even a message), and it could draw itself before the leaderboard had arrived, which is the very thing a leaderboard link opens on. 🔴 But neither fault explains the blank page, and it is still open. Measuring inside the screen showed the data present and no error at all while the page still drew nothing, and the server returns real data for all three addresses it calls. So this is a display fault, recorded rather than quietly bundled into the fix. The most likely cause is a layout problem that has already caught one other screen in this app twice.

  • Mobile app: being signed out mid-session now says so — and the fix for it took three attempts, two of which were wrong. Being returned to the login screen with no message is indistinguishable from a crash, which is exactly how members describe it when they report it. The first attempt made the sign-in machinery ask the pop-up system directly, which killed eight test suites outright. The second wrapped that in a "try, and carry on if it fails" guard; the tests went green and it shipped in a commit called verified — but the code-style gate had not been run, and it correctly rejected the change: that guard makes the app take a different internal path depending on whether the pop-up system is present, which is a genuine latent bug, not a style complaint. The third attempt removes the dependency instead of hiding it: the sign-in machinery now announces the message, and a small piece that lives inside the pop-up system listens and shows it. Both wrong versions are now blocked by tests, not just by the style gate.

  • Mobile app: found why the overnight on-device test run has never once passed, with proof. It failed on its first-ever run (2026-08-19) before the phone emulator even started, and it produced no evidence of why. Cause: the test machine has no configuration file, so the app had no signing key for the login token, and login crashed at the moment it issued the token — bad passwords returned a clean "wrong password", so probing the login page the obvious way said the API was healthy. Reproduced end to end locally, and confirmed the fix returns a successful login. Two separate faults destroyed the evidence: the failure report asked for the last 40 lines of the server log, but a crash report there is about 70 lines long, so it printed the tail and cut off the one line naming the cause; and the login check did not tell the API it wanted a machine-readable answer, so the recorded response was 600 bytes of web-page HTML instead of the error. Both fixed. 🔴 Unproven until the next push — this workflow only runs overnight or on demand, so it cannot be verified locally.

  • Nobody could sign in to the accessible site when it was pointed at the experimental backend — and the way this hid is the lesson. Development-only backend. The accessible site refuses to start a session unless the sign-in reply contains four specific pieces of information; the experimental backend was leaving one of them out, on every route that signs someone in. The real backend sends it everywhere. Fixed.

    🔴 Ten pages had been checked and all ten matched. They were all signed-out pages, and the signed-in ones simply bounced to the login screen — which is exactly what a signed-out check expects to see. "Every page matches" and "nobody can sign in" look the same from outside unless you check the sign-in reply itself. The new test checks that reply directly, and checks the values are real positive numbers rather than merely present, because the site rejects a zero just as firmly as a missing field.

  • The accessible site's blog and help pages were never the problem. Both returned an error against the experimental backend. The cause was that the accessible site tells the backend which community it is serving by name when nobody is signed in, and the experimental backend only understood community numbers. The real backend accepts both. So every signed-out page was affected; blog and help were just the two that had been looked at. All ten pages checked now behave identically to the real backend, with real content.

    The fix is deliberately narrow: the community name is accepted only when nobody is signed in. Once someone signs in, their community comes from their login token and nothing else — otherwise a request could be made to read another community's data. Both halves are locked down by tests.

  • Posting to the feed now works end to end against the experimental backend, verified by the post appearing on the feed afterwards rather than by the absence of an error. That makes two member actions proven through the app's own forms — creating a listing and posting — with sending credits reaching an open, working form and attending an event blocked by the separate events-structure work.

  • The one remaining untested member action turns out to be blocked, not untested. Attending an event could not be exercised because the events list showed nothing to attend. The reason is now established: the app checks each event against its own expected structure, the experimental backend returns an older shape, and so the app discards every event. The list is empty by design, not by accident. This is scheduled work rather than a mystery, and it stays counted as neither working nor broken until that structure is matched.

  • A fifth thing that was quietly failing on the live platform: the mobile navigation menu never loads. GET /api/menus/mobile returns a server error every time. This was confirmed against the live-derived copy of the real database, on a real community with real menu entries — not just the test fixture — so it is not an artefact of test data. The neighbouring menu address works fine, which narrows it precisely: the mobile handler assumes every menu it receives carries a list of items, and the fallback menus it can receive do not, so it fails and the error is swallowed without ever being written to the log. Not fixed — surfaced for your decision, since the live backend is treated as read-only in this work.

  • We had been measuring the experimental backend against a friendly sample, and now measure it against what the app actually calls. A tool that reads the entire frontend's source and lists every address it calls had never been run here. Run: the app calls 2,016 distinct addresses, where our comparison list held 170. The honest score against the real list is 77 of 195 (39.5%), where the sample said 46%. Nothing broke — 63 of those addresses had simply never been compared before, and only 10 of them match. Administrator-only addresses are now measured separately, because our test signs in as an ordinary member and those would all answer "refused" on both sides, which proves nothing while flattering the score.

  • The script that prepares the comparison environment was silently leaving it half-prepared. One line passed a file path in a form Windows could not resolve, so the step that switches on every optional feature failed — and because of where it sat, the script carried on regardless and reported success. Any measurement taken in that state would have shown roughly 27 addresses "failing" when they were merely switched off. Fixed, plus a guard that now aborts rather than continuing with a half-prepared environment.

  • The best measuring instrument turned out to be inside the app all along, and was being thrown away. The app already checks, as it runs, whether each response matches what it expects, and reports exactly what is wrong. The test was cutting that report off after 200 characters and discarding it. Captured properly, it reports 60 problems on a single address — and shows the gap is larger than "a few field names differ": the real backend serves a structured, versioned description of an event, with the organiser, schedule, location, permissions and figures each as their own block, while the experimental backend returns a flat older shape the app rejects outright. Nothing was broken by this discovery; it was already true and simply invisible.

  • The accessible site has now been run against the experimental backend as well, and a control run stopped me reporting five faults that do not exist. Development-only backend. Four of six pages behave identically to the real backend. Five pages first appeared to be refused outright — but running a second copy of the accessible site against the real backend, everything else identical, showed the same redirects there: they are the ordinary "please sign in" gate, not a refusal. Two pages, the blog and the help section, genuinely fail only against the experimental backend; the obvious explanations were each checked and ruled out, so it is written down as a precise open question with steps to reproduce rather than a guess.

  • A way to tell which of the remaining response differences actually matter. There are 862 individual differences across 63 addresses, and treating them alike is why a genuinely damaging one hid among cosmetic ones for weeks. A new tool ranks them by whether the app ever reads that piece of information, so work can start where it has an effect. Roughly a dozen addresses turn out to have no differences the app reads at all.

  • My own test wrongly reported that staying signed in was broken, and the correction matters more than the finding. Simulating an expired login by deleting the stored pass does not test renewal — the app simply finds nothing and returns you to the sign-in page without ever trying. Asked directly, renewal works correctly, including properly refusing a second use of the same pass. The test now says plainly that it reports the app's behaviour and not the backend's health. A genuine difference did surface underneath: the two backends use different addresses for renewing a login, which is recorded as an open question.

  • A member can now be shown to actually do something against the experimental backend, not just browse it. A listing was created through the app's own form — typed in, submitted, and confirmed afterwards to exist with exactly the text that was typed. Until now every write had been tested by talking to the backend directly, which skips the form, the security token and the validation the app really uses.

  • The test of member actions was itself throttled into meaninglessness. Each full pass makes around 190 requests against a development ceiling of 200 a minute, so the run tripped its own limit and reported failures that were nothing but throttling — two of them looked exactly like backend faults. The development ceiling has been raised; the live one is untouched. The actions themselves remain unproven either way, and are recorded as untested rather than as working.

  • The real app has now been run against the experimental ASP.NET backend for the first time, and it works — which immediately exposed two faults that comparing responses side by side had dismissed as cosmetic. Development-only backend; nothing a member uses today is affected.

    The unchanged app signs in and runs: the dashboard, listings, members and wallet pages all load real content, and 190 requests succeeded with none failing. Nothing in the app was changed to achieve this — only which backend it points at, which is the whole point of the exercise.

    Why it was worth doing. A tool already existed that asks both backends the same question and compares the answers. It had scored the events list as "differing by a few field names", which reads as tidying-up. In a real browser, one of those names turned out to be load-bearing: the app asks for an event's start_date, the experimental backend called it something else, so the app built an invalid date and the entire "Upcoming events" panel on the dashboard collapsed into an error message. The endpoint was returning a perfectly well-formed answer the whole time. Fixing it took the dashboard's visible content from 1,365 to 6,870 characters.

    The second: the "Needs your attention" panel was left showing a raw internal label instead of a button word like "Review", because the backend never sent which action was needed.

    🔴 Both were fixed by ADDING the missing information, never by removing what was already there. Removing risks breaking something that depends on it, and needs separate evidence per endpoint — that exact shortcut had already cost 82 failing tests earlier the same day.

    Then the app was pointed at the backend directly, without the development proxy — and it could not sign in at all. Browsers refuse a request whose headers the server has not explicitly permitted, and the experimental backend never permitted the security header the app attaches to everything it writes. Reading worked; every write, including logging in, was blocked before it left the browser. Nothing behind the login was reachable. It also never permitted the app's actual development address, only a substitute port that a documentation note had adopted as a workaround for this very gap. Both are fixed, and an unknown address is still refused — checked, not assumed. Both ways of running the app now work: 188 requests, none failing, all four pages rendering.

    🔴 A false pass I nearly reported. My first attempt at that direct test was still quietly going through the proxy, so it passed while proving nothing. The setting I thought I had changed never reached the app. What caught it was checking where the requests actually went. The test now works out and states which way it ran, and says plainly when a run proves nothing about this — so the same mistake cannot be repeated silently.

    🔴 Honest about coverage. This proves the app starts and browses against the experimental backend, both through the proxy and directly. It does not prove the accessible site does (never tried), covers five pages rather than all of them, exercises no member actions like posting or transferring credits, and is too short to test what happens when a login expires. No readiness score has been claimed for it.

  • Accessible frontend: two more forms completed, quieter feed cards, and small polish. Sellers marking an order as shipped can now include a tracking link and delivery method (the system always accepted them; the form never asked). Organisations approving or declining a volunteer can now attach a note the applicant will see. Both come with hand-written translations in all 11 languages. Quiet feed posts no longer stack "No likes · No comments" and "No reactions" above their buttons — counts appear once there is something to count. The matches pages format their average score correctly for right-to-left and Japanese readers, the header unread counter no longer looks like a phone-app bubble, and star ratings no longer flash a focus ring when clicked with a mouse. All verified against the disposable test environment, including creating a real poll and event through the forms.

  • Six things members and employers could try to do have been failing with a server error every single time, and now work. This is the live platform, not the experimental backend. Saving a search, running a saved search, posting an employer review, creating a job-offer template, previewing one, and the AI chat on a job vacancy all failed outright — every attempt, for every person, for months.

    The cause was one wrong name in six places. A shared helper for reading what the browser sent was renamed during the move to Laravel, and six places were left calling the old name. Calling something that does not exist stops the request dead. All six now use the correct helper, and each was checked against the running system before and after: saving a search now succeeds, and the ones that should refuse incomplete input now say clearly what is missing instead of collapsing.

    Submitting an idea with no title also failed with a server error, for a related reason — nothing checked the title before the code that needed it. It now returns a plain "title is required".

    🔴 Why no safety net caught this, which matters more than the fix. Two nets had a hole in the same place. The code analyser cannot see this class of mistake, because Laravel's base controller has a catch-all that might handle an unknown method at runtime, so the analyser stays quiet. And the only test covering the save-search endpoint checked that it refuses people who are not signed in — it never signed in, so it never reached the broken line, and it passed cleanly the whole time. Proving the door is locked proves nothing about the room behind it.

    Both holes are now closed. The analyser setting that reports this class of mistake is switched on — measured first as costing zero new findings, and verified against the exact settings the build uses. A new check fails if anything calls a helper that does not exist, and two new tests sign in and confirm a search is genuinely saved. Every new check was deliberately proved to fail when the original fault is put back, because a check that has never failed is not a check.

    Someone had already hit this once and fixed only their own line, leaving a note about it in another file. The remaining five were never swept for.

  • Accessible frontend: the last four audit findings, now translated into all 11 languages. Opening "manage your support" without an active supporter subscription no longer lands on an unexplained page — it now says there is nothing to manage yet. The insurance form gained the amount-of-cover, start-date and notes fields the system always expected but never offered (they were silently sent empty). A plain feed post no longer shows "Post" twice ("Post" tag over a "Post" heading) — the heading now says who wrote it, in the member's own language, where before the fallback was English-only for everyone. And the same time-credit balance no longer reads "100.00 hours" in the wallet but "100.0" on the dashboard — every page now formats hours the same way, keeping quarter-hour precision.

Changed

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

Fixed

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

Added

  • Three more places on the experimental ASP.NET backend were saving made-up information instead of refusing. Development-only backend, no real community affected. Creating a group task with no title saved it as "Untitled task"; submitting an idea with no title saved "Untitled"; creating a wallet category with no name saved "General". All three now refuse, matching what the real backend does — and the two that the real backend has an opinion about were checked against it word for word, including its exact wording and status code, which differed from what this backend was sending in three separate ways at once.

    🔴 One of them had a check that could never fire. The group-task code says "if the title is blank, refuse" — but the line immediately above it filled the blank in with "Untitled task", so the condition was unreachable. It read like validated input and wasn't. That is worth knowing as a shape to look for elsewhere: a placeholder applied above a guard silently disables the guard.

    🔴 Two web addresses exist on this backend that the real one does not have, and two of the three faults lived on them. The app doesn't use either, so nothing depends on them; whether they should exist at all is recorded as a separate question rather than settled quietly here.

  • Creating a listing on the experimental ASP.NET backend now checks what was sent, and stops inventing the parts that were missing. Development-only backend, no real community affected. It accepted anything: a listing posted with no title at all was saved as "Untitled listing", because the code substituted that text rather than refusing. The real backend refuses the same request, and refuses on rules each community sets for itself — a shortest allowed title and description, whether a category is required, and whether offers or requests are permitted at all — reporting every problem at once rather than the first one. The ASP.NET version now reads those same per-community settings, which it was already publishing to the app but never applying, and answers identically. Both refusal cases the comparison tool tests now match where they previously disagreed.

  • The comparison tool can now test a listing being created successfully, not only being refused. A valid listing needs a real category, and the two backends number their categories differently, so a single fixed request could only ever exercise the refusal. The tool now looks up a valid category from each backend just before it runs. That immediately exposed a difference that had been invisible: both backends accept the listing, but the real one replies with 76 pieces of information about it and the ASP.NET one replies with 11.

  • Accessible pages now use the correct singular wording and consistent section labels. Fourteen count labels across events, exchanges, listings, discussions, polls, search, volunteering, notifications, marketplace, jobs and organisations now say forms such as “1 reply” instead of “1 replies” in all 11 languages, with genuine Irish and appropriate zero forms. Thirty-two pages that belong to a wider area now show a translated section caption above the main heading; standalone confirmation, error, sign-in, verification and unsubscribe documents remain deliberately uncaptioned.

  • The member directory now explains why it lists fewer people than have joined, instead of just looking empty. On a community of 369 members the directory could list a dozen and say nothing about it, which reads as a broken page or a dead community. It is neither: the directory hides people who have opted out of being found, plus whichever profile-completeness rules the community has switched on. GET /v2/users now returns community_total (every active member), directory_total (the subset it may list) and directory_criteria (the visibility rules actually in force for that tenant) alongside the existing pagination meta, computed in one aggregate query that mirrors the listing query's own visibility conditions — both counts exclude the viewer, so they are directly comparable. Both frontends render the explanation: React shows a secondary Alert above the results, web-uk a govuk-inset-text panel, each naming only the rules that community applies and linking the signed-in member to their own privacy settings. The note is deliberately suppressed when nothing is held back, while searching, and in Near me mode, so a community that hides nobody never apologises for an absence that does not exist. React also gained a Showing X of Y members count for the unsearched, partially-loaded case, which previously reported only the number on screen.

    🔴 The copy deliberately does not mention recent activity. The directory has never filtered on last login — the shortfall is opt-outs and profile rules only — and both the backend docblock and the tests record that so the wording cannot drift into implying otherwise.

    New members.coverage_* strings across all 11 locales in both catalogues (lang/*/govuk_alpha.php and react-frontend/public/locales/*/common.json), with hand-written Irish. Regression coverage: UsersControllerTest (meta contract, criteria reflect tenant config), MembersPage.test.tsx (note shown only when a gap exists, hidden while searching and in Near me), and a new web-uk/tests/members-directory-coverage.test.js exercising the wording builder directly.

  • Recorded a 62-value improvement in the PHP translation ceiling. .github/php-lang-untranslated-baseline.json drops from 258 to 196 untranslated values, retiring the Irish govuk_alpha_courses (48), govuk_alpha_federation (13) and govuk_alpha_commerce (1) entries that earlier translation work had already cleared but never re-baselined.

  • Fixed: text in the mobile app no longer slides up under the clock. On any screen you could scroll, content passed behind the status bar with nothing between them, so what a member had typed collided with the time and battery icons and both became hard to read — worst on the sign-up form, where the surname field and the clock overlapped. Every scrollable screen was affected.

    The cause is that the app draws right to the edges of the screen — the default for our Expo version and a requirement of Android 15 — so the status bar is see-through and the app shows through it. There is now a small strip of the app's own background colour behind it. It cannot be tapped, so it never swallows a button press, and it shrinks to nothing on a phone that has no status-bar gap.

    Checked that it does not hide anything, which was the obvious risk in painting over the top of the app. One screen (Create) sets no top spacing of its own, so I looked there specifically — the strip covers only empty background. And the More menu after the fix is identical to a picture taken before it, with the card's top edge and heading fully visible.

  • Fixed: the feed's filter row no longer cuts off mid-word. The row of filters across the top of the feed (All, Following, Saved, Posts, Exchanges…) was sliced through a word — "Exchan" — with empty space after it and nothing to suggest it slides sideways. It read as broken text. The row now runs to the edge of the card, so the last filter is cut at the card's own rounded boundary, which is the normal way an app says "there is more this way".

    A soft fade would be nicer still, and our design library has a component for exactly that, but it needs an extra piece of native software installed and the app rebuilt — not worth it for a fade. Noted in case that gets added for another reason.

  • Checked the mobile app at large text size for the first time, and it holds up well. Large text is usually where labels get chopped off, so this was the most likely place to find more problems. Mostly it was not: the sign-up form stays readable with the terms line wrapping onto two lines, the More menu shortens long descriptions with "…" instead of clipping them, the Create screen wraps all six descriptions cleanly, and the bottom tab labels still fit.

    One thing seen but not confirmed as a fault: at large text the More menu's small heading looked sliced at the top. It is not caused by the new strip — I checked that directly — and may just be a picture taken mid-layout. Written down as something to reproduce properly rather than claimed as a bug.

    Not covered: the two largest text settings Android offers, where wrapping usually gives out.

  • 🔴 The mobile app can now take on each community's own colours — proven working on a phone. This is the groundwork for the change you asked for: one colour throughout, and that colour being the community's own rather than the design library's purple.

    Tested with the Agoris community, whose brand colour is teal. On the sign-in screen the logo tile and the Sign in button are now both teal. Earlier the same screen showed a blue logo beside a purple button. Measured on the device to be certain: the button is exactly the teal generated for that community, not an approximation.

    Why it needed building rather than configuring. Thirteen parts of the design library decide their own colour from a single value, and the library offers no way to set it. That value is fixed when the app is built, so each community's colours have to be prepared in advance. There is now a small file listing each community's colour, a script that turns it into what the app needs, and a switch that picks the right one once the app knows which community you are in.

    Adding a community is a one-line change plus an over-the-air update — no app-store release. And until that update lands, a brand-new community simply gets the platform's default colours, which looks deliberate rather than broken. That fallback is not a nicety: without it the app would crash on launch for a community it did not recognise, and there is a test holding it in place that I confirmed goes red when removed.

    One detail worth knowing. The label on a coloured button is worked out rather than assumed. A community might pick a pale colour that white text cannot be read against, so the script measures the contrast and picks white or near-black accordingly. The website hardcodes white and its own notes admit that is only checked for the default colour.

    It already matters: in dark mode the teal button uses dark text, because white would have failed — and so does the default blue one. A yellow, mint or pale-blue brand colour would get dark text too, with tests proving it rather than leaving the branch unexercised.

    🔴 This took three attempts to make safely, and the reason is worth recording. Lightening a colour for dark mode makes it a different colour from the one the app paints things with by hand — so each attempt broke something: 79 buttons that painted their own background, 91 more that did it conditionally, and 72 icons that hardcoded white beside a label that had become dark. Every one of those is now fixed, and each fix was worth making on its own: the 72 icons were already wrong for any community with a pale brand colour, invisible on the main button of every screen, and nobody had noticed because both current colours happen to be dark.

    What is still to do: the colour list currently holds two communities, because the real colours for the other nine live in the production database and I have not read them. And roughly 128 places still set an icon's colour by hand — those become unnecessary once the single colour is in place, and removing them is the next step. So this is the mechanism proven, not the job finished.

  • A flaw in my own checking, worth recording. I had been verifying type-safety with a command that printed "typecheck OK" whether or not it passed — the way it was written, the success message ran unconditionally. Real errors did still appear in the output, and I caught and fixed each one I saw, but the reassuring line at the end was meaningless.

    That is the fourth instance this week of the same shape: something reporting success while proving nothing. The others were in the app and its tests; this one was in how I was checking my own work. Verification now gates on the actual result, and every check was re-run properly.

  • 🔴 Found by accident, and more important than the fix that found it: the screenshot check could not see most changes to the app. After fixing the rounding, I compared the app against its saved reference pictures to confirm the change. The check reported "0 pixels different — everything matches". I had just measured the corners changing from 26 to 18 points, so both things could not be true.

    Counting the differing pixels by hand gave 9,168 — the check was reporting 40. The cause is a tolerance setting: the check ignored any colour difference smaller than a certain amount, and this app is largely light grey panels on white cards, which are only a shade apart. So an entire class of change was invisible to it: a panel moving, a corner rounding differently, one surface swapped for a similar one. It would have said "perfect match" to all of them.

    This matters beyond one setting. The mobile readiness report described those screens as "checked to the pixel", and that was overstating the protection considerably — I had been relying on a check that was mostly asleep.

    Now tightened, and the number was chosen by measurement rather than taste: at the new setting an untouched screen still reports exactly zero, while both genuinely changed screens report a clear difference. One step tighter and an untouched screen reports 1.7% difference from ordinary rendering noise, which would have everyone ignoring it within a week.

    It immediately earned its keep. With the tolerance corrected, the check caught a second screen I did not know had changed (the More menu), and its difference pictures marked exactly the twelve corners involved and nothing else. Both screens' references have been updated after I looked at those pictures.

    There is now a test that fails if the tolerance ever drifts back, and I confirmed it goes red with the old settings restored.

    The pattern across this week is worth naming: three separate faults where something reported success while doing nothing — a styling name that emitted no styling, a container that crushed an icon to a few pixels, and now a check that compared images and saw nothing. In each case the green result was the problem. I have started treating "this passed" as a claim needing evidence rather than a result.

  • Fixed: a rounding value the app asked for 625 times had never been created — and a correction to what I told you about it. The app asks for rounded panel corners 625 times across 99 files, using a name that was defined nowhere: not in the app's styles, not in the design library, and not at any point in this project's history. It was written against a convention nobody ever built, and the styling tool silently ignores a name it does not recognise.

    🔴 I first reported this as "632 square corners". That was wrong. The design library's own cards and buttons already round their corners, so an ignored instruction still leaves a rounded corner. Counted properly, by looking at what each one sits on:

    • 382 asked for the same rounding the element already had — no visible fault at all
    • 171 asked for the smaller inner rounding and got the larger one — slightly too round
    • 69 sat on plain elements with no rounding of their own — genuinely square

    So it is 69 square corners and 171 slightly wrong ones, not 625. I also said it was "probably the most visible single improvement", and that was an overstatement — it sits well below the colour work. The fix is the same either way: one line defining both values, chosen so the 382 that were accidentally right stay exactly as they are.

    A third name, used once, turned out to be undefined too. Removed rather than invented, since the button already had the rounding it was reaching for.

  • Fixed: colours that had already failed a readability check were still being used in six places. Five colours were replaced back in August for being too faint to read. The old values were left behind — three of them filling the coloured tile on the forgot-password, reset-password and verify-email screens, which is exactly where somebody already locked out of their account has to read something. Those now use the corrected colours, measuring comfortably above the standard.

    The other three were dead code: a fallback colour written in case the theme had no value, when the theme always has one. Worth noting for later — every such fallback in this app is dead, and there are about a dozen more. They are harmless but misleading, so they are folded into the colour cleanup rather than done piecemeal.

    Two decorative uses are deliberately left alone: they are per-item icon tints in a menu, part of a larger palette that needs a proper decision rather than a mechanical swap.

  • A new check that catches the whole class of "styling name that does nothing". It reads the app's own styling instructions and fails if any rounding name is not actually defined, naming it and where it appears.

    Two flaws in my first version of that check, both worth recording, because both are the same trap as the bug it guards against — a check that looks like it passes while examining the wrong thing. It read whole files, so it matched a name written inside a code comment explaining why that name had been removed, and reported a problem that no longer existed. And one valid style stripped down to an empty name and was reported as a phantom. Both fixed; it now reads only the actual styling attributes.

  • 🔴 Fixed: password-reset emails did not work on the Android app. If a member had the app installed and tapped the reset link in their email, Android opened the app, the app could not understand the address, and it quietly threw away the reset code and dropped them on the sign-in screen. There was no error and nothing to tell them what had happened. Verified fixed on a real device: the link now opens straight onto "Set a new password".

    This was one symptom of a much bigger fault. The app tells Android it can handle every address on app.project-nexus.ie, but it only actually knew 27 kinds. Of 254 member pages, 169 opened to a blank framework error screen — and 65 of those had a perfectly good screen in the app that no link could reach. Among them: every marketplace page, jobs, blog posts, the whole federation section, and identity checks.

    All 125 pages that have a mobile screen now open properly. Confirmed on a device for password reset, marketplace collections, a job, and the wallet.

  • Fixed: a link the app genuinely cannot open now explains itself instead of showing a developer error page. Some pages only exist on the website — staff tools, for instance. Those used to land on the app framework's own "Unmatched Route" screen, which is a diagnostic for programmers, and a signed-in member was simply left stuck on it. There is now a proper screen that says the link cannot be opened here and offers two ways out: open the real page in the browser, or go back to the app. Never a dead end.

    Staff consoles and the two mid-sign-in redirects (identity and social login) are now deliberately declined by the app so they continue to the browser, because swallowing them left the member holding a code the app could not finish using.

  • Fixed: fifteen buttons were showing raw code instead of words. Five Cancel buttons in confirmation dialogs and five Retry buttons on error screens displayed text like common:cancel — the internal name of the phrase rather than the phrase itself. Those are the two worst possible places for it, since a member is already stuck when they see them. Four places showed common:unknown where a name was missing, and one close button had no label for screen readers.

    The translation files were 100% complete in all seven languages — the callers were simply asking for the wrong names, so every existing check passed. There is now a check that reads the app's own code and fails if any phrase it asks for does not exist, which is a class of bug this platform could not previously see.

  • Two new checks, both proven to fail when the fault is put back. One reads the list of every member page and fails if a page has a working mobile screen that no link can reach — the check that would have caught the password-reset fault years earlier. The other verifies every phrase the app asks for actually exists. Both name the exact file, line and fix in their failure message.

    One honest note: while building the "cannot open this link" screen, its own explanatory text did not appear on the phone for two attempts, even though it appeared correctly in tests. Wrapping the content in a vertically-centred container turned out to be the problem. I could not narrow it down to a single cause with certainty, so I have written down exactly what I know and what I don't in the code, rather than a tidy explanation I can't back up.

  • Second look at the mobile app: 31 screens in both light and dark, up from 16 in light only. The first sweep stopped early twice, both times because of how it was written rather than anything wrong with the app. It pressed "back" to leave screens, which on Android walks out of the app when there is nothing to dismiss — that lost 22 screens and photographed the phone's home screen as though it were the sign-up page. It also reached each screen by tapping through the previous one, which worked until the Groups screen, which opens over the tab bar and took ten screens down with it. Each screen is now visited independently, so nothing can cascade.

  • The two-colour problem is worse than first reported: it happens inside single buttons, 128 times. The clearest example is on the Wallet screen, where the Back button's arrow is blue and the word "Back" is purple — one button, two brand colours. The cause is one pattern repeated across the app: the icon is explicitly given the community's brand colour while the text beside it is left to the design library, which uses purple. Counted properly: 128 controls across 48 files. "Donate" on the wallet has a blue heart and a purple label; "Send credits" next to it is a fully blue button.

    Some buttons are forced to the community colour by hand — Polls' "Create poll" and the "+" on Listings are correctly blue. That the workaround exists in some places and not others is what makes the app look careless rather than simply purple.

    🔴 Still not fixed, and deliberately so. Repairing a two-tone button means putting its icon and its label on the same colour — but which colour is exactly the decision that is outstanding. Doing it now means either touching 128 places twice or guessing on your behalf.

  • Found a second systemic problem: text scrolls up underneath the clock. On any screen you can scroll, content passes behind the status bar with nothing behind it, so what a member has typed collides with the time and battery icons and both become hard to read. Clearest on the sign-up form, where the surname field and the clock overlap.

    This is not one screen's mistake. The app draws edge-to-edge — the default for our Expo version, and required by Android 15 — and nothing paints a background behind the status bar. The usual fix is a thin shaded strip the height of that bar. Left alone for now because it belongs in the app's root layout and changes every screen, which deserves a deliberate decision rather than being slipped in during a bug hunt.

  • Two alarming-looking things checked and cleared. A pale stripe down the right edge of five dark screenshots turned out to be the Android scrollbar — sampling that column across all 62 images showed it on the same five screens in both light and dark, darker than light content and lighter than dark. And a completely blank screenshot of the More menu was the sweep's own stray "back" press, not a rendering fault; the screen was confirmed fine by a separate check.

    Recorded because both would have been reported as serious faults, and neither is real.

  • For balance: most of the mobile app is fine. Of 31 screens photographed, most had nothing wrong. Polls is a good example — clear heading, correctly coloured button, and a proper "No polls yet" message instead of a blank space. The sign-up form's password hint correctly says twelve characters, which is the real rule. The two systemic findings above account for most of what makes the app feel unfinished, and both are single decisions rather than long repair lists.

    Honest coverage: about 106 of roughly 137 screens still have no picture. Nothing was tested at a large font size, on a small screen, on a tablet, on a real phone, or on a broken network — all places where more will turn up.

  • Every listing card in the mobile app had a blank grey circle on it. It was meant to be an arrow. Nobody had ever looked at the app's screens, so it shipped that way. The cause is worth knowing because it will happen again: the shared card component from our design library adds 16 points of padding on all sides by default, and setting a size on it does not remove that padding. The circle was 36 points across, so 32 of those went to padding and the arrow was drawn into the 4 points left over. It did not error or disappear — it drew a few pixels wide, which on a phone looks like an empty button.

    Measured rather than guessed: on the same card the heart icon drew at 45×47 pixels and the arrow at 10×12. After the fix the arrow measures 48×50 and is visible. There is now a check that scans for the same mistake anywhere else in the app and names the file, line and fix — and it was confirmed to go red when the fix is removed.

    🔴 No unit test could have caught this. The icon is present, the structure is correct, and the test runner has no concept of layout, so it cannot tell that a box is 4 points wide. It was found by looking at a picture.

  • Found the reason the mobile app "looks off": it shows two different brand colours at the same time. On the sign-in screen the logo is blue and the Sign in button is purple. On the More screen one card manages four brand colours at once. On Listings, "Recommended" is a blue pill sitting next to purple text.

    The app has two sources of brand colour and no connection between them: our design library's buttons and tabs use a fixed purple, while everything the app colours itself uses the community's own brand colour. The website had this exact problem and fixed it — it pushes the community's colour into the design library at start-up. Mobile cannot currently do the same: I checked both libraries and neither offers a way to change that colour while the app is running.

    Not changed, because it needs your decision. Every option repaints the whole app, and one of them means dropping per-community branding on mobile — which matters, since a community whose colours are green or orange currently gets purple buttons regardless. The options are written up with what each costs.

  • A visual sweep of the mobile app now exists, and ran for the first time. Before this, 6 of roughly 137 screens had ever been photographed. There is now one command that walks the app and collects screens to look through, kept deliberately separate from the three screens under automatic pixel comparison so live data cannot break that check.

    Honest about coverage: it reached 16 screens of 26 attempted. After the Groups screen the tab bar could no longer be tapped, so seven screens plus the wallet were never reached. Dark mode has not been swept at all, and about 121 screens still have no picture of any kind. This is a start, not an audit.

    One blank screenshot that looked like a serious fault turned out to be the sweep's own doing — a stray "back" gesture had walked the app out to the phone's home screen. Verified separately that the screen renders correctly. Recorded because that is the second time a stray back press in this codebase has produced a convincing false alarm.

  • Event check-in without a signal is now properly tested — including the case that could credit a member twice. The mobile app lets a steward check people into an event with no phone signal, holding the check-ins on the phone until it can send them. Attendance turns into time credits, so this code decides who gets paid for showing up, and it can fail in two directions: a dropped check-in is unpaid work, and a duplicated one is credit nobody earned — worse, because it has to be taken back off a member who did nothing wrong. It was the least-tested important thing in the app at 32.67%; it is now at 94.97%, with 41 tests, and the floor was raised so it cannot slide back.

    The branch worth knowing about: when the phone tries to send its queued check-ins and the connection dies, the send may well have arrived and only the reply been lost. The app deals with this by remembering the batch it was sending and reusing the same reference when it retries, so the server recognises the work it has already done. Nothing was testing that. Had it broken, every person scanned at that event would have been credited twice, and the app would have looked like it was working perfectly.

    Also now pinned: a pass from last week's session of a recurring event is refused; an expired pass says "expired" rather than "invalid", which is the difference between a steward knowing what to do and hunting a fault that is not there; the same pass scanned twice is refused; a revoked steward device cannot pick up a fresh guest list and carry on; and members' names never appear unencrypted in the file on the phone.

    The signatures in these tests are real, not stand-ins. Faking that check would have made the forgery test pass while proving nothing — which is exactly the failure the test exists to catch. All 41 tests were then verified to be capable of failing, by deliberately breaking the code four ways and confirming each break turned the right test red.

    🔴 Honest limit: this is proven on a workstation, not on a phone. Nothing has been run with aeroplane mode on a real or emulated device. It shows the decisions are correct; it does not show the phone behaves when the radio actually drops.

  • Two things in the mobile readiness report were wrong and have been corrected. Both were my own measurements, and both made the app look worse than it is.

    Accessibility was marked "Weak" on a figure that measured the wrong thing — it counted files that mention any accessibility setting, which marks a plain layout file as a failure for having nothing to press. Measured properly: of 44 things a member can actually tap, 2 have no name for a screen reader, and both are harmless — one already reads out its visible text, and the other is in a file nothing uses. What genuinely is unchecked is button size and whether a screen reader makes sense of the app on a real device, and the report now says so instead.

    Three components are unused entirely — the app's own Badge, Card and Divider wrappers have no users anywhere; screens use the ones from the component library directly. They still carry tests, so they contribute passing tests for code no member can reach. Recorded rather than deleted: removing them is a judgement call about whether they are groundwork for something, and that is not mine to make.

  • Adding the four missing mobile languages turns out to be two jobs, and Arabic is blocked. Dutch, Polish and Japanese are ordinary work — about 17,200 phrases, and no payment or account is needed for the translation. Arabic is not, and doing it now would make the app worse rather than better: the mobile app has no right-to-left support at all. Arabic text in a left-to-right layout means back arrows pointing the wrong way and labels on the wrong side of the screen — which reads to an Arabic speaker as broken software, rather than as software that simply does not speak their language yet.

    Right-to-left support is a layout project of its own across all 189 screens, not something to slip in behind a translation. Recommendation: add Dutch, Polish and Japanese, and hold Arabic until that work is scoped. Nothing has been created for either — this needs your decision first.

  • The riskiest decision in the mobile app is now tested, and one real security gap in it was closed. Every time the app starts it decides where to send you: to sign-in, to your feed, or to whatever a notification was about. Get it wrong and members are either locked out or dumped on the home screen every time they follow a link. That decision lived inside the app's 759-line start-up file wrapped in six layers, and could not be tested at all. It is now a small standalone piece with 49 tests covering every branch — including the one that makes a notification tap open the thing it was about rather than losing the race to the home screen.

    The security gap: the same file strips your login token out of crash reports before they leave the phone. It only removed the exact spellings Authorization and authorization — but that name is case-insensitive, and equipment in between can rewrite it as AUTHORIZATION, which was being sent to the crash-reporting service verbatim. It now matches regardless of case, and also strips cookies and proxy credentials. A failure here looked exactly like success, because crash reporting keeps working either way.

    Behaviour is unchanged; all nine end-to-end tests were re-run against the rebuilt app to confirm the app still starts.

  • One flaky test recorded honestly rather than papered over. The registration test fails roughly one run in four. The cause is now understood: it dismisses the on-screen keyboard between fields, and on Android that same gesture navigates backwards when no keyboard is open — enough of them and the app quietly exits to the home screen, after which the test hunts for a form field in an app that is no longer running.

    Three fixes were tried and all were worse, so the test is unchanged and the diagnosis is written into it instead. Using the dedicated "hide keyboard" command is no safer — it is the same gesture underneath. Removing the dismissals made it fail every time, because the lower fields really are covered by the keyboard. And a guard checking the form was still on screen fired constantly, because the form scrolls. A real fix needs something the test tool does not currently expose. Until then: if that one test fails, re-run it before concluding the app is broken.

  • The members directory now says how many of the community it is showing, and why it is not showing everyone. A community with hundreds of members could list only a handful and give no hint why, which reads as though the directory is broken or the community is empty. It now says plainly how many people have joined against how many are listed, and — only when people really are being held back — explains what puts a member in the directory: being happy to be found, and whatever profile requirements that particular community has switched on. It stays quiet when nobody is being held back, so no community apologises for an absence that does not exist. Members also get a link to check whether they are listed themselves.

    One correction worth recording: the shortfall was assumed to be members who had not signed in for a while. It is not. The directory has never filtered on last login, and a member who has not signed in for years is still listed — there is now a test holding that true. What actually removes someone is having opted out of being found in their privacy settings, having a suspended or pending account, or failing a profile-completeness rule the community has turned on. The wording says that, rather than the assumption.

    The same explanation appears on the accessible frontend, in all eleven languages.

  • The mobile device tests can now run automatically each night, though that has not yet happened once. Everything built for the mobile app this week has been operator-run — someone has to start it by hand. That is exactly how the nine end-to-end tests came to sit unused for months, so leaving it that way would have let the work decay. There is now a nightly job that stands up a database, the server, an Android emulator, builds the app and runs all nine tests against real data.

    Being clear about its status: it has never actually run. Every individual step was tried by hand on this machine first, but the job description itself is untested, and the first run should be treated as a test of the job rather than of the app. Checking it against reality already caught two real mistakes — the standard command for serving the site serves the wrong folder in this project, and the health-check address returns "not found" under the simple server the job uses. Both are fixed. That two errors surfaced from desk-checking alone is a fair sign the first real run will need another pass.

    It deliberately does not fail on the screenshot comparison. The reference images were taken on this machine, and pixel-exact comparison does not carry across to a different machine — text is drawn slightly differently. It photographs the screens and saves them for you to look at instead. Turning it into a real check needs reference images captured from a successful run of the job itself.

    It also runs nightly rather than on every change, because it takes around 35 minutes; a check that slow on every commit gets ignored within a week.

  • Three key screens of the mobile app are now protected against looking wrong, and three more are photographed but honestly excluded. The sign-in, profile and wallet screens are photographed on every check and compared pixel-for-pixel against an approved copy — they reproduce exactly, run after run, in both light and dark mode. The wallet matters most: it shows balances and time credits, and it is where the same faint-text problem was first spotted on the website.

    Being straight about the other three. The feed, listings and messages screens are photographed too, but they are deliberately not compared, because they genuinely cannot be. The feed is ordered by a recommendation algorithm, so two identical checks return a different order — an 18% difference with nothing actually wrong. Listings and messages animate their contents in one after another, so a photograph taken a moment early catches them mid-move; two separate attempts to wait it out did not fix it. All three also show relative times like "6d ago" that change daily. They are still captured, because looking at them by eye catches a broken layout, and the check says out loud on every run that they were skipped. Making them comparable needs test data with fixed dates and a predictable order — real work, not done, and not pretended.

    A check that fires at random is worse than no check, because people learn to ignore it. That is why those three were excluded rather than hidden behind a loose tolerance.

    Two transient things are cropped out before comparing, for the same reason: the clock in the status bar, and the scrollbar that fades in and out down the right edge — that scrollbar alone caused a 1% false difference on the dark sign-in screen. Everything between those two edges is compared strictly.

    The check was also confirmed to still catch real changes: comparing a dark-mode photograph against a light-mode reference reports 94-97% different and fails, as it should.

  • The nine end-to-end tests now all pass, driving the real app on a phone emulator against your local server. They had never been run. Getting there meant fixing four separate things, each of which failed silently — which is almost certainly why nobody ran them.

    What was wrong. The test suite's own settings file contained nothing but comments, and the test tool refuses to start on that — so running the whole suite had never worked, though running one test at a time skipped the file and seemed fine. The app was forbidden from talking to your local server at all: a security rule blocks unencrypted connections for every build, and it silently overrides the development exemption that was supposed to allow them. That same rule was also why the app showed a blank screen earlier — it could not fetch its own code. And a release build deliberately refuses a local server address and uses the live one instead (a sensible guard added in June after a stale setting shipped to production), so the tests were logging into the live site where the test accounts do not exist.

    What changed. Development builds may now make unencrypted connections to local addresses only — not a blanket exemption, so a test build still cannot quietly send plain traffic to any server on the internet. Production is untouched: it keeps its certificate pinning and still refuses unencrypted connections entirely. Because that split is one careless edit away from disabling production's protection, a new automatic check asserts both halves and blocks the build if either slips; I confirmed it fails when I deliberately weakened production.

    There is also a new one-command runner that checks all four prerequisites before running anything and names whichever one is wrong, rather than reporting a missing button nine times. If a prerequisite is unmet it says plainly that nothing was tested — which is not the same as passing.

    A trap worth knowing: the screenshot tooling turns animations off to make images comparable. That makes Android report "reduced motion", which makes a warning banner appear across the bottom of the screen — directly over the tab bar. Five tests then failed because taps meant for the "More" tab hit the banner instead. The tooling now puts animations back, and the test runner restores them defensively.

    Result: 9 of 9 passing, covering sign-in, sign-out, browsing listings and groups, viewing events, messages, profile, search and registration.

  • The mobile app can now be built and run on a phone emulator on your machine, and a check now catches it looking wrong. You were right that Android Studio was installed — I had only checked for the toolkit it downloads separately, found that missing, and reported the two as the same thing. Studio was there; its toolkit had never been downloaded, and confusingly the settings pointing at it were already in place, aimed at a folder that did not exist.

    Everything needed is now installed and, importantly, proven by using it rather than assumed: the phone emulator starts, the app compiles (first build 6 minutes), installs, launches, and shows its real sign-in screen. Also installed: the app-driving test tool the nine existing end-to-end tests need.

    The visual check works. Two separate photographs of the same screen come out pixel for pixel identical, which is what makes comparison trustworthy — and a deliberately altered screen is caught at 97% different. Both directions were tested, because a check that cannot fail is decoration. Sign-in screens in both light and dark mode are saved as the reference to compare future changes against, and both were looked at by eye before being accepted.

    Being straight about how much this covers: one screen. The sign-in screen needs no password, which makes it a dependable starting point. Every screen behind sign-in needs the app driven there automatically and some test data set up first. So this is a working mechanism with very little coverage yet — the honest description is "the check exists and fires", not "the app is visually tested".

    Six setup traps were found and written down, because each would cost the next person an afternoon. The most valuable: the toolkit installer reports success while installing nothing if its licences are unaccepted, so its result cannot be trusted. Android Studio's own bundled Java is too new for the app's build system and fails with an unhelpable message. The app-driving tool's own documentation says it needs a Linux compatibility layer on Windows — it does not. And a local release build fails on a crash-reporting upload step that has no account configured, which is why the cloud build settings all switch it off.

    One local misconfiguration fixed along the way: the app was pointed at the wrong port for your local server (8088; it is 8090), so it could never have reached it from the emulator.

  • There is now a tool for putting back the groups the old tidy-up job hid — and it has deliberately not been run. It works from the group history log, so each group goes back to exactly what it was before the job touched it rather than to a guess. It refuses to run platform-wide: you name one community at a time. It reports and changes nothing unless you explicitly tell it to write, and it skips any group somebody has dealt with since, so it cannot quietly overturn a decision a person made. Checked against production: 431 of hOUR Timebank's 434 groups are still sitting where the faulty job left them (427 archived, 4 dormant), all of them were active before it ran, and not one has been touched by hand since — so nothing would be overwritten. Nine tests cover it, including the two cases where it must leave a group alone.

  • Muted and coloured text in the mobile app was too faint to read, and now it isn't. This is the same problem that was just fixed across the website, found the same way — by measuring instead of looking. The mobile app has one place where its colours are defined, so one bad colour is not one bad screen, it is every label of that kind everywhere in the app. Seven combinations failed the accessibility standard. The worst was the colour used for all secondary and muted text, which came out at 2.45 against a required minimum of 4.5 — barely half, in light mode, on every quiet label in the app. Also failing: green "success" text on its own pale green background (2.91), amber warnings (3.04), red errors on a pink background (3.95), and blue information text on pale blue (4.24). Two dark-mode combinations failed narrowly and were also corrected; the rest of dark mode was already comfortable and is deliberately untouched, so it looks exactly as it did. The replacement colours were calculated, not chosen by eye — each is the lightest shade of its own colour that still meets the requirement on the darkest background it ever appears on, so the app keeps as much colour as the standard allows. Four of them are the exact values the website settled on, so the two now match rather than drifting apart. A new automatic check recalculates all seventeen colour combinations on every change and fails if any drops below the standard; it recalculates rather than comparing against saved numbers, because a saved number quietly becomes wrong the moment someone updates it to match a fault.

  • The parts of the mobile app that talk to the phone itself are now tested. These were the app's blind spot, and the pattern was consistent: everything touching the screen was well tested, and everything touching the phone was not — because a test environment replaces those parts with stand-ins. Now covered from nothing (or nearly nothing) to complete: the store holding your login (which is what logs people out unexpectedly if it fails), live messaging connection and its authentication, both Stripe payment paths, the setting that decides which server the app talks to, the vibration feedback, the screen shown when something crashes, and the jobs section's server calls — which was the only one of 46 such modules with no test at all. That is 131 new tests; the suite is now 1,694 tests and still passes completely with none skipped.

    Two things were discovered while doing it, both of which had been hiding real gaps. Four files were not merely untested — no test could reach them, because a shared test setup file replaces them for the entire suite; they showed as zero and looked neglected when they were actually substituted. And the two payment files meant for web were never being tested at all: the test tool silently loaded the phone versions instead, because of how it picks between files that share a name. Both are now documented where the next person will trip over them.

  • Corrected something stated in yesterday's mobile testing guide. It said the mobile app's server-call layer never reports failures by raising an error, and that error-handling code around those calls is therefore dead and can be deleted. That is true of the website and false of the mobile app — they are different implementations, and the mobile one does raise errors. Acting on the guide as written would have deleted working error handling and turned failed actions, including accepting a job offer, into ones that report success. The guide now spells out the difference.

  • The mobile app now has the same kind of safety net the rest of the platform has. It was the one part of the platform with no readiness standard and no way of noticing when it fell behind. Four things are new, and all four run automatically on every mobile change.

    It now notices when the app calls something the server no longer has. Every screen in the mobile app talks to the server through 46 small modules, and the tests for those modules use a stand-in server rather than the real one. That is normal and fast, but it means a web address that the server has renamed or removed still passes every test, gets approved by an app store, and then fails on somebody's phone — which for a phone app can mean weeks before anyone notices. There is now a check that lists all 403 addresses the app uses and confirms each one still exists. 402 do. It found a real fault the first time it ran: the mobile organisation dashboard has an "auto-pay" switch that calls an address the server does not have, so tapping it fails. The web app deliberately removed that same switch, because payments now happen automatically when hours are approved and the switch no longer meant anything. Mobile kept it. Whether to remove the switch or add the missing server address is a product decision, so it has deliberately been left for you rather than changed quietly — it is recorded, and the check will keep reporting it until it is dealt with.

    It now notices when the web app grows a feature the mobile app does not have. All 254 member pages on the web app have been written down with a decision against each one: covered on mobile (125), deliberately not on a phone with a stated reason (65), genuinely missing (33), or not yet judged (31). If someone adds a new page to the web app, the mobile check fails until a decision is recorded for it. "Not needed on a phone" is a perfectly good answer — saying nothing is not, because that is exactly how a phone app drifts a whole release behind without anyone spotting it. This same blind spot cost real time on the accessible site earlier this month.

    Test coverage is now measured honestly and can only improve. The mobile app has a genuinely strong test suite — 1,563 tests, all passing, none skipped or excluded, and it finishes in under 90 seconds, which is better discipline than the web app can currently claim. But the coverage figure was flattering itself: files that had no test at all were quietly left out of the calculation instead of dragging it down, including the app's 759-line start-up file. Fixing that lowers the honest figure from 74% to 71.7%, and it revealed that the untested parts are consistently the parts that touch the phone itself — saved login tokens, live messaging, payments, offline event check-in, and the start-up file. Coverage is now tracked area by area rather than as one average, so a slide in any one of those shows up instead of being hidden by the 10,000 well-tested lines elsewhere. Floors can be raised, never lowered.

    The mobile checks now actually run when they need to. The mobile part of the build only used to wake up when a mobile file changed — which would have made the two new checks decorative, since both of them watch for changes outside mobile. They now also wake when the web app's page list or the server's address list changes.

    Two written guides go with this: a readiness score for the mobile app across 14 areas, each with the measurement behind it and no credit given for effort that nothing verifies; and a testing guide covering how to run each check, what it does and does not prove, and which parts genuinely cannot be tested on your machine. Three stale instructions were corrected along the way — a build file referenced in the mobile end-to-end test setup does not exist and never has, and the mobile readme twice told you to replace placeholder artwork that is in fact real.

    Being straight about what is still missing, since this is the point of having a score: nothing checks that the app looks right — there is no screenshot or visual comparison testing of any kind, which is exactly why visual bugs are getting through. The nine end-to-end tests that drive the real app have to be started by hand, so nothing automated proves the app even opens. And no iOS version has ever been built by anything, here or in the build system, so every claim about iPhone is unverified. Those are the next three pieces of work, and they are the ones that need decisions from you rather than just time.

Fixed

  • A shared change to every write response was half right, and the wrong half was taken back out. Development-only ASP.NET backend. Two things were bundled as one: adding a small block of information the real backend always includes, and removing a flag the real backend never sends. The first is safe — at worst it leaves an unused extra key. The second takes something away, and applied to every write it turned 82 tests red in one run. Eleven measurements showed those eleven responses from the real backend leave the flag out; that is not the same as proving it should be stripped from every write here. So the removal is back to applying only where it was actually counted, and the addition stayed. The deciding fact: with the removal taken out, the write score is unchanged at 10 of 18 — it had cost 82 failing tests and gained nothing. The real backend genuinely does omit that flag on the three administrator responses I measured, so that remains a real difference, written down as something to fix one endpoint at a time rather than by one sweeping rule.

  • The comparison tool was under-reporting how different two responses are, by a factor of up to eight. It printed at most eight missing field names with nothing indicating the list was cut short, so a response missing sixty-nine pieces of information was reported as missing eight — and reviewing that list looked like reviewing the whole difference. It now always prints the true count alongside the sample and says how many more there are. This is a measurement fix, not a code fix: nothing about either backend changed, but three write-side differences turn out to be far larger than recorded (a created listing 69 fields short, a created poll 38, a profile update 24).

  • Closed the broker-shaped half of the same events escalation, and corrected the severity recorded for its admin half. EventConfigurationService::canCreate() ends $role === 'admins' ? $isAdmin : ($isAdmin || $user->role === 'broker'). The admin half became tenant-scoped via TenantAdminScope, but the broker half stayed a bare role-string comparison, so "is a broker somewhere" satisfied a community that had restricted event creation to its own brokers and administrators. Now requires the broker's account row to belong to that community. Deliberately not TenantAdminScope: a broker is an operational role with no hierarchy — it reaches no subtree, and AdminTier refuses it outright — so this is a plain same-community test; a broker needing cross-community reach would hold a platform flag, which the admin half already covers. tests/.../EventConfigurationCanCreateTest gains test_staff_policy_refuses_a_broker_of_another_community (verified failing before the fix) and test_staff_policy_admits_a_platform_admin_from_elsewhere; test_staff_policy_admits_a_broker_but_not_a_plain_member now uses a LOCAL broker, since what it pins is broker-yes/member-no and the actor's home tenant was incidental to that.

    🔴 Severity correction to the TenantAdminScope entry above: that defect was a service-layer defence-in-depth failure, not a hole reachable through the API, and the wording overstated it. App\Http\Middleware\Authenticate rejects any actor whose tenant_id differs from the resolved tenant with 403 tenant_mismatch, on both of its auth branches, unless the actor is a PLATFORM admin (is_super_admin, is_god, or those role strings) — and its own comment records the intent: "Allow platform super admins to access any tenant. Tenant super-admins are still scoped to their own tenant." So a plain admin of another community, a network admin, and a cross-tenant broker are each stopped before any events service runs. The nineteen-ability measurement was taken against the policy directly, which is exactly how the cross-tenant actor suites are written and why they bypass HTTP. The fixes stand regardless — these services are also reached from commands, queued jobs and internal callers that are not behind that middleware, and a service must not depend on a caller it cannot see — but no deployed or pushed build was ever exploitable this way by a non-platform actor.

    🔴 A consequence worth recording for whoever reads 5373940c8 and 6adcd4ff6 next: their stated beneficiary, "network admins acting on a sub-tenant", cannot reach those paths over HTTP at all, because is_tenant_super_admin is not in the middleware's cross-tenant exemption. The actor those commits actually unblock in production is the platform admin — which TenantAdminScope tier 1 still admits unconditionally, so the reported Partner Demo symptom stays fixed.

  • Closed a cross-tenant privilege escalation in the events area: an admin of one community held full admin authority over every other community's events. Never released — introduced by 5373940c8 earlier the same day and caught before deployment (production was on 4e8c77288, which predates it). 5373940c8 correctly stopped tenant-scoping the acting user's identity (an organiser whose account row lives on another tenant must still reach their own event), but the deleted line in EventPolicy::hasValidContext() was also the only thing scoping event admin authority, and isTenantAdmin() was AdminTier::allows() — a predicate that reads roles and flags and is deliberately tenant-unaware. Measured on a tenant 2 event with an actor whose home tenant was 999: all nineteen abilities, including viewRoster, viewWaitlist, messagePeople, exportPeople, manageFinance, reconcileCredits and transferOwnership. A cross-tenant plain member was unaffected (view only), so the fault was specific to admin markers — and reproduced for role='admin', is_admin, role='tenant_admin' and is_tenant_super_admin alike.

    The fix moves the tenant question off the identity check and onto the authority decision, via a new canonical predicate App\Support\Authorization\TenantAdminScope::allows($user, $tenantId): platform admins (is_super_admin/is_god, or those role strings) reach every tenant; a network admin (is_tenant_super_admin) reaches its own tenant plus its subtree by materialised-path prefix, matching SuperPanelAccess's regional level; a plain community admin (role='admin'/'tenant_admin', or is_admin) reaches its own tenant only; everyone else, including broker/coordinator carrying a stale admin flag, reaches nothing. It fails closed on a missing tenant_id, a non-positive tenant, an unknown target tenant and a lookup error, and takes the actor explicitly rather than reusing SuperPanelAccess::canAccessTenant(), which reads $_SESSION and memoises the first answer in a static — wrong inside a policy evaluating several users in one request.

    Applied at every events authority decision that had been de-scoped: EventPolicy::isTenantAdmin() (now authorising against the event's own tenant_id rather than ambient context), EventService::isTenantAdmin() (which also gates whose unpublished events appear in the directory listing — tenant_id added to its SELECT, since a partial row fails closed by design), EventPublicationWorkflowService::isTenantAdmin() plus its locked approve/reject actor re-read, and EventConfigurationService::canCreate() (a foreign community's admin could create events wherever creation was restricted to admins; the members setting still admits everyone by design). EventRoleService was examined and deliberately left alone — its actor lookup is still tenant-scoped, so it was never exposed, and adding the check there would have failed closed for legitimate same-tenant admins.

    🔴 Crucially the original bug stays fixed: an organiser whose account row lives on another tenant still gets all nineteen abilities on their own event, verified directly. Ownership is decided separately in hasImplicitFullAuthority() and was never the tenant question.

    🔴 The failing test was right, and had been dismissed twice. EventPolicyTest::test_standalone_event_permission_matrix_separates_detail_roster_and_meeting_access asserted no abilities for a cross-tenant admin and had been red on main since 5373940c8; both that commit and 6adcd4ff6 recorded it as pre-existing noise needing "separate attention". It was reporting this. Its expectation is now ['view'] — a published event is visible to any viewer, the public index shows it to anonymous visitors too, so withholding view would be stricter than the public contract; assertOnlyAbilities asserts the other eighteen are refused. Note assertOnlyAbilities reports only the first mismatched ability, which is why CI showed a single vague Unexpected view decision line for what was a nineteen-ability breach.

    Regression coverage: new TenantAdminScopeTest pins all four tiers in 15 tests, including a network admin reaching its own branches but not its parent and not a sibling, and the fail-closed paths. It builds its own hub-and-branch hierarchy rather than reading the dev database's tenant 2/101/102 fixture, which does not exist in nexus_test or CI and had silently turned all 15 into skips. Verified against the whole Feature/Events + EventsControllerTest failure set before and after the change: zero new failures there, one resolved. That directory has 73 pre-existing failures when run together, which is a separate, untouched problem.

    🔴 That verification was not wide enough, and CI caught what it missed. Seven tests under tests/Laravel/Unit/Services/ — the cross-tenant actor suites written the same day to pin 5373940c8 and 6adcd4ff6 — were never run before pushing, and six of them broke. Five were genuine fixture defects: they describe their subject as "the exact production shape: role='admin', is_tenant_super_admin=1, home tenant elsewhere" — a NETWORK admin overseeing another community — but expressed it only as an admin whose tenant_id is 999, a tenant with no row at all and no relationship to the tenant being acted on. That is an unrelated stranger holding an admin flag, and it passed only because authority was tenant-unaware. The new Tests\Laravel\Concerns\MakesNetworkAdminHierarchy trait now builds the relationship the description implies (home tenant becomes the acting tenant's parent, rolled back with the transaction), so those tests assert their stated intent instead of an escalation. The sixth, EventPublicationWorkflowCrossTenantActorTest::test_cross_tenant_admin_can_approve_a_pending_review_event, used a bare role='admin'; its docblock says it exists to pin the LOCKED ACTOR RE-READ, for which the persona is incidental, so it now uses the network shape — with a plain outside admin it would have asserted the escalation rather than the re-read. A new test_plain_admin_of_another_community_cannot_publish_here pins the security property directly, in its strongest form: an admin of the PARENT community, without the network flag, is still refused.

    The seventh, EventConfigurationCanCreateTest::test_admins_only_policy_admits_an_admin, was a deliberate design assertion rather than a fixture defect and was reversed on an explicit owner decision: it asserted that a bare role='admin' on an unrelated tenant may create events in a community that had restricted creation to its own administrators, which is the same escalation in a different door. It is now test_admins_only_policy_refuses_a_plain_admin_of_another_community. What the surrounding tests legitimately pin is untouched and still passes — the actor LOOKUP stays global, so a cross-tenant actor is found rather than missed before the members short-circuit (the original bug), and platform and network admins still reach the tenant. Two positive cases were added because the file had none: every test in it used a cross-tenant actor, so nothing would have caught the admins policy refusing the community's OWN administrators — now covered by test_admins_only_policy_admits_a_local_admin and test_admins_only_policy_admits_a_platform_admin_from_elsewhere.

  • Closed a cryptography advisory published upstream on 2026-08-18: paragonie/sodium_compat v2.5.0 -> v2.5.1. Advisory PKSA-32g2-byr9-drtw, "Incorrect Ed25519 public key validation" (affects >=2,<2.5.1 and <1.24.1, no CVE assigned). The package is a transitive dependency of pusher/pusher-php-server, whose ^1.6|^2.0 constraint already permitted the patched release, so this is a lockfile-only change with no composer.json edit. composer audit --locked now reports no advisories, and Ed25519 detached sign/verify was exercised against the upgraded package. This is what turned the Security Vulnerability Scan workflow red on main; the same scan passed on the preceding commit because the advisory did not yet exist.

    The lockfile's content-hash also changes, correcting pre-existing staleness rather than anything in this fix: c56b8c442 (chore(release): 1.6.1) bumped the version field in composer.json, which feeds the hash, without regenerating the lock. composer validate now passes.

  • Approving event registrations, managing the waitlist and viewing the attendee list now work for admins whose account lives on a different community. Registration was the last part of the events area still carrying the fault fixed in the two entries below, and it had been left alone deliberately because registration is not quite like the rest: usually a member is signing themselves up, not managing somebody else. Looking at it properly showed the two halves need opposite rules, and only one half was wrong.

    Four lookups resolved the acting person "inside the event's community", so an admin working on another community was refused when approving, rejecting or cancelling a member's place, using the bulk actions, or simply opening the attendee list. Fixing the outer one alone would have changed nothing — two of the four sat deeper in, and re-checked the same thing after permission had already been granted. All four now identify the person by who they are; what they may do is still decided by the event's own community rules, and strangers, suspended accounts and events belonging to other communities are all still refused.

    The other half is deliberately left as it was, and is now written down as such. A member can only sign up for events in their own community. That is a real rule, not an oversight: signing up runs safeguarding checks between the member and the organiser that only make sense inside one community. The lookup that enforces it was starting to look like the same bug, so there is now a comment at it explaining why it stays, and tests that fail if someone "finishes the job" by removing it.

    Ten new automatic tests cover admin approval, cancellation, the waitlist, the attendee list, both same-community rules, strangers, suspended accounts and cross-community reachability. Seven were confirmed to fail against the old code.

    One further problem was found and NOT fixed, because it is not safe to fix blind. If the person who created an event has an account on a different community, nobody can sign up to that event at all — not even a local member signing themselves up. Since the publish fix below now lets such an event be created and published, this is reachable. It is left alone because the obvious fix would skip a safeguarding check rather than apply the right one: the safeguarding code has a separate, distinct check for people in different communities, and quietly using the same-community one instead would weaken it. That needs a decision, so it is pinned by a test that describes it and will start failing the moment it is fixed.

  • Marking who attended an event now works for organisers and admins whose account lives on a different community. This is the last corner of the same fault fixed below in the publish machinery: four lookups on the check-in path (marking someone attended, bulk check-in, and undoing a check-in) resolved the acting person "inside the event's community", so an admin working on another community — or the event's own cross-community organiser — was refused with "Only the organizer or admin can mark attendance". All four now resolve the person by who they are; what they may do is still decided by the event's own community rules, strangers and suspended accounts are still refused, and events on other communities remain out of reach. Six new automatic tests cover organiser check-in, admin check-in, undoing a check-in, strangers, suspended accounts and cross-community reachability — three were confirmed to fail against the old code.

  • An event you create can no longer get stuck as an invisible, unpublishable draft when your account lives on a different community. Every new event starts life as an unpublished draft, and going live is a separate "Publish" step on the event's own page. Nine separate lookups across five files in the publish machinery resolved the acting person "inside the event's community" — so an admin working on another community (the exact situation on Partner Demo) was treated as a stranger to their own event: bounced off its page seconds after creating it, shown no Publish button, and refused if anything called publish anyway. All nine now resolve the person by who they are, not where their account row lives; what they may do is still decided by the event's own community rules, and events themselves remain strictly separated per community. Fourteen new automatic tests cover the organiser, network admins, platform admins, strangers, suspended accounts and cross-community reachability — six of them were confirmed to fail against the old code.

  • Your own draft and pending-review events now appear in the events list, clearly badged. Before, a draft was hidden from the list even for the person who created it — the code that was meant to show you your own drafts was cancelled out by an older filter, so a freshly created event simply looked lost. Your unpublished events now show in your list with a "Draft event" or "Pending review" badge (nobody else sees them, and single dates inside a repeating series still stay tucked behind their series entry). Six new automatic tests pin who sees what.

  • Community admins are now emailed when an event is submitted for review — the alert used to be silently swallowed by a default setting. The bell notification for "an event needs review" already worked. The matching email never went out to anyone who hadn't opted in, because the platform's default frequency for event emails is "off", and that default muted even this operational alert. A review request is now treated as operational mail: it sends immediately unless the admin has personally switched event emails off or chosen their own frequency — personal choices still win. Two new end-to-end tests walk the whole journey from a member pressing "Submit for review" to the admin's bell and inbox.

  • The main app's "your session is about to end" countdown is now accurate when you come back from another tab. The 30-second countdown in the warning dialog used to tick down on a browser timer, and browsers deliberately slow those timers in background tabs. So if the warning appeared while you were in another tab, the number on screen fell behind reality — you could come back to a dialog cheerfully showing 25 seconds when the session had in fact already run out. The countdown is now anchored to the actual clock, and the moment you return to the tab it re-checks the real time: if the session ran out while you were away you are signed out straight away, otherwise the dialog shows the true seconds remaining. This is the same clock-anchoring fix already applied to the accessible frontend (below). Your session itself was never at risk — the server always expired it on time — this fixes what the dialog told you. Covered by three new automatic tests that simulate a tab being hidden.

  • The accessible frontend's "your session is about to end" warning now works properly — and its "stay signed in" button no longer signs you out. Three faults were found and fixed together, all verified in a live browser and covered by new automatic tests.

    The warning now appears on time, even after you've been in another tab. It used to be run entirely off browser timers, and browsers deliberately slow those down in background tabs (and pause them while a laptop sleeps). So the warning fired minutes late — usually at the exact moment you came back to the tab, which is why it seemed to "only activate sometimes when activating a tab". The warning is now anchored to the actual clock: the moment you return to a tab it checks the real time. If the session has already run out it signs you out immediately; if the warning is due it shows it with the true time remaining, not a fresh countdown it can't honour. This matters most for shared council machines: before this fix, a signed-in tab left in the background was never signed out at all.

    "Stay signed in" actually worked against you. The button called a server endpoint that had a naming clash with the session library's own internals, so every call crashed mid-response and never finished. The page treated that as "the session could not be extended" and sent you to the login screen — the button did the opposite of its label. A new test proved the crash first, then proved the fix.

    Being active now genuinely keeps you signed in. The session cookie previously kept its original 30-minute expiry from the moment you signed in, no matter how active you were. It now renews with activity, activity in one tab counts for your other tabs too, and the page quietly confirms with the server every few minutes while you're working so the two never disagree.

    The restore is done and verified. All 431 groups are active again, each returned to exactly the status it held before the faulty job touched it, read from the group history log rather than guessed. hOUR Timebank now has 434 active groups and none archived. The whole geographic structure is back with its membership intact — Munster (170 members), Co. Cork (151), West Cork (114), Leinster, Ulster, and every county and town group beneath them. Every restore was itself written to the history log, so this change is as reversible as the one it undid. Not one group had been touched by hand since July, so nothing anybody decided was overwritten.

    And the nightly job that caused it will not run again. It is no longer scheduled at all. The command still exists and now reports by default — it will tell a community which groups look dead, and it cannot change one unless a person explicitly asks it to. Hiding a group is now something a person does, in the admin panel, one group at a time.

    The measurement that settled it. The four protections added earlier the same day were tested against the live database on the evening of the restore. They work: 153 groups were protected by them. But the run still wanted to hide 278 of the 434, and was stopped only by the rule that refuses any sweep touching more than a fifth of a community. In other words the last line of defence was the only thing standing between a restore and a repeat within hours. That is an argument against having the automation at all, not for tuning it — which is the decision taken.

    For the record, the thresholds it used were 90 days with no activity to hide a group, and 180 days to archive it, checked nightly at 03:30 for every community. Three things about that design are worth remembering if it is ever revisited: both states hid the group equally, so the two stages gave a member no warning; nobody was ever notified, before or after; and a group owner had no way to undo it, only an admin.

  • More accessible-site polish: raw data, wrong-size numbers, empty gaps, and a stylesheet nearly half of which was dead.

    Things members were reading that they shouldn't have been. Listing descriptions showed a literal <br> between every line and &amp; for every ampersand — that affected every listing on the site. Marketplace item pages showed raw database values like "like_new" and "local_pickup" instead of "Like new" and "Local pickup", even though the readable versions were already prepared and simply unused. The seller profile printed two facts twice ("Member since March 2024 | March 2024"). The polls category filter mangled labels into things like "Local_events". And a poll option with one vote said "1 votes", a show with one episode "1 episodes", a club with one member "1 members".

    A rating bar that only worked in English. The seller rating bar was being given "4,5" in languages that use a comma as the decimal point, which isn't a valid value, so it rendered empty for those members.

    Layout consistency. Leaderboard statistics rendered at the wrong size because two styling rules were fighting; the numbers came out half again too large. Three empty gaps are gone: a "Closed polls" heading sitting over blank space, an empty button row leaving an unexplained gap above your notifications, and three stacked single-link paragraphs on the group page that are really one list. On the event page, an organiser met up to eight identical grey blocks before any event content; they're all links to other pages, so they now look like links.

    Error pages. "Skip to content" didn't actually move keyboard focus — the one page where someone lost most needs it to work. The licence and attribution text in the footer was also rendering at the wrong size, on every page of the site.

    One person, one face. The member directory showed grey circles and a member's own profile showed a blue one at a different size — the same person, one click apart. Now consistent, and photos that aren't square are cropped rather than squashed.

    Nearly half the stylesheet was dead. 1,030 lines removed; the file is down from 2,643 lines to 1,414. That dead weight is what hid the 37 missing styles for so long. Worth being straight about a near-miss: an earlier check of mine reported the whole abandoned system as unused, and it wasn't — live code styles the session-timeout warning, every loading spinner and the star ratings with it. Deleting it all, as first proposed, would have broken visible things. Only the rules nothing references were removed, and every surviving style was checked by name in the finished stylesheet afterwards.

    Still to do, honestly: thirteen more counted labels are still wrong the same way ("1 replies", "1 hours", "1 results"). None has an existing translated equivalent to copy from, so each needs new plural wording written in ten languages — including Irish, Arabic and Polish, where guessing the grammar would ship a different kind of mistake. They're left for a translator rather than guessed at. Forty-two pages are also still missing their section caption, for the same reason.

  • The accessible site's biggest cause of "pages look a bit off" is fixed: 37 pieces of its own styling were missing. The pages were asking for 37 named styles that had never actually been written, across 73 pages. A missing style doesn't cause an error — the page just falls back to the browser's plain defaults — so instead of one obvious break you got widespread, low-grade wrongness that was easy to see and hard to name.

    What you should notice now: the member directory reads as rows again (photo, name and "View profile" side by side) instead of everything stacking down the page; photos sit beside titles on events, listings, marketplace and groups instead of above them; facts like "Location / Hours given / Rating" run along one line instead of each value dropping onto its own indented line — that one alone affected 31 pages; replies in a conversation are indented with a thread line, so a reply no longer looks identical to a new comment; the reaction you picked is now visibly marked, and your own row in a leaderboard is highlighted, neither of which showed at all before; skill tags wrap along the line instead of one per line; progress bars look like part of the site instead of a small grey operating-system widget; and blog, help and legal articles are properly typeset instead of rendering as a wall of browser-default text.

    Both new "this one is yours" markers use an outline or a bar alongside existing wording, never colour on its own, so they don't rely on being able to distinguish colours.

    One bug my own change introduced, found by checking in a real browser. Six pages use a bright green "success" panel as permanent decoration — including the home page, where it was squeezed into a narrow column. Toning it down to a calm grey box turned the text inside it white-on-nearly-white: invisible, and worse than what it replaced. The cause is subtle (the green panel doesn't set a text colour directly, it changes a variable everything else reads), and no test would have caught it. It is fixed and the text now sits at 17.65:1 contrast, far above the 4.5:1 minimum. Worth saying plainly because it is exactly the kind of thing that only shows up by looking.

    A new automatic check now fails the build if any page ever again asks for a style that hasn't been written — and it reads the finished stylesheet, so a rule that exists but never reaches the browser still counts as missing.

  • The accessible site's pages now agree with each other — the back link stops moving, and long pages stop running the full width of the screen.

    The back link sat in two different places depending on the page. The Design System puts it above the main content; only 22 pages did that, and the rest opened it inside the page body, which renders it about 40px lower. Walking between two otherwise identical pages made the link jump. 137 pages moved; the link now sits in the same place everywhere. A further 79 were deliberately left alone because their back link isn't a simple single line and moving it mechanically would have risked breaking the page — those are listed rather than guessed at.

    60 text and form pages now hold their text to a readable column width instead of running the full width of the screen. This is a large part of the "wall of text" feeling. It was applied carefully rather than everywhere: 108 pages that contain tables, card lists or tabs were left full width on purpose, because narrowing those would make them worse, not better.

    Two section menus (Jobs and Ideation) are now proper navigation landmarks, matching Courses, Marketplace and Federation. Someone navigating by landmark could previously jump to the section menu on three of those areas and not the other two.

    Search results and notifications now read as one list — they were missing the line above the first item that every other list on the site has — and the messages screens no longer carry two copies of a hand-written layout style that also made their filter links too small to tap comfortably.

    A hidden fault found on the way. The phase banner at the top of every page was stored in the same slot the back link belongs in, so any page putting its back link there deleted the banner unless it remembered a specific extra line. One page — the AI chat page — had already lost its banner this way. Moving 137 back links into that slot would have spread the problem across the site, so the banner was made impossible to delete first. That page has its banner back.

  • Five faults on the accessible site where a control looked fine and quietly did nothing. Found by a fresh audit and fixed together. Each one was invisible in the page itself, which is why none had been noticed.

    Choosing "Private" or "Secret" when turning an idea into a group gave you a public group. The privacy choice was never sent to the server at all, and the group name you typed was discarded too (the group silently took the idea's title instead). Anyone who used this expecting a private space did not get one. This is the most serious of the five and was fixed first.

    Saving or publishing an idea draft always failed. The form's boxes and the code that read them had drifted onto different names, so the server received an empty title every time and refused it — the page just said the draft could not be saved. "Publish" could never publish at all. Drafts now save, publishing works, and if you leave the title empty you get the specific "enter a title" message the page was already written to show.

    Choosing "Every 2 weeks" for a repeating event silently created a weekly one. The small piece of code that tells the server "every second week" was blocked by the site's own security rules, so the setting never left the page. Organisers would have found their event repeating twice as often as they asked. Now verified in a real browser: choosing "Every 2 weeks" sends the right value, and switching back to weekly sends the right value too.

    The "Print" button on an event check-in pass did nothing when clicked. Blocked by the same security rule. It works now, and it is hidden for anyone whose browser cannot run it rather than being offered and doing nothing.

    The session warning was missing entirely from 110 pages. Two invisible pieces of the page layout — the one that powers the "your session is about to end" warning, and the one that translates the "you have N characters remaining" counter — were being dropped by any page built a particular (and perfectly reasonable) way. On those 110 pages a signed-in member got no warning before being signed out, and on 22 of them the character counter spoke English no matter which of the eleven languages they had chosen. This pairs with the session-timeout fixes above: those made the warning correct, this makes it actually appear.

    A page told members they had made no data requests, right after they had made one. The "Requests you have made" section on the data rights page could never show anything — there is no way for the site to read your own requests back, only staff can — so it always displayed "You have not made any data rights requests yet", directly underneath the confirmation saying the request had been received. Someone could reasonably have submitted the same request over and over. The section has been removed; the confirmation message remains.

    Why a green test run missed all of these. In three cases the tests were written from the code rather than from the form, so they filled in field names the real page never uses and agreed with the bug. In one case a single test asserted both the confirmation message and the contradicting "no requests yet" line on the same page without anyone noticing. The tests now use the real form fields, and three new automatic checks make these classes of fault fail the build in future: one refuses any page code that the security rules would block, one proves the shared layout pieces survive however a page is built, and the updated ones pin the corrected field names.

  • On the accessible site, member photos had no size control at all, and the member profile page showed no photo whatsoever. Two faults, found together once images started loading.

    The profile page. Viewing a member showed their initials and never their photo, while the members directory showed photos correctly. The shared piece of markup the profile page uses to draw an avatar contained no image at all — it could only ever draw initials. The directory writes its own image markup, which is why one page worked and the other could not. That shared piece now draws the photo when there is one and initials when there is not, so the messages pages that also use it gain the same thing.

    The sizing. Nothing anywhere asked for an image at the size it was being displayed at, so a full-size upload was being sent to a browser to be drawn in a small circle. Measured against production: one real profile photo is 160,561 bytes, and the same picture requested at the size it is actually shown is 2,260 bytes — 71 times smaller, and returned in a more modern image format automatically. The platform already had the means to do this and the React app has been using it all along; the accessible site simply never called it. It now does, at 29 avatars and 5 photo galleries, each asking for the size it draws.

    And the reason there was "no mechanism" at all: six of the image styles the pages refer to did not exist in the stylesheet. Not wrong — absent. Feed photos, card cover images, organisation logos, the largest avatar size and the two-factor QR code all named a style that was never written, so those images had no size rule whatsoever and were drawn at whatever size they were uploaded at. A photo a few thousand pixels wide then pushes the page sideways, which fails the accessibility standard on reflow (WCAG 2.2, 1.4.10) — it is not a tidiness problem. Feed photos were also sitting in a plain bulleted list, because the grid style they named had never been written either. All six now exist, sizes are declared in the markup as well as the stylesheet so the page does not jump as pictures load, and round avatars now crop a rectangular photo instead of squashing the face in it.

    Being straight about two things. The same audit found about thirty more styles the pages name that do not exist — whole layout patterns for member rows, listing rows, comment threads, timelines and progress bars. They are not images and fixing them changes how pages look, so they are recorded for a decision rather than changed quietly. And this was verified by checking the produced HTML and the compiled stylesheet, plus 25 new tests among the 2,544 that pass — not by looking at the pages in a browser, which for this site needs the separate throwaway environment because the local database holds real member data.

  • A mobile safety check has been reporting a false alarm on every automated run, and nobody could see it. The check confirms the mobile app is not calling server addresses that no longer exist — the thing that otherwise ships to an app store and fails on somebody's phone. To know whether its record of the server is current, it fingerprints the three files that define the server's addresses. It fingerprinted the raw bytes, and Windows and Linux store line endings in text files differently: the developer machine writes one form, the build system reads the other. So the fingerprint taken on the dev machine could never match the one computed in the build system, for all three files, on every single run. The check therefore said "the record is stale, nothing was verified" every time it ran in the build system — while passing on the machine it was generated on. It was found the only way it could be: by forcing a complete check run before a deploy, which is exactly what that gate exists for. Line endings are now levelled before fingerprinting, and the fix was confirmed by checking the stored fingerprints against what the build system actually sees, rather than by rerunning it and hoping. With the check genuinely working: 402 of the app's 403 server addresses exist. The one that does not is the organisation auto-pay switch already recorded as needing your decision — it is unchanged and still waiting. Being straight about one gap: there is no test pinning this fix. The mobile test suite covers app code, not build scripts, and inventing a home for one while another piece of work is in flight in that area risked more than it protected. The working check in the build system is the guard for now; a proper test belongs with the next mobile task.

  • "Send Now" on a newsletter reported "Request Timed Out" even though the newsletter went out. Sending was doing all of the work while the admin's browser waited: every email was addressed, rendered in the recipient's own language, and handed to the email provider before the page got an answer. There is a deliberate quarter-second pause between emails so the platform is not treated as a spam source, so the wait grew with the size of the list. The newsletter sent to hOUR Timebank on 18 August took 184 seconds for 256 members. The admin screen gives up waiting after 30, so it showed a failure for a send that was working perfectly — and an admin who believed it and pressed the button again was only stopped by a separate safeguard. Sending now delivers a first few emails immediately and hands the rest to the every-minute background job that already finishes scheduled and repeating newsletters, so the button answers in seconds and says how many members were queued. The newsletter's own statistics page now refreshes itself every ten seconds while a send is in progress, so the count climbs in front of you instead of freezing part-way and looking stuck. "Resend to non-openers" had exactly the same fault and is fixed the same way. The browser is also allowed 60 seconds rather than 30 for these two actions, so a slow email provider cannot make a working send look broken again.

    Two further problems were found and fixed while doing this. A scheduled newsletter was sent by the same all-at-once code, and it holds a lock that stops every other timed job on the platform while it runs — so a large scheduled newsletter could quietly hold up the 8am digests and the midnight leaderboard for as long as it took to send. And the background sender had a 45-second limit written into it that never actually took effect, because the step it was meant to bound drains the entire queue in one go before the limit is ever consulted; the limit now works as its own comment always claimed. Two tests pin the new behaviour: that a send stops at its time limit and leaves the rest properly queued, and that the background sender, which has no such limit, still empties the queue in one pass.

  • A nightly tidy-up job silently archived a whole community's groups, and the groups page has been showing three of them since. On the night of 13 July 2026 the automatic "mark inactive groups dormant or archived" job archived 415 of hOUR Timebank's 434 groups in a single run, including the entire geographic structure the community is organised around — Munster (177 members), Co. Cork (156), West Cork (117), Leinster, Ulster, and every county and town group beneath them. Archived and dormant groups are both hidden from the groups directory and from sub-group lists, so from a member's point of view the community's groups simply disappeared. The job was doing what it was written to do: it judged a group "active" only by discussion threads, replies to them, and the date people joined. A geographic hub group has none of those by design — it exists as structure, not conversation — so on the day the 180-day line was crossed they all failed the test together. Four things now prevent a repeat. A group that has sub-groups underneath it is never demoted automatically, because demoting it hides the whole branch. A group that has more than one active member is never demoted automatically — automatic hiding is for groups nobody is in. A group the community has deliberately featured is never demoted automatically. And any single run that would demote more than a fifth of a community's groups is refused outright, changes nothing, and records an error — a sweep that size is a fault in the test for activity, not a tidy-up. That fifth is counted against every group the run examined, and the limit never drops below five groups, so a community with only a handful of groups can still be tidied at all — which does mean a single run in a very small community can still touch more than a fifth of it. Separately, "activity" now also counts events, feed posts, announcements, files and shared media, so a group that is busy anywhere other than the discussions tab is no longer read as dead. Six tests cover the four protections and the widened signal. Not fixed here: the 427 groups already archived in production are still archived. Every one of those changes was recorded in the group audit log with its previous status, so they can be restored exactly — but that is a change to live member-visible data and needs the owner's go-ahead, not a side effect of a code fix.

  • On the accessible site, no profile pictures and no photos were loading — anywhere. Every avatar and every picture posted to the feed showed as a broken-image icon, and the same was true of member photos in the directory, dashboard cards, marketplace items, club and organisation logos, job candidate photos, and your own photo in settings. The cause: the API gives these back as a partial address like /uploads/…, which a browser completes using the address of the site it is currently on. The accessible site does not hold the pictures — the API does — so every one of them was requested from the wrong place and came back "not found". Confirmed against production rather than assumed: the platform's default profile picture returns "not found" on accessible.project-nexus.ie and loads normally from api.project-nexus.ie. Two things had masked it. The community logo in the site header was already being completed properly, so the page did not look obviously broken; and inside the feed, avatars on comments were handled correctly while avatars on the posts directly above them were not, which made it look like a data problem rather than one consistent mistake. All member-uploaded pictures are now completed the same way the header logo already was. Pictures hosted elsewhere (a partner community's own server) are deliberately left pointing at that server, matching how the main app behaves, so this fix cannot blank them out. Community branding keeps its stricter rule, which stops a community pointing the site header at an outside host — the two rules are now separate on purpose and a test holds them apart. Eleven existing tests had been written around the broken behaviour and were expecting the wrong addresses; they now expect the working ones, and a new test pins the fix. Known limit, deliberately not changed here: the accessible site's security policy only permits images from itself and the API, so a picture genuinely hosted on an outside server still will not display. Allowing that would share every viewer's IP address with that outside host, which is a decision for the owner rather than something to slip into a bug fix.

  • Status colours for text now come from one place, and a new check stops the faint ones coming back. Until now every page picked its own shade of green, amber, red or blue from the raw colour palette, which is how the same too-faint text kept reappearing in different places — each one had to be found and fixed individually. There are now four named colours — success, warning, danger and information — defined once and used everywhere, and 137 places across 68 files were moved onto them. Light mode gets the fix; dark mode is untouched, because the shades already used there were measured and already pass, so keeping them means the appearance you see in dark mode does not change at all. The colours were calculated rather than chosen by eye: each is the lightest shade of its hue that still meets the standard on the darkest background it appears on, so the platform keeps as much colour as the requirement allows. A new automated check now blocks a faint palette colour being used for text anywhere in future — and it was deliberately built to be precise rather than blunt: it does not complain about decorative icons, about colours used on dark backgrounds, or about shades that genuinely pass, because a check that cries wolf gets ignored.

  • The same too-faint colouring was fixed everywhere else it appears in text, across 49 pages and components. The Wallet page was where an accessibility check happened to catch it, but the same pale amber, pink, red and green were used for text in 84 other places — status labels, totals, error messages, counts and figures. All now use a darker shade of the same colour. Decorative icons were deliberately left alone: the standard sets no contrast minimum for them, and darkening them would have restyled the app for no benefit to anyone. Dark mode is untouched, since there the requirement runs the other way. The shade was not chosen by eye — the contrast of every combination was calculated, which showed that one step darker is not enough where a colour sits on a stronger tint of itself, so those cases go two steps darker. Two limits worth stating: this covers text the checker could identify with certainty, and roughly 200 further uses are written in a form where the colour and its background are decided separately, so each needs looking at individually. Those are not yet done.

  • Coloured figures and labels on the Wallet page were too faint to meet the accessibility standard. The pending-credits chip, both Donate buttons — the one on the page and the one on the community fund card — the three summary cards and the credit/debit amounts in the transaction list were drawn in a light amber, pink or green on a pale tint of the same colour. Measured against the 4.5 minimum they came out at 2.95, 3.24, 3.89 and 2.46 — not marginal misses; the amount figures in particular were roughly half the required contrast. All now use a darker shade of the same colour, measured at 4.67 to 5.37, so the page keeps its colour coding while the text is actually readable. A fourth case was found by calculation rather than by the test: the green summary card measured 3.33 and would have failed too, but it did not appear in the test run, so nothing had flagged it. The colours are unchanged in dark mode, which was already fine.

  • The highlighted label in the phone navigation bar was too faint to meet the accessibility standard. The label under the currently-selected icon — "Listings", "Messages" and so on — was drawn in the community's accent colour at a very small size. On the default colour that measures 4.46 against a required minimum of 4.5, so it failed the standard, narrowly but genuinely. It affects every language and every page, since it is the main navigation on a phone; a community using a lighter brand colour would fail it by more. The label now uses the normal text colour, which is safe whatever colour a community picks. The selected tab is still clearly marked: the icon stays in the accent colour, keeps its highlighted background, and is drawn slightly larger and heavier. Found only because an earlier fix let an accessibility check reach this screen for the first time.

  • On a phone, ten of the main pages did not tell a screen reader what page you were on. People using a screen reader typically jump straight to a page's top-level heading to find out where they are. On a wide screen these pages have one, inside the banner at the top. On a phone that banner is deliberately removed to save space and the page title moves up into the bar beside the logo — but it moves there as ordinary text, not as a heading. The result was that on a phone, Listings, Events, Groups, Members, Search, Resources, Volunteering, Marketplace, Feed and Exchanges had no heading at all: the page announced nothing, and there was nothing to jump to. Each now carries a heading that is invisible on screen and read out by screen readers, using the same title already shown in the bar, so it is correct in all eleven languages with no new wording to translate. Nothing changes visually, on any screen size. This follows the approach the Blog page already used for exactly this situation. Found because a newly added Irish accessibility check failed on Listings; the check itself was also wrong in a separate way and has been corrected.

  • If saving a member's language choice failed, nothing was recorded and their language silently reverted. Choosing a language does two things: it changes the screen immediately, and it saves the choice to the member's account. When a signed-in member is next recognised, the account's language is applied — so if that save failed, the member saw their language change, and then on their next page load it quietly went back to the old one, with nothing written to any log to explain it. The error handling that was supposed to record the failure could never run: it waited for the request to raise an error, but this app's network code reports a failure by returning a result that says "failed" rather than raising anything. So the handler was unreachable code and every failure was silent. It now checks the result properly and records the failure, including a note that the choice will revert. Two tests pin this — one for the failure being reported, one for success staying quiet — and the failure test was confirmed to fail against the old code before being kept. Rare, but it made someone using the platform in their own language look like the platform was ignoring them.

  • Dependency updates had stopped reaching the web app entirely, and nobody was told. The automated service that proposes dependency updates — including security ones — had been failing on every run. It could not read the web app's dependency file, because postcss was listed twice: once as a normal dependency, and once in the block that forces a safe version on other packages that pull it in. Package managers reject that combination, so the service gave up before proposing anything, for all packages rather than just that one. The duplicate was removed, keeping the entry that does the real work (the forced-version one, added in May to close a security finding) and dropping the redundant one added later alongside the page builder. Confirmed afterwards that the safe version is still being forced on packages that pull it in, and that the site still builds.

  • The mobile app's release check was failing on five packages that had drifted a patch version behind. Nothing was broken by anyone: the Expo toolkit published new patch releases and the app's locked versions stayed where they were. It surfaced only because the mobile release check is normally skipped — it runs when the mobile folder changes, and the release gate counts a skipped check as passed, so the drift had been accumulating unseen. Brought back in line and verified: 18 of 18 toolkit checks pass, the mobile type check is clean, and all 1,563 mobile tests across 263 suites pass.


Back to all releases