Skip to main content

Give feedback

Back to all releases
Changelog

1.5.9

Released 2026-08-06

Changed

  • "View as this member" was refused to the super-admin of a timebank, for every member of their own timebank. Reported with a screenshot: pressing it on the admin Users page produced "Impersonate Failed" and nothing else. The cause was the permission gate on that address. There are two gates in the platform — one for ordinary administrators, one for the owner of the whole installation — and this sat behind the second, which is written to deliberately refuse the super-admin of an individual timebank. That is the right rule for the pages it normally guards, which act across every timebank on the installation. It is the wrong rule for signing in as one of your own members, which never leaves your timebank.

    A third gate now sits between the two: super-admin of something, whether the installation or your own timebank. It deliberately does not require the timebank to have others beneath it, because helping your own member has nothing to do with hierarchy — the timebank-with-branches rule would have excluded every ordinary timebank for a reason that does not apply to them. An ordinary administrator is still refused; only super-admins pass.

    What the platform already checked, and still does: the member must belong to your timebank, cannot be you, and must sit below you — so a super-admin can view a member as themselves, but not a fellow administrator. Borrowing a colleague's authority is exactly what this must never become. Those ranks are re-checked at the last moment under a database lock, so somebody being promoted mid-action cannot widen it. Every use is written to two logs.

    One related annoyance fixed at the same time: the Users list offered the option on fellow administrators, where it could only ever fail. It is now shown only where it will work.

  • A network administrator can now retire one of their own timebanks, and step into a member's account to help them. Both were things the platform intended to allow and did not, and both were asked for directly.

    Retiring a timebank. "Delete" here means deactivate — the timebank is switched off and can be switched back on; permanently destroying the data is a separate action that remains with the platform owner alone. Until now a network administrator could switch one of their timebanks back on but not off, which was an oversight rather than a decision. They can now switch off any timebank beneath them, but not the one they themselves operate from — doing that would lock them, and everyone in that timebank, out of the platform. The platform refuses it, and the button is no longer shown there; a short line explains why and who to ask. The existing protections stay: the master timebank can never be switched off, and neither can one that still has active timebanks beneath it, which must be dealt with first.

    Stepping into a member's account. The platform already allowed a network administrator to do this for members of their own timebanks — it checked properly, allowed their own branch, and refused an unrelated timebank, the platform owner, and themselves. But the button was only ever shown to the platform owner, so nobody else could reach it. The screen was being stricter than the rules it was enforcing, which is the worst combination: the person is allowed to do it, sees nothing, and has nothing to explain the absence. The button now appears for anyone the platform would actually permit, and is still withheld where it would be refused — a platform administrator, an account that is not active, or yourself.

    Both were checked by hand against a running server as a real network administrator, from both directions: what they can now do, and what they still cannot. Every new test was confirmed to fail if the fix is removed.

  • Two large parts of the platform had no documentation at all, and four code comments said things that were not true. A review of the platform against an external tester's list of user journeys was carried out by reading the code rather than the documentation, and the difference mattered: working from the documentation alone, a reviewer concludes that the safeguarding, guardian and consent features do not exist. They do — around thirty database tables' worth, and the strongest-built parts of it use encrypted identities, single-use expiring links and a history table the database itself refuses to let anyone alter after the fact. None of that was written down anywhere. Neither was the permissions model, which is why the same tester asked four separate times whether an administrator sits above or below a broker. Two new pages now describe both: docs/ROLES-AND-PERMISSIONS.md and docs/SAFEGUARDING-AND-CONSENT.md, together with shorter summaries in the architecture map and in the guide the coding assistants read.

    The permissions page also records, for the first time, how the super-admin panel limits reach: a network's own administrator can only see and act on their own community and the communities beneath it, enforced by checking both ends of any cross-community action, while only the platform owner is unrestricted. That was already built correctly; it simply was not written down, so nobody could confirm it.

    Four comments in the code were corrected because they asserted the opposite of what the code does. The one that matters most sat above the only tool an administrator has for correcting a member's time-credit balance: both the function and the route said the action was recorded in the audit log, and it is not — there is no audit entry of any kind, and the required reason survives only as a text prefix on the transaction description. Two related defects in the same function are now written down beside it: the adjustment is not marked as an administrative one, so it is indistinguishable from an ordinary member-to-member transfer, and the acting administrator is recorded as the other party to the transaction without their own balance changing, which inflates their apparent totals on the admin dashboard every time it is used. The remaining three: a block of routes was labelled "public" and described as working for signed-out visitors when in fact every route in it requires a login (there is no way for a member of the public to browse offers at all); a function's documentation gave a web address that does not exist; and the description of moving a member between communities understated what happens to their money — because a balance is stored on the member's own record while their transaction history is stored per community, a moved member arrives with a real balance and no history behind it, while the community they left keeps that history. That is now spelled out in three places, including directly above the function that does it, since no test covers it.

  • Tests now use the whole development machine instead of one core at a time. The project carried a rule that only one heavy test suite could run at a time, because on the old 16 GB machine a second one really did cause the computer to seize up and report failures that were not real. The development machine is now a 16-core, 96 GB workstation, and that rule was leaving almost all of it idle: test files were being run strictly one after another. They now run concurrently, with the number of parallel workers worked out from the machine itself rather than fixed in the file, so a smaller laptop still behaves sensibly and this workstation uses half its threads. Measured before and after, with the same tests passing in both cases: the interface component suite went from 79 seconds to 12, the groups pages from 72 seconds to 9, and the events front-end step from 67 seconds to 11. The checks that run on GitHub are deliberately left exactly as they were, because those run on much smaller machines and their timings were hard won. NEXUS_VITEST_MAX_FORKS=1 forces the old one-at-a-time behaviour when a test needs to be examined in isolation.

  • Full static analysis of the PHP code can now be run locally, which previously was not possible. The advice was to analyse a few files at a time because a full run "hung". The real cause was that the local PHP container was capped at 2 GB of memory while the analyser is asked to use up to 2 GB - the entire allowance - so it was being killed rather than finishing. The local container is now allowed 8 GB, which is under a tenth of the machine's memory, and a complete run finishes in about eight minutes with no errors. The production server's limits are deliberately untouched, because that machine has 16 GB in total and its existing setting is correct for it.

  • Documentation that assumed a small, slow machine has been corrected. Six places told contributors to run test suites one at a time or strictly in sequence. A new page, docs/LOCAL-PERFORMANCE.md, records what the machine actually is, the before-and-after measurements, which settings adjust themselves automatically, and - importantly - one thing a faster processor does not fix: the project files are shared into the Docker container over a slow Windows-to-Linux bridge, and reading the PHP source through it takes 4.6 seconds versus 0.045 seconds when the same files sit inside the container. That is roughly a hundred times slower, and it is why giving the static analyser six times as many workers made it only 11% faster. Moving the working files inside that boundary was then investigated properly and rejected, with the reasons recorded so nobody repeats the exercise: it speeds up the static analyser (from 7 minutes 38 seconds to 53 seconds) and, as far as anything measured shows, nothing else. Notably it does not speed up the PHP tests at all - the same 30 test files took 539.8 seconds through the slow bridge and 539.1 seconds without it, which is the same number. Slow PHP tests turn out to be roughly 1.4 seconds spent inside each individual test, with the framework start-up at 94 milliseconds and a database query at 0.29 milliseconds. That cause was then tracked down and fixed - see the next entry.

Removed

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

Security

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

Fixed

  • A timebank whose position in the family tree was missing would have been billed for every member on the platform. Billing counts a timebank's members including all the timebanks beneath it, which is how a network is charged as one. It works out "beneath it" from a short text field recording where that timebank sits in the tree. If that field was ever empty, the search became "match anything", and the timebank was credited with every active member on the whole installation. Measured on the development copy: the correct answer for a test network is 5 members; with the field empty the same query returned 29, which was every member of every timebank there.

    That figure is not cosmetic — it decides whether a timebank has outgrown its plan, whether it is put into a grace period, and what it is quoted. So the failure was over-charging a community by however much the rest of the platform happened to weigh, growing quietly as the platform grew.

    No timebank has an empty position today, and billing is not charging anyone yet, so nobody was affected. It was fixed now because that field is filled in as a second step just after a timebank is created, so an interruption at the wrong moment leaves exactly this state — and the moment to find a billing error is before the invoices, not after. Where the position is missing the platform now counts only that timebank's own members, which under-states rather than over-states, and writes a line to the log saying it happened. The identical flaw was fixed in the access rules earlier this week; this was its last remaining copy anywhere in the code.

    Three tests now cover it, and all three were confirmed to fail if the fix is removed. Worth recording why that mattered: the first version of those tests passed even with the fix taken out, because in the test database the timebanks holding most of the members happen to have no position of their own, so there was nothing for the faulty search to sweep up. The tests now seed an unrelated timebank that does have one, which is what makes the fault visible.

  • "View as this member" (impersonation) never worked, in any of the three places it was offered. Pressing it appeared to do nothing: a new tab opened showing the administrator's own account, or the sign-in page. Nobody was ever signed in as the member.

    The server hands the browser a short-lived pass that says "let this administrator view this member". That pass is not a sign-in key, and the part of the server that checks sign-in keys only accepts sign-in keys — so every request the new tab made was refused. The browser then quietly recovered the way it recovers from any refusal: by renewing the administrator's own sign-in. That is why the new tab showed the administrator. The step that was meant to trade the pass for a real sign-in as the member had never been built; the function written to do the trade existed but nothing anywhere called it. Two earlier attempts at this fixed how the pass travels between the two tabs, which was never what was wrong.

    There is now an exchange step: the new tab trades the pass for a genuine sign-in as the member, and only that sign-in is used. The pass can be spent once, expires in five minutes, is bound to one community, and is re-checked at the moment it is spent — so an account promoted to administrator during those five minutes can no longer be entered.

    Three further problems were fixed alongside it:

    • The two tabs were sharing one set of credentials, because browsers share that storage across every tab on a site. Signing in as the member therefore overwrote the administrator's own sign-in in their original tab. The member session now lives in storage private to its own tab, so neither tab can disturb the other, and the administrator signing out no longer ends the member view.
    • From the super-admin panel the button could not have worked even in principle: the panel exists to browse other communities, but it called the single-community endpoint, which answers "user not found" for anybody outside the current one. It now uses a cross-community endpoint, bounded so that the administrator of a network still only reaches their own branch.
    • There was no way to stop, and no sign you had started. Every viewed page now carries a notice saying whose account you are looking at, with a button that ends the view immediately. Ending it cancels that session on the server without touching the member's own sign-ins on their own devices.

    A viewing session is deliberately short-lived and is issued with no means of renewing itself — viewing somebody's account should not create a long-lasting key to it. When it lapses the administrator starts again.

    This went unnoticed because the tests only checked that the wrong people were refused a pass. Nothing checked that the pass could sign anyone in, and it could not. There are now tests that end by presenting the resulting credential to a real endpoint and confirming who it signs in as.

  • The super-admin panel link was missing from the sidebar until the page was refreshed. After signing in, the administrator of a community that can have branches beneath it saw no way into the super-admin panel; it appeared only after a full page reload, and then behaved normally for the rest of the session. The sign-in reply carries a deliberately small record of who you are, and the field that says how far your super-admin reach extends is not part of it — that field is worked out by the server and only sent with the fuller profile. The app was keeping the small record for the whole session, and treats a missing reach as "none", so the link was hidden. Reloading the page happened to fetch the fuller profile, which is why the refresh appeared to fix it. Signing in now fetches the full profile straight away, exactly as signing in with a fingerprint or face already did, so the link is there immediately. If that fetch fails the session still opens on the smaller record rather than dropping the user back to the sign-in page.

  • The admin Matching Analytics page showed "Failed to load" on any community that has not generated matches yet. Nothing was actually failing. The server answered correctly; the page then threw the answer away and showed an error.

    The cause is a quirk of the language the server is written in: PHP cannot tell the difference between an empty list and an empty set of labelled counts, so when there are no dismissal reasons, no algorithm versions and no scoring samples to report, all three come back written as empty lists rather than as empty sets. The page checked the answer strictly before displaying it, insisted those three had to be labelled sets, and rejected the entire response over it — including the parts that did have real numbers in them. So a community with real member and listing figures to show saw none of them.

    The page now accepts an empty list where an empty set of counts is expected, and only there: a list with anything in it is still rejected, as is any count that is not a number. Confirmed against what the live server actually returns for the test community, which is where the exact figures in the new tests come from — asked again on 2026-08-06, and all three of the values in question came back as empty lists exactly as described, so this is the shape a real timebank with no matches yet actually sends.

    This slipped through because the page's existing tests fed it a hand-written answer in the shape the page wanted rather than the shape the server sends — different names for several figures, and empty sets written the way the page preferred. Two new tests now use the real answer, one at the page level and one on the checking code itself, and both were confirmed to fail before the fix.

  • The admin Performance page crashed to an error screen, and the figures it was built to show did not exist anywhere on the platform. It now measures them for real. Opening the page replaced the whole admin area with "Something went wrong". It was asking the server for request timings, slow database queries and memory use at the address of the general event-counter, which answers with a completely different set of figures — so the page reached for a list that was not there and stopped dead.

    Behind that was the bigger problem: nothing on the platform recorded how long anything took. No table, no code, nothing to point the page at. Half the feature had never been built.

    It is built now, and deliberately built so the monitor cannot become the slowest thing on the platform:

    • Every request is counted, but not every request is written down. Each one bumps a single hourly tally, which is why the totals and the hourly chart are exact rather than estimated. A full record is kept only for requests that are actually interesting — slow to respond, heavy on memory, making an unusual number of database queries, or repeating the same query over and over. On a healthy site that is a handful of rows a day, not one per visitor.
    • Nothing is recorded until after the reply has been sent. Measuring a request cannot slow it down, because the writing happens once the visitor already has their answer.
    • Repeated-query patterns are spotted automatically. The most common cause of a slow page is asking the database the same question once per item in a list. The recorder notices when one query shape repeats past a threshold and flags the request, so the page can point at it.
    • Slow queries are stored with the exact place in the code that ran them — file, line and function — so there is something to act on rather than just a complaint.
    • No member data is ever stored. Only the shape of a query is kept, with the values stripped out, so these diagnostics tables cannot accumulate names, addresses or anything else about a person. There is a test that fails if that ever changes.
    • It cleans up after itself. Detailed records are deleted after a fortnight and the small hourly tallies after three months, tidied up nightly. The old records are not needed and would grow for ever.
    • It can be switched off entirely, and if it ever is, the page says so rather than showing a page of zeroes, because zeroes read as "your site is perfectly fast" and nobody could have checked that. It also has to survive its own failure: if the recording breaks, the visitor's request still succeeds and the problem is written to the log instead.

    The page itself shows real figures at last, and three details it was always meant to show but never did — the query count, the peak memory figure and where a slow query came from — were on screen as bare labels with no number after them. Those now carry their values, in all eleven languages.

    Worth recording why nobody noticed. The page's tests handed it a hand-written set of figures in exactly the shape it wanted — a shape no part of the server has ever produced — and the test file was on the list the automated checks skip, so nothing was watching. It has been taken off that list, the skip list's ceiling has been lowered so it cannot creep back, and there is now a test that compares the page's expected shape against a real server response and fails if either side is renamed without the other. On the server side, seventeen tests cover what gets recorded and, just as importantly, what deliberately does not.

    One honest limitation: the on/off switch is for the whole platform, not per community. Checking a per-community setting on every single request would itself cost a database query on the busiest path in the system, which is the opposite of the point.

  • The network super-admin panel offered a button that would always have been refused, and told the person they were seeing the whole platform when they were not. Walking the new panel by hand for the first time, as the super-admin of a timebank that has timebanks beneath it, turned up two things no test had caught. The dashboard offered a "Federation Controls" button, which is a platform-owner-only area — pressing it could only ever have produced a refusal, and a refused button is worse than an absent one, because the person cannot tell whether they lack permission or the platform is broken. And the heading described the page as a "platform-wide overview", which for a network administrator is simply untrue: they are shown their own timebank and the timebanks beneath it, and nothing else. That wording is exactly how somebody concludes they can see everyone's data. The button is now shown only to the platform owner, and the description now says what a network administrator is actually looking at. Confirmed from both directions on a running server — the platform owner still sees the button and every platform-only area, and a network administrator sees only their own branch.

  • The link to the super-admin panel could fail to appear for the administrator of a network. The admin menu works out its contents once and then reuses that answer until something it watches changes. It was not watching the one value that decides whether the super-admin entry is shown. For the platform owner this made no difference, because a different value it was watching changed at the same moment. For the super-admin of a community that has communities beneath it, nothing on the watched list ever changes — they are not a platform owner before their account details load, and they are still not one afterwards — so the menu was never rebuilt and the entry never appeared. The value is now watched, and a note above it records why removing it breaks only the branch case, which is the harder one to notice.

  • The automated screen-reader check could not sign in, so it was about to go back to checking nothing. The check was taught to log in as a real member on 2026-08-05, because it had been quietly scanning the login page instead of the member pages. On GitHub it then failed at the first step. The cause was a single setting: that job stored sign-in sessions in memory and threw them away at the end of every request, so the sign-in form's security token never survived long enough to be checked and the sign-in was rejected outright. Reproduced both ways to be sure — with the setting as it was the sign-in is refused and the member is bounced back to the login page, and with it corrected the member reaches their dashboard. The setting is left alone for the other checks in the same job, which want no files written.

  • A member could agree to a guardian arrangement and nothing else — they could not refuse it, and could not change their mind. When coordinators record that someone is responsible for supporting a member, that member is the subject of the arrangement. The only button they had was "I agree". There was no way to say no, and no way to withdraw afterwards — undoing it was a staff-only action. There was not even anywhere in the database to store a refusal.

    A consent that cannot be refused is not consent, it is a button. And a consent that cannot be withdrawn falls short of the ordinary expectation that withdrawing is as easy as agreeing.

    A member can now agree, refuse, or withdraw agreement they previously gave, and change their mind in either direction afterwards. A reason can be given and is never required — making somebody justify refusing a safeguarding arrangement is pressure to agree, so the screen says explicitly that they do not have to. Their coordinators and the named guardian are told when they refuse or withdraw, each in their own language, because a refusal is a safeguarding signal that must not sit silently in a table.

    Three further gaps closed at the same time:

    • The guardian could see nothing. They were emailed that they had been made responsible for someone and then had no screen for it — they could not see the arrangement, or whether the member had agreed at all. Half the relationship was invisible. They now have a "People you support" section, which appears only if they actually support someone, and which states that it does not let them act on anyone's behalf.
    • Nothing told a member there was a decision waiting. The only routes in were an email, or knowing to look several clicks deep in settings. There is now a prompt on the dashboard, which shows nothing at all when there is nothing pending.
    • Every change is now recorded in a trail that cannot be rewritten. Who did what, when, in what capacity, and any reason they gave. The database itself refuses attempts to alter or remove those records — that is tested, not assumed.

    Worth being straight about one limit: the records are protected against the application and ordinary database edits, not against someone with full database access who empties the table outright.

  • The same screen now exists on the accessible version of the site, which had none of it. When the guardian-arrangement screen was first built it was built only in the main app. The accessible site — the plain-HTML version intended for people who need the most accessible experience — was left with nothing: no way to see an arrangement made about you, and no way to agree, refuse or withdraw. That was the wrong way round, because the people most likely to be under such an arrangement are the people most likely to be using that version.

    It is now there in full, and it works entirely without JavaScript: every action is an ordinary form with a button. It shows who has been recorded as responsible for you, when, any note from your coordinators, your current answer, and any reason you gave. It offers the same three answers, with the same optional reason and the same explicit statement that you do not have to give one. Guardians see the people they support and whether each has agreed. It is linked from the settings hub — an unlinked page is an undiscoverable one — and it has been added to the list of pages the screen-reader check scans, where it passes with no problems found.

    Both versions call exactly the same underlying code, so the rules about what answers are possible, the record of who did what, and the notifications to coordinators cannot drift apart between them.

  • A carer could be told they were allowed to read a vulnerable person's messages, and it was never true. On the linked-accounts screen a family saw four identical on/off switches: view activity, manage their listings, send and receive time credits, and view their messages. The first three now work. The fourth never did — nothing in the platform ever checked it. It saved, it showed as on, and no part of the system paid it any attention.

    That is worse than the feature being absent, because a family could reasonably act on it. In a safeguarding feature it is the most serious kind of error, and it appeared in both the main app and the accessible version. The clinching detail: the database column's own description lists three permissions. The fourth reached both screens and never reached the design.

    The switch is gone from both, and both now say plainly that carers cannot read messages, rather than the option quietly disappearing — a family who had switched it on needs to know it never did anything. The same permission can no longer be sent from the accessible version at all. It has not simply been deferred: letting a carer read a dependent's conversations exposes the other person in that conversation, who never agreed to it. The platform's existing answer for oversight is to notify the people involved, and until that notice exists for carers this must not be offered. The test that used to demand the switch exist now demands it does not.

  • A member could be told someone had been made responsible for them, follow the link in the email, and find nothing. When coordinators record that one member is a guardian for another, both people are emailed, and the ward's email links to their safeguarding settings. That page never showed the arrangement. Worse, nothing anywhere could record the ward's agreement to it — so the figure on the admin dashboard counting how many people had consented was permanently zero.

    The behind-the-scenes half of this was built earlier in this release. What was missed is that no screen ever called it, which is the same fault it was meant to repair: the original bug was a function nothing called, and it had been replaced by an address nothing called. A member still could not see or agree to anything.

    There is now a "Guardian arrangements" section in safeguarding settings. A member sees who has been recorded as responsible for them, when, any note the coordinator added, and can give their agreement — which is what finally records it. Only they can: a guardian trying to agree on their behalf is refused, and there is a test for that boundary specifically, because an agreement signed by the wrong person is worse than none. The section states plainly that this record does not allow anyone to create listings, use the member's time credits, or read their messages, because it genuinely does not.

  • Two unrelated features were both called "guardian", with nothing to tell them apart. A member can link a family account and choose "guardian" as the relationship. Coordinators separately record "guardian arrangements". These are different things in different places with different consequences — a link a member sets up can grant real abilities, while a coordinator's record grants none at all — and they are so unconnected that no single file in the codebase touches both. Nothing on screen explained which was which. Both screens now say what they are and where the other one lives.

    New wording added and translated into all eleven languages. Three separate translation faults were caught and fixed before this shipped, all in the same short label. Machine translation dropped it entirely in every language on the first attempt, because it contained a date placeholder. Reworded without the placeholder, it was then skipped in every language by a safeguard that treats single words as computer values rather than text. Forced through, it came back in Japanese, Spanish, Polish and Portuguese meaning audio recording. The English was changed to something unambiguous instead of overriding the safeguard, which is the point: that safeguard was right and the fix was to stop giving it an ambiguous word.

  • The accessibility check that guards the screen-reader frontend was scanning the login page instead of the pages it claimed to cover. The accessible (GOV.UK-style) frontend has a browser accessibility scan that runs on every relevant change and is marked as blocking. Twenty-three pages were listed. Six of them — the activity feed, offers, messages, events, volunteering and the knowledge base — are only visible once you have signed in, and the scan never signed in. Asking for a page you are not allowed to see sends you to the login page, and the browser follows that quietly. The scan then checked four things: that the page has a main content area, a heading, a "skip to content" link, and the phase banner. The login page has all four. So each of those six pages passed by scanning the login page a second time, and the result was reported as a clean pass.

    Two more gaps sat alongside it. Anything the scanner rated "moderate" was thrown away before the result was judged — and heading order, page landmarks and form labelling, which are exactly what somebody navigating by screen reader relies on, are routinely rated moderate. And the pages that carry the core exchange journey — the dashboard, an exchange, the wallet and the member's own profile — were not in the list at all, which is the journey the tester specifically asked about.

    All three are fixed. The scan now signs in as a real member, covers ten signed-in pages including the exchange journey, and fails on moderate as well as serious problems. Two safeguards keep it honest rather than merely passing: signing in is now a prerequisite step, so a broken login fails the whole run instead of letting every page quietly fall back to the login screen; and each signed-in page now asserts that it was not redirected, because having a heading and a main area is not proof of which page you are on. Both safeguards were tested by deliberately breaking them — with a wrong password the run stops and twenty-seven checks are reported as not run, and with an expired session the individual page fails and names itself. Under the old version both of those situations passed.

    The genuinely good news: with all ten signed-in pages actually being scanned for the first time, there are no violations at any level, including the "minor" ones that still do not fail the build. The accessible frontend really is clean. It simply had not been checked.

  • A second accessibility check was scanning an error page four times and calling it four pages. Separate from the one above, this check covers the main React app. It listed four addresses — a community's home page, its sign-in page, About and Help — and served them from a built copy of the app with no server behind it. Measured properly, three things were wrong at once, and each on its own made the result meaningless.

    The tool serving those files answers every address with the same single file, so all four were byte-for-byte identical. With no server to talk to, the app cannot load the community's settings, so what it actually displayed was its "Unable to connect" screen — for all four. About, Help and the sign-in page were never examined and could not have been. And the check was written so that three separate things could go wrong silently: the scanner's own error messages were written into the file meant to hold its results, its failure signal was explicitly discarded, and the small piece of code that counted the problems ended with an instruction to report zero if anything at all went wrong. A scanner that crashed outright was recorded as a clean pass.

    Scanned honestly, that error screen had one serious problem and no page landmark at all — meaning somebody using a screen reader had nothing to navigate to, on the one screen that appears when everything else has failed. The problem was the "Try again" button: white text on the standard brand colour measures 4.46 against a required minimum of 4.5. A fraction under, and a real failure.

    Both are now fixed on that screen, and the check has been rewritten to examine exactly what a server-less copy of the app can honestly show — the app's shell and that offline screen — using the same browser-based tooling as the rest of the suite, with failures that actually fail. Pages with real data in them are covered by a different check that runs the whole platform in containers and signs in as both an ordinary member and an administrator; that one was already sound, and it is the one to trust for real pages.

    A wider issue was found on the way and is not fixed, deliberately. The colour used for text on brand-coloured buttons is fixed as white, while the brand colour itself can be changed by each community and each member. Nothing checks that the resulting combination is readable. The offline screen now uses a fixed, checked colour instead, but everywhere else that pairing is still unverified. That needs its own piece of work, because changing it affects how the whole platform looks.

    Two smaller notes, recorded so nobody assumes otherwise. The performance report that runs on front-end changes also produces an accessibility score, but it only ever loads that same server-less copy — so its score describes the offline screen, not the app, and it is advisory rather than blocking. And pa11y, an accessibility tool, is listed as a dependency of the project but is not used anywhere at all.

  • Reporting is now findable in one place. A partner organisation's technical reviewer reported that they could not find the reporting well enough to judge it. The figures were separately wrong and have been fixed, but the finding itself was about finding it: reporting was spread across five different panels, and seven analytics screens had no menu entry at all and existed only for someone who already knew the web address. The one report whose download genuinely matched its screen was filed under a different panel entirely. Everything an administrator can report on is now listed under one heading, and each entry only appears if the community actually has that feature switched on — a link to a disabled area is a dead end, and there is a test that checks exactly that.

    Five of those newly listed entries were briefly missing their menu labels in all eleven languages, which would have shown administrators the internal name of the screen instead of its title. The wording already existed elsewhere in the same files — every language already had a proper translation of each one for use in the trail of links across the top of the page — so the labels were taken from there rather than machine-translated, and no new wording was invented in any language.

  • A guardian arrangement recorded no consent, because nothing could write it. When staff record that one member is responsible for another, the record has a place to note the second member's consent — and nothing in the platform could fill it in. The function that would have done so was written but never called from anywhere, so the figure on the admin dashboard counting how many people had consented was permanently zero, no matter what happened. The member on the receiving end also had no way to see the arrangement at all: they were emailed about it, and the link in that email led to a page that did not show it.

    Both halves are now fixed. A member can see who has been made responsible for them, and can give their consent, which is what finally populates that record. Consent can only be given by that member — a guardian attempting to consent on their behalf is refused, and there is a test for that boundary specifically, because a consent record signed by the wrong person is worse than none.

  • Marking a "delete my data" request as completed did not delete anything. When a member asks to be erased, the platform records the request for a coordinator to action — deliberately, because deciding a data request is a human and legal judgement rather than something to automate, and there is already a daily alarm that flags requests left unattended past the legal deadline. What was missing is that the coordinator's own action did nothing: marking the request "completed" wrote the word "completed", stamped who did it and when, and left every piece of the member's data exactly where it was.

    That is worse than an unprocessed request. It manufactures a false compliance record — the paperwork says the erasure happened — and it silences the overdue alarm, because the request no longer looks open. Completing an erasure request now performs the erasure. The code to do it already existed and was already written to be called this way, including a safeguard that refuses to record the request as completed if any part of the erasure failed, leaving it for a coordinator to retry rather than reporting a false success. It was simply never connected.

    Two claims from the original review did not survive checking, and are recorded here so they are not repeated: the absence of automatic processing is intentional and documented, not an oversight; and members are told their request has been submitted, not that their account has been deleted, so the wording was accurate all along.

  • New members were never told they were waiting for approval — and that is the normal case, not a rare one. Most communities require a coordinator to approve new members, and that is what a freshly set-up community does by default. Yet the sign-up screen only ever said "check your email". The member then verified their address, tried to sign in, and got a single line of red text with no explanation of who approves them, how long it takes, or what to do.

    What makes this one frustrating is that the screens were already built. The sign-up page has a proper "waiting for approval" panel, and the app was already written to look for that information — the sign-up process simply never sent it, so the panel was unreachable. The verify-your-email page had the same panel, and it too could never appear, because it depended on a community setting that is deliberately kept private and so was never available to it. All three points now explain the situation properly: the sign-up confirmation, the page after verifying an email address, and the sign-in screen, which now offers a way to contact the community. New wording added and translated into all eleven languages.

  • There was no way to decline a membership application. A community could approve someone, suspend them, ban them, or delete them outright — and nothing else. In practice an application was declined by leaving it pending forever, which is invisible to the applicant and leaves no reason recorded anywhere. There was nowhere on the member record to put a reason even if someone had wanted to.

    Coordinators can now decline an application with a reason, which is required. The reason is stored on the record itself rather than buried in a log, along with who declined it and when, and the applicant is notified in their own language — an application quietly absorbed is indistinguishable from one ignored. It is reversible: approving someone clears the earlier decision, so a community can change its mind without making the person apply again. Declining is restricted to applications; removing an existing member is still a suspension or a ban, which are different decisions with different consequences.

    This also fixed a real fault found on the way. The member record's list of allowed states did not include "declined", yet the sign-up code already tried to set exactly that when two people race for the last use of an invite code. Because this database rejects out-of-range values rather than quietly accepting them, that path failed outright — so instead of the intended "invite code no longer valid" message, the person got a server error and the half-created account was left pending. Adding the missing state fixes both.

  • New members ticked the terms box and the platform kept no record of which terms they had agreed to. The tick was checked — registration was refused without it — and then discarded. Nothing was written anywhere. A properly built table for this already existed, recording the exact version of each document a member accepted along with the date, their address and their browser, and it even had "registration" as one of its listed ways of accepting — a value that had never once been used. The function to write those records existed too, and already defaulted to "registration". It had simply never been called from the signup process.

    The consequence: the only record a member ever got was created later, the first time they signed in, when a separate prompt asked them to accept. So between signing up and first signing in there was no evidence of what anyone had agreed to, and for anyone who signed up and never signed in, there was none at all. Registration now records the exact version at the moment of signing up. Both the main app and the accessible version go through the same code, so both are covered.

    Two deliberate limits. If a community has not set up any terms documents, nothing is recorded, because there is no version to point at — and signing up still works, which is covered by a test. And accounts created by signing in with Google or similar are not given a registration record, because that route never shows a terms box; recording agreement there would be inventing consent the person never gave. Those members are still correctly captured by the prompt at first sign-in, and there is now a note in the code explaining why this must not be "tidied up" later.

  • A completed exchange could not be corrected, so a mistake moved real credits permanently. Once an exchange was marked complete, that was final: there was no way to undo or amend it. The only tool an administrator had was the single-member balance adjustment, applied by hand twice — once to each person — with no link back to the exchange that was wrong and, until earlier in this release, no record of who did it or why. For a platform whose entire purpose is an accurate record of hours given and received, that was the most serious gap found in the review.

    A broker or administrator can now reverse a completed exchange, restoring both members' balances in a single step, with a mandatory explanation recorded in the exchange's own history — which both members can already see — and in the community's audit log.

    It is built to match the one place on the platform that already did this correctly, the marketplace refund, point for point. The original record is never altered or deleted; the correction is its own separate entry, mirroring the first, so the history of what happened stays intact and the correction is visible beside it. The amount is read back from the original entry at the moment of reversal rather than taken from whoever is asking, so a reversal cannot move a different sum from the one that was moved — there is a test proving that even if the recorded hours are altered afterwards, the reversal still returns exactly what actually changed hands. The two member records are always locked in the same order, so two corrections happening at once cannot jam against each other. And a second reversal of the same exchange is impossible: it is blocked both by a check in the code and by a rule in the database itself, because a check alone can be beaten by two requests arriving together.

    One deliberate decision: if the person who received the credits has already spent them, the correction still goes through and their balance is allowed to go below zero. Refusing would leave the record permanently wrong, so the shortfall is instead shown honestly as a debt. This matches how refunds already behave elsewhere on the platform.

    This reverses; it does not re-post at a corrected figure. Amending a wrong number of hours means reversing and then recording the exchange again correctly. Building a second way to move credits, purely to save that step, would have meant a second thing to get wrong.

  • Carers and guardians were shown permissions the platform never actually honoured. A member can link another person to their account as family, a carer, a guardian or an organisation, and grant them named abilities: view my activity, manage my listings, send and receive time credits for me, view my messages. Only the first of those four was ever checked anywhere. The other three were offered as switches in both the main app and the accessible version, with those exact labels, and nothing in the platform consulted them — there was not even a way for a carer to attempt any of it. A family could have been told a carer was able to spend a dependent's credits when they could not. It failed in the safe direction, so nobody gained an ability they should not have had, but people were being misinformed about a safeguarding arrangement.

    Two of the three are now real. A carer with permission can post a listing for the person they support, and can send time credits from that person's balance. Both go through exactly the same code as the member's own equivalent action, so the carer's route carries the same spending cap, the same over-spend protection, the same safeguarding contact checks and the same duplicate-submission protection — a second, weaker path for someone else's money would have been the wrong way to build this. The safeguarding contact check is repeated at the moment of use rather than only when the link was first approved, so a restriction that lands later takes effect immediately, and a link the dependent has not yet approved grants nothing at all.

    Crucially, the platform now records who actually did it. A listing posted by a carer still belongs to the person it was posted for, and credits still come from that person's balance — but a new field on both records names the carer who acted. Without it, a carer's action would have been indistinguishable from the dependent's own, which for a feature that lets one person spend a vulnerable person's credits is not acceptable. Every such action is also written to the community's audit log, and the dependent is notified in their own language that something was done in their name, because a proxy action the owner never learns about is not consent.

    The third permission, viewing the dependent's messages, is deliberately still switched off pending the notice work — the people messaging that member never agreed to a carer reading their conversations, and the platform's own answer to this elsewhere is to tell them. That is being built separately rather than quietly shipped.

  • The only tool for correcting a member's time-credit balance kept no record of who used it or why. Both the function itself and the route that reaches it stated in writing that the action was recorded in the audit log. Nothing was written anywhere. The reason the administrator is required to type survived only as a text prefix on the transaction description, in a field nothing can search. Every use is now recorded properly — who did it, which member, the reason, and the balance before and after — and deliberately written in the same single step as the balance change, so an adjustment can never happen without its record. If the record cannot be written, the adjustment is undone.

    Two further faults in the same place. The adjustment was not marked as an administrative one, so it was indistinguishable from an ordinary transfer between two members. And the acting administrator was recorded as the other party to the transaction while their own balance was never changed — which meant every single adjustment quietly inflated that administrator's apparent totals on the admin dashboard, because those totals are calculated by adding up transactions per person. Credits created or removed by administration now correctly have no second party, matching how the rest of the platform already records this. The existing test had been asserting the faulty behaviour rather than catching it; it now checks the correct behaviour, and two further tests cover the audit record and the transaction type.

  • A disputed exchange could never be resolved by anyone, and the credits were stuck. When two members each confirm a different number of hours and the gap is too large to average, the exchange is marked as disputed. Nothing could then happen to it — not by either member, not by a broker, not by an administrator. The credits had not yet moved and never would. This was the worst kind of dead end because the platform openly advertised that something should be done: the broker dashboard counted disputed exchanges as needing attention, and a separate monitoring check raised an alarm as they aged, so the warning could be triggered but never cleared.

    Most of the machinery already existed and was simply unreachable — the code that completes an exchange already handled the disputed case and even had a dedicated "dispute resolved" email written for it, but the only way in was through a check that excluded disputed exchanges. A broker can now settle a dispute by setting the final number of hours, with a mandatory note explaining the decision. The figure is held to the same limits a member's own confirmation would be, so an arbitrator cannot enter a number the platform would have refused from a participant, and the decision is written to the exchange's own history, which both members can already see. A broker who is one of the two parties is blocked from deciding their own case.

    Found while doing this: the existing code for a broker to cancel an exchange they are not part of tested a permission flag that does not exist on the platform — there is no such column and no such property — so the check was always false and a genuine broker would have been turned away. It now uses the platform's proper permission test. That path had no way in either, so nobody had hit it.

  • Turning a phone sideways left signed-in members with no way to navigate the app. The bar of buttons along the bottom of the screen was set to disappear once the screen was wider than 768 pixels, on the assumption that anything wider is a desktop and would show the full menu across the top instead. But the top menu only appears at 1024 pixels and above, and the hamburger button that opens the slide-out menu was deliberately built for signed-out visitors only. A phone held sideways is around 900 pixels wide, which falls straight into that gap: the bottom bar had gone, the top menu had not arrived, and there was no hamburger — leaving nothing but the small account dropdown, which does not list the feed, listings, messages, exchanges, events or groups. Everything on the platform was still reachable by typing an address, and nothing else. The bottom bar now stays until the top menu takes over, so exactly one of the two is always present at every screen width. The gap is closed by construction rather than by picking a new number: the two conditions are now exact opposites of each other. Reported by Timebanking UK during testing. The separate phone app was unaffected because it is locked to upright, which is why this was never noticed internally.

  • Two of the main admin reports were broken, and the tests were checking made-up data so nobody found out. The "Hours" report has three views. Opening the one that lists hours per member crashed outright, because the server sends that list wrapped in a container with a page count and the page was treating the container itself as the list. On top of that, roughly twenty figures, chart labels and table columns across the Hours and Member reports were permanently blank or zero — the page was asking for information under names the server has never used, such as asking for "month" where the server says "period", "unique givers" where it says "unique providers", and a month-by-month retention grid the server does not produce at all. Each one silently produced nothing rather than an error.

    Why it survived: the automated tests for both pages supplied invented data in exactly the same wrong shape the pages were reading, so the tests agreed with the bug and reported success. The Member report's tests were weaker still — several of them ended in checks that can never fail, such as asserting that the page exists. Both sets of tests now use the shapes the server genuinely returns, copied from it, and check real values, so this class of fault fails immediately instead of passing quietly. Fixing the test file's typing also removed ten long-standing type warnings, and the project's warning ceiling has been lowered to match so they cannot come back.

    Also fixed while in there: the member list showed only the first fifty members with no way to reach the rest, despite the page count already being available; and the retention table, engagement figures, contributor leaderboard and inactive-member list now show the figures the server actually computes rather than placeholders. Six new labels were added and translated into all eleven languages.

  • Report downloads did not match what was on the screen. On the Hours report, the download button always fetched the "by category" breakdown no matter which of the three views was open — so someone looking at hours per member received category totals in a file named after the view they had been reading. The two missing download types have been added, and they now call the same code that draws the screen, so the two cannot drift apart again. On the Member report the download sent no filters at all, so all six views downloaded the same complete all-time member list under six different names; four of those views have no matching download on the server, so the button is now switched off for them rather than handing over an unrelated file, and the file that remains is named after what it actually contains.

    A separate date fault affected every report and every download: asking for a range ending on the 30th of the month excluded the whole of the 30th, because a date with no time attached is treated as the first instant of that day. A one-month report was quietly returning one day short, on screen and in the file. Fixed in both places that build these date filters.

  • A rejected listing told the member it had failed but never which field was wrong. The server already reports precisely which fields are at fault and why, but the form discarded that and showed a single generic message. Field-level messages now appear against the fields themselves, and anything the server reports that is not tied to a specific field still goes to the pop-up message, so nothing can be swallowed. Reported by Timebanking UK during testing.

  • Permanently deleting a community was recorded in the audit log without saying what had happened. Wiping a community is the most destructive thing the platform can do — it is irreversible and removes every trace of that community's data and members. It is supposed to leave a permanent audit entry saying so. The entry was being written, with the full description, who did it, when, and the before-and-after figures. But the field that says which kind of action this was came out blank, so the entry did not appear under "Tenant Purged" anywhere, could not be filtered for, and did not appear in any count by action type. In practice the record existed but could not be found.

    The cause: the list of permitted action names stored in the database had never been given "tenant purged", even though the rest of the code had been written expecting it. The database is deliberately configured in a forgiving mode, so instead of rejecting the unknown name it quietly stored a blank and reported success. Nothing failed, nothing was logged, and no part of the system could tell.

    This had already happened for real. A community was permanently deleted on 5 July 2026 — 4,821 records and 32 members — and its audit entry has been sitting there unlabelled ever since. That entry is now corrected as part of the fix; it was the only affected entry out of 2,129. Two further action names were missing for the same reason, both concerning the granting and removal of the highest level of administrator access. Neither had been used yet, so nothing was lost, but they would have failed identically.

    Three things now prevent a repeat. The code checks the action name before saving rather than trusting the save to complain, because the save never did. If a name is ever unrecognised again, the entry is still saved — under a visible "Unrecognised Action" label rather than a blank, so the record and everything else in it survives — and the problem is written to the error log and reported back to the caller. And a test now holds the code's list of action names and the database's list against each other, so they cannot drift apart again unnoticed; permanently deleting a community is also now covered by a test that checks the audit entry is correctly labelled.

  • The PHP tests were running in the wrong mode on developer machines, and were 3.4 times slower as a result. Every test spent about 1.1 extra seconds re-checking every translation file on disk. The code already had a shortcut to skip that during tests, and a second shortcut to load only three languages instead of eleven, but neither was ever switching on locally. The reason: the test setup announced "this is a test run" in one place, while the Docker container announced "this is a development machine" in another place that the framework reads first, so the container always won. Setting it in the place the framework actually checks first fixes it. Measured on the same test files before and after: a group of six went from 145 seconds to 41, and a group of thirty from 540 seconds to 160. The results were identical in both runs - same test count, same assertions, same pre-existing failures - and the two areas most affected by the language shortcut were checked explicitly, with all 102 internationalisation tests and all 53 passkey tests passing.

    Two honest caveats. This does not make the automated checks on GitHub any faster - they were never affected, because they set the mode correctly already, which is precisely why the problem stayed invisible for so long. And roughly half a second per test remains, which is Laravel starting a complete application - 47 components and 4,077 web addresses - once for every single test. Reducing that further would be a real design change, and has not been attempted.

  • The slowest stretch of the automated checks was split into more parallel groups. The PHP tests ran in six parallel groups whose times ranged from 15 to 29 minutes — the slowest group set the pace for every push. They now run in ten groups, aiming for roughly 12–15 minutes each. The split method is unchanged (it balances by file count, deliberately, for stability), so the exact times need a few runs to settle and will be re-measured rather than assumed.

  • The full test suite now runs automatically every night. The checks that run on each push deliberately test only what changed — that keeps pushes fast, but it means a problem spanning two areas can hide if only one of them changes. (That is exactly how the mobile-app agenda bug stayed invisible for weeks.) A complete run of every check now happens nightly at 3:30am on GitHub's machines, so anything the shortcuts miss is caught within a day instead of whenever someone next forces a full run. The nightly run and the ordinary push runs can no longer cancel each other.

  • Changing a shared data contract now wakes every app that depends on it. The files describing the agreed shape of event data (contracts/) are read by the server, the website, and the mobile app — but changing them previously triggered checks for none of the three. All three now run whenever a contract file changes, which would have caught the mobile agenda bug on the day it was introduced.

  • Deploy verification is now smart about what "fully checked" means. The safety net added earlier today demanded that every check ran on the exact version being deployed — safe, but it forced a redundant half-hour full re-run before almost every deploy, because ordinary pushes correctly skip checks for areas that didn't change. The verifier now accepts a check that passed on an earlier version when none of the files that check watches have changed since — the same rule the checks themselves use to decide when to skip, applied consistently. A check that failed on the newest code it ran against still always blocks, a skipped check still never counts as passed, and the list of which files each check watches now lives in one shared place (.github/ci-paths.yml) so the deploy verifier and the push-time checks can never disagree. Combined with the new nightly full run, deploy verification normally completes in seconds instead of forcing a fresh 40-minute run.

  • Deploying now waits for the checks, and refuses if they did not all run. The deploy script pushed to GitHub and immediately told the server to go live. But pushing is the thing that starts the automated checks, so the deploy and the checks began at the same moment and the deploy always finished first — nothing ever read the result. Code could go live and the checks could fail twenty minutes later, with no connection between the two.

    There was a second, quieter problem. The checks skip whole sections when they judge an area untouched, and a skipped section counts as a pass on the overall result. So a commit could carry a green tick while its PHP tests, its React tests, the container build, the end-to-end run and the accessibility audit had never run on it at all. That was the normal state, not an edge case.

    Deploying now stops after pushing and asks GitHub one question: has every required check actually run, and passed, on exactly this version of the code? A skipped check counts as "not checked". If the answer is no, it starts a full check and waits; if that does not pass, nothing is deployed and the reason is printed. If it cannot reach GitHub or is not signed in, it refuses rather than assuming the best. ALLOW_UNVERIFIED_DEPLOY=1 overrides it for a genuine emergency and says loudly that it has.

    Nothing was removed or made faster: everything that ran before still runs. The change is that the deploy is now connected to the result. Requesting a full check run also genuinely runs everything now — the container build and translation-drift checks previously stayed skipped even when everything was explicitly asked for.

  • The old recurring-events engine has been retired and removed. The platform carried two engines for months; the newer one is now the only one. This removes the split that caused editing a single occurrence to fail, and it retires a generator with real defects: it ignored the weekdays an organiser selected (a "every Monday and Thursday" series simply repeated weekly from whatever day it started), it treated the first occurrence as one interval after the date you chose rather than on it, and it could not record that an individual occurrence had been changed.

    Two deliberate consequences. A series can now run to 366 occurrences instead of 52 — the old ceiling was a limitation of the removed generator. And the rollout switch no longer restores the old behaviour: it now governs only the optional extras (rolling recurrence, revisions, blueprints) and what the API advertises. Turning it off does not bring the old engine back, because there is nothing to bring back.

    Existing series were converted first, and protection against a double-submitted series — which previously only guarded the old path — was carried over to the new one rather than deleted with it.

    Also corrected as part of this: the platform's own health check treated "new engine on, automatic extension off" as a misconfiguration, which is now the normal resting state — so every installation would have reported itself unhealthy the moment the engine was switched on. The genuinely broken combination (automatic extension on with the engine off) is still reported.

  • A tool to finish the recurring-events engine migration. The platform has carried two recurrence engines for some time: the older one that every existing repeating event uses, and a newer one that was built to replace it but never switched on. Being stuck between the two is what broke editing a single occurrence, because the database was enforcing the new engine's rules on the old engine's data. events:migrate-recurrence-to-v2 converts existing series onto the newer engine properly — translating each series' repeat pattern using the engine's own logic rather than a reimplementation, and giving every occurrence the calendar identity the newer engine expects.

    It is deliberately cautious. A dry run reports exactly what would change and writes nothing. It never creates, deletes or reshapes occurrences, so registrations and attendance stay attached to the events they are already on. It can be run twice safely. And it refuses a series rather than forcing it whenever conversion would be unsafe — an unsupported repeat pattern, two occurrences sharing a start time, or an occurrence whose identity is referenced by records that cannot be rewritten.

    After conversion, editing one occurrence of a series records that it diverged from the rest — something the older engine could not track at all.

  • Editing one occurrence of a repeating event failed for everyone. Saving a single occurrence also tried to record per-occurrence "override" bookkeeping, but the database only accepts that bookkeeping from the newer recurrence engine — and every occurrence any community has ever created came from the older engine, which is the default. So the save was rejected outright. The content change itself was always valid; only the bookkeeping was impossible, so it is now skipped for older-engine occurrences, which have no concept of it. Every recurring occurrence on the platform was affected.

    This also corrects an earlier note in this changelog: the published-series restriction described previously is real, but it was not what blocked these saves.

  • The new Platform switches screen returned a server error on every change. The endpoint called a helper that does not exist on the base controller, so the first click failed. Its test only proved that a community administrator is refused — the working path was never once executed. It is now covered end to end: reading the switches, saving a mode, saving a toggle, reverting to the server setting, and rejecting a bad value with a clear message rather than a crash.

  • Moved Platform switches out of Growth & Discovery into Platform operations, beside Module configuration, where the per-community switches it governs already live. It had been filed under SEO only because that was where the one other owner-level screen happened to sit.

Added

  • Deploys are now watched for half an hour after they go live. The deploy process tested the new version before switching traffic to it, but nothing watched the minutes after — a problem that appeared ten minutes post-switch was only noticed when someone complained. The deploy command now waits for the switch, then watches production error levels against the newly deployed version for 30 minutes, using the per-deploy error tagging added earlier. If errors spike well above normal it raises the alarm and prints the one-command rollback — it never rolls back on its own, and if it cannot check (for example, a missing monitoring key) it says "unverified" rather than pretending health. Verified end to end against the live platform, which surfaced one counting bug before release: logged error messages (as opposed to crashes) were silently excluded from the count, so five real errors read as zero. The watch now counts exactly what the error dashboard calls an error.

  • A quick local check to run before pushing. node scripts/preflight.mjs looks at what actually changed and runs only the relevant fast checks — catching type errors, broken tests you just touched, missing changelog updates and licence headers locally in a few minutes, instead of twenty-plus minutes later on GitHub. It is deliberately honest: a check that could not run (for example, because Docker is off) is reported as "not checked", never quietly counted as a pass. It uses the same changed-area rules as the GitHub pipeline, which remains the authority — the heavy suites stay there. On its very first run it caught a real mistake (an unrefreshed in-app changelog copy) before it reached GitHub.

  • Platform switches you can set yourself. Platform-wide rollout gates — the attendance-credit engine, the newer recurring-events engine, rolling recurrence, recurrence blueprints, timed waitlist offers and optional analytics — used to live only in server environment variables, so raising one needed someone with server access. There is now a Platform switches screen for the platform owner. A switch there sets the ceiling; each community still controls its own settings underneath, so turning something on centrally never enables it for anybody by itself, and reverting a switch hands the decision back to the server configuration. Only that fixed list of switches can be changed, and a tenant administrator cannot reach the screen at all.

    Worth knowing: the other capabilities listed on a community's Event settings page — ticketing, agenda, offline check-in, broadcasts, registration forms, invitations, safety evidence and federation delivery — are not switches. They report whether the feature is installed, which depends on the database, so they stay read-only.

Fixed

  • The buttons in our emails were invisible in Outlook. A member of a partner timebank received a "New exchange request" email and replied that there was no link to accept it. The link was in the email — but the button was coloured with a gradient, and Outlook throws gradient backgrounds away. That left white lettering on a white card: the button was there, occupying space, and unreadable. The same applied to the coloured banner at the top of the email, and to the button in every other email the platform sends — exchanges, events, volunteering, matches, newsletters and the shared email template. It had been that way since those emails were written.

    Every coloured panel and button in our emails now carries a plain colour as well as the gradient, written as two separate instructions so a mail app that rejects one still applies the other. Buttons are additionally built the way email buttons are supposed to be built — as a coloured table cell around the link, which also fixes desktop Outlook. Under every email button there is now the plain web address as text, so even if a mail app strips the styling completely, the recipient still has something to click or copy.

    A check now refuses this pattern in the code itself. There is no way to notice it at runtime: the email we send is perfectly valid, the send succeeds, and the damage happens inside the recipient's mail app.

  • "1.00 hour(s) hour(s)" in exchange emails. The exchange request and exchange completed emails printed the word "hour(s)" twice. The English wording was being added by the code as well as by the translation, which also meant the English word was appearing in the middle of German, French and Japanese emails. The code now supplies only the number and lets each language supply its own wording.

  • Server errors now name the exact deploy that produced them. Errors reported to the monitoring service carried only the platform version (e.g. 1.5.8), which spans many deploys — so "which deploy introduced this error?" was unanswerable, and errors from background workers carried no identifier at all. The error reporter now uses the same build stamp as the X-Build response header, derived the same way, so an error's release always matches the deployed code that threw it. The website side already did this correctly; the server now matches. Takes effect from the next deploy.

  • The mobile app would have refused to read event agendas. The events work added a new event_status field to the shared event-agenda contract. The server and the website were both updated to know about it; the native mobile client was not. That client checks every response against an exact list of expected fields and rejects anything unfamiliar outright, so it would have thrown a contract error rather than showing the agenda. The client now recognises the field — treated as optional, and with the status values read as plain text, so a future lifecycle value cannot break it the same way. Unfamiliar fields are still rejected, which is the point of the check.

    No released build was affected: the native app is in testing and has no users. It went unnoticed because the mobile checks only run when the mobile folder or the CI configuration changes, and none of the events work touched either — so a green tick on those commits never covered this.

  • The event analytics tab said "could not be loaded" for almost every event. The request was succeeding; the page was throwing the answer away. One field — the per-channel message delivery breakdown — is an empty map for any event that hasn't sent notifications yet, and PHP has only one array type, so an empty map was sent as an empty list rather than an empty object. The page validates that response field-by-field and rejected the whole thing. It is now always sent as an object. Both test suites missed it because neither could see it: the backend test asserted individual values while its own passing response contained the malformed field, and the frontend tests used hand-written fixtures where an empty map is unambiguous. The new test checks the actual JSON type.

  • Per-channel delivery counts were inflated. In the same breakdown, a channel that first appeared partway through the results was seeded with the running totals accumulated for earlier channels instead of starting at zero, so second and subsequent channels reported everything counted before them as their own.

  • Event check-in and safety pages were completely broken in production. Both send a request header that neither of the two CORS allow-lists included, so the browser refused the request before it left the page — and reported it as "unable to connect", which looks like an outage rather than a configuration gap. Both headers are now allowed in both places, and a test pins every custom header the frontend sends against both lists so this cannot recur.

  • Saving a repeating event failed with the unhelpful message "Invalid status". Changing the repeat pattern of a series that has already been published is refused on purpose: regenerating its dates would discard occurrences people have registered for. That rule is right, but the message told the organiser nothing at all. It now explains the rule and what to do instead — edit the single occurrence, or cancel the series and start a new one.

  • The event Federation tab showed a raw internal label (manage.federation.health.not_configured) instead of readable text, because none of the five sharing-status labels had ever been translated. All five now read properly in all eleven languages: "No partners set up", "Sharing normally", "Sending to partners", "Deliveries failing" and "Withdrawn from partners".

  • Event settings looked broken because Save appeared dead. Every change to community policy needs a reason recorded against it, which is good governance — but the Save button simply greyed out with no explanation, as did every Restore button on the page. The page now says which of the two things is missing ("nothing to save yet" or "add a reason"), marks the reason field as required, and shows a notice at the top when there are unsaved changes.

  • Cleared the events audit backlog — the five findings previously left for a decision are now all fixed.

    Cancelling an event now cancels its unsent announcements. Registrations, the waitlist and reminders were all being cancelled; broadcasts were simply missed, so an announcement scheduled before the cancellation still went out afterwards telling attendees to turn up. Drafts and scheduled broadcasts now die with the event, their queued deliveries are cancelled, and the reason is written to the broadcast's own audit history so an operator can see why. Anything already mid-send is left alone and reported rather than being allowed to block the cancellation.

    A cancelled event's agenda no longer reads as though it is going ahead. Sessions keep their own "scheduled" status by design — the programme is preserved as a record — but nothing told the reader the event itself was off, so every session looked live. The parent event's status now travels with the agenda, and both the React workspace and the accessible running-order page show a clear cancellation notice.

    A retried "create recurring series" request no longer duplicates the whole series. On the default recurrence engine each occurrence's identity was derived from its own new database row, so the uniqueness constraint could never catch a repeat submission: a double-click or a client retry silently produced a second complete set of occurrences that nothing downstream could tell apart. An identical series from the same member within a short window is now recognised as a retry and the original is returned. Genuinely different series are unaffected, and the window is configurable (or can be switched off).

    Broken event emails will now raise an alarm. Every existing health check watched the legacy reminder tables and their email category — but under the default configuration those are never written, because event mail flows through the newer outbox pipeline instead. Email delivery for events could therefore fail for a tenant without a single warning. The audit now watches the live pipeline directly: deliveries stuck in the queue, deliveries abandoned after retries, and outbox entries nothing ever picked up.

    Offline check-ins can now prove the code was really scanned. A device is given each attendee's credential fingerprint so it can verify a scan with no signal, but sync accepted that same fingerprint back as the evidence — so a device holding the list could record a check-in for someone it never met. It granted no permission the operator lacked, but it did mean the offline record couldn't be trusted to mean "this person was there". Devices may now send the code they actually scanned, which the server verifies itself, and a new setting refuses anything less once every device supports it. Existing devices keep working unchanged.

  • Deeper events audit — subsystems the first review never reached (registration/waitlist/ticketing, recurrence, offline check-in, broadcasts, safety, agenda, federation), plus notification locale, background jobs, query plans and index coverage. Four fixes landed:

    Guests now occupy venue capacity. max_guests_per_registration caps how many guests one member may bring; it says nothing about how many people the room holds — and because a registration is always counted as exactly one capacity unit, guests were invisible to the capacity check entirely. A two-person event would accept a member, then ten guests from that member, then ten more from the next: twenty-two people against a stated capacity of two, with the capacity gate never firing once. For a community or council venue that number is frequently a fire limit, so guests are now counted against it like anybody else, with a clear "this event has reached capacity" response rather than the generic validation error. Withdrawn guests give their seat back, and events with no capacity set are unaffected.

    Wallet and XP ledger lines render in the recipient's language. The attendance-reward description was translated at the moment of the check-in scan, so it used the organiser's locale — a Spanish member checked in by an English-speaking volunteer got an English line permanently written into their wallet history. Unlike an email this cannot be re-sent or re-rendered, so it needed the recipient-locale wrap the project already requires for notifications. The reversal description had the same defect and the same fix.

    Four indexes for hot paths that were scanning far more than they needed. The monthly treasury cap total runs on every mint and filtered a column (completed_at) that no index covered, so it summed the tenant's entire completed-claim history on each check-in. The admin claims ledger's default unfiltered view could not use its status-prefixed index for sorting and fell back to sorting the whole ledger. The per-member monthly visit count filtered visited_on while both visit indexes were built on visited_at. And the anonymous public listing — the crawler-facing one — had no index matching its actual filter-and-sort shape. Also batched a per-venue staff count that was issuing one query per venue directly beside two already-batched aggregates.

    Verified along the way and deliberately not changed: the admin feature toggle does correctly invalidate the tenant bootstrap cache (a stale payload during testing turned out to be an artifact of writing the database directly); the partner-venue engagement summary's lifetime totals are intentional rather than a broken time filter, and are now documented as a known scale ceiling rather than silently altered.

    This pass also confirmed a long list of subsystems as sound under adversarial reading — cancellation correctly closes reminders and pushes a federation retraction, check-in and registration both refuse cancelled and unpublished events, offline check-in honours device revocation and cannot double-apply, recurrence v2 cannot duplicate occurrences or lose per-occurrence overrides, guardian-consent tokens are single-use, and every events notification path already renders in the recipient's language. Remaining findings that need a product decision rather than a patch (scheduled broadcasts surviving event cancellation, agenda sessions not reflecting a cancelled parent event, and duplicate occurrence sets if a create-recurring request is retried on the legacy engine) are recorded for the owner rather than changed unilaterally.

  • Re-audit hardening across the events module and the Coventry features — four review passes (money paths, security, frontend wiring, end-to-end journeys) plus a live-stack E2E, then fixes for everything that survived verification. The four that mattered most:

    Money is now atomic. The treasury mint and reclaim each wrote the balance and the ledger row as two separate statements; a failure between them (deadlock, connection blip) left the balance permanently changed with no transactions row, the claim marked failed — which is retryable — and the retry then paid again: a silent double-credit with a single ledger entry. Both wallet writes now commit atomically with their ledger row, and the claim's completion commits in the same transaction as the money movement (so a claim can also no longer strand at pending after a successful mint). A regression test injects a failure after the ledger insert executes and proves the balance rolls back and the retry pays exactly once.

    Drafts no longer leak into the member events list. The list query never filtered publication_status, so every member saw full cards — title, image, date, organiser identity — of other members' Draft and PendingReview events; only the detail click 404'd. Members now see published events plus their own unpublished ones; tenant admins keep full visibility for moderation.

    Public pages now tell the truth about cancelled and hybrid events. Cancelling an event only writes operational_status, which the public projection never read — a cancelled event kept advertising itself as a normal upcoming event to anonymous visitors, sign-up button and all. And the create form only ever writes allow_remote_attendance, never the raw is_online column the public pages branched on — so every hybrid or online event created through the standard form showed as in-person-only publicly (including in search engines' structured data). The shared public projection now carries operational_status and computes attendance_mode with the member contract's exact semantics; both React pages and both accessible What's On pages render Cancelled/Postponed tags, hybrid events show venue and online marker, schema.org output uses EventCancelled/EventPostponed/MixedEventAttendanceMode, and the register call-to-action disappears on cancelled events.

    The kill switches now match how incidents actually run. The platform mode (EVENTS_ATTENDANCE_CREDIT_MODE) gates admin retries too — previously a retry could mint after an operator had switched minting off. And the audit ledger plus the reversal endpoint deliberately keep working with the tenant flag off, because disabling the flag is the first response to a bad batch of rewards — previously that same action locked admins out of inspecting and correcting the damage. Retry (which mints) stays behind both gates.

    Also fixed: CSV formula injection in the partner-venue visits export (member-controlled names now pass through the codebase's standard CsvExportSanitizer, as every other export already did); the monthly-cap input in Event Settings could not accept any value below 1 credit (keystroke coercion wiped the field on the leading "0"); staff re-checking a member in after an undo now see "reward already granted earlier" instead of an indistinguishable success toast (the credit outcome now rides the transition payload on both check-in paths, with the response schema updated in lockstep — it is .strict() and would otherwise have rejected the new field); the accessible venue pass gained the "get a new code" rotate action the React pass already had; challenge titles/descriptions get explicit length validation instead of relying on non-strict MySQL truncation; the accessible What's On pagination link no longer drops a literal "0" search term (PHP array_filter falsy trap); its check-in error states use the notification-banner pattern instead of a misused error summary; and the navigation registry's multi-feature gates are now carried through to the Navbar's secondary filter so dual-gated items can never leak through a future refactor. The monthly cap's calendar-month clock (app timezone) and the retry-mints-the-frozen-claim-amount rule are now documented decisions in code.

  • Public events get their front door, and the three Coventry modules get honest labels. Signed-out visitors now see a "What's on" item in the main navigation (desktop and mobile) whenever a community has public events switched on — the navigation registry gained anonOnly and multi-feature gating to express "shown only to visitors, needs both events and public_events", with a parity test covering the new mechanism on both surfaces. A signed-in member who follows a shared /whats-on link is handed through to the full community events page instead of being shown a sign-in prompt, and the sign-in buttons on both public pages now return the visitor to the page they were reading after logging in. The three new module cards (public_events, event_attendance_credits, partner_venues) carry a Beta badge — the same honesty mechanism caring_community and courses use — and the attendance-credits card's Configure button now leads to Event Settings, where its monthly cap and claims ledger live.

    Spec hygiene: /v2/public/events and /v2/public/events/{id} are now documented in openapi.json, and — the part that keeps this fixed — the events OpenAPI coverage test's path matcher now includes public/events, so the spec ⇄ routes check enforces the public surface in both directions. It previously matched events… and admin/events… but not public/events…, which is exactly how the public endpoints shipped undocumented with a green gate; the regex fix was verified by watching the test fail before the spec entries were added.

Fixed

  • main briefly went red on the React navigation-registry suite after the venues nav item was auth-gated without updating the test's pinned list of authenticated-only destinations. The pin now includes venues, and the same commit adds the anonOnly/multi-feature parity coverage above. Root cause: a registry policy change landed without running its dedicated policy suite. Prevention: the registry's pinned-policy tests are now part of the standard per-workstream battery alongside layout suites.

  • The accessible (GOV.UK) frontend catches up with the Coventry features: partner venues, a public What's On, and radius parity. Members on the accessible frontend get a venue directory, their visit history, and a venue pass whose QR is rendered server-side as inline SVG — no JavaScript required, in keeping with this frontend's HTML-first rule — encoding the same check-in URL as the React pass, so venue staff scan one canonical flow regardless of which frontend a member uses. Venue staff get a no-JS confirm page: the scan lands on a GET that deliberately records nothing (link-preview crawlers prefetch URLs), and the visit is recorded only by the explicit confirm POST, through the same service rules as everywhere else (staff authorization, one visit per member per day, XP and challenge progress).

    What's On (/{tenant}/accessible/whats-on) is the accessible frontend's first logged-out events surface: anonymous visitors browse published community events with search and upcoming/past filters, and each event page shows the venue-accessibility answers (step-free, hearing loop, quiet space…) a visitor needs before deciding to attend. It serves exactly the public projection the /v2/public/events API serves — the field allowlist was extracted to a shared PublicEventProjection class precisely so two copies of a privacy boundary cannot drift; organisers appear by first name only, and drafts, private-group events and unknown ids all return an identical 404. The service navigation now shows What's On to signed-out visitors and Partner venues to signed-in members, feature-gated per tenant.

    Radius parity: the accessible frontend's "near me" filters now offer the 100 km option (previously defined in translations but unreachable — the whitelist stopped at 50) and default to the member's saved match-preference radius, mirroring the React useSavedRadiusKm behaviour; an explicit choice in the URL always wins. All new strings ship in all 11 locales, including two per-module translation files, with the machine pass corrected where it produced an out-of-vocabulary Italian "Cosa succede?" (now "In programma") and untranslated Dutch/German labels.

  • Partner venues are now reachable and fully manageable — previously the entire feature was invisible unless you typed the URL. Members get a "Partner venues" item in the main navigation (desktop and mobile, gated on the tenant flag) and a "My venue pass" button on the Wallet page; admins get a Partner Venues sidebar entry. The admin page catches up with its own API: a status filter (active/paused/archived), a per-venue engagement report (total visits, unique members, last-30-days) that the backend always returned but the page never rendered, CSV export with venue and date-range filters, and staff are now added by searching members by name instead of typing a raw numeric user ID.

    GDPR: erasing an account now revokes the member's venue pass. The pass is a standing bearer credential — venue staff can record a visit from the QR alone, no login — so like passkeys and API tokens it must not survive Article 17 erasure; previously it did. Visit history rows deliberately survive (PII resolves through the anonymised member record, the same posture as messages), and a regression test pins both halves. Also added: rate limits on every admin venue endpoint (the CSV export streams up to 20,000 rows and previously had none), HTTP-layer tests for the whole admin surface (validation, 403s, tenant isolation, CSV content), and OpenAPI documentation for all fourteen partner-venue operations.

  • Challenges can now be created and managed from the admin panel — previously members could see and claim them, but no admin could create one without a database console. New Challenges page under Engagement (sidebar entry, Gamification Hub tile, /admin/gamification/challenges) with the full lifecycle: create, edit, activate/deactivate, delete — the delete confirmation warns that member progress (including completed-but-unclaimed rewards) is erased with it, because the progress table cascades on delete; deactivation is the reversible alternative.

    The action-type choices come from the server, not the form. Challenge progress only advances through the engagement junction, and only three actions are wired through it (partner venue visits, verified event attendance, event RSVPs) — so the form offers exactly those, and the API rejects anything else. Offering any other XP action would create a challenge stuck at zero forever while looking like a working feature.

    Event RSVPs now advance challenges. The "going" RSVP on both frontends previously awarded XP directly and never touched challenge progress; both now route through EngagementService, so an admin-created "Attend three events" challenge actually moves. XP is unchanged (same amount, same reference-based idempotency — the existing idempotency tests pin this). The stale ChallengeFactory — which invented columns and vocabularies that exist nowhere — now matches the real schema and draws from the same constants the service validates. Eight new backend tests cover the CRUD surface, authorization, tenant isolation, the unsupported-action rejection, the delete cascade, and the RSVP→challenge wiring.

  • Attendance rewards are now fully operable from the admin UI — and the ledger gained a retry, a reversal, and a monthly budget. Until now the reward engine was sound but admin-blind: the endpoint that sets a per-event amount had no consumer anywhere in the product, so switching the module on did nothing an admin could see. Events admin now has an "Attendance reward" action on each event (amount, ceiling, per-status claim totals, and a clear notice when platform minting is off), members see an "Earn X time credits for attending" chip on event pages — on both the React and accessible frontends — and Event Settings gained an Attendance rewards section.

    A failed reward is no longer a dead end. Previously, if the wallet write failed during check-in, the failed claim permanently blocked that member's reward: the ledger's unique key made every later check-in report it as already paid. A later check-in now resumes a failed claim through the same money path, and admins get a tenant-wide claims ledger (GET /v2/admin/events/attendance-claims, /admin/events/attendance-rewards in the UI) with Retry for failed mints and Reverse for completed ones. A reversal records a child claim (parent_claim_id, claim type attendance_reward_reversal), reclaims the credits (member → community, transaction type event_attendance_reversal — kept ≤30 chars because transactions.transaction_type is varchar(30) and this database truncates rather than rejects), and requires a written reason. If the reclaim itself fails, the original claim is restored to completed — the ledger never claims money moved when it did not. One reversal per reward, enforced by both a conditional state transition and the child claim's unique subject key.

    Monthly mint cap: Event Settings now takes an optional monthly ceiling on treasury minting (attendance_credit_monthly_cap). A reward that would overshoot is recorded as a failed claim (monthly_cap_reached) without ever blocking the check-in itself, becomes payable again when the month rolls over or the cap is raised, and a reversal frees its budget. Admin retries deliberately do not bypass the cap. Twelve new backend tests cover the cap, the resume path, retry, reversal, the failed-reclaim rollback, and the HTTP endpoints; the three new endpoints are documented in openapi.json (whose events coverage test now enforces them).

  • Attendance rewards ("skill gifting"): a community can now grant time credits for a verified event check-in. The claim ledger for this was built some time ago and left deliberately unreachable — EventCreditService returned disabled for every input, and EventAttendanceService threw outright if it ever returned anything else, because the funding model had not been decided. It has now been decided and implemented: the reward is minted against the community (sender_id IS NULL, transaction type event_attendance_reward — the same shape as starting_balance, community_fund and admin_grant), so no member and no organiser is debited and hosting an event is never a personal cost.

    Three independent switches must all be on, so no single misconfiguration can start moving credits: the EVENTS_ATTENDANCE_CREDIT_MODE=treasury env mode, the tenant's event_attendance_credits flag (default off), and a per-event amount. Any unrecognised mode still fails closed and logs at critical, exactly as before. Setting the amount is a tenant-admin action (PUT /v2/admin/events/{id}/attendance-reward) rather than a field on the organiser's own form — the community is paying, so deciding an event pays out is the community's call, and that admin action is the payer-consent step the service's contract required.

    The reviewed semantics, all covered by tests: paid once per member per event, guaranteed by the ledger's unique key rather than by a prior read, so a re-check-in cannot double-pay; triggered by verified check-in only, never by an RSVP, which is unverified; a flat per-event amount rather than one derived from attendance duration, because duration-derived amounts invite gaming the check-out time; clamped to a community ceiling rather than rejected, so a stale over-ceiling amount left on an old event cannot mint more than was agreed; an organiser cannot reward themselves, mirroring volunteering's self-verification block; and a mint failure never costs the member their check-in — the claim records failed for an admin to retry and the attendance stands.

    The interlock in EventAttendanceService was relaxed to an explicit allow-set, not removed: it still aborts on any status the reviewed writer would not produce, so a future unreviewed writer cannot slip a success-shaped value past it. A test pins every returned status against that allow-set, because adding an outcome without adding it there would break check-in. The time-credit ticket gateway stays closed and untouched — a reward for attending is not the same thing as pricing a ticket in credits.

    Two further guarantees worth naming. The tenant flag is read for the event's tenant rather than the ambient request context, because a queue worker or console command can be pointed anywhere — there is a test that settles an event while a different tenant is the ambient context. And with the mode off, behaviour is byte-identical to before: the existing attendance, offline check-in, idempotency and wallet suites all pass unchanged, which is the strongest evidence that enabling this for one community cannot affect any other.

    Fast-follows deliberately not in this change: a member-facing "earn X credits for attending" badge, an admin UI control for the amount (the endpoint exists and reports configured amount, ceiling, mode and per-status claim totals), and a per-tenant monthly mint cap with alerting.

  • Public events advertising: communities can now put their published events on the open web at /whats-on. Events previously required an account even to browse, so a community had nowhere to point people who had not joined yet. A new public_events tenant flag — default off, and effective only alongside events — adds a read-only listing and detail page for anonymous visitors, with schema.org/Event JSON-LD for rich results. Registration still requires signing in; nothing public is a write path.

    The public payload is an allowlist, built field by field rather than by stripping keys off the member DTO. That direction matters: a denylist would silently start publishing whatever field someone adds to the shared serializer next. RSVP state, attendee lists and counts, capacity, online joining links, organiser contact details and safety/agenda internals are all absent, and the tests assert their absence precisely because no positive assertion would ever catch a future leak. Individual organisers are published by first name only — the member listing shows full names, but that audience is already inside the community, and a resident should not get their surname on the open web for offering to host a craft session. Venue accessibility is published deliberately: it is what a disabled visitor needs in order to decide whether to come, and withholding it until sign-up defeats the point.

    Audience rules are shared with the authenticated listing rather than reimplemented. The visibility predicate was extracted into one method now used by the member listing, the public listing and the public detail lookup, so the three cannot drift; a null viewer reaches only ungrouped events and events in active public-visibility groups, because the owner and membership branches are viewer-scoped. Public discovery additionally filters to published events. An event that would not appear in the public list also cannot be opened by guessing its id — private-group, draft and archived events all return 404 rather than 403, so the endpoint cannot be used to probe for their existence.

    The page lives at /whats-on, not /events, for a load-bearing reason: TenantShell chooses the route registry by path, not by auth state, so declaring a public route at /events would have handed the lighter public registry to signed-in members too and replaced their real events page. A distinct URL also gives an organisation something clean to link to. A route-gate test pins this, along with both feature gates.

    Sitemap and prerender exposure are deliberately not included: SitemapService::getEventUrls() and the prerender auth-required route list are two independent, intentional gates, and opening them is a separate decision. Covered by 11 backend tests (feature gate, anonymous access, field-absence, organiser-name policy, draft/archived/private-group exclusion with positive controls that the lifecycle columns really persisted, tenant isolation, and two proving the member API is unchanged) and 12 frontend tests.

  • Partner venues: a member pass QR that venue staff scan to record engagement at local premises. A community can now keep a directory of partner venues — a café, a shop, a leisure centre — and record when a member is recognised at one. Each member gets a membership pass whose QR encodes a frontend URL, so staff scan it with any phone camera, land on a page that asks them to confirm, and one tap records the visit. Recording it advances XP and any admin-defined challenge keyed to the venue_visit action, and tenant admins get a per-venue engagement rollup plus a CSV export of the visit log.

    This records engagement only. The platform issues no coupon, prices no discount, and moves no credits or money here; a venue's offer_summary is descriptive text about whatever that venue chooses to offer on its own terms. Discount mechanics remain entirely inside the marketplace / merchant-coupon modules, which are untouched and stay off by default. The feature is a new partner_venues tenant flag, default off, with no dependency on marketplace, merchant_coupons or caring_community.

    The design deliberately reuses the volunteering QR check-in pattern already proven in production rather than the marketplace coupon machinery: a DB-stored 32-byte token, a QR pointing at a frontend landing page, and a deliberate human tap (never an on-load action, because link scanners and chat clients prefetch URLs — a prefetch of the scan URL therefore cannot disclose who holds the pass, since member details are only returned in the response to that tap). Staff authorisation reuses the existing typed org_members pivot under a third org_type of partner_venue, following the club precedent, so a venue can have several staff accounts on shift; the shared role enum was not altered. Three new tables carry tenant_id throughout. Members can rotate their own pass token, which invalidates the old QR.

    Two properties are enforced at the database level rather than in application logic. A unique key on (tenant_id, venue_id, user_id, visited_on) makes a second scan on the same day a friendly no-op instead of an error or a double count — that key is simultaneously the idempotency guarantee and the anti-gaming ceiling. And staff cannot record their own visit, mirroring volunteering's self-verification block, so the ledger stays a staff attestation rather than self-report.

    This also makes admin-defined challenges functional for the first time. ChallengeService::updateProgress() had existed with zero production callers, so a challenge could be created and its reward claimed but its progress never advanced from any real member action. A new EngagementService is now the single junction between an action and both reward systems (XP and challenge progress), and is the first caller of that method; both halves are fault-isolated so recording engagement can never fail the underlying visit. New action keys venue_visit and event_attendance_verified were added to both XP tables.

    Covered by 19 backend tests (authorisation matrix including cross-tenant token rejection, same-day idempotency proving a single row and no second XP award, the XP-plus-challenge-progress wiring, challenge completion reporting, paused-venue and self-scan refusals, staff-of-several-venues disambiguation, feature-gate 403) and 12 frontend tests, plus route-gate assertions that the pass and scan-landing routes can never resolve unauthenticated.

Fixed

  • The Spanish and Arabic "give feedback" links on the accessible frontend were dead, because a translator translated the mailto: URL scheme. Spanish rendered it as enviar correo a: and Arabic as the transliteration ميلتو:, so in both languages the footer feedback link pointed at a scheme no browser understands and simply did nothing. Both now use mailto: again.

    The checker that should have caught this had a hole, and that is the more important fix. hasSuspiciousCorruption() exempted anything isUrlOrEmail() recognised, and its bare-email pattern (^[^\s@]+@[^\s@]+\.[^\s@]+$) matched the Arabic corruption outright — the mangled value contains no spaces, so ميلتو:feedback@project-nexus.ie?subject=… read as "an email address" and skipped every corruption check. Spanish was only caught incidentally, by an unrelated question-mark heuristic, because it happened to contain spaces. There is now an explicit check that a translated value keeps the URI scheme its English source had, evaluated before the URL exemption, plus a bare-email pattern that rejects : and ? so a broken URL can no longer masquerade as an email. Verified by re-breaking the value and confirming the gate fails, then confirming it passes once fixed — and the scheme list is a known-scheme allowlist rather than "any word before a colon", which on the first attempt flagged ordinary labels like Error: and Rating: :value of 5.

    A sweep of every lang/ namespace (PHP and JSON, all ten locales) found no other mangled scheme: 10 locale values carry a real URI scheme and all are now correct.

    And the check is now wired into CI. scripts/check-govuk-alpha-translations.mjs existed but nothing invoked it, so its findings had been sitting unread — the Spanish breakage was being reported to no one. It runs as a blocking step in the Translation Drift Detection job. Same lesson as the token-integrity gate: an unenforced check is not a check.

  • Partner venue visits now appear in a member's data export, and the pass token deliberately does not. Venue visits are movement data — where a member went and when — recorded by venue staff rather than entered by the member, which is precisely why they belong in a subject access request. The membership pass token is excluded on purpose: it is a live bearer credential, so writing it into a downloadable archive would turn a leaked export into a usable pass. Members can see and rotate the token in the app instead. Two tests cover it, including one asserting the token appears nowhere in the archive JSON.

  • Three build- and routing-level gates caught real defects in the partner-venue and public-events work. Recorded rather than folded silently into the feature commits, because each is a distinct class of mistake worth seeing.

    venues and whats-on were added as top-level routes without being added to RESERVED_PATHS. That list is what stops a tenant slug being mistaken for an app route, so until this was fixed a community whose slug happened to be venues would have had its pages resolve to the venue directory instead. tenant-routing.test.ts derives the expected set from the router itself, which is exactly why it caught it.

    The two public events pages hoisted getFormattingLocale() into a local variable and passed it to toLocaleString. The locale-formatting contract requires the call at the formatting site, and it is a blocking prebuild step, so this failed the production build while tsc --noEmit was perfectly happy — a reminder that typechecking is not the build.

    The admin venues page also surfaced raw server error strings in toasts and used raw database values (venue.status, row.role) as translation fallbacks. Both are barred for admin UI; every status and role the API can return already has a key, so the fallbacks were unreachable anyway.

    Finally, the new GET/PUT /api/v2/admin/events/{id}/attendance-reward endpoints are now documented in openapi.json. An event route that exists but is undocumented fails EventOpenApiCoverageTest, which asserts the live route table and the published spec match in both directions — so the spec cannot drift from reality in either direction.

  • Every "near me" filter opened at a hardcoded 25 km, ignoring the search distance the member had already saved. match_preferences.max_distance_km has existed for some time — editable on the Matches preferences page, clamped to the tenant ceiling server-side — but nothing outside that one page ever read it. So a member who set their radius to 50 km still got 25 km on Listings, Events, Volunteering and Members, and had to change it again on every page, every visit.

    A new useSavedRadiusKm hook is the shared reader. It fetches the preference once per session (memoised at module scope, because several filters can mount on one page and this must not become N identical requests), and snaps a saved value to the nearest offered option so a preference of 30 km opens the dropdown on 25 rather than showing a blank selection. Changing the radius in any filter now writes it back, so the next page opens on the same distance. Clearing filters returns to the member's saved radius rather than the platform fallback — clearing a filter should not discard a preference.

    Two wiring points, because Members hand-rolls its distance control instead of using the shared ProximityFilter: fixing the component covers Listings, Events and Volunteering together, and Members needed the hook separately (it adopts the saved value only if the member has not already changed it on that page). The caring-community copy of ProximityFilter is deliberately left alone — its only consumer sits behind a module that is off by default.

    One defect found while testing rather than after shipping: the write-back was documented as fire-and-forget but only guarded a rejected promise, so a synchronous throw from the API client propagated into whatever UI flow had changed the radius — it broke the mobile filter-apply flow outright. Now guarded both ways, with a regression test that makes the client throw synchronously.

  • A safeguarding refusal shown after a blocked send attempt vanished about five seconds later, taking the member's only explanation with it. When MessageService::send refuses a direct message on safeguarding grounds, ConversationPage replaces the composer with a panel naming the reason (vetting required, coordinator-mediated contact) and what to do next. Two background 5-second timers then overwrote that panel: the message poll, and the blocked-policy recheck. Both read the preflight safeguarding meta, so on any conversation where the preflight says "allow" they cleared both the panel and the composer gate — the member saw the refusal for a few seconds, then it disappeared and the composer came back with no indication anything had happened. Retrying just produced the same refusal again.

    The two answers can legitimately disagree, and the send-time one is the authoritative one. MessageService::send re-evaluates the gate a second time inside the write transaction, against locked tenant-scoped rows (MessageService.php:636, documented there as the "Definitive write check"); the preflight state in getConversation() — the safeguarding meta every GET returns, the poll included — comes from the unlocked read. A background "allow" is therefore weaker evidence than the denial the member's own send attempt just returned, so it no longer overturns it.

    Scoped deliberately narrowly. Only an explicit allow arriving on a background timer is held back, and only against a source: 'send' notice. A background "unavailable" still applies, so fail-closed behaviour is intact. Preflight restrictions still auto-clear on the recheck interval, which is what that interval exists for — a recipient can withdraw a contact preference while someone else has the conversation open. And the member is never stranded: the panel's own "Check again" button, returning focus to the tab, and a visibility change are all treated as authoritative re-asks and still unlock the composer immediately.

    Found as a latent second instance of the poll-overwrites-the-gate family fixed below, and deliberately split out because it is a product decision about safeguarding semantics rather than a test-mechanics repair. It was confirmed as a real behaviour rather than only a test artefact: with the panel on screen, one 5-second poll erased it. Two regression tests cover both halves of the rule — the denial survives both background timers firing, and an explicit recheck still clears it. The first fails without the fix. Verified deterministic with the poll interval forced to 50 ms across three consecutive runs; that margin is what surfaced a one-commit window where the provenance of the notice was not yet recorded when a poll landed, now closed at the point the notice is set.

  • A single failed background message poll could lock a member out of their own composer and show them a safeguarding warning. ConversationPage polls for newer messages every 5 seconds, and that handler applied the response's meta.conversation.safeguarding to the composer gate before checking whether the request had succeeded. The API client resolves rather than throws, so a 503, a maintenance window, an expired session or a failed token refresh arrives as { success: false } with no meta at all — and the safeguarding evaluation reads absent policy information as fail-closed "unavailable". The composer was therefore replaced by the "policy could not be evaluated" panel on a transient network blip, recovering only when the 5-second recheck next succeeded. The poll now moves the gate only when the response actually carried a conversation payload, matching the sibling refreshSafeguardingPolicy which already checked this.

    Nothing is loosened. MessagesController::show() returns meta.conversation on every response including this poll, so a genuine revocation still arrives and still locks the composer; the authoritative preflight paths (loadConversation, plus refreshSafeguardingPolicy on window focus, visibility change and its own interval) remain fail-closed; and send-time enforcement was always server-side, so an open composer is not itself an authorisation. Only responses carrying zero policy information are now ignored.

    This was found while root-causing the CI-only ConversationPage.test.tsx flake that had reded main twice and was being masked by --retry=1. The test failure was never a timing lottery: three tests mocked api.get with URL branching that never modelled the polling URL, so the poll answered with a payload contradicting the state under test, and a second 5-second interval disagreed on the same deadline — so the gate oscillated rather than failing once, which is precisely why a retry never rescued it. Those mocks are now a single total helper driven by one source of truth, mirroring the real endpoint. Verified deterministic with the poll interval forced to 50 ms (~100 polls per test), and a new regression test pins the component fix, which it fails without.

  • Claiming the same challenge paid out differently depending on which frontend you used; both paths now award through one shared routine. The accessible frontend awarded XP, the challenges.badge_reward badge and a notification bell; the React path (POST /api/v2/gamification/challenges/{id}/claim) awarded XP only, ignoring badge_reward entirely and sending no bell. So a member who finished a challenge with a badge attached got the badge on one frontend and nothing but XP on the other — and which one they got depended on where they happened to click claim.

    React was confirmed as the incomplete path rather than assumed to be. badge_reward is a real varchar(50) column on challenges, and ChallengeService is the only place a challenge badge is awarded anywhere in the codebase — every other awardBadgeByKey() caller belongs to a different subsystem (badge collections, course completion, leaderboard seasons, two admin grant endpoints), so nothing else was quietly granting these badges and unifying the two paths cannot double-award. The two challenge bells are also distinct and fire at different moments: complete_claim ("claim your reward") when progress completes, complete_earned when the reward is claimed. Adding the claim bell to the React path therefore does not duplicate the completion bell.

    ChallengeService::awardChallengeReward() — previously private, which is precisely why the React path grew its own reward logic — is now public and is the single award routine both paths call. It returns what the challenge is configured to award, so the API response reports reward.badge alongside reward.xp instead of pretending XP is the whole payout. Two smaller divergences at the same site went with it: the React path left user_challenge_progress.claimed_at NULL where the accessible path stamped it, so the two frontends were writing different ledger rows for the same event; and the tenant + existence check is now single-sourced through a new ChallengeService::getModelById(), which both paths use. That accessor exists because the reward needs the model — resolving a challenge into an array is how badge_reward got dropped silently in the first place, since a caller only copies the fields it knows about. The XP ledger description is unified on Challenge: {title} (the React path previously wrote Completed challenge: {title}).

    This completes a decision deliberately deferred: the earlier claim() repair aligned the two paths' claim semantics (progress row required, completion required, conditional UPDATE against double-pay) and kept awardChallengeReward() only so badge_reward would not become dead code, leaving the rewards themselves for a separate call. This is that call.

    Regression cover is real-database, in tests/Laravel/Feature/ChallengeRewardParityTest.php: four tests comparing a normalised snapshot of everything a claim writes — XP ledger rows, badge keys, bell counts by type, the resulting total, and the claim ledger — between a member who claims via HTTP and one who claims via the service, for a challenge with a badge and for one without, plus the API's reported reward and a both-paths double-claim refusal. The badge key is taken from the live definition list and asserted to resolve, because awardBadgeByKey() silently no-ops on an unknown key: a hardcoded key that did not exist in a given environment would make both paths award no badge, and two empty snapshots compare equal, so the test would have passed vacuously on exactly the bug it exists to catch. Run against the pre-change code, three of the four fail — on the missing badge, on the missing notification (caught by the no-badge case, where XP alone was identical), and on the API reporting null for the badge.

  • The two remaining ChallengeService methods were written against an imagined schema; repaired against the real columns rather than deleted. create() and getAll() are the same class of defect as the claim() fix below, in the same service — and they survived that pass because neither has a caller. Between them they referenced four columns the challenges table does not have: status, category, starts_at and ends_at. The real columns are is_active, challenge_type, start_date and end_date.

    The two methods failed in opposite and equally unhelpful ways. getAll() filtered on status and category, so either filter threw SQLSTATE[42S22] Unknown column on the count() before a row was read — and unlike most of this codebase that method has no catch-all, so the first caller to pass one would have taken an uncaught 500. create() was the quieter half: none of its four phantom keys appear in Challenge::$fillable, so Eloquent discarded all four silently instead of failing, and a caller setting a category would have watched the write succeed and the value evaporate. Under the non-strict session sql_mode this app runs (config/database.php sets strict => false) that failure mode leaves no trace at all.

    create() was also broken independently of any phantom column, which is the part that would have bitten first: action_type and end_date are both NOT NULL with no default, and both defaulted to null here, so every call threw 1048 Column 'action_type' cannot be null. Both are required payload keys now and are validated up front, so a bad payload fails naming the field instead of surfacing an integrity-constraint error from inside the driver. challenge_type is checked against its enum for the same reason the event- and tandem-status fixes needed it — under this sql_mode an out-of-enum literal truncates to '' and persists as a challenge nothing can ever match. starts_at/ends_at survive as input aliases only, so a caller written against the old shape keeps working and its dates actually land in start_date/end_date. getAll()'s status key is likewise kept as an explicit alias onto is_active rather than dropped, because a filter that silently matches everything is the same trap in a new place. Its tenantId argument was decorative — the result depended entirely on ambient TenantContext — and is now an explicit predicate alongside the global scope, matching getById() and claim().

    Repairing was chosen over deleting: both methods are public API with existing coverage in two test files, they are the obvious admin-CRUD pair for a table that has read and claim endpoints but no management endpoints, and the next person to wire up challenge administration is precisely the person the trap was set for.

    npm run check:db-columns cannot see either defect, by design — Eloquent mass-assignment and where() are deliberately outside its scope, since where() can legitimately name a joined table's column. Its tracked count is unchanged at 17. This was found by reading the CREATE TABLE in database/schema/mysql-schema.sql against the code, and confirmed against the live nexus_test table, which matches the dump exactly here. Regression cover is real-database, in tests/Laravel/Feature/SchemaWriteRegressionTest.php (FIX 10): eight tests covering the write of real columns only, the refusals for each NOT NULL column and the enum, the statusis_active and challenge_type filters, and tenant scoping. The strongest of them asserts that a row created by create() is then visible to getChallengesWithProgress() and getActiveChallenges() — a row with the old null-ish dates would have inserted and been permanently unreachable. The pre-change code was run first to confirm the tests watch something real: getAll() threw Unknown column 'status' and Unknown column 'category', and create() threw 1048, so neither method could execute at all.

  • The React locale translator's --google path could silently eat {{placeholder}} tokens; fixed, and a stray literal {{count}} already showing to members in six languages removed. translate-i18n-gaps.mjs masked each {{token}} as <nexus0/> before sending text to the engine and restored it with a regex requiring the literal word "nexus". Google Translate translates the tag name — it returns <nexo0/> for Spanish and Portuguese and <lien0/> for French — so the restore missed and the placeholder was lost or left on screen as a literal tag. The identical bug cost 207 values in lang/govuk_alpha.php before it was fixed in the sibling PHP translator; this file still carried it, so the next --google run over the React locales would have reproduced it.

    The three protections from the sibling script are ported across. The mask is now <x0/> — a single meaningless letter gives an engine nothing to translate. Restore matches by index with a tolerant tag name (/<\s*[A-Za-zÀ-ɏ]{1,10}\s*(\d+)\s*\/?\s*>/g), treating the digits as the token's identity and the name as decoration, and falling back to the matched text when the index is unknown. Most importantly, the placeholder multiset of every result is now compared against the English before it is written, at the merge point in main() so that Google, DeepL and OpenAI are all covered by one gate rather than each backend policing itself: on mismatch the English is kept and the value is reported. A missing translation is recoverable, whereas a dropped {{count}} renders as literal text in a member's face. DeepL's ignore_tags list was renamed to match the new mask.

    Verified by round-tripping all 4,031 placeholder-bearing values in locales/en/*.json (zero failures), by replaying the observed failure shapes — <nexo0/>, <lien0/>, <Nexo0 />, spaced and slashless variants — through the new restore, and end-to-end against a stubbed engine that both renames the tag and drops a placeholder, confirming the renamed tag still restores and the dropped one is refused with the English kept.

    Auditing the existing locales for damage from past runs found no mask artefacts in any of the 552,368 values across the eleven languages. It did surface a different placeholder defect: kb.json's feedback.yes / feedback.no carried a trailing ({{count}}) in German, Spanish, French, Irish, Italian and Portuguese, while English and the other four locales carry the bare word. The article page appends the count itself in JSX and passes no interpolation values, and i18next's skipOnVariables default leaves an unfilled placeholder in place — so those six languages were rendering "Ja ({{count}}) (3)" on every knowledge-base article. The suffix is removed, and the French and Irish values, which were still the English "Yes"/"No", are translated.

  • A quarantined admin test suite was failing for a recorded reason that was not the real one; fixed and returned to the blocking gate (quarantine 55 → 54). GdprConsentTypes.test.tsx was listed as a "delete-confirm dialog race", pointing at the known HeroUI slot="close" hazard — but that hazard only manifests in a real browser, never in jsdom, so it could not have been the cause here. Both delete tests located the confirm button by textContent === 'confirm', while the page passes confirmLabel={t('enterprise.gdpr_delete')} and the test setup loads the committed English locale files, so the button actually reads "Delete". The lookup returned undefined and an if (confirmBtn) guard turned that into a silent no-click: the test failed having exercised nothing, and no amount of work on the dialog would have moved it. ConfirmModal's own tests passed the whole time because they never override confirmLabel.

    ConfirmModal's action buttons now carry stable data-testid values (confirm-modal-confirm / confirm-modal-cancel) and the tests target those rather than a translated label — the same i18n-independent approach used for the earlier assertion cluster, and available now to the ~40 other admin pages that use this modal. One of the two tests also called waitFor(() => document.querySelector(...)), which resolves on any returned value including null and so never actually waited. Verified 12/12 with --retry=0 so the suite is not merely retry-rescued, alongside ConfirmModal's own two suites (23/23) and four consumer suites (41/41) to confirm the shared-component change is additive.

    This also means the two frontend tests added with the consent-type fix below are now genuinely enforced by CI; while the suite was quarantined they were excluded from the shard job.

  • All three live schema defects fixed, and every one turned out to be the code being wrong rather than the schema. No table was created and no column was added; in each case the working store already existed and the broken code was writing somewhere imaginary alongside it. The gate's tracked list shrank from 22 entries to 17.

    Creating a GDPR consent type now works, and the consent-types page is no longer permanently empty. consent_types is a platform-global catalogue: slug is its unique key, tenant_consent_overrides and consent_version_history are foreign-keyed onto it, and per-tenant customisation is what the override table is forGdprService has always resolved the effective version by COALESCEing a tenant override over the global row. Adding the missing tenant_id would have broken the unique key, both foreign keys, and duplicated the override table. So the column came out of the code instead. The blast radius was wider than the one flagged INSERT: the list endpoint filtered on ct.tenant_id too, threw, and returned [] from its catch, so the admin page had been showing "No consent types" since March rather than showing an error; update and delete filtered on it and returned 500; and the separate GDPR consents list used it in a subquery and was also permanently empty. The INSERT additionally omitted current_text, which is NOT NULL with no default and would have failed the insert on its own.

    Because the catalogue is shared, creating, editing or deleting a consent type is now restricted to a platform super admin — those three writes change GDPR definitions for every community on the installation, and a delete cascades away every other tenant's overrides and version history for that type. Reading the catalogue stays open to any tenant admin, with the consent counts scoped to their own community, and the admin page hides the three controls rather than offering buttons that would 403.

    Claiming a challenge on the accessible frontend now works. The claim ledger is user_challenge_progress.reward_claimed, which the React path has always used; ChallengeService::claim() was an orphan reimplementation against a challenge_claims table that has never existed in any schema. Creating that table would have been the wrong fix twice over — the method also never awarded anything, so it would have converted a visible failure into a silent one. It is reimplemented on the real ledger with the same semantics as GamificationV2Controller::claimChallenge(): the member must have a progress row, it must be completed, and the flip to claimed is a conditional UPDATE so a double submit cannot pay out twice. A second phantom reference at the same site is also gone — the method filtered on challenges.status, a column the table does not have, which would have kept it throwing even after the ledger was corrected.

    Member category preferences were already being saved. The flagged match_preference_categories sync was vestigial: the canonical match_preferences.categories JSON column — the one the matching engine actually reads — is written and read correctly, and the side-table block sat below it swallowing its own failure at debug level. The dead blocks are removed rather than backed by a new table. The dead MatchingService::recordInteraction duplicate went with them: it wrote match_history.score/.distance (really match_score/distance_km), omitted tenant_id entirely, and had no callers, since every live interaction goes through MatchLearningService::recordInteraction.

    Two more things surfaced on the way and are fixed rather than left. AdminEnterpriseController called Log::error() in two catch blocks without ever importing the facade, which is live in production, not merely latent: PHP raises Error: Class "App\Http\Controllers\Api\Log" not found, and because an Error is not an Exception it escapes the enclosing catch (\Exception $e) entirely — so the handler meant to return a structured 500 instead produced an unhandled fatal and lost the log line that would have explained it. One of the two sites is the GDPR export failure path, which has nothing to do with the three defects above and would have failed this way on any export error. PHPStan had known about both since they were introduced and was carrying them in the baseline as class.notFound with count: 2, which is why nothing ever surfaced them; the import is added and that baseline entry is removed. And the consent-types page's catch was dead, because the API client resolves rather than throws; a failed load rendered as an empty list, which is the same shape as the bug being fixed, so it now checks res.success and surfaces the error. The list endpoint likewise stops returning [] on failure.

    Regression cover is real-database, in tests/Laravel/Feature/SchemaWriteRegressionTest.php (FIX 7–9): the consent-type insert, the catalogue listing with tenant-scoped counts, a tenant admin being refused, the claim flip, claim idempotency, refusal before completion, and category round-tripping. All eight were run against the pre-change code to confirm they actually watch something: the six consent-type and claim tests fail without the fix (the three claim tests on Unknown column 'status', before the phantom claims table is even reached), while the two category tests pass either way — that change removes dead code rather than altering behaviour, so they lock in the existing contract instead of proving a repair. Two existing ChallengeService unit tests had mocked challenge_claims — enshrining the phantom table and keeping the broken path green — and were rewritten against the real ledger.

  • All 22 flagged database references triaged: none is schema drift, three were live, and one was the checker's own fault. All three live defects are fixed in this same release — see the entry above; this entry records how they were found and ruled on. Each was tested against the live database through information_schema rather than only the committed dump, because "the code is wrong" and "the dump has gone stale" need opposite responses. Every one is absent from the live database too — there is zero dump drift here. The query was proved to discriminate first, with positive controls (users.email, group_content_flags.resolved_at, match_history.match_score, post_hashtags.hashtag_id, user_skills.skill_name) all returning present; a check that answers "absent" to everything is indistinguishable from a broken one. The live database does carry 727 tables against the dump's 723, so drift exists — just not in any of these.

    Three were reachable in production (all three now fixed — see the entry above):

    Creating a GDPR consent type had never worked. POST /v2/admin/enterprise/gdpr/consent-types inserted a tenant_id column into consent_types, which does not have one — consent types are global, so the per-tenant assumption was wrong rather than the column name being a typo. The insert threw, was caught, and returned HTTP 500 to every tenant admin, every time.

    Claiming a challenge on the accessible frontend had never worked. ChallengeService::claim() both read and wrote challenge_claims, a table in neither the dump nor the live database, so it always threw; the caller reported to Sentry and redirected with status=challenge-claim-failed.

    And the previously-found match_preference_categories. This one was over-called here: fixing it established that the canonical match_preferences.categories JSON column was already being written and read correctly, so category preferences were not in fact being discarded — the side-table block was vestigial code swallowing its own failure, not a live data-loss bug. It is counted as live because the reference was real and reachable, not because members lost preferences.

    Sixteen are unreachable — real mismatches on code paths with no callers and no route, several of them mutually confused (DeliverableService writes deliverable_comments.body while DeliverableController writes content, and neither column exists). They are still wrong and still recorded, but nothing hits them. Establishing that needed care: a first pass matching bare method names credited DeliverableService::create() with 267 callers, because every ->create( in the codebase matched. Qualifying by class collapsed most to zero — AdminListingsService::approve looked live until the controller turned out to inject ListingModerationService, a different class.

    One was my checker being wrong. PasswordResetController writes users.password_changed_at — but guards it with SHOW COLUMNS FROM users LIKE 'password_changed_at' first, so the write never runs without the column. That is a deliberate optional-column shim, and flagging it would have pushed someone to delete a compatibility guard. The gate now recognises runtime existence checks and drops it.

    Getting that exemption right took two attempts, and the first was worse than the bug. Treating any guard anywhere in a function as excusing every write in it silenced 505 of 8,442 checks at a stroke. Requiring the guard to name the specific identifier recovered most, and a second flaw remained: a Schema::hasTable() guard proves the table exists, not the column, so allowing it to excuse a column mismatch lost 335 more checks. Column mismatches now require a guard naming the column. Coverage settled at 8,283 checks with 159 genuinely guarded writes skipped — and all three mutation tests were re-run afterwards, because an exemption is exactly the kind of change that can quietly turn a gate into decoration.

Added

  • A blocking check that PHP cannot write a column which does not exist. Following a service found writing three non-existent columns for four months, every place in app/ where both the table and the column are written as literals — 8,442 of them across 1,733 files — is now verified against the committed schema dump on every push. It runs in the Migration Safety Gate, which is in the release gate's dependency list, so a failure genuinely reds the build.

    It parses the committed dump rather than connecting to a database. A gate that needs a database can pass vacuously on a runner whose env config points somewhere empty, and "0 problems found" then means "found nothing to look at" — this way a green result means the same thing on CI and on a laptop with Docker stopped.

    Two design choices are deliberate. Precision over recall: the first draft scanned a fixed window after ->update( and swept up keys from return arrays as if they were columns — 93 hits, almost all noise. It now walks the argument list with bracket matching and reads keys only where a column name is the only thing a key can be. where(), orderBy() and select() are out of scope entirely, because those can legitimately name a joined table's column and one false positive is how a gate gets switched off. And the baseline is enforced in both directions: an entry that no longer occurs also fails, so a fix cannot land without removing its entry, and the list can only shrink.

    The gate was mutation-tested rather than assumed: it catches the three real group_content_flags columns when pointed at the pre-fix file, reports zero on the fixed one, fails when a bad column is injected, fails when a still-present entry is removed from the baseline, and fails when a phantom entry is added.

    The scan found 21 candidates, of which four are verified. HashtagService::syncTags writes post_hashtags.tag where the column is hashtag_id; MatchingService::recordInteraction writes match_history.score and .distance where they are match_score and distance_km — and a migration written in March says in its own header that it added the columns "that MatchingService writes to", so the fix landed in the schema and never in the code. All three are dead: syncTags has no callers, and the live interaction path is MatchLearningService::recordInteraction, which uses the correct columns. The fourth is live: MatchingService::savePreferences, reached from the accessible frontend, syncs to a match_preference_categories table that exists in no migration and no dump, inside its own try/catch, so a member's category preferences are silently never saved.

    The remaining 17 surfaced when the scan was widened from the fifteen services of one 2026-03-20 commit to all of app/. They are recorded as untriaged and explicitly marked not to be quoted as defects: the pattern match is sound, but nobody has yet confirmed whether the code is wrong or the committed dump has drifted from the live database, and that distinction has bitten this project before.

  • Why catch (\Throwable) is the reason any of this was invisible, written down. The idiom is pervasive here — 2,882 occurrences across 540 files, 350 of them returning a falsy default from the catch — and it is not a smell to be stamped out. But it converts a schema error into a success-shaped value: three methods returned null, false and [], all of which read as "nothing to do" rather than "broken", and the tests covering them asserted the swallowed value. The agent guide now says plainly that when auditing a service whose methods each swallow Throwable you must assume nothing works until proven, and that a new catch-all must not turn a contract error into a success. Since the idiom cannot be removed, the static check above is the compensating control.

Changed

  • The nine admin strings PHP actually reads are now translated, and the 38,743-value dump around them is gone. The admin translation namespace existed twice: once as the React admin locales, and once as a 3,981-key PHP copy of them per language. The PHP copy accounted for 93% of the entire untranslated-value debt — 38,743 of 40,191 values — and almost none of it was reachable. Nine keys are: four Gmail-API status messages in the mailer, and five member-statistics labels in the CRM export. admin_nav and admin_dashboard had zero call sites in the whole codebase.

    So the PHP copy shrinks to exactly those nine keys in all eleven languages, hand-translated rather than machine-filled, and the two dead namespaces are deleted outright (22 files). The untranslated ratchet falls from 40,191 to 1,448 — a 96% reduction — and what remains is small enough that every value in it is real work rather than noise.

    One thing the debt ledger had wrong, and it mattered: those nine keys were never being served by the PHP files at all. The __() helper asks the JSON translator first and only falls back to Laravel's .php loader, so the live values come from lang/<locale>/admin.json — where all nine were still verbatim English in every one of the ten non-English languages. Hand-translating only the PHP side, as planned, would have translated a path nobody reads and left the visible strings in English. Both sides are now translated and both are held by tests, including one asserting that no non-English locale's live value is byte-identical to English — a regression the untranslated ratchet structurally cannot see, because it only scans .php files.

  • The remaining untranslated debt is down to 249 values, and the allowlist that got it there now has to prove itself. After the admin shrink, 1,448 values were still byte-identical to English, of which 186 were distinct. Most were not work at all: 864 occurrences are strings with nothing in them but placeholders (:community alone accounts for 680), and the rest are language endonyms — which are written the same in every language by definition — plus units, brand names and a feedback mailto: link. Those go on the invariant allowlist globally. Single-word borrowings that are genuinely the same word in one language but not the others go on it per language.

    The judgement in that second group is exactly where an allowlist rots into a suppression list, so it is no longer a matter of trust. Each per-language entry had to pass a mechanical check: that language never renders the same English value differently anywhere else in lang/. A counter-example means a translator did not, in fact, leave it alone. This is what separates the entries that are there from the ones that are not — Status is invariant in Dutch, where all thirty occurrences are identical, but German, Polish and Portuguese also produce Stand, Stan and Estado, so all three stay counted. Irish gets no entries at all: it borrows far less than the continental languages, and every one of its remaining values is genuine work.

    The check now runs inside the gate and fails the build, so a future entry that contradicts the lang files cannot be added — and because a bad entry lowers the count, it is checked before the baseline can be written, or the suppression would be permanent the moment it was introduced. What remains is 249 values that are all real translation work: the Irish plural forms, and about sixty single words that the language in question demonstrably does translate.

Fixed

  • A group ban check that reported "not banned" for everyone is gone, and the flag writes next to it were dead for the same reason. GroupModerationService::isUserBanned() queried a group_bans table that has never existed — no Laravel migration, no legacy SQL migration, no entry in the schema dump — inside a catch (\Throwable) that logged a warning and returned false. It had no callers, so no live control was being bypassed, but it would have handed its first caller a silent "allowed" on every call. It is removed rather than implemented: platform-wide group bans were never a feature. The real, enforced ban is per-groupgroup_members.status = 'banned', checked by GroupService at join and on membership resolution — and standing up a second, platform-wide ban concept is a design decision with its own admin surface and appeal path, not a cleanup.

    The same defect was in three more methods, in a form that hides better. They query group_content_flags, which does exist, but wrote columns that do not: updated_at (the table has none), plus moderated_at and action_taken where the real columns are resolved_at and moderation_action. Every insert and update therefore threw and was swallowed by the same catch-all, so flagContent() always returned null, moderateContent() always returned false, and getModerationHistory() always returned [] — content flagging had never worked at all. The column names are corrected against the live schema, and moderateContent() now scopes both its SELECT and its UPDATE by tenant_id, which it did not (it matched on id alone, across tenants).

    Guarding the class of bug rather than the instances: two tests now assert that every table the service queries has a CREATE TABLE in the schema dump, and that every column its write paths touch exists on group_content_flags. Both read the committed dump rather than a live database, so they cannot pass vacuously in a shard with different environment config. The parser was mutation-checked against the live table to confirm it returns all thirteen real columns and rejects the three phantom ones.

    Two things the docs asserted that the code does not. docs/modules/groups.md listed group_bans as a real table in the schema table and described the ban check as a control; both are corrected, along with a test-coverage line claiming a "ban check" test that did not exist. More consequentially, the guide presented GroupModerationService as the groups moderation path. It is unreachable — zero callers anywhere in app/ — and the live admin endpoint GET /v2/admin/groups/moderation reads the platform-wide reports table instead, touching neither the service nor group_content_flags. A reader following the guide would have instrumented the wrong table. Left alone deliberately: the vestigial group_user_bans table, which survives in the schema dump without a user_id column and so cannot record which user is banned; nothing reads or writes it, and dropping a table is the owner's call.

  • Cross-cooperative hour transfers, hour gifts and federation peer registration no longer refuse members in English. Forty-eight refusal messages across three services were hardcoded English strings thrown as exception messages. The controllers on both the member and admin sides pass $e->getMessage() straight into the error response, so every one of them reached the user verbatim — and each sat next to a translated fallback in the UI, which meant the interface looked localised while never being localised. "Insufficient banked hours", "You cannot gift hours to yourself", "Destination cooperative must be different from source" and forty-five others now come from lang/en/api.php and are translated into all ten other languages.

    All three services are now held by the localisation regression test, so the English cannot come back. The test was run against the pre-change code first to confirm it actually fails there: four failures across the three services, none afterwards.

    The plan for this work described eight of those strings wrongly, and translating them would have broken federation. They looked identical to the rest — string literals assigned to an error key in a service — but five of them are the error field of the JSON response the inbound transfer endpoint returns to a peer install, not to a person, and three others only ever reach a log line. One of the five, signature_invalid, is what the controller compares against to answer 401 instead of 422. A remote deployment we do not control reads these. They are now named constants with a comment explaining why they are not text, the controller compares against the constant rather than a repeated literal (a typo there would have silently downgraded an invalid signature to 422), and a test pins each published value. That keeps the sweep's "no literal assigned to error" rule strictly enforceable without an exemption list — which is the kind of list that grows until it hides a real defect.

    Two translation repairs worth noting, both in the same class of error: Google capitalised a sentence-initial peer_slug / base_url / shared_secret in five places, but those are literal request field names an integrator has to type, not words; and Irish rendered "hyphens" as Ceachtanna ("lessons"). Machine translation of technical strings needs the field names checked afterwards.

    The remaining tail is 414 prose exception throws across app/Services/, unchanged in character: the largest are in the login and SSO flows, which reach the user through a different surface and need their own analysis before being touched.

  • Stories, municipal surveys, member support levels and paid push campaigns stop refusing in English too. Another 51 hardcoded refusal messages, on the same path: thrown by the service, passed through $e->getMessage(), rendered to the user verbatim. 28 new keys, and four messages wired to story_not_found / story_expired / paid_push_campaign_not_found / member_premium_tier_not_found keys that already existed unused rather than duplicating them. All four services are now under the regression test — which brings it to nine services — and were proven failing before the change, one failure per service.

    Two messages put a raw database id in front of a userTier 7 not found, User 42 not found. Those are the only places the English wording changed; the id is a log detail, not something a member can act on, and the translated messages name the thing rather than its primary key.

    Everywhere else the original wording is preserved exactly, including phrasings a copy editor would want to fix (title is required). That was a deliberate reversal: the improved wording had been written first, and it broke ten assertions in the services' own test suites, which match on the literal English text. Rewording user-facing strings is a separate decision from making them translatable, and mixing the two turns a localisation change into a behaviour change that has to be argued route by route. The messages now come from lang/en/api.php, so the wording can be improved later in one place, in every language at once.

    Two throws are deliberately left in English: the Stripe webhook path's recordEvent invariants. Only Stripe's delivery log and ours ever see them. Adding lang keys would have put two more values into eleven locale files that nobody will read — the exact shape of debt the admin-namespace shrink removed 38,743 of. They are private named constants, so the "no hardcoded English refusal" rule stays enforceable without an exemption list.

  • The OAuth token mint returned 500 in every test, which is why the platform's sharpest authenticated surface had no test at all. CorsHelper::handlePreflight() read $_SERVER['REQUEST_METHOD'] directly. PHP-FPM always sets it, so production was never affected — but Laravel's test HTTP kernel dispatches a Request object without writing the superglobal, so the read raised "Undefined array key REQUEST_METHOD" and every controller calling that helper answered 500 under test only. The one controller that does is the v1 federation OAuth token mint, which is deliberately outside the federation authenticator because it has to be reachable to exchange credentials for a token. Any attempt to write a feature test for it died inside CORS handling before reaching the endpoint, so nobody had.

    It now reads the method from the Request when there is one and falls back to the superglobal otherwise. Production behaviour is unchanged; the audit below is what the fix bought.

    Root Cause: a static helper reached for a superglobal instead of the framework request, so its behaviour depended on how PHP was invoked rather than on the request. Prevention: the regression test asserts preflight is still detected with the superglobal absent, and the twelve federation audit tests now exercise the endpoint end to end — the same assertions returned 500 before this change and the correct status after. A second copy of the class carries the same line and is tracked separately rather than folded into this change; it was described here as unused, which turned out to be wrong — see the entry below.

  • The second copy of CorsHelper was not unused — it is the one on the hot path, and the fix above had reached only the quieter of the two. App\Core\CorsHelper and App\Helpers\CorsHelper have been byte-similar duplicates since the 2026-03-20 src/ inlining refactor split them, and the App\Helpers one is live in two places: EnsureCorsHeaders, prepended as the outermost middleware, calls its isOriginAllowed() on every API response that Laravel's HandleCors did not already stamp; and AppServiceProvider calls its getAllowedOrigins() at boot to merge tenant custom domains into config('cors.allowed_origins'). The App\Core copy — the one that got fixed — has exactly one call site, the legacy_v1 federation preflight. So the REQUEST_METHOD read is now fixed in both, and the regression test covers both rather than one.

    The copies had also drifted in the opposite direction, which is the more consequential half of this. The CORS subdomain hardening of 2026-04-12 — which replaced "accept any subdomain of an allowed host" with an explicit CORS_ALLOWED_SUBDOMAINS allowlist that also rejects nested labels such as a.b.project-nexus.ie — was applied to App\Core\CorsHelper only. It never reached the copy that actually serves the request path, so the hardening does not currently apply to it. That is deliberately left unchanged here: it alters origin-acceptance behaviour on the outermost middleware for every API response, and it belongs to its own reviewed change rather than to a duplication cleanup. It is recorded in the class docblock so it cannot be lost again.

    Worth stating for whoever consolidates these: the two are not interchangeable. App\Helpers::getAllowedOrigins() merges tenant custom domains from the database and App\Core's does not, and AppServiceProvider depends on the merging behaviour — so collapsing onto the App\Core implementation without reconciling that first would silently stop tenant custom domains passing CORS.

    Root Cause: a duplicated class, where each fix landed on whichever copy the author had open — twice, in opposite directions — so neither copy was ever the correct one. Prevention: the preflight regression test is now data-driven over both copies, and a third test discovers CorsHelper copies from disk and fails if one exists that the provider does not cover, so a future duplicate cannot inherit the trap silently. Each fix was verified by reverting it and re-running: the App\Helpers assertions fail with Undefined array key "REQUEST_METHOD" and pass once restored.

Security

  • The overall panel's entry guard was still turning away the very people it had just been opened to. Hiding the platform-only sections from the panel's menu was not enough on its own. The guard on the panel door still required a platform-wide administrator, so a community administrator would have clicked the newly added "Super Admin Panel" link and been bounced straight back to the ordinary admin screen. A link that goes nowhere is worse than no link.

    The door now admits both kinds of administrator, exactly matching who the server will serve. And the platform-only screens inside — platform income, pricing, the federation controls, the provisioning queue — now refuse politely on their own account, rather than relying on being hidden from the menu. A hidden menu item is a convention, not a lock: a bookmark or a pasted address still opens the page, which then fires request after request that gets refused, and looks broken rather than closed. A community administrator arriving that way is now returned to their own dashboard.

    To be clear about what was ever at risk: nothing. The server has been the authority throughout, it is split into the two tiers, and twenty-four tests prove a community administrator is refused every platform-wide action. This was about refusing cleanly instead of failing messily. Ten further tests cover the two guards, including that a community administrator genuinely gets in, that the flag alone is not enough without the server's say-so, and that an administrator whose sign-in details predate the new field still keeps full access.

  • The overall administration panel is now split in two, so a community with communities beneath it can have its own — seeing only its own branch. This was always the intent, and most of the machinery for it was already built and correct: the tree, the branch matching, and the confinement on the important listings. What was missing was any separation between powers that belong to a single branch and powers that belong to the whole platform. Everything sat behind one door marked "platform only", which is why a community administrator got the ordinary admin panel with extras and no way into an overall panel at all.

    The endpoints are now sorted into two groups. The first — the community list, the member list, the tree, the summary figures, the activity log, and the actions that create, rename, move and manage communities and their members — confine themselves to the caller's own branch, so a branch administrator may use them. The second stays platform-only: platform income and pricing, the switches that control connections to other installations, platform-wide rollout settings, and granting platform-wide administrator rights.

    That split is not a matter of taste. Two concrete reasons decided it. The billing actions take the community they act on straight from the request and only check that the caller is an administrator — so letting a branch administrator near them would have let one branch change another branch's billing. And granting platform-wide administrator rights is the way out of your own branch entirely, so it can never be a branch-level power. Deleting and permanently purging a whole community also stay platform-only: they are irreversible, and building a network needs creating and moving, not destroying.

    Twenty-four tests hold the boundary, and they are the point of the whole exercise: a branch administrator's community list, member list, tree and activity log each contain their own branch and nothing from a sibling branch; guessing a sibling's identifier gets refused; they cannot change another branch's billing, cannot grant platform-wide rights, cannot purge a community, and cannot sign in as another member. A platform administrator still sees every branch and still reaches everything. An ordinary administrator reaches neither.

    Nothing is switched on for anyone yet — the panel's own screens still need to hide the platform-only sections before a branch administrator is given the door key.

  • A platform-wide administrator whose account happened to sit on the wrong community was being treated as a local one. Two parts of the system disagreed about what the platform-administrator flag meant. The gate guarding those screens let such a person straight through, platform-wide — but the code that decides how much they can see only granted full reach to accounts on the very first community, or to the top-level owner account. Anyone else with that flag was quietly confined to their own community's branch.

    Both now use the same definition, written out side by side so a future change to one is obvious in the other. This also removes a risk introduced by the fix below: without it, a genuine platform administrator whose community had no recorded position would have been locked out of the panel entirely.

  • A community administrator whose community had no position recorded in the hierarchy would have been able to see the entire platform. The platform supports a community that has communities beneath it having its own administrator, who should see only their own branch. That boundary is enforced by comparing the start of a text field that records each community's position in the tree — and that field is allowed to be empty.

    An empty value does not mean "no access". It means everything. Anything at all "starts with" nothing, and the equivalent database filter matches every row. Measured rather than assumed: the filter an empty value produces matched every community in the database.

    Worse, the six places that apply that filter were written so that an empty value skipped the filter altogether — handing back the whole platform rather than nothing. Two of them had a second version of the same fault: an empty result from the first lookup also skipped filtering. In the one part of the system where the safe failure is "show nothing", every path failed the other way.

    This was not exploitable, for a single reason: the gate on those screens currently refuses community administrators outright, so nobody could reach the code. It has been fixed now precisely because that gate is about to be opened up — a change that would otherwise have turned a sleeping fault into a genuine cross-community data leak.

    Fixed in four places, all now failing safe, with fifteen tests covering it: access is refused outright rather than granted with an empty position; the individual permission check refuses instead of allowing; the database filter matches nothing instead of everything; and the six filter sites now share one function that has to be told explicitly to allow. The same guards were added to a second, currently unused copy of this logic so that connecting it later cannot reopen the hole. Also verified: a legitimate branch administrator still sees their own branch and still cannot see a sibling branch, and a similar-looking position (such as /900/ against /9001/) does not match.

    One thing deliberately not done: the column was not made mandatory, despite an earlier note of mine recommending it. Creating a community inserts the record first and fills in its position immediately afterwards, because the position is built from the record's own identifier — so making it mandatory would break creating communities altogether. That two-step write is also how an empty position could arise in the first place, which is why the safe-failure code above is the real defence rather than a nicety.

  • Two security advisories were published against Guzzle, the library the platform uses to make outbound web requests, and we were on an affected version. Both were published on 2026-08-03 and affect every release below 7.15.2; we were on 7.15.1. The more serious one (rated high) is that a web address written in an unusual but still valid form can slip past checks that decide whether a host is allowed to be contacted. The second (rated medium) is that a cookie set for one address can stay attached to requests to addresses beneath it when it should not. The library is now on 7.15.2, which is the version that fixes both. It was the only package that moved.

    On how exposed we actually were: no code in app/ uses Guzzle directly — every outbound request goes through Laravel's own HTTP wrapper, which sits on top of it — and the platform does not ask Guzzle to make host-allowed-or-not decisions, which is the specific thing the first advisory undermines. So the likely real-world exposure was low. That is a reason to be calm about it, not a reason to leave it: the same library carries our requests to payment and identity providers and to federated partners, and "we probably do not use the vulnerable path" is a weaker guarantee than being on the fixed version. The automated dependency check that flagged this is the one CI job that was still failing, and it now passes.

  • The CORS subdomain hardening written on 2026-04-12 was live on production for the first time on 2026-07-30 — for three and a half months it had been applied only to the copy of the class nothing on the request path calls. The hardening replaced "accept any subdomain of an allowed host" with an explicit CORS_ALLOWED_SUBDOMAINS label allowlist that also rejects nested labels, and it landed on App\Core\CorsHelper, whose one caller is the legacy_v1 federation preflight. The copy that EnsureCorsHeaders — the outermost middleware, on every API response — actually calls is App\Helpers\CorsHelper, and it kept the permissive rule.

    This was not a theoretical gap. Probing production before the change, api.project-nexus.ie returned Access-Control-Allow-Origin: https://evil.project-nexus.ie for an Origin of that name, and did the same for https://a.b.project-nexus.ie — alongside Access-Control-Allow-Credentials: true, which is what makes a reflected origin worth having. config/cors.php matches exactly and has no origin patterns, so Laravel's own HandleCors could not have produced those headers; the reflection could only have come from the unhardened copy, which is also how the hot path was confirmed rather than assumed.

    The behaviour change was enumerated against production data before being made, because it applies to every API response. Neither CORS_ALLOWED_SUBDOMAINS nor CORS_ALLOWED_ORIGINS is set there, so the built-in default (app,api,staging,admin,super-admin,project-nexus) governs. Of the hosts that sit under a configured apex domain, exactly two live ones lose the grant they had: accessible.project-nexus.ie and accessible-uk.timebank.global. Both are the accessible (GOV.UK) frontend, server-rendered by the PHP app, and their only fetch() targets a same-origin Laravel route — they never made a cross-origin request, so nothing they do stops working. Everything else that was reaching the API cross-origin is granted by exact match: the six tenant custom domains in tenants.domain, and the static origin list.

    One tenant needed checking rather than assuming, and it changes a failure mode. Tenant 11 is served at uk.timebank.global, runs the React app, and therefore does call the API cross-origin — and uk is not an allowlisted label, so the permissive wildcard was what granted it. It survives because it is a row in tenants.domain, which isOriginAllowed() matches exactly. The consequence is that its CORS now depends on that database-and-cache lookup, where previously the wildcard was a fallback: during a simultaneous Redis and database outage that tenant would see a browser CORS error instead of a readable 5xx. timebanks.us and pairc-goodman.com already depended on that step, since no configured apex host covers them. Adding https://uk.timebank.global to ALLOWED_ORIGINS in production's .env would remove the dependency; that is an owner change and has not been made.

    The matching rules now live in exactly one place, App\Support\CorsOriginMatcher, which both copies delegate to. Porting the logic a second time would have reproduced the thing that caused the bug. The two classes still exist, because they are not interchangeable — App\Helpers::getAllowedOrigins() merges tenant custom domains from the database and AppServiceProvider depends on that, while App\Core's returns only the configured list — but nothing security-relevant is duplicated between them any more. This supersedes the previous entry's statement that the divergence was deliberately left unchanged.

    Two smaller findings came out of single-sourcing it: a malformed Origin header reached str_ends_with() with parse_url()'s false return and raised a TypeError rather than being rejected, since both copies guarded only null; and setting CORS_ALLOWED_SUBDOMAINS replaces the defaults rather than adding to them, so any deployment that sets it must list every label it needs including app. Both are now covered by tests.

    Root Cause: the fix for a security defect was written into a duplicated class, and landed on the copy with one caller instead of the copy on the outermost middleware — so the hardening existed, was reviewed, was tested, and did nothing. Prevention: the rules are single-sourced, and both helpers' test classes plus the new matcher's assert rejection of unlisted labels and nested labels — coverage neither copy had, which is why nobody noticed the hardening was inert. The assertions were checked for discriminating power by running the new inputs through the old permissive rule: seven of nine change verdict, and the two that do not are the allowlisted labels that must keep passing. Not deployed — this is an origin-acceptance change on every API response and the switch is the owner's.

  • legacy_v1, the platform's partner API, is now audited route by route — and is still switched off, waiting on a deliberate decision to enable it. All fifteen /api/v1/federation/* routes are classified by caller, credential, required scope, and what they read or mutate; three of them move value or create content. Twelve tests cover it.

    The route list is now derived from the router rather than written out by hand. The existing kill-switch test lists twelve of the fifteen routes literally, which means it proves nothing about a sixteenth route added tomorrow — the exact failure the kill switch was built to prevent. The audit reads the route collection instead and fails if the surface changes size, if any route is missing its protocol gate, or if any route answers anything but 503 while the switch is off. Switching legacy_v1 on is also proven to open legacy_v1 and nothing else.

    The central question was what a minted token can actually do, since the mint is the one route deliberately outside the federation authenticator. Answers, all proven rather than read: a token cannot widen its own scope (the mint intersects the request with the key's stored permissions, and a request matching nothing is refused); a token is bound to its key's tenant; a tampered payload does not validate — which is what makes the first two load-bearing, because the middleware reads tenant and scopes from the token in preference to the live database row; a wrong secret and an unknown client id are indistinguishable, so the mint is not an oracle for which client ids exist; and a revoked or expired key cannot mint.

    One property is worth stating rather than discovering later: narrowing a key's permissions does not affect tokens already issued. Scopes travel in the token, and the per-request database check confirms only that the key is still active. The exposure window is therefore the token lifetime — one hour by default, twenty-four at most — and revoking the key, rather than narrowing it, is the immediate control. That is ordinary bearer-token behaviour; it is now a known number instead of an assumption.

    No security findings in the fifteen routes themselves. One recorded non-finding: the grant_type check is an exact string comparison sitting behind Laravel's global input trimming, so surrounding whitespace is accepted. That is normalisation, and it is now asserted, so removing the trimming middleware cannot silently change this endpoint.

    Nothing was enabled. Turning an external federation protocol on is a security-relevant production change and belongs to the owner, on the same footing as a deploy.

    One follow-up: five of the token tests initially passed locally and failed in the pipeline, because the JWT signing secret comes from an environment variable that a developer's .env usually sets and the test environment does not — so the mint answered "failed to generate token". The test now pins a fixed, non-secret signing key of its own, which is what it should have done from the start: these tests are about scope, tenant binding and signature verification, not about deployment configuration. Root Cause: a test depended on ambient environment configuration rather than establishing its own. Prevention: the secret is a constant in the test, verified by running the suite with the environment variable cleared — five failures before the change, none after.

Fixed

  • Eight more admin test suites are back under the gate, and the assertions they used could never have worked. These were the group the previous pass named as next: tests asserting the literal English that a t() call produces, in an environment where no translation resources load. The subtler half of the problem is that i18next's missing-key output is the same string for every key, so an assertion matching on it could not have distinguished view_profile from delete_data even in principle — it was not a translation that had drifted, it was an assertion with no discriminating power.

    Each is rewritten to assert a data-* attribute carrying the raw value the component was actually given: user status and role, breach severity and data categories, audit action, consent type, GDPR request type, error-log action, permission category. That is stable whether or not translations load, and it says what the test means.

    One was doubly stale. GdprConsents renders the API's human-readable consent_type_name and falls back to a translated "unknown" — and the fixture never set a name, so the raw consent type the test looked for was never on screen under any conditions.

    All eight were verified together with retries disabled — 82 of 82 — so none is passing by retry rescue. Known-broken suites drop from 64 to 55 of 1,283.

    Four suites in the same directory stay quarantined, and deliberately so: they are not this cluster. One is a delete-confirmation dialog race, one asserts a computed percentage, one an error-message spy, and one looks up checkboxes by a derived label. Recording which failure belongs to which cause is the point of the list; lumping them together is how a quarantine file stops being a work queue.

Documentation

  • The contributor documentation was telling people to do the exact thing that created the 99,139-value translation debt. A review of every maintained document against the last month's work found five that had fallen behind, and one of them was actively harmful: both docs/I18N.md and the agent guide instructed contributors to add a new English key "to every other locale file". That satisfies the structural parity gate — which compares key sets and cannot see a wrong value — and is precisely how 62.3% of non-English PHP values ended up as byte-identical English while CI stayed green. Both now say to translate the key, name the two translation helpers, and explain why parity alone proves nothing.

    Also missing from the i18n page: the blocking untranslated-value ratchet, the invariant allowlist and the rule an allowlist entry has to survive, and the fact that __() reads a namespace's .json file before its .php one — so a .php namespace can be entirely dead while the live JSON beside it is entirely English. And a name-collision hazard worth stating outright, because it nearly caused a wrong deletion during this review: admin_nav exists as a live React namespace used by 39 components and existed as a dead PHP file of the same name in a different tree.

  • The testing and CI pages described a pipeline that no longer exists. Neither mentioned the eight-shard full Vitest suite, which has been blocking since 2026-07-28 and is what actually gates a release. CI.md still asserted that the only blocking frontend checks were type-checking, lint, contracts and the build; TUTORIAL.md told readers CI reported frontend tests as non-blocking evidence "until that runner issue is resolved". A green pipeline now proves 1,228 of 1,283 suites, and the 55 that are skipped, along with the shrink-only rules governing that list, are documented rather than implicit.

    TESTING.md asks, in its own closing section, to be updated "when a green check no longer proves what this page says it proves". That is the obligation this closes. Both pages also now record the two structural traps that made an earlier gate enforce nothing — a job-level continue-on-error swallowing its own blocking steps, and a job missing from the release gate's dependency list — and the two environment differences that let a test pass locally and fail on a CI shard.

    The federation manual needed no changes: it was brought up to date on 2026-07-29 and already states that every external protocol is switched off and that the v1 partner API has no replacement.

  • The public Features page said external federation had real partners exchanging data daily. It has none, and has been switched off for three days short of a fortnight. The entry read "Live with external timebanking platforms — partnerships established, messages flowing", with a note asserting "Real partnerships exist and exchange data daily." External partner federation has been off platform-wide since the 2026-07-27 deploy; every external endpoint answers 503 before a credential is examined, no partner is connected, and twelve weeks of retained access logs contain zero external callers. The same false claim was mirrored in all ten non-English translations, and it sat under a subheading promising honest labelling.

    The root cause was that the label vocabulary could not express the truth. All three maturity levels asserted production use — GA meant "used in production", Beta meant "working in production today". A capability that is finished, tested, deliberately switched off and used by nobody had no honest label available, so it was marked Beta and the prose drifted to match the label rather than the reality. There is now a fourth level, Built, not enabled, in a neutral colour because nothing is wrong with a completed feature that is waiting for demand. The maturity legend explains it, and it is translated into all eleven languages.

    Federation is also now presented as the two different things it actually is. Connecting communities hosted on one installation is in everyday use and stays unmarked; connecting to other installations and other platforms is the dormant part. Those had been blended into a single list where four live internal features and two dormant external ones all carried the same Beta chip. The new copy states plainly that the groundwork is done and audited, that other operators have expressed interest, that nothing further is being built until a concrete integration is on the table — and invites anyone who wants to federate to get in touch.

  • A newsletter template told members the Komunitin federation was live and their data was travelling between communities. "The full Komunitin federation spec is live — members, listings, events, and reputation now travel with you across connected communities", seeded in both the PHP and React translation trees and in all eleven languages. Nothing travels anywhere: the protocol is switched off and no install is connected. It now says the protocol support is built and tested, ready for the day there is another platform to connect to.

  • The admin integration page told administrators every endpoint on it was live, and published two that do not exist. Of the fourteen endpoints it lists, only the two OpenAPI documents are reachable as shipped — the rest sit behind the Partner API switch (itself off, and additionally a per-community setting that starts off) or the external federation switch. The page also invited admins to "share this URL with integration partners". Worse, two of the published Partner API operations — PUT and DELETE on a webhook subscription by id — have no route at all, so a partner building against them received a 404 rather than a 503. Those two entries are removed from IntegrationShowcaseService with a note not to add an endpoint without a route behind it.

  • The OpenAPI document described itself as the complete API and recommended itself for building federated integrations. It contains 843 paths and none of the partner-facing federation protocols: not one of the Komunitin, Credit Commons, Native V1 or Partner API routes, nor the aggregates, inbound hour-transfer or external webhook endpoints. Exactly one external federation endpoint appears in it, and its response list omitted 503 — the only status a partner can actually receive today. The description now states what the document covers and what it does not, and points to the federation manual; the one external endpoint documents its 503.

  • The README's Quick Start listed two URLs that do not work after following it. The sales site is behind an opt-in Compose profile that none of the documented commands start, and the accessible UI URL used a community slug that does not exist on a fresh install — the seeder creates only the Master Tenant, which has no slug, so the documented address returns a hard 404. Both now say what extra step they need.

  • A documented deploy command in the agent guide could not run, in five places. It was written ssh -i "$PROD_SSH_KEY" -o RequestTTY=force "$PROD_SSH_USER@$PROD_SSH_HOST", but deploy.env defines only PROD_SSH_HOST and PROD_SSH_KEY, and PROD_SSH_HOST is already a full user@host string. The command expanded to @azureuser@host and ssh rejected it; a sixth variant double-prefixed the user a different way. scripts/deploy.sh had it right all along.

    The same guide claimed Husky pre-commit and pre-push hooks run lint and tests locally. There is no .husky/ directory at all, exactly one hook is installed — the staged-PHP-test gate — and scripts/pre-push-checks.sh is a script nothing invokes. It also said an i18n check "runs in pre-push", which nothing does. CI is the only safety net, and the guide now says that plainly, because believing otherwise is how a push gets treated as pre-verified.

  • RouteServiceProvider's own docblock said the opposite of what the file does. It stated "there is NO /api prefix" — left over from the pre-Laravel router — while line 182 applies ->prefix('api'). That wrong comment had already propagated into two published documents as a login URL that does not exist. The docblock now explains the real arrangement, including why a handful of live routes sit under /api/... with no version segment.

  • Documentation that named symbols which do not exist. The deepest sweep found a class no gate in the project can see: guides citing JobVacancyService::createVacancy(), FeedActivityService::deleteActivity and ::ensureActivity, an event class GroupChatroomMessageSent, a React component, an endpoint, a test file and a config path — none of which are in the repository. A reader greps for one of these, finds nothing, and stops trusting the whole document. Each was corrected to the real symbol or the claim removed, and all 1,813 remaining Class::method references, source paths, test paths and route pairs across docs/modules/ were then verified to resolve. Every one now does, or is explicitly documented as absent.

    Two of them turned out to be about the code rather than the prose, and neither is what it first looked like. GroupModerationService::isUserBanned() queries a group_bans table that has never existed in any migration or in the schema dump — but the method has no callers, so nothing is being silently bypassed; it is a dead path that would trap the next caller, and it is tracked separately. And the volunteering guide described an insufficient_balance guard on auto-payment that the code deliberately removed: the org balance is a reconciliation figure rather than a spending limit, so approved hours are always minted, and the old guard was itself the bug because it left approved hours permanently unpaid. The code is right there; the documentation had not caught up.

  • How this was done, and what it says about doing it again. Every claim was checked against the code, and every finding was then attacked by an independent reviewer before anything was edited — which mattered, because three plausible-sounding findings were wrong, including one repeated from a stale local plan and one about a namespace that exists in two trees with the same name and different lifecycles.

    The more useful lesson ran the other way. The findings held up well: six of six spot-checked independently were real. The fixes did not — an independent recheck of the first 84 corrections found 35 second-order defects in the corrections themselves: a stale value left in a sibling file, a new sentence contradicting an old one nobody removed, off-by-one counts, and identifiers that were invented while fixing something else. Three further rounds each found real defects in the previous round's work, converging 35 → 20 → 14 → the symbol sweep. Correcting documentation is not lower-risk than changing code, and it needs the same verification discipline.


Back to all releases