1.6.0
Released 2026-08-12
Added
🔴 We had never once tested what happens when a lot of people use the platform at the same time — and now we have, which immediately told us something important. A partner organisation reviewing the platform asked for evidence of load testing by name. There was none: not a partial answer, none at all. The platform already measures how fast every real request is in production, but that only says how it copes with today's traffic and nothing about ten or a hundred times it, which is exactly the question a national rollout asks. There is now a tool that pretends to be a crowd of members browsing at once and reports how long each of them waited — reporting the slowest one in twenty and one in a hundred, not the average, because an average hides the person who waited nine seconds behind ninety-nine who waited one. The first run found that the platform serves roughly the same number of requests per second whether five people or forty are using it — the extra people simply wait longer. That is the fingerprint of a queue: requests lining up behind a fixed number of workers instead of being handled side by side. It is a settings question with a measurable answer, which is a far better position than not knowing, and it tells us exactly when the current server needs more workers or more capacity as the platform grows — planning information rather than a problem. It also showed the abuse protection working hard — nothing blocked at five users, nearly half of requests slowed down at forty — which is reassuring, and means the limits need raising in a test environment to measure the platform rather than its own defences. Three deliberate choices. It refuses outright to run against the live site, because 369 real members share one server and load-testing them is an outage you scheduled yourself; overriding that takes a specifically named instruction. It needs no third-party tool at all, which matters for a platform whose whole promise is that anyone can take a copy and run it themselves. And it counts "slow down, you're going too fast" replies separately from genuine errors, because treating them as failures would make correct behaviour look broken, while treating them as successes would overstate how much the platform can really take. 🔴 The figures are honestly labelled as not being capacity figures, twice, in the write-up: on a development machine the software reloads its settings on every single request and reads its code from a slow shared folder, so a request costing two seconds there costs a fraction of that live — proven by including one deliberate control that skips the software entirely and answers in 24 milliseconds. Also written down: nobody has yet agreed how fast is fast enough or how much downtime is acceptable, so the pass marks in the tool are a starting point for that conversation and not a promise.
Added a guard so the browser tests cannot gain any more checks that pass without checking anything. Two patterns were found yesterday while diagnosing a Safari test: 139 checks that look as though they wait for the page and do not, and 83 that can never fail at all because they end in "or true". Neither is a fault in the product — but both are how a genuine failure hides behind a retry, which cost hours of diagnosis in one day. Fixing all 222 in one pass would be worse than leaving them: done mechanically, an always-passing check becomes a wrongly-failing one, and that breaks the build for everyone. So instead there is now a ceiling: the build records today's counts and fails if either number goes up. The debt can only shrink, and it can be paid down whenever someone is already working in those files rather than as one disruptive sweep. This follows the same pattern the project already uses for untranslated wording and database-column references. Proven rather than assumed: adding one of each pattern makes the build fail; a comment that merely mentions the pattern is correctly ignored (three text-based guards in this project have previously flagged their own documentation); and the legitimate "click this if it happens to be there" form is correctly not counted, because that genuinely should not wait. The check also refuses to pass if it finds no test files at all — a check that reports success having measured nothing is the exact fault it exists to catch. 🔴 One correction: an earlier note said 226 of the first pattern. The precise figure is 139; 226 came from a rougher search that also caught unrelated checks.
The new accessible site had no error reporting at all — if it had gone live, a crash would have been invisible. Setting up the reporting account was only half of it, and the smaller half. The site had nothing that could report a fault: no library, no setup, nothing. The deployment configuration I wrote yesterday passed it a reporting address, and that address was read by nobody — so it would have looked configured while reporting silently into the void, which is the same class of fault as the bot check that was quietly switched off in production for months. Now: a reporting project created in your existing account, named to match the two you already have; the library wired into the site so faults are actually sent; and privacy matched to the rest of the platform — no IP addresses, no cookies, no form contents, and web addresses stripped of anything typed into them, because a search box on this site carries whatever a member searched for. The constant machine traffic (the health check runs every ten seconds) is excluded so it cannot swamp the account. Verified properly rather than assumed: I sent a real fault through and confirmed it arrived, then marked it resolved so it is not left sitting as an open alarm. One detail worth recording because it would have been easy to miss: the version label had to be written in the exact same shape as the other two apps, because the automatic thirty-minute check that watches for problems after a deployment searches for that exact shape. With it written any other way, the new site would have been invisible during precisely the window where a new fault matters most. That check now watches all three, and the new one is optional so its absence can never break the watch. 🔴 One thing still needed from you: the reporting address is saved on this machine, but it has to be added to the server’s own settings file before a live deployment would report anything. 14 new tests, full site suite 2,082 passing.
The new accessible site now has a way to be deployed — built, not used, and deliberately switched off until you say otherwise. Measured on the server first rather than guessed: it has 16 GB, uses 6.6 GB idle with 9.3 GB free, and a deployment peaks around 12.3 GB because the main app's build is given a 4 GB allowance and runs at the same time as the other one while both live copies keep serving. So roughly 3 GB is spare at the tightest moment. The new site was then measured directly instead of estimated — 66 MB idle, 71 MB at its busiest across 260 page requests, with its server process at 123 MB. Its allowance is set to 384 MB, not the 512 MB originally planned, because the measurement did not justify it. 🔴 One separate thing worth knowing: the only out-of-memory kill on that machine was the page-prerendering worker hitting its own 3 GB ceiling, not the server running out. It sits at 99.99% of that ceiling permanently. Adding memory to the server would not have prevented it, and it has nothing to do with this work. A serious trap was found and avoided along the way. The new site refuses to start without proper secrets — which is right — but the way that requirement has to be written means the deployment tool reads the whole configuration file before doing anything. On the live server, where those secrets are not set, every future deployment of the existing site would have failed outright. Not the new site failing: everything failing, as a side effect of preparing something nobody had asked to deploy. Found by reproducing it deliberately, then fixed by moving the new site into a separate opt-in file. The strict secret handling is kept, and the existing deployment path is now provably untouched — there is a test asserting exactly that, including that the function returning the two existing port numbers still returns two, because eight places in the deployment script read exactly two and a third value would have been silently discarded. Also added: a small address where the site reports its own name and version, which is the only way to prove a switchover actually happened — without it a web address that was missed keeps serving the old site perfectly happily and nothing notices; a web-server configuration file that falls back to the old site when the new one is not deployed, so a rollback cannot fail its own safety check and abort itself; a read-only listing of every community web address with a probe reporting which site each one is really serving; and a protection refusing to let anyone build the new site from the wrong place — the exact mistake whose obvious "fix" would drag the entire codebase into the main application's image. Proven by breaking it three ways on purpose. 🔴 Two honest limits. Nothing has been deployed and no switchover or rollback has been rehearsed on a real server. And the web-server fallback behaviour that makes rollback cheap is not yet verified against the live server's own configuration — that has to be checked before any switchover, not discovered during a rollback. Readiness score moved from 592 to 631 out of 1000. Verified: full site suite 2,068 passing, deployment script syntax and every configuration file parse, the new deployment contract test passes all 20 checks, and every documentation gate green.
A task can now say how many hours in total the member is willing to give, not just how long one job takes. Requested by a timebank moving across from the system they use today, which keeps these as two separate numbers on every task. The platform only had one. "Estimated hours" answers how long should I expect this to take, so both sides know what they are agreeing to. The new "Hours available" answers how much am I prepared to give altogether, for a member who is happy to help but does not want to end up committed to an open-ended amount. Leaving it blank means no limit, which is exactly how every existing task already behaves — so nothing needed changing on the thousands of tasks already published, and nobody has to go back and fill anything in. It is deliberately not a tick box marked "unlimited": one blank field has a single meaning, whereas a box plus a number can end up disagreeing with itself. Two mistakes are caught rather than stored. A total lower than the length of a single job is refused, because that task could never be booked even once — a three-hour job with a two-hour total is unbookable the moment it is published. And clearing the field puts the limit back to "no limit" rather than to zero, which is the trap here: an emptied form field arrives as an empty piece of text, and the platform's number handling would quietly turn that into 0, leaving the task looking as though it had been used up. Both are pinned by tests, along with the total appearing where members read the task. Verified: 6 new platform tests, the frontend typechecks, and the database-column check passes against the refreshed schema.
Safari on a Mac was told to look for a menu Safari does not have. The Install App button never could open Safari's own install prompt, because Safari does not offer one to websites — so it falls back to showing written instructions. Those instructions told the reader to open "a three-dot or three-line menu", which is Chrome's and Edge's menu. Safari has neither, so the button appeared to do nothing useful. It now recognises Safari on a Mac specifically and gives the real steps: the Share button, then "Add to Dock". Getting this right needs care in one place — every major browser puts the word "Safari" in how it identifies itself, Chrome included, so the check for Safari has to come last or it would claim Chrome and Edge as Safari too. An iPad is the mirror image: it identifies itself as a Mac, and the only thing separating them is whether the screen responds to touch. Both traps now have tests, and those tests exercise the detection for real — the existing ones handed the answer in and asserted it came back, which could never have caught this. All eleven languages have the new wording.
The one thing the new accessible site could not do, it can now do — and that was the last thing holding up replacing the old one. Event staff can record attendance by typing or pasting a signed attendee code, for when a camera or an offline staff device isn't available. The existing accessible site has always had this. The new one couldn't, for a structural reason rather than a missing page: every other action on the site works by asking the platform over the web, but this particular one was written to reach inside the platform directly, so there was simply nothing for the new site to call. That is why the page-by-page comparison sat at 706 of 707 for months, and why the old site could not be retired. There is now a proper platform endpoint for it, and the new site uses it. The comparison is now 707 of 707, with nothing missing. The protections that make this equivalent to the old site, rather than merely similar, are each pinned by a test, because "similar" is what makes a replacement unsafe. Scanning alone changes nothing — a tick box is required, so a link preview or a search engine fetching the address can never mark somebody present. A code that cannot be read and a code belonging to somebody the staff member isn't allowed to see give the same answer, so the form can't be used to work out who is registered. Reversing an attendance record requires a stated reason. And the platform looks up the record's current version itself, because somebody holding a scanned code has no way to know it. One deliberate improvement over the old site: the old one invents a fresh submission key every time, which means the key can never match a previous submission — so a double-tapped scan was never actually protected by it. The new endpoint requires the key to be sent, and the page issues one per form, so a double tap replays the same action instead of recording a second one. Three blocks the old site's page had and the new one was missing were also brought across: the privacy explanation, the "no wallet change" note, and the lost-device warning. All the wording already existed in all eleven languages. Verified: 13 new platform tests and 2 new site tests, and the guard test was checked by deliberately removing the guard to confirm it fails; the full site suite at 2,063 passing; static analysis clean; both generated inventories regenerated with every quality measure still at zero. One existing test had to be narrowed: it checked the whole page for an action the roster form correctly withholds, and was only ever passing because this form was missing.
The standard terms that ship with the platform are now an actual agreement members are asked to accept — which they never were before. This is the piece that makes the whole terms-acceptance system mean something. The acceptance machinery only ever acts on a document, and the standard terms had only ever existed as words on a page — display text on the terms page and in the translation files, never a record in the platform. So there was nothing to accept, and no member had ever been asked. Measured before the fix: five communities, zero documents. Now every community has the standard terms as a published document requiring agreement, and new communities get it automatically the moment they are created — through both routes a community can be created by, so the two can't drift apart. The wording is not new. Every sentence is the same sentence already shown on the terms page — all eight sections, covering time credits, account responsibilities, community guidelines, prohibited activities, safety, liability, account closure and changes to the terms. A test reads the shipped text and fails if the two ever drift apart, so this can't quietly become a second, different set of terms. Several protections matter here because this is a legal record, not ordinary content. A community that wrote its own terms is never touched — not its wording, not its version number — because members have agreed to a specific version, and replacing that underneath them would invalidate every agreement on file while leaving the records looking perfectly valid. A community that has deliberately marked its terms as not requiring agreement is also left alone; the tool reports it rather than quietly reversing the decision. Running it twice changes nothing. A community's own name is inserted into the text and is escaped, so a name containing markup can't break the document. And the stored text is checked to survive the platform's own content-cleaning step untouched — otherwise a member could be shown something slightly different from what they agreed to. For existing communities this is a deliberate one-off command, not an automatic part of a release, with a preview mode that shows exactly which communities and how many members would be affected before anything is written. Bundling something this visible into an unrelated deployment would give nobody the chance to pick the moment. Verified end to end in a browser: signing in landed on the acceptance page, the terms read correctly with a working contents list, accepting recorded the agreement against the exact version and went straight through to the dashboard, and reading was never blocked at any point. 16 new tests; 85 legal tests and 165 tests across the affected endpoints all pass; static analysis and the database-column check clean.
Changed
New members now start in light mode instead of dark. Raised by a community coordinator, in plain terms: dark mode is hard on older eyes, and it was the first thing they saw. A member could always change it, and their choice has always stuck — but having to go and fix the appearance before you can comfortably read anything is a poor first impression, and most people never look for the setting. The platform default is now light. Nobody's existing choice is affected: a saved preference still wins, whether it was made explicitly or by asking the platform to follow the device. The browser tint used before the page has finished loading was moved to match, so the app no longer flashes dark on its way to light.
About forty documents said the new accessible site must never replace the old one — which is now the plan, so an assistant reading them would refuse the work. The instruction files carry that prohibition as a hard rule, and it was correct when written: the new site was an unproven candidate. Since the decision to switch over, those same lines actively obstruct it. There is now one public page that says what is actually happening — the accessible frontend takeover page — covering the decision and its date, that both existing web address shapes are kept exactly so nothing anyone has bookmarked changes, where the changeover has got to, the seven things still needed from the owner, and a table naming every document that makes a status claim and what to watch for in each. The phase of the changeover is stated in exactly one place, on that page. Everywhere else now points at it instead of repeating a claim that can go out of date. The instruction files were then edited one line at a time rather than swept with a find-and-replace, and each superseded line is flagged as superseded rather than deleted — silently removing a prohibition leaves nobody able to tell whether it was lifted or lost. In place of "the old site is the source of truth" there is now a two-phase rule: while the old site is still running it remains the specification for how pages behave and the page-count comparison is a live alarm; once it is switched off the new site owns page behaviour. In both phases the platform's own API remains the authority on what the data looks like — that never changes. About twenty evidence documents got one short header each instead of roughly 185 line edits. Those lines are dated records with code references; rewriting them would destroy the trail, and some of them use the word "candidate" to describe the old site, which a blanket replacement would have corrupted. The development pause was split rather than flipped. One marker paused two separate pieces of work. Leaving it as "paused" was untrue, because this work is now authorised; flipping it to "lifted" would have falsely reopened the experimental backend, whose live database has had no successful backup since March while it changes its own structure every time it starts. It is now two markers, the old single name is refused outright, and that backup warning is preserved word for word. Three genuinely wrong statements were also corrected. A verification routine told you to change into a folder that no longer exists — a temporary working copy deleted after it was merged, so anyone following those steps would simply have failed. Three documents described a configuration file at the top of the project that is not there and never was in this repository; the only file of that name belongs to the paused backend. And the new site's own release procedure makes lifting its deployment hold conditional on a file that was never copied into this repository, so as written the hold cannot be lifted at all — that is now recorded as something the owner needs to resolve rather than a step somebody will discover at the worst moment. Verified: all documentation checks pass, formatting clean with zero errors across 78 files, the score checker green, and every link in the new page confirmed to resolve.
The readiness scores in the project's own documents were unchecked by anything, and one of them was measuring the wrong question. Several documents carry a hidden line recording a score out of 1000. They look like a checked figure. Nothing in the repository read them — so a score could contradict its own table, be quietly lowered, or drift away from the evidence it claimed to summarise, and no check anywhere would notice. There is now a checker, and it runs in the build. It insists every score equals the total of its own table, that the table's columns actually add up, that every score names which rubric it belongs to, and that the counts quoted in the document match the generated inventories rather than a remembered number. Its guiding rule is written into it: the generated evidence is the truth and the document follows it — the earlier attempt at this had it backwards and ended up asking a document to contradict its own evidence to satisfy an old pin. I proved the checker works by deliberately breaking it three ways — a score that disagrees with its table, a row inflated so the column stops adding up, and a retired name brought back — and confirming each one fails. Separately, the accessible site's score has been retired rather than adjusted. It was 663 out of 1000, measuring "how closely does this candidate copy the old site?". That cannot answer "is this safe to serve to real people?", because it scored no deployment, no switchover, no way back, and no retirement of the old site. There is now a new scoring document that measures the right question, standing at 592 out of 1000 (59.2%). 🔴 The percentage went down and nothing got worse — 200 points of work that was previously out of scope entered the total for the first time and start near zero, while three previously-measured areas actually improved. Because that is so easy to misread, the new document carries a compulsory line-by-line mapping of the old score onto the new one, so nobody can claim it went backwards or quietly convert one number into the other. The old document keeps every figure and every deduction reason exactly as audited — it is the audit trail — and its hidden line was renamed, so anything still reading the old name now fails loudly instead of reporting a retired number as current. Two other markers were made unambiguous: the ASP.NET score now names its own separate rubric with a warning never to add the two together, and the documentation-quality score was renamed from "score" to "index" because a perfect 1000/1000 for documentation, sitting in the same shape as two product scores, invited exactly the wrong conclusion. One more real fault fixed in the page-counting tool: it finds pages by searching for the literal text of a registration call, so any file that named its router something else would have contributed zero pages with nothing failing — the count would simply have been smaller. That has already cost three working pages weeks of invisibility. It now refuses to run rather than under-report, with two tests covering both ways it can happen. Verified: full site suite 2,065 passing, code style clean, all documentation checks passing, and the new checker green with its three deliberate breakages each confirmed to fail.
🔴 Requiring members to agree to the terms is now ON by default, because it is a legal obligation rather than an option. It previously shipped switched off, on the reasoning that turning it on can stop members acting and so deserved a deliberate decision. That reasoning was wrong about which way round the risk sits: a default of "off" meant every new installation silently failed the obligation until somebody remembered to set a server setting — and a setting nobody set is not a decision anybody made. The compliant state is now what you get by doing nothing, and an installation that wants something weaker has to ask for it explicitly. Two things follow from this that are worth being clear about. First, an installation that sets nothing will start requiring agreement as soon as this code is deployed — that is the intent, not a surprise; anywhere that wants a measurement period first now has to opt down to "recording only" on purpose. Second, the behaviour on a mis-typed setting has been reversed to match: it used to fall back to "off" so a typo couldn't start blocking people, and it now falls back to enforcing, because with agreement as the baseline the dangerous typo is the one that quietly switches an obligation off. Either way it is no longer silent — a wrong value is now logged by name. The admin panel display was updated in step, so the two can never disagree about what the platform is actually doing. One important limit, stated plainly: this is necessary but not sufficient. Enforcement only bites where a community actually has a document marked as requiring agreement. There are currently five communities and zero legal documents on this development machine, and nothing in the product creates a default one — so today the setting enforces against an empty list and blocks nobody. Making the legal position real needs the shipped standard terms to exist as a document members are asked to agree to, which is a separate piece of work and a decision about wording that isn't mine to make. Verified: 69 legal tests pass, the five gated endpoints' own suites still pass at 170 tests (they pass because there is nothing to enforce, not because enforcement is inert), static analysis clean, and the admin page confirmed live showing "Enforced on changes".
Fixed
Checked the assumption the whole quick-undo plan rested on, and it holds. If we point a community's web address at the new accessible site and then need to undo it, the undo works by putting the old settings back — but that only helps if the web server still accepts its own configuration when the new site is no longer mentioned in it. That had never been checked against the actual server, and it was written down as the one assumption everything else depended on: if it were wrong, an undo would fail at the moment you most needed it. It is now verified in both directions on the real machine, read-only, without touching the live configuration. With the new site configured, the configuration is valid and requests go to it. With it removed — exactly what undoing produces — the configuration is still valid and requests fall back to the platform as before. I also proved which of the two paths is actually chosen in each case, rather than just that the file parses, because "it parses" would have been true even if both paths were being ignored. Readiness for the accessible-site changeover moves from 640 to 651 out of 1000: the deployment path has now been exercised four times on the real server and the undo route is proven, with the remaining marks held back until a real web address is switched over and the undo is rehearsed for real.
The six broken phone-and-tablet tests flagged yesterday are fixed, and not one of them was a fault in the product. Every single failure was a test looking for something that has never existed on the page. The tests searched for a navigation bar by two names the app has never used, a menu drawer by a label it does not carry, and a "Dashboard" link in the header that is not there. One used an iPad setting the testing tool removed in an upgrade, so it crashed before a browser even opened. One opened a web address that is not the "create a listing" page at all and then reported the form missing — it was on a "not found" page. One looked for form boxes by an internal name that the form library invents fresh on every visit, so it could never match. And the sign-in helper waited for a page this app stopped landing on, which meant four other test files were also being denied a signed-in member — that is now fixed for all of them. The real lesson is that a wrong search term fails identically to a broken feature: all six reported "element not found", which reads exactly like something being missing. Six sat there looking like genuine product faults. 🔴 Worse, three tests were passing while checking nothing at all. The touch-target test measured the first button on the page, which is hidden, so its check never ran — it has been silently green for as long as it has existed. The modal test looked for a Delete button that is not on that page and skipped its entire body. And the landscape check from yesterday was only passing because one of these broken searches matched nothing: the moment the search was repaired the check went red, because it had been written to look at the first navigation bar in the page rather than the first visible one — and the desktop bar is always present, merely hidden on a phone. A dead search term had been holding a broken check upright. All three now check something real: the touch-target test measures every visible button against the accessibility minimum and waits for the listings to actually load first (a deliberately shrunk button went undetected until that wait was added), and the modal test opens the Create menu, confirms it fits the screen, and closes on the Escape key. Everything was proven by deliberately breaking what it checks and confirming the right failure, then putting it back — including putting the original sideways-navigation fault back and watching only the tests at the affected widths go red while the narrower one correctly stayed green. Also covered for the first time: a signed-out visitor's menu button, which is a different button from a signed-in member's and had no test at all. Four small identifying labels were added to the page so the tests can find the header, the bottom bar, the menu tabs and the account button reliably — nothing a member sees changes, and they replace searches that were relying on English wording that changes in ten other languages. 🔴 One honest limit: this was run against the local development stack in Chrome only. The other test files that share the repaired sign-in helper were not individually re-checked, and any remaining failures in them are separate pre-existing problems.
🔴 The mobile navigation fault a tester reported twice was fixed a month ago, but nothing tested it at the size where it actually happened. A partner organisation's technical reviewer reported in July that the bar along the bottom of the screen could cover the Submit button on a phone, and that it was worse held sideways. The cause was found and fixed: a signed-in member holding a phone sideways fell into a gap between two screen-size rules and ended up with no menu at all. The fix works, and has been live since 9 August. But the test suite's own landscape check uses a screen 667 pixels wide, and the gap starts at 768 — so it sits below the fault and could never have caught it. A modern phone held sideways is 844 to 932 pixels. The fix was therefore argued rather than proven, which is why the same tester was still carrying it on his list a month later. There are now two checks at 844 and 932 pixels wide, signed in, confirming a member still has a menu and that the Submit button is reachable and not sitting underneath it. I proved these checks work by deliberately putting the original fault back and watching them fail with the right explanation, then removing it again. They pass twelve times out of twelve across two browser engines, including Safari's. The old 667-pixel check is kept, but now says in writing that it does not cover this and points at the ones that do — because a check that looks like coverage and isn't is worse than no check. 🔴 Found while there: six of the nine tests in that file have been failing for unrelated reasons — selectors that match two elements because the header renders a phone and a desktop copy, a device preset that no longer exists in the current test tool, and a sign-in helper that waits for a page this app no longer lands on. Flagged separately rather than fixed here.
A deploy was refused because a brand-new service had not warmed up yet, and the refusal did not say why. Nothing reached members: the old version kept serving throughout and the switch was never made. 🔴 The new accessible site does work on the server — it built, started, reported itself healthy, and served a real 24 KB page when asked directly. What failed was the check: it asked once, nine seconds after the service started, and a service that new still has to reach the platform, work out which community the request is for, and prepare its page templates for the very first time. A minute later the identical request was perfect. So a working deploy was refused over a cold start. The check now waits up to thirty seconds instead of asking once — long enough for a first request, far too short to hide a genuine fault, so a page that never appears still stops the deploy before anyone is moved onto it. 🔴 And the refusal message was almost useless, which cost real time. It said only "did not render an accessible page" — no status code, no response — because of how the request was made, so it could not tell apart "the platform was unreachable", "a page template is broken" and "the wrong community was asked for". I had to reproduce the request by hand to find out. It now reports the status code and the start of the actual response, plus the command to read the service's own log.
A real deploy stopped safely and exposed two more line-by-line reading faults — one of them in yesterday's own fix. Nothing reached members: the site kept serving the old version throughout, and the switch was never made. 🔴 The safety check that stopped it was wrong. It refuses any database change that adds a required column to a live table — a sound rule. But it reads one line at a time, and the change it objected to marks the column optional on the next line. Entirely safe, refused anyway. A safety check that cries wolf is one people learn to override, and overriding this one risks breaking the version still serving members, so it now reads whole statements. Verified in three directions: the change it wrongly blocked now passes, and genuinely unsafe changes are still caught whether written on one line or several. 🔴 The instruction to include the new accessible site was silently thrown away — again, one layer deeper. Yesterday it was fixed by moving from a setting that the remote connection discards to an explicit instruction on the command. That instruction survived the connection this time, and was then discarded by the step that puts the deploy into the background, because that step rebuilds its own list of instructions by hand and nobody added it. The deploy log said plainly "not included". Had I not read that log, this would have been reported as a successful rehearsal with the new site never built. 🔴 The same list was also dropping the instruction not to run database changes — and because running them is the default, a deploy told explicitly to skip them ran them anyway. That is the opposite of what was asked, on the setting reserved for emergency recovery. Both fixed, and there is now a check that compares the two lists mechanically, so a third instance cannot slip through: proven by removing the fix again and watching it fail.
Started paying down the browser-test debt, and found that nothing was checking the browser tests for basic correctness at all. 🔴 Nine decorative checks removed and seven raced checks made reliable, across the federation pages and admin member management. Several were checking the very thing their test was named for and passing regardless — whether a federated listing shows which community it came from, whether a federated member shows their community badge, whether the events page has any filters at all. Those now genuinely verify it. Two were turned into real checks after confirming against the code that the platform truly behaves that way; one was replaced honestly, because whether a member list shows page numbers depends on how many members there are, so the test now checks the list appeared instead of pretending to check pagination. Recorded totals fall from 155 to 148 and 83 to 76. 🔴 The bigger find: the browser test suite was never type-checked. There is a configuration file for it, but no command and no automated step ever used it — so fifteen genuine errors had built up unnoticed, and a mistake of mine yesterday (declaring the same name twice) was committed with every check green. These are not cosmetic: four of them mean a test passes an option that is silently ignored, so the test does not do what it appears to do. I fixed mine, added a check that catches this, and set it to run automatically whenever browser tests change — proven by deliberately reintroducing my own mistake and confirming it now stops. The fifteen existing errors are recorded as tracked debt so the check starts green and cannot grow. 🔴 And the new check nearly shipped broken itself — the way it invoked the type checker silently produced no output on Windows, which would have made it pass while measuring nothing. It caught itself, because I had built in the rule that measuring nothing is never a pass. That rule has now paid for itself twice in two days.
Corrected the records that had drifted, and closed the last gaps the audit found. 🔴 A headline figure in the readiness document was wrong while every check stayed green. It reported the build-quality subtotal as 512 out of 550; the rows actually add to 519. It had been correct once and was never updated as the route score improved three times. The automated check adds up the table columns and cannot see a number written in a sentence, so the one figure a reader is most likely to quote was the one nothing was watching. 🔴 Two records disagreed about whether the error-reporting key was on the server. It is — added yesterday, with a backup — but the readiness document still said it had to be done. Two enforced documents contradicting each other on the same fact is worse than either staying silent, and this was the one readers are told is canonical. 🔴 The route tally looked like it had lost a route: 707 plus 12 plus 3 comes to 722, but only 721 exist. Investigated rather than assumed: nothing is missing. The site's front page is registered twice on the platform side under two different names, and the accessible site serves both from one page, so one page is legitimately counted twice. The counts measure different things; that is now written down where the next person will look. 🔴 The claim that Safari is always tested was not true. That job does not run on proposed changes at all, and can be skipped entirely if an earlier step fails — the same silent-skip shape it was written to avoid. I have not widened it, because a real Safari run is slow and the service it needs is not set up; but the comment now says plainly what it does and does not cover, so nobody trusts coverage that is not there. Also: the browser-engine tests were running at a window width where several checks quietly skip themselves, and at a different width from the other Safari layer, so the two were not comparable — now pinned. And three files that make up the new deployment path carried no licence header, because the licence check only looks at three file types and never saw them.
Fixed four more ways the terms-acceptance screen could get in a member's way when the platform itself would not have. 🔴 A community that marked a document "show this, but do not require it" was still blocked on every page. The screen decided from a list built for display, which deliberately ignores that setting, while the platform's own rule honours it — so the two halves disagreed about who was blocked, and the member was stopped over a document their writes would have been accepted for anyway. The platform now states plainly whether any outstanding document is one it will actually refuse over, and all clients follow that. 🔴 Accepting on another device could leave someone stuck in a redirect loop for up to a minute. If you accepted on your phone, or an administrator withdrew the document, the accessible site kept its own minute-old answer and kept sending you back to a page that then sent you forward again, until the browser gave up. It now clears that stale answer whenever the platform says you are clear, however you got there. 🔴 Form submissions across the whole accessible site were being counted three times against one shared allowance. Two page groups were connected without a path, so their submission limit ran on every form post anywhere in the site — cutting the real allowance from twenty submissions per five minutes to about six, and worse for several members sharing one internet connection. Invisible to tests, because that limit is switched off when tests run. 🔴 The signal that lets us measure the impact before switching enforcement on was unreadable by any browser. It was being sent but not permitted, so the browser hid it — meaning the measurement would have missed the app most members use. Both permission lists now agree, and a new check fails if they ever drift apart again, which is a problem that only ever shows up in production. Also corrected a note in the code that still described the old default and claimed the phone app had no acceptance screen; both stopped being true the day they shipped.
Repaired the tests and safety checks that could not fail — and one of them was hiding a genuine bug that affects volunteers on the door. 🔴 Seven checks on the new attendance feature would have passed even if the feature had been deleted. They only asserted "the answer wasn't success", which a missing page satisfies just as well as a correct refusal. They now assert the exact response, and I proved it by deleting the feature: all thirteen checks fail, where seven used to pass. 🔴 The check that mattered most was hiding a real defect. It accepted three different outcomes, so it could not tell success from refusal. Tightening it revealed that when a check-in scanner retries after a network drop — precisely the situation the safeguard exists for — the platform refuses the retry and reports a conflict. The register ends up correct, but the volunteer holding the phone is told it failed, and may then check the person in again by hand. I have not patched it: the cause sits in shared code that the live check-in screens also use, so a hasty fix could break check-in for real events. It is written down as its own job with the evidence attached, and the test now pins the current wrong behaviour so the fix cannot be forgotten. 🔴 The new safety check I added yesterday could be walked past by ordinary code formatting. It read one line at a time, so any statement a formatter had spread over several lines was invisible to it. Sixteen real problems were hidden that way. The honest consequence is that the recorded figure had to go up, from 139 to 155 — not because anything got worse, but because the counter was undercounting. I listed every one of the sixteen in the record, with the reason, because a limit that quietly rises is worse than no limit. It only shrinks from here. 🔴 And the check could still pass while measuring nothing — its own note claimed otherwise. If a pattern broke, its count fell to zero and the tool announced an improvement. It now refuses. I proved this by deliberately breaking one detector: the tool correctly stops instead of congratulating itself. Also fixed: a browser check that was satisfied by the site header, so it passed on a completely empty listings page.
Made the cookie promise and the code agree, in all eleven languages — and finished the crash-reporting change that was only half done. 🔴 The cookie policy page was telling members the opposite of what the platform does. The banner had been corrected to say anonymous fault reports are always recorded; the policy page one click away still said error tracking happened "only with analytics consent", and listed it as an example of an analytics cookie. So a member who chose "essential only" could go and read a false statement about their own choice. No automated check could catch it — the wording existed and was translated everywhere, so every translation check passed. Nothing compares wording against behaviour. Now corrected in English and all ten other languages, with fault reporting listed as essential, and a test that fails if it ever drifts back. 🔴 Machine translation nearly made it worse, and this is worth knowing. The first pass reversed the meaning in four languages — German, Polish, Spanish and Dutch all turned "sent whatever your choice" into "sent according to your choice", which says the opposite — and four rendered "performance" as theatrical performance. I rewrote the English into plain short sentences and did it again; all ten now read correctly. Idiomatic English does not survive machine translation, and on a compliance page that is not cosmetic. 🔴 The "we always report crashes" promise was only true for outright crashes. Around a thousand places in the app report a handled error, and every one of them was still silently dropped for anyone who declined analytics — so the promise was untrue for the large majority of faults. Now genuine faults are always reported and everything else (who you are, speed measurements, session replay) still needs your consent, exactly as intended. Because faults now send regardless of choice, I tightened what travels with them: web addresses are stripped of anything typed into them, so a password-reset link or a search term can no longer ride along on a crash report; the same for the trail of recent activity attached to a report. The accessible site had four more of these: a noise filter that was switched on but could never match anything, a crash handler that killed the process before the crash could be sent, a reporting function that was documented as connected but called from nowhere, and a member's network address surviving a routine that promised to remove it. All four fixed, all four now tested.
Audited every commit from this session and fixed four serious faults it had shipped. Three independent reviewers re-read all 43 commits, each asked to check assumptions rather than results — "what would still be green if this were broken?". CI was fully green throughout and told us nothing about any of these. 🔴 The legal-acceptance rule was not applying to four of the fourteen actions it was supposed to protect. The list of pages that must never be blocked (so the "please accept" screen can load) was matched loosely, so it accidentally also covered acting on behalf of a supported child — creating their listings, transferring their credits, uploading their images, preparing support actions. A member who had not accepted could still do all four, while the system reported itself as enforcing. Fixed, and there is now a test that walks every protected action rather than the single one the old test checked. 🔴 The accessible site ignored the "measure but don't block" setting entirely. Its own note claimed it honoured it; no code read it. So a community choosing to measure the impact for a week before enforcing would still have blocked every accessible-site member for that week. The platform now publishes whether it is really refusing requests, and all three apps obey that one answer instead of each guessing. 🔴 The post-switchover check could not detect the failure it was built for. If a web address quietly reverted to the old accessible site, that produces no response — and "no response" was being treated as "not switched over yet", so the deploy passed. Worse, nothing remembered that the new site was live, so one ordinary deploy without the extra flag would have reverted every accessible address back to the old site at a normal HTTP 200 with nothing failing. Both fixed: the switchover is now recorded, after which silence is a hard failure and a deploy that would drop the new site must say so explicitly. 🔴 A mistyped deploy flag was silently ignored —
--with-web-ukinstead of--with-webukdeployed without the new site and reported success, which is the same silent-no-op fault the flag was created to replace. It now stops before anything is pushed. Also fixed: a refusal that ran too late (after the push and a several-minute wait), a failure message that could not stop a deploy because it ran in a subshell, the health check sharing a request budget it could exhaust on its own, a drift alarm that would have cried wolf every ten minutes, and a domain-inventory command that reported "nothing to do" when it had actually failed to read anything.Rehearsed the new accessible site locally before going anywhere near the server, and it corrected two things I had written down as fact. The production image was run against the local platform. It works: it serves a real community page at 25 KB, and it reports its own identity in exactly the form the deployment check looks for. 🔴 First correction — what happens when its session store is unavailable. I had documented that the site reports "not ready" until its session store connects. It does not. It never starts answering at all: the process runs, logs the connection failure on a loop, and nothing is listening. The safety outcome is the same or better — a deployment in that state can never be declared healthy, so it aborts before any traffic moves — but anyone debugging would have gone looking for a "not ready" response that never appears. Corrected where it was written. 🔴 Second correction — how a community's own web address reaches the platform, which I had recorded as an unresolved risk. I had written that it depended on a particular header surviving an internal call, and that this was unproven. Wrong question. Putting a listening post between the two halves showed the community's address is forwarded as its origin, not as its host — and the platform resolves the community from exactly that, by design, with a deliberate guard against one community's request resolving as another's. So the mechanism was already correct and is now observed rather than assumed. What genuinely remains is a live request on a real community address, which cannot be tested here: no local community has an address set, and I would not invent one in a shared database another session is using. That gets proven during the switchover soak. Readiness 637 to 640 of 1000.
The one Safari test that needed retrying was not a Safari problem, and the reason it kept failing is that a "wait up to three seconds" was not waiting at all. The listings page test checks that either a heading or some cards are on screen. It asked whether they were visible "with a three second timeout" — but that particular check is an instant snapshot; the timeout is ignored entirely. So instead of allowing three seconds for the page to draw, it looked once, the moment the page frame appeared, and raced the app's first render. It was flaky in both browsers. Chrome failed once and passed on its first retry; Safari failed twice and passed on its second — which is Safari simply being slower to paint, not a Safari fault. Worth stating plainly because "a Safari test failed" invites the wrong conclusion. It now uses a check that genuinely waits for either the heading or the cards. A quietly-swallowed error was removed too: the old form hid the specific complaint you get when a page has two headings, reporting "no heading found" instead of the real problem. 🔴 The same mistake is widespread and worth its own piece of work: 226 checks across the browser test suite are built on that same non-waiting snapshot while looking as though they wait. Worse, 83 assertions can never fail at all — they end in "or true", so they pass whatever the page does. The project's own test helper already warns against exactly that pattern, so it is known; the instances remain. None of this is broken behaviour in the product; it is a large amount of testing that reports success without checking anything.
Safari testing was switched on but never actually ran — the browser was never downloaded, and the step that was supposed to download it reported success. Safari's engine had never been tested in this project, so a Mac-only fault could not be caught. It was enabled earlier today, and every Safari test then failed with a message about a missing program file, which reads like a broken test rather than a broken setup. The real cause: the build server keeps a copy of the downloaded browsers to save time, and it decided whether that copy was still valid using only the testing tool's version number — not which browsers were in it. Adding Safari didn't change that version, so the server reused a saved copy from when only Chrome was installed, concluded it was up to date, and skipped the download. The install instruction did list Safari. It simply never ran. Fixed so it cannot happen again: the saved copy is now identified by which browsers it contains, so adding or removing one automatically invalidates it. The browser list is now written down once and used both for the download and for naming the saved copy, so the two cannot drift apart. And there is a new check that confirms each browser is genuinely on disk before any test runs, so the next mistake of this kind announces itself in plain words instead of surfacing as a confusing test failure. 🔴 While testing that new check I found it had the same disease as the thing it was checking: given an empty list it reported success having verified nothing. Fixed, and both outcomes proven — an empty list now fails, a real list passes.
A broker test had a one-in-ten chance of failing, and the cause was that it never gave the member enough credits to complete the exchange. It surfaced as an unexplained failure on the build server with a single word of evidence — "Unable to complete this exchange" — and it passed every time locally, so it looked like the build server being unreliable. It was not. The member accounts these tests invent are given a random starting balance between zero and fifty credits. The test resolves a dispute at five credits, and completing an exchange refuses when the payer cannot cover it — so on a uniform draw from zero to fifty, it fails one time in ten. Measured, not guessed: it failed on the 6th attempt, then the 11th, then after adding an explicit balance it passed twenty times out of twenty. Every other exchange test in that file already set a balance for exactly this reason; this one was the outlier. Two things were fixed along the way so the next one is not a mystery. The failure was undiagnosable because two completely different problems produced a byte-identical response — a genuine crash and a refusal with an unrecognised reason both came out as the same opaque message, and the only description of the real cause went to a log the build server discards. The crash path now records what actually threw, and the refusal path records what it refused with, so the two can be told apart. On the build server the crash detail is also included in the response, which is where it becomes visible; that is strictly limited to the test environment, because an internal error message in a real response is something an attacker reads. 🔴 One honest caveat: the underlying trap is still there. Any test that moves credits using an invented member without setting a balance is the same coin flip. I have not counted how many others do that, and I am not going to guess — but the durable fix is to stop the invented member having a random balance at all, which is a broader change than this one.
My own push turned main red in two ways, both caught by checks that only run on the build server. Neither was visible locally, because I had run the tests for the things I wrote rather than the checks that guard the whole project. First: a new page heading was flagged as corrupted text in German. The heading is “What has changed”, and the German translation phrased it as a question — “Was hat sich geändert?”. A guard exists to catch mangled characters, and one of its signals is a translated line gaining a question mark the English never had, which is a genuine sign of broken encoding. Here it was a real translation choice, not corruption. Rather than weaken the guard I checked the other nine languages: every one of them renders that heading as a statement, matching the English. German was the odd one out, so it now reads “Was sich geändert hat” — which is both the natural German heading and consistent with everyone else. The generated copy the accessible site actually reads was re-synced too, since it had gone stale the moment the source changed. Second: the new event check-in address was missing from the platform’s published interface description. A check insists every maintained event address appears there, and mine did not, so anyone reading the interface list would not have known the endpoint exists. Now added, with its real responses documented — including the deliberate detail that an unreadable code and a code for someone you are not allowed to see return the same answer, so the endpoint cannot be used to find out who is registered. One thing worth recording about how I did it: my first attempt rewrote the whole interface file and produced a 28,000-line change for a 50-line addition, which nobody could review and which could have hidden anything. I threw that away and inserted the entry as text instead, leaving every other byte untouched.
🔴 The platform only found out something had broken if the member had accepted optional cookies. Crash reporting was switched on by analytics consent, so one click on "Essential only" — or simply ignoring the banner — meant every fault that member hit was invisible to us for ever. This is measured, not theoretical: 2 error reports in 14 days across 369 members, and a coordinator who ran into real problems over three days generated no client-side record at all. We were blind to precisely the people careful enough to decline optional cookies, who are also the people most likely to be evaluating the platform. Crash reports are now always sent. What they contain is deliberately narrow: the fault, where it happened in the code, the browser, the operating system and the software version — enough to find a Safari-only fault, which is what prompted this. Everything beyond "it broke" still requires consent, and now genuinely rather than incidentally: the member's own identifier, performance timings, and session replay, which records the screen. Without consent a crash is reported but attached to nobody. The existing safeguard that strips passwords, tokens and email addresses is untouched. The cookie banner said the wrong thing and has been corrected in all eleven languages — it listed "Error tracking" under optional cookies, which is now false, and on a consent banner a false description is a compliance problem rather than a cosmetic one. It now says anonymous fault reports are always recorded, and lists what analytics consent actually adds. Six tests pin the split in both directions. 🔴 Two tests already existed for this and were worthless: named "is disabled when no consent stored" and "is disabled when analytics consent is false", they asserted only that the function did not throw — so they passed identically before and after the behaviour changed, pinning nothing. They have been replaced with tests that actually check.
Real Safari on real macOS can now be tested, and CI says so plainly when it isn't. The Safari engine check added earlier in this release covers the engine, not the browser — so Safari's own native menus, content blockers, extensions and Lockdown Mode were still untested, and those need genuine macOS, which our build machines are not. There is now a second layer that connects to a remote machine running real Safari. It is deliberately not tied to one supplier: one setting points it at any compatible service, or a self-hosted one, so we are not locked in. It runs the fast, high-value checks only, because that kind of service is billed by the minute and is the wrong place to run 831 tests. 🔴 The part that matters most is what happens when it is not set up. That check runs on every push regardless and states in the build output that real Safari was not covered — because a check that quietly skips while the build goes green is exactly how Safari went untested for the entire life of this project. It never implies coverage it did not provide. Turning it on needs an account and two credentials, which is a decision rather than a code change; the whole procedure, both layers, and what each can and cannot prove are written up in docs/REAL-SAFARI-TESTING.md. That page also lists five classic Safari faults that were tested and ruled out — old-style date parsing, a missing blur prefix, an unsupported animation feature, full-height layouts under the address bar, and cross-domain permissions — so nobody spends time re-checking them.
I audited my own work from this session and found five faults in it — four of them the exact kind I had spent the session finding in other people's code. Every test passed and every check was green. Green said nothing about any of these. The worst was in my own instructions. The command I had written down for deploying the new accessible site would have deployed without it, succeeded, and reported success — because the deploy runs over a remote connection and under elevated privileges, and neither carries settings across. Nobody would have noticed until a web address pointed at nothing. It now travels as a proper option rather than a setting, so it cannot be silently lost, and the deploy log now states plainly whether the new site was included. Setting it the old way is now refused with an explanation instead of ignored. Second: the two addresses that report whether the site is alive and which version it is were sitting behind two checks that need the platform to be reachable. Those are the addresses you read precisely when something is wrong. One decides whether a switchover actually happened; the other tells the system the site is healthy — so a problem elsewhere could have got the site restarted over a fault that had nothing to do with it. Both now answer before any of that. Third: the tool listing which community web addresses need switching over was broken, and announced it by printing nothing — indistinguishable from a clean result. It was hiding its own error message. With the error visible it immediately exposed two further real faults: it looked for a container that is not running on this server, and the one that is running has no database tool in it. All fixed, and the list now works for the first time: three web addresses to switch over, all currently on the old site. That is a much smaller job than feared, and we had no way of knowing before. Fourth: a security check written loosely enough to pass even if the protection were removed. Tightening it turned up something genuinely reassuring — the platform refuses unauthorised access in two independent places, and the second re-checks who you are from scratch, which also defeats a stale-permissions attack. I proved it by switching the first one off and watching the refusal still happen. Fifth: a comment in my own new checker claimed a protection it did not have — it said an unregistered score would be caught, when it only ever read a fixed list. It now scans the whole project, proven by planting one and watching it fail. Verified: full site suite 2,083 passing, 13 platform tests, every documentation and deployment check green, and each fix demonstrated by deliberately breaking it first.
🔴 Safari's browser engine had never once been tested here, and the pipeline was green the whole time. A Mac user reported dropdown menus behaving inconsistently, and there was nothing that could have caught it — for three separate reasons, each enough on its own. There was no desktop-Safari check at all. The one mobile-Safari check sits in a job that only runs when certain optional settings are present; they are not, so that job is skipped and the run still reports success. And every other check installs only Google's browser engine, so even the Safari check had no Safari to run in. Safari's engine is now installed, and a desktop-Safari check runs on every push, deliberately placed in a job that cannot skip, alongside the existing Google-engine one so the two are compared every time. A new menu test opens each header menu three times over, because the complaint was "inconsistent" rather than "broken" and a single successful click would have proved nothing. On the reported fault itself, the honest answer is that it did not reproduce. Safari's engine, at the same version generation the reporter's own device announced, behaved identically to Google's on every menu, both locally and against the live site, with no errors of any kind; one common Safari gap was checked and ruled out rather than assumed. This does not clear Safari, and it would be wrong to say it does — the engine is not the whole browser, so Safari's own native menus, content blockers, extensions and Lockdown Mode remain untested, and a real Mac is needed for those. Writing the test produced three false results before it produced a true one, and each is now recorded in the test itself: the standard desktop-Safari window is narrower than the point where the site switches to its mobile menu, so every test silently skipped while the run went green; the header menus turned out to be panels of links rather than classic menus, so an early version declared two perfectly good menus broken in both engines; and the menu button stays briefly "pressed" after closing and swallows an immediate second click, which made a rapid reopen look like a failure — in Google's engine two runs in three, and never in Safari's. The finished test was run eighteen times across both engines with retries switched off, and passed every time.
One documentation check could not run on this machine, and when I got it running it turned out to check less than its name suggests. The build that produces the public documentation site needs Python, which is not installed here, so it was the one check with no local equivalent — meaning a documentation change could only be judged after a push, and a push republishes the public site. It now runs in a throwaway container pinned to the exact versions the build server uses, so nothing was installed on the machine. The Phase 7 documentation work passes it. 🔴 But I had told the owner it checks broken links, and it does not. The project configuration deliberately switches link checking off in that tool, so the strict mode has nothing to complain about — proven by adding a link to a page that does not exist and watching it pass. Broken links are caught by a different check that was already passing, which is why nothing slipped through. What the site build uniquely catches is a navigation entry pointing at a missing page — confirmed by breaking one on purpose and watching it fail. Both checks are now described accurately where they run, so nobody over-trusts either, and there is a one-command way to run the site build locally.
🔴 A member of a community that has no web address of its own could not sign in, and asking for a new password sent them nothing while telling them to check their email. Reported by a coordinator at a new timebank, and confirmed on the live server. A community can either have its own address or sit at a path underneath its parent's — for example
uk.timebank.global/minehead-and-coast-timebank. Start from the parent address on its own, with no community name in it, and the platform decides you are at the parent community. Her account only exists in hers. So the sign-in form looked for her in the wrong community, did not find her, and reported wrong details. The password reset was worse: it looked in the same wrong place, found nobody, sent no email, and still answered "we have sent you a link". She waited for an email that had never been created. Three separate pieces of evidence on the live server agreed: her account exists in one community only, her failed sign-ins are recorded against the parent, and the platform writes a record of every reset it actually issues — there was none for her. This is not a mail problem. Her other emails were all delivered normally, and she is not on any blocked-sender list. Both entry points now also look at the communities beneath the one you arrived at, so a community works whether or not it has its own address. The search is strictly limited to that parent's own family — it can never reach sideways into an unrelated community, and there are tests for both directions. The reply to the member is deliberately unchanged, still the same wording whether or not an account was found: saying "no such account" would let anyone use the form to discover which email addresses are registered. What changed instead is that the failure is now recorded. It always had a log line, written at a level the platform discards everywhere by default — which is precisely why this could happen to a real person for days and leave no trace. That single detail is the reason it took evidence rather than reading the code to find. Where the same address genuinely exists in two sister communities the platform now says so in the log and sends nothing, rather than guessing. Verified: 5 new tests covering both entry points in both directions, the 45 existing sign-in and password tests still pass, and static analysis is clean.A critical email alarm had been crying wolf every hour for days, over three demo addresses. The platform's email health check was counting suppressed messages as delivery failures. A suppressed message is the opposite of a failure — it means the block list did its job and the platform deliberately did not send. Three test addresses on recurring digests, on two demo communities, were enough to raise a critical alert every hour, for ever. Worse, the alert reported "100% failure": the percentage was worked out against all mail including the suppressed ones, so on a quiet day three skipped messages out of three read as total collapse. An alarm that is always on is an alarm nobody reads, which is how a real email problem would have been missed. Genuine failures and bounces are now counted and rated on their own, against messages actually attempted. Suppressions are reported separately and, in small numbers, quietly — they still appear in the admin health view but no longer raise an alert. A domain-wide block still does, because that shows up as both a large number and a large share of all mail, and that distinction is what keeps this from becoming a way to hide a real fault. Found while investigating an unrelated report. 4 new tests, the 25 existing email-monitor tests still pass, and the wording is corrected in all eleven languages.
A visitor's cookie choice on the accessible site was recorded nowhere but their own browser. The existing site keeps a record of every cookie decision — who, when, what they chose — which is the point of asking. The new accessible site only set the cookie in the browser, so for anyone not signed in there was no record at all that they had been asked or what they answered. The platform could always store this for anonymous visitors; the only thing stopping it was that the endpoint sat behind a login. Rather than move that endpoint and change behaviour for everything already using it, there's now a second, public one alongside it, rate-limited because anyone can reach it. Verified against the running platform: an anonymous request writes a proper record with no user attached, while the original login-protected endpoint still refuses as before. Two smaller things fixed on the same page: its title was a hardcoded English word on an otherwise fully translated page (the translation already existed, unused), and it wasn't being given a piece of information the rest of the site had already worked out. The important design point: recording never costs the member their choice. The cookie is set and the page moves on regardless — a slow or failing platform can't turn a consent click into an error. That's guarded twice, and the second guard exists because three existing tests caught it: the first version only handled a failure that arrives late, not one that happens immediately, and in that case the whole page broke — meaning someone clicking "reject analytics" would have got an error page and lost their choice. Exactly the opposite of the intent. Verified: 16 new tests, the full site suite at 2,061 passing, accessibility gate 24 of 24, and the platform's own cookie tests still passing.
🔴 The bot protection on the contact form has been switched off in production the whole time, by a code fault rather than a setting. The check reads its secret key from the environment at the moment of each request. Every deployment runs an optimisation step that caches settings and, from that point, stops reading the environment file at all — so the check found nothing, no matter what was correctly configured on the server. And when it finds nothing, it treats that as "not configured" and lets the submission through. So the contact form has had no bot protection in production, silently. Nothing surfaced it: the request succeeded, and the only trace was a diagnostic line at a level the platform discards by default. It also meant the widget added earlier in this release would have appeared on the form and verified nothing. The check's own tests passed throughout — because they set the value through the environment and then read it back the same way, exercising the broken path rather than the real one. Fixed by reading from a proper settings file. Proven on the container rather than assumed: with a key configured, the value now survives the optimisation step, while the old environment read comes back empty — which is exactly the before-and-after that shows what was wrong. A test now fails if the service ever reads the environment directly again. One thing worth recording: the first version of that test searched the file for the offending pattern and matched the comment explaining not to do it — the third time in this release a text-based guard has caught its own documentation, so it now strips comments before checking.
"Report mode" for the new terms check would have recorded nothing at all, while looking like it was working. Report mode exists for one purpose: to run in production without blocking anybody and produce a record of who would have been blocked and which app they were using. It was writing that record at the lowest importance level — and the platform discards anything below "warning" everywhere by default, production included. So the record was being thrown away. It still set a marker on each response, which is exactly why this would have been easy to miss: the mode would have appeared healthy while producing no evidence, and the decision about whether it is safe to start enforcing rests entirely on that evidence. Found by actually switching the mode on and looking for the log line rather than trusting the tests. The record now goes out at warning level, which is also the honest level for "this request would have been refused". A test pins it, and I confirmed the test genuinely fails if the level is put back — otherwise it would be a test that proves nothing. The setting is now also documented in the example environment file with the warning not to skip report mode.
Every "we have updated our terms" notification sent members to a dead end, and which dead end depended on whether they clicked the bell or the email. The same notification was building two different links for the same document: the bell used the web app's address for it, the email used the accessible site's. Each one was broken on the other site. Both now use one address, and it is the one that works everywhere: the web app gained a matching route, and the accessible site already redirects the older form. A third case was broken on both sites — when a document has not been given a short name of its own, the platform falls back to its internal name, which is written with underscores. No page on either site matches that, so those links failed everywhere they were sent; underscores are now converted. The address is also now built in one place with a proper guard, so a stray character in a document's short name can no longer produce something odd. A second fault was fixed in the web app's own terms check: the pages showing what changed between two versions were being blocked by the very gate asking the member to accept the changes — because the check only looked at the last part of the address, and "versions" was not on its allowed list. A member facing the acceptance prompt could not read what had changed before agreeing to it, which is the difference between informed consent and just a button. Verified: 6 new tests on the address handling, 6 more on the redirect, the whole frontend typechecks, the separate test-type gate reports no new problems, and all 40 legal tests pass in the container.
A form submitted from a slightly-out-of-date page on the accessible site would have silently thrown away everything the member typed. Two web addresses on that site permanently redirect to their proper home — the community-prefixed address on a community's own domain, and the older
/alphaaddress renamed in July. Both were sending the kind of redirect that browsers deliberately convert into a plain page request. That is correct for someone following a bookmark or a search result. It is wrong for a form submission: the browser drops the submitted data and asks for the target page instead, so the member lands on a page that looks as though nothing happened, with no error and no explanation. It matters in exactly the situation these redirects exist for — somebody who had the page open before a deployment, or whose offline-capable app cached the old address. The main platform already handles this correctly on the very same redirects, using the method-preserving variant for anything other than a page view. The accessible site now matches it. Two tests cover both addresses in both directions.A policy that does not apply yet was being shown as though it had just started applying. Legal documents have a date they come into effect, and that date is very often set in the future — a community publishes updated terms today to take effect next month. The new accessible site was putting that date through a "how long ago was this" formatter. A future date gives a negative answer, which fell into the "just now" case. So terms that do not apply for another month read as though they had come into force moments earlier. That is not an unhelpful date, it is a false statement about when a legal document started binding people. Legal dates now show as a proper date, in the reader's own language, with the exact machine-readable date alongside so nothing is lost. A document not yet in effect now says so in a warning above its text, and its date is labelled "Comes into effect" rather than "Last updated". Three other faults on the same page were fixed while there. Pictures were being stripped out of legal documents on this site only — a diagram explaining how an exchange works is an accessibility measure, and the main platform's own rules allow images; the site was throwing them away. The community's summary of what changed was being discarded even though the platform returns it; it is the single most useful thing on the page when a document has just been updated, and it now appears under "What has changed". And long documents now have a contents list, built on the server from the document's own headings so it works with JavaScript switched off. Verified: 40 new tests plus the rewritten shared test, full site suite 2,009 passing, code style clean, branding guard passed, accessibility gate 24 of 24.
Two web addresses on the accessible site answered "page not found" when people were being sent to them by email and by the notification bell.
/termsand/privacyboth returned an error. Meanwhile the platform's own "we have updated our terms" notification has long been sending members a link to exactly/terms. Every one of those already-sent links dead-ended on this site. Both now redirect permanently to the real pages. Two files were removed in the process: unused page templates for terms and privacy that were never reachable, contained hardcoded English legal text of their own — a second, invented set of terms sitting in the codebase — and were missing the licence header every file is supposed to carry.A comment warning about a trap in the site-comparison tool triggered that very trap. The tool that measures how closely the two accessible sites match finds pages by searching the code for the text of a page registration. A comment was added explaining this and cautioning against writing one out — and because the comment contained an example, the tool counted the example as a real page, inventing a page called
/.... That is not cosmetic: the count of pages the new site has beyond the old one feeds the score used to judge readiness. The tool now ignores anything on a commented-out line, with a test covering commented-out registrations in both styles, so prose can describe the hazard without becoming an example of it.The contact form on the new accessible site would have failed for everyone the moment the bot check was switched on in production. The main platform's contact endpoint is the one place that insists on a Cloudflare bot challenge, and the new site rendered no challenge at all — so every message sent would have been rejected. It looked fine only because the check quietly lets everything through while its secret is unset, which it is in development. The challenge is now on the contact form, and only there. It was being handed to five other forms — sign-in, two-factor, forgot password and reset password — which deliberately dropped it back in May in favour of quieter defences, so it sat there unused on all of them; that is now removed, along with a comment that claimed the opposite of what the code did. The bot check's own key is no longer expected in this site's settings either, because it is the main platform that verifies, not this site. A bot check must never be the only way to reach a community, so the page now always offers another route: the community's own email address whenever it has published one, and for signed-in members the report-a-problem form, which has no challenge. Those appear on the success page and, more importantly, on the failed challenge page — where a visitor previously hit a dead end. A visitor with JavaScript switched off is now told plainly that the check needs it and pointed at the email route, because the challenge has no non-JavaScript mode at all; the existing site leaves those visitors with no way to make contact, which contradicts its own HTML-first rule. All 28 new pieces of wording exist in all eleven languages. Verified: 15 new tests for this alone, the full site suite at 1,962 tests passing, code style clean, branding guard passed, accessibility gate 24 of 24, and both translation gates green with no new English introduced.
The tool that inventories which platform endpoints the new site calls was silently blind to three of them, and had lost its own quality guarantees. It reports, for every endpoint the site uses, whether a test actually proves the request it makes. That report had been at zero unproven for a long time; the 23 endpoints added over the last few days had page-level tests but none that checked the requests themselves, so it had quietly slipped to 21 unproven, 9 of them able to change data. A page test can pass while the real code calls the wrong address, so this is a genuine gap and not bookkeeping. It is back to zero, with 23 requests now pinned exactly — including two that matter on their own terms: the safeguarding reason must travel privately and never appear in a web address, and the public event pages must never send a member's credentials, because doing so would silently switch them onto the member data set, which publishes more fields than the public one. Separately, one helper chose its address by looking it up in a list, which reads perfectly well but is invisible to the inventory tool — it collapsed three real safeguarding endpoints into a single meaningless entry with no matching platform route. It is now three plain functions with a small dispatcher on top, so the page still submits one form and the inventory tells the truth. Every count the tool reports is back to clean. Found by actually uploading one, which nobody had done before — no community in the development database had a logo, so this code path had never once run with real data. Inside Docker the accessible site talks to the main platform on an internal-only hostname, and it was reusing that same address when writing the
<img>tag the browser loads, plus the security rule that decides which image sources are permitted. The result: every community logo would have been a broken image, and the security rule would have been permitting a host the browser can't resolve while blocking the one it can. There are genuinely two different addresses needed here — one for the server talking to the platform, one for the browser fetching pictures — so there is now a separate setting for the browser-facing one. It falls back to the existing address when they're the same, so production and the non-Docker development setup need no configuration and behave exactly as before. The safety check that stops a community pointing its header at some third-party website is unchanged and still tested. Verified by resolving the generated image address from outside the container: it returns the actual image.
Changed
- The "Not affiliated with GOV.UK" line has been removed from the accessible site's header, and the header now matches the existing site exactly. The line was described in the project's own notes as mandatory, so it was checked properly before being touched. Two findings settled it. First, the existing accessible site never had it — not anywhere, in any language — so keeping it meant the two accessible sites disagreed about their own header, and the existing one is the agreed reference. Second, the licence does not ask for it: the GOV.UK Frontend package declares itself MIT, and its own documentation says the code and sample code are MIT while the written documentation is under a separate government licence. MIT requires the licence notice to be kept; neither licence requires a visible statement denying any connection. The real protection was never that sentence — it is not using the crown, the crest, the official logotype, the official header or footer, the government font, or any government copyright wording, and the automated branding guard still refuses every one of those. It is now stronger in one respect: a test insists the removed line stays gone, so it cannot quietly reappear as an unexplained string. The header is now identical to the existing site's: same elements in the same order, confirmed by comparing the two running sites rather than by reading the code. Tenant logos work as they should — an uploaded image, the dark-background version preferred because the header is always dark, sized by shape rather than a hardcoded height, and falling back to the community's name when no logo has been uploaded. Two things surfaced while doing this and are worth knowing: the branding guard caught a phrase in my own explanatory comment, which is the guard working correctly; and the development container only shares the source and public folders, not the scripts folder, so changing a build-check script leaves the container running the old copy and crash-looping until the image is rebuilt. Verified: 5 new header tests, full suite 1,911 passing, code style clean, branding guard passed, accessibility gate 24 of 24, and the live header compared side by side against the existing site.
Added
The admin panel now shows whether the terms check is switched on, without anyone needing server access. It appears on the existing Legal Compliance page. It names the current state in words — not switched on, recording only, or enforced — explains in a sentence what that state actually does to members, lists which documents are covered, and says plainly that this cannot be changed from that screen. It is deliberately read-only, with no button, switch or dropdown of any kind, and a test fails if one is ever added. The reason: this setting can stop members using the platform, so it should stay a considered change to a file on the server rather than something a mis-click can do. An unrecognised value is described as "not switched on", matching what the platform actually does with a value it doesn't understand — showing "enforced" there would tell an administrator the opposite of the truth. If the platform is running older code that doesn't report the state, the panel shows nothing rather than guessing. Verified on the running admin page as a real signed-in administrator: it displays "Reporting only", explains what that means, lists the covered documents, and contains zero controls. All 13 new pieces of wording exist in all eleven languages, and the translation gate improved by 10 (it also picked up ten pre-existing gaps in the same file). Plus 9 new tests on the display and 2 on the platform response.
The phone app now has its acceptance screen, which was the last thing standing between the new terms check and being usable. The app previously had no legal handling at all — across 562 places where it talks to the platform, none knew what to do if the answer was "you need to accept the updated terms". So switching the check on today would have left phone-app users unable to do anything, seeing only a generic error with no explanation. That is why the check ships switched off. Two screens: one listing what needs accepting, and one for reading a document in full. The reason the app could not react at all was a real gap in how it reads errors — the platform sends a short machine-readable code with every refusal ("you need to accept the terms", "you need to finish setting up", and others) and the app was keeping only the human sentence, so it could tell that something was refused but never what. The code is now kept, which fixes this and every future case of the same kind. Reacting to it happens in one place rather than at all 562, because a refusal only some screens knew about would show as a generic error on all the others — exactly the confusion this is meant to remove. The screen deliberately cannot be swiped away, since dismissing it would land the member back on the action that will refuse them again and read as the app being broken; the way out is a sign-out link on the screen itself, because "I do not accept" has to have an answer. Each document is named, marked new or changed, and readable in full in one tap — agreeing to a list of titles is not consent. A failed acceptance is never reported as success. And a failed check is never treated as "nothing to accept", because guessing that would send the member straight back into a refusal they cannot see the cause of; they get a retry instead. The document reader keeps a policy's real structure — headings, paragraphs and bullet points as separate items, with proper punctuation and quote marks — rather than using the app's existing text-flattening helper, which merges everything into one block and leaves
&on screen. That is fine for a blog excerpt and not for something a member is being asked to agree to. All wording exists in all seven languages the app supports, reusing the translations already written for the accessible site rather than commissioning a second set — the same sentence must not be translated twice into the same language by two different tools. Verified: 34 new tests, the entire app suite at 1,484 passing across 217 files, typecheck clean, no new code-style errors.The accessible site now has its acceptance page, so a member who owes an agreement sees a page explaining it rather than a refusal. The server-side check went in earlier; this is what a member actually sees. It names each document, says whether it is new or changed (calling something "updated" when they have never seen it is simply wrong), shows its version, and links to the full text — a list of titles with a button underneath is not consent. It works with JavaScript switched off: one plain form, one button. Most of the work went into making sure it cannot trap anybody, because a page that stands between a member and the whole platform is dangerous if it goes wrong. Four separate things prevent that. It only ever interrupts a page view — never a form submission, which would silently throw away what was typed. It leaves the entire legal section reachable, including the pages showing what changed, so a member can read before agreeing; that is done by matching the whole section rather than the last part of the address, which is the mistake that had blocked those pages in the web app. Signing out always works — "I do not accept" has to have an answer. And its answer is remembered for only a minute and thrown away the moment someone accepts, so accepting cannot send them straight back. It also fails open: if the platform cannot be reached the member carries on, because the real refusal happens on the server anyway and an unreachable status check must not become an unavoidable wall. A failed acceptance is never reported as success — the member is returned with an explanation, and stays blocked, because telling somebody their agreement was recorded when it may not have been is the one thing this page must never do. Verified in a real browser end to end against the running platform: signing in showed the page, the document link worked from it with its contents list intact, accepting went straight through to the dashboard with no loop, the agreement was recorded in the database against the exact version shown, and returning to the dashboard afterwards was unimpeded. Plus 33 new automated tests, the full site suite at 2,041 passing, code style clean, branding passed, accessibility gate 24 of 24.
The platform can now actually require members to accept updated terms — until today nothing on the server checked. The only thing enforcing acceptance was the React website, in the browser. Any other way in — the accessible site, the phone app, or anything holding a valid login — simply ignored a pending acceptance and carried on. Since the accessible site is due to take over from the old one, that gap was about to become permanent. The check now lives in the platform itself, where every client has to go through it. It is switched off. That is deliberate and important: switching it on can stop members using the platform, so it is not something that should happen as a side effect of a release. There are four settings — off, report, and two levels of enforcement. Report mode never blocks anybody; it records who would have been blocked and, crucially, which app they were using, so the effect can be measured in real use before anything is enforced. The phone app has no acceptance screen yet, so enforcing today would lock out any of its users with a pending document — report mode is how that gets discovered on purpose rather than by complaint. Several things were built with care. The check lets a blocked member go on doing everything they need in order to unblock themselves: read the documents, accept them, sign out, and load the ordinary page furniture — that last one matters more than it sounds, because the accessible site asks the platform for the member's name and unread counts on every page, including the acceptance page itself, so blocking those would leave the member permanently stuck on a page that cannot load. It lets community admins through so they can always fix their own documents and help someone who is stuck. It fails open: if the platform cannot reach the cache it needs, the request goes through rather than being refused, because a compliance check that takes the whole product down when a dependency hiccups is worse than one extra action slipping past. And it honours a setting communities have been given for years and which nothing ever read — a document can be marked as not requiring acceptance, and until now that choice was silently ignored. Two failure modes got their own tests because they are the ones that would hurt: accepting and immediately retrying the blocked action must work first time (otherwise it is an endless accept-then-blocked loop with no way out), and publishing a new version must immediately start applying to everyone who has already accepted the old one. Verified in the container: 20 new tests, all 72 existing and new legal tests passing, and static analysis clean.
Members can now read the earlier versions of a legal document, and see exactly what changed between two of them. Neither accessible site had any way to do this, even though the platform has kept full published version history all along and already offers it through its own interface. There are three new pages: a list of every published version newest first with the current one clearly marked, a page for reading one earlier version, and a comparison showing what was added and removed. Care was taken over the things that go wrong with pages like these. An old version says it is an old version before its text, not after — somebody arriving from a search result or an old email would otherwise read terms that no longer apply with nothing to tell them. A version belonging to a different document is refused rather than shown under the wrong heading, since version numbers are shared platform-wide. And the comparison does not rely on colour to say what changed: added and removed text is marked up so screen readers announce it, carries a plus or minus sign visually, and the page has a written key. That last point needed a specific fix — the site's standard cleaning step for published content removes exactly the two pieces of markup that carry that meaning, which would have left colour as the only signal and looked perfectly fine in a browser. The comparison pages use their own narrower cleaning step instead. When a comparison can't be produced — the platform limits how often it will build one — the page says so plainly rather than showing an error. Communities can also now publish their own accessibility statement, which the platform has always supported and both sites were silently ignoring; the standard statement remains what everyone sees until a community publishes one.
The new accessible site now matches the existing one page for page, bar one — and the last pages built are the most sensitive ones. These are the pages where a supporter can read a supported person's messages, and every safeguard in the original is now in the new site too. Nothing is fetched until the supporter says why. The form asking for a reason is the page, not a box on top of already-loaded messages — a test proves no messages are requested before it is answered. The reason is held in the visitor's session for thirty minutes and sent onward privately; a test proves it never appears in the web address, not even the free-text part. A reason that has expired counts as no reason, and the stale entry is cleared rather than left lying around. One person's reason does not unlock a different person. The free-text note is capped. An unrecognised reason falls back to a known one rather than being stored as-is. And read-only means read-only: no reply box, no actions, and a voice message is named but never playable — the recording itself is not put on the page at all, which a test pins. Page agreement moves from 703 to 706 of 707. The one remaining page is blocked on the main platform, which offers no safe way to do it, so the new site correctly refuses to invent one. Verified: 17 new tests, full suite 1,906 passing, code style clean, branding passed, accessibility gate 24 of 24. One follow-up is written down: now that both sites send the reason privately, the old web-address method can be switched off — and removing it is what makes that exposure impossible rather than merely unused.
Fixed
- The main app no longer writes safeguarding reasons into the server logs. This is the change that actually closes the exposure the previous entry only made closeable. When a supporter reads a supported person's messages they must state why, and that text can name a person and describe a concern — and the React app was sending it inside the web address, so it was written to access logs, browser history, the header passed on to other sites, and any shared screenshot, every single time. It now travels in the private header instead, and a test pins it: the address must contain no reason at all. One non-obvious hazard was found and handled rather than discovered later. The app avoids repeating identical requests by remembering them by web address only — so the moment the reason moved out of the address, two reads with different reasons would have looked identical, been merged into one request, and one of them would have gone unrecorded. That is exactly the record this feature exists to keep. Those reads now take the client's documented "never share this request" path, which also means a response arriving after the supporter has navigated away is discarded instead of being written to a closed page. Verified: the page's 5 tests pass, the whole frontend typechecks, code style clean, and the separate test-type gate reports no new problems.
Added
The reason a supporter gives for reading someone's messages can now be sent privately, instead of only inside a web address. Before a supporter can read a supported person's messages, they must state why, and that reason is written to a permanent record. It is free text, and it can name a person and describe a safeguarding concern. Until now the only way to send it over the network was inside the web address — which means it was written into server logs, browser history, the "where did you come from" header passed to other sites, and any shared screenshot. The existing accessible site sidesteps this by keeping the reason in the visitor's session and handing it straight to internal code, never over the network at all; the project's own notes spell out why. But no other client could do the same, because there was no other way to send it. There is now: a dedicated request header. The old web-address method still works so nothing breaks, and it stays as a fallback rather than the preferred route — retiring it is the point at which the exposure actually goes away. A request body was deliberately not used, because for a read request some proxies and caches discard it, which would silently lose the reason. A blank or spaces-only header is treated as no reason at all and still refuses to return anything. The header was added to both permission lists that govern which headers browsers may send, since missing either breaks only in production. Verified in the container: 15 tests on the message viewer including 4 new ones, 22 tests on the header permission lists, and static analysis clean. 🔴 This change does not by itself stop the exposure — it makes stopping it possible. The main React app still sends the reason in the web address today, so anyone using it is still writing that text into the logs. Switching it over is a small, separate change and is worth doing.
The new accessible site now has the approval queue, the activity summary, and both ends of the message-access consent loop. Five more safeguarding pages, and the point of each is that nothing happens to somebody without their say-so. The approval queue: when a helper prepares a listing or a transfer for someone they support, nothing happens at all until that person answers — approve, decline, or the helper withdrawing their own request. Declining never requires a reason. An item that was already answered or has expired now says so, rather than reporting a general failure. The activity summary is read-only in the strictest sense — there is no action on the page whatsoever, and a test proves it submits nothing about the person it describes. Someone else's member number never gets past the name check, and "that isn't yours" and "you don't have permission" give the same answer, so the page can't be used to find out whether a person exists. The message-access loop is the careful one: a supporter can ask to see a supported person's messages, and asking grants nothing — it only raises a request that the supported person alone can agree to. Withdrawing is one press, takes effect immediately, asks no reason, and cannot be undone, because turning it back on must always need fresh agreement. A tooling trap is recorded alongside: the tool that measures how closely the two sites match finds pages by looking for a literal piece of text in the code, so a first attempt that named things slightly differently left three real, working pages invisible to it — the sites looked further apart than they were. Verified: 22 new tests, full suite 1,889 passing, code style clean, branding passed, accessibility gate 24 of 24. Page agreement moves from 698 to 703 of 707, and every safeguarding page that could be built without an unanswered question now exists.
A member using the new accessible site can now see, agree to, refuse or withdraw from a guardian arrangement. When coordinators record that someone is responsible for supporting a member, that member is the subject of the arrangement and the only person who can answer it — and until now the new site had no screen for it at all, on the site most likely to be used by the very people these arrangements are about. Everything works by pressing a button with no JavaScript. The care in the original was carried over rather than approximated. Agreeing to an arrangement grants nothing — it is a record, not a permission — so the controls for what a guardian may actually help with only appear after the member has agreed, because a permission must never be allowed to stand in for the consent. Giving a reason for refusing is offered and never required, since making somebody justify refusing is pressure to agree, and there is a test proving a refusal with no reason is accepted. The page only ever offers answers the system will actually accept, so nobody is presented with a button that then fails. The staff-only exit is refused if someone tries to submit it as a member's answer. And two things are deliberately impossible from this page: granting a "draft only" level that has no screen behind it, and granting message access — which has its own separate consent process, and which a long-dead setting must never be able to switch on. "That arrangement isn't yours" and "you can't do that from here" are reported as different messages, because they mean different things to the person reading them. Two more accessibility faults in the existing site were found and deliberately not copied: its error message cannot be moved to by assistive technology, and it announces itself twice by marking two nested elements as alerts. The repository's own rule caught the first the moment the old markup was brought across. The new site follows the official pattern; the existing site should be corrected to match. Verified: 23 new tests, full suite 1,867 passing, code style clean, branding passed, accessibility gate 24 of 24. Page agreement moves from 695 to 698 of 707. including a member pass that works with JavaScript switched off.** These are the pages where a member shows a code at a local partner venue and staff scan it to record the visit — no money and no discount is involved, only a record that the visit happened. All five are done: the venue directory, the member's own pass, getting a fresh code, the page staff land on when they scan, and the confirmation that records the visit. The pass code is drawn on the server as an image, so it appears even with JavaScript off and without fetching anything from an outside service, which is how the existing site does it and the reason a small code-drawing library was added — a well-established one with no dependencies of its own and no known security problems. Several careful behaviours were carried over rather than reinvented. Landing on the scan page records nothing: preview services routinely fetch links behind the scenes, and a scanned pass must not be used up by that, so the visit is only recorded when staff deliberately confirm it. Getting a new code redirects afterwards, so refreshing the page cannot keep replacing the code. The pass code itself never appears as readable text anywhere on the page — it belongs in the scannable image only, and a test now checks it is absent. And where the existing site gets plain answers back from its own internals, the new site has to translate two of them from error codes, so staff still see "this pass is not valid" or "you cannot record visits" rather than a dead-end error page.
A real problem was caught while doing that, and it affected the What's on pages shipped just before. Two community settings — publishing events to the open web, and partner venues — are switched off by default on purpose, so a community opts in to each. The new site's list of defaults was missing both, and anything missing from that list was being treated as switched on. The practical effect: a community that had never made a choice could have had its events published on the open web without ever agreeing to it. Both are now correctly off unless a community turns them on, with a test to keep it that way. Worth stating plainly: the strongest evidence the fix is right is that an existing test which encodes the other site's rules started passing again on its own, with no changes to it. A note now records that any future setting added on the main platform has to be mirrored in that list.
The new accessible site now has the public "What's on" event pages, and logged-out visitors finally get a link that works. Measuring the two accessible sites against each other found the new one had fallen 18 pages behind the existing one — not because anything broke, but because the existing site kept being built during a pause. The first two of those are now done: the public event listing and the event detail page, both of which anyone can read without an account. Six behaviours were copied deliberately rather than approximated, because each one is a decision someone made for a reason. The pages need two community settings switched on, not one, and when either is off they answer "page not found" rather than "forbidden" — a public page that admits it exists but won't let you in is an invitation to go poking. The filter only accepts three values and quietly falls back to "upcoming" for anything else. A corrupted "show me more" link starts a fresh first page instead of showing an error. A missing event and a private one give the same answer, so the page can't be used to discover unpublished events. Times show in the timezone where the event is actually held, and an all-day event shows no time at all rather than reading as midnight. And searching for the literal character "0" survives paging — it is treated as nothing by both languages involved, which is exactly the kind of thing that vanishes silently. The pages read the public event feed, never the member one: the public feed deliberately publishes a narrower set of fields, and quietly using the member feed would risk showing something the public version withholds. A navigation bug was fixed on the way through: logged-out visitors were being shown an "Events" link that only ever bounced them to a sign-in page, because the existing site swaps that link for "What's on" and the new site had never copied the swap. One difference was left in deliberately and it is worth knowing: on the existing site the "What's on" navigation item never marks itself as the current page, because two pieces of code spell its name differently — one with a hyphen, one with an underscore. That is a small accessibility fault, so it was not copied; the new site marks the page correctly, and the existing site should be corrected to match. No translation work was needed — all eleven languages were already in place. Verified: 21 new focused tests, the full suite at 1,808 tests passing, no code-style problems, branding check passed, and the accessibility gate still 24 of 24. Page-for-page agreement between the two sites moves from 688 to 690 of 707.
Fixed
The Web UK accessible site shipped a mobile-menu script to every page that had never once run. The file looked for two things on the page — a menu button and a navigation list, each identified by a specific name — and neither name exists anywhere in the project. So it found nothing, stopped immediately, and did nothing, on every single page load, for 2,319 bytes each time. The real danger was not the wasted bytes: the file appeared to implement two behaviours that a reader would reasonably assume were working and tested — moving focus into the menu when it opens, and closing it with the Escape key. Neither has ever happened. Nothing tested the file, which is why nobody noticed. The menu itself has always been driven by the official GOV.UK component, which is why it works correctly. Checked before deleting anything, because the whole point of this site is to behave identically to the existing Blade accessible site: Blade uses byte-identical markup for the same official component, and Blade's own script file contains no keyboard handlers at all. The official GOV.UK bundle contains zero occurrences of "Escape". So Escape-to-close has never worked in either site, and removing this changes nothing a user could observe — it removes dead weight, not a feature. Verified after removal: the isolated accessibility gate passes 24 of 24, the full Web UK suite passes 59 files and 1,787 tests, and a freshly started server serves five scripts instead of six with the menu's accessibility state — its expanded/collapsed reporting, the link it controls, and the fact that none of its 8 collapsed links can be reached by keyboard — all unchanged.
The security gate went permanently red the moment the .NET backend moved into this repository, and every one of its complaints was mistaken identity. The scanner started reading the .NET project files and failed on three things, none of which are real. It matched a PostgreSQL test helper — a small library whose only job is to start a throwaway database for tests — against the PostgreSQL database server itself, and handed it 26 historical database vulnerabilities going back to 2015. The version numbers make the error obvious once seen: the helper is version 3.10.0, and the real database has never had a version 3. It matched a Serilog logging component against an unrelated Unix utility that happens to be named
file; the same mistake was already recorded in this project for a PHP testing library, so this is a known pattern reaching a new language. And it matched five OpenTelemetry packages against a genuine vulnerability that belongs to a different language entirely — it is a flaw in the Go version of OpenTelemetry, where a command is invoked by bare name on BSD and Solaris systems and can therefore be impersonated. Our packages are the .NET version, running on Linux, and contain no such code; the two share a product identifier, and the affected Go version range happens to overlap our version numbers, which is the whole of the resemblance. All three are now suppressed with the reasoning written out, in the same narrowly-scoped style as the existing entries. Checked rather than assumed: .NET's own vulnerability audit — which identifies packages precisely instead of guessing from names — reports no vulnerable packages across all five projects, and the real OpenTelemetry .NET vulnerabilities that do exist were looked up individually and confirmed not to apply, because the versions in use are already past every fix. The OpenTelemetry suppression is deliberately limited to that single mistaken vulnerability so genuine .NET ones still stop a release. One limitation is stated plainly in the file rather than hidden: the database-helper suppression lists the 26 vulnerabilities seen today, so the next PostgreSQL vulnerability published will trip this gate again and the list will need extending. The durable alternative was considered and rejected for now — this project has no working example of it, and one note in the file records it failing before, so it would need proving in a real run rather than assuming.A leftover folder from an automated session is now a documented rule rather than a surprise. A previous automated session created a second working copy of the project on disk to do its work in. When that work was merged, the branch was tidied up but the folder was not, leaving tens of gigabytes of orphaned files for the owner to discover. Nothing caught it, because that way of working is not part of the normal process here. The contributor guide now states plainly that second working copies are not used, and that any session creating one is responsible for removing it — with the correct command, since simply deleting the folder leaves the project in a confused state. Three specific traps are recorded alongside, all of which were hit for real: Windows refuses to delete a folder while any program is working inside it, so the removal has to happen from elsewhere; the command that checks whether a branch is safe to delete compares against your own out-of-date copy and will wrongly claim work would be lost; and files copied into the running web container land in the real working copy rather than staying inside the container, which had already left a stray test file behind.
The two experimental services have been switched off in production. The experimental .NET backend, its database, its message queue and the experimental accessible site were all stopped on 10 August, on the owner's instruction. Their web addresses now return a "service unavailable" response by design, and the addresses themselves are kept. The reasoning was straightforward once the facts were gathered: nobody was using them — not a single request in the logs, only their own background jobs waking up every few minutes — the information inside amounted to 13 test accounts, and nothing in this repository could patch them, because they were released from the separate repository that has since been closed. The version of .NET they run also stops receiving security updates on 10 November. On top of that, the software was offering its interface — including a message-queue administration screen — to every network connection, with only a firewall rule standing between that and the open internet. A dependency was caught during the pre-flight checks that would otherwise have caused an outage: the experimental accessible site was quietly pointed at the experimental backend rather than the main one, so switching off the backend alone would have broken it. Nothing was deleted. All stored information remains on disk, three checked database copies are kept, and a final verified copy was taken immediately before switching anything off. Restarting it all is a single command, written down alongside. The main platform was confirmed untouched and healthy throughout: the sales site, the app, the main backend and the accessible site all responded normally, before and after. The nightly backup was switched off at the same time, since it would otherwise complain every night about a service that is off on purpose, and both addresses were removed from the uptime checker for the same reason.
The public README described the project as smaller than it is, and got one important fact backwards. Anyone reading it would have understood this as one backend with two clients. It is actually two complete backends and three clients, and the README now says so — while being blunt about which are which. The main Laravel backend, the React app and the Blade accessible site are labelled primary: they are what runs the live service. The ASP.NET backend and the Web UK accessible site are labelled secondary: development-only comparison work, each a full stack in its own right with its own database, its own tests and its own server, sharing no database with the main system. The architecture diagrams in both the README and the architecture guide now draw that second stack inside a dashed boundary, so the separation is something you can see rather than something you have to already know. The factual error: the README said both experimental services were "not deployed". They are both live, on their own web addresses — they simply cannot be updated from this repository, because they were released from a separate repository that has since been closed. That wording had already misled several decisions, so it now says exactly that, along with the three separate safeguards that keep the secondary work from ever affecting a live release.
Deployment documentation now records the things that were only in people's heads. Three gaps were closed. The two experimental web addresses are now listed alongside the main ones, each marked clearly as having no deployment route from this repository at all — which is deliberate, not an oversight, and the three separate safeguards keeping them out of the main deployment are written down so nobody quietly removes one. Monitoring and backups are now listed in one table: what runs, where it runs, how often, and how it reaches you. That table records something confusing that had already caused a wrong assumption — the alert credentials exist as repository secrets on GitHub, which is why deployment-drift warnings reach the phone, but the production machine has none, so the new nightly backup can currently only write to a log file there. The exact fix is written down. Finally, the notes now record which of the two production halves was actually serving live traffic when last checked, because the previous note had gone stale and a wrong assumption there is how a deployment goes to the wrong place.
The experimental backend's database is backed up every night again, and this time you will be told if it stops. The old job had failed every single night for five months — 156 attempts, no successes, no warning. The cause was small and total: it tried to contact a server address that was blank, and died before it ever reached the backup step. Nothing checked, nothing complained, so silence looked exactly like everything working. The replacement runs nightly at 02:40 and does four things the old one did not. It checks the backup afterwards — the file must be readable and must contain at least as many tables as the live database, because a file of roughly the right size is not proof of anything. It stops immediately on any failed step rather than carrying on. It raises an alarm on the same channel already used for deployment warnings. And a second job each morning checks the backup is no more than two days old, so "the nightly job quietly stopped running" becomes visible instead of invisible — which is precisely how five months went unnoticed. It only reads: it never starts, stops or writes to anything. Verified on the real server: a genuine backup was taken and checked (265 tables), the freshness check reported correctly, and a deliberately broken run was confirmed to fail loudly rather than report success. Old backups are cleared after 30 days. One thing still needed from the owner: the alarm channel credentials are not yet on the server, so failures currently only reach the log file — the script says so loudly rather than pretending otherwise.
The experimental .NET backend now runs on a version that will still be receiving security patches after November. The version it used, .NET 8, stops being supported on 10 November 2026, and that backend is a live site on the internet. It has been moved to .NET 10, which is supported until November 2028. .NET 9 was never an option — it went out of support back in May. Verified before committing: the code builds with no errors, and all 3,424 tests pass — 3,386 in the main suite plus 38 messaging tests, with zero failures, the same count as before the move, so nothing was quietly dropped. The container image was also rebuilt and checked: it runs the new version and still starts as a restricted, non-administrator user. Three real problems came up and were fixed rather than papered over. The security library used for logins was too old for the new version and refused to build. The container image would not build at all, because Microsoft changed the underlying operating system for .NET 10 — two commands the old recipe relied on no longer behave the same, and the user identity the image wanted was already taken; that identity was deliberately preserved so any existing stored files keep working. And a genuine published security advisory turned up in a text-cleaning library, surfaced only because the newer tooling checks for known vulnerabilities automatically; it was fixed by a small version bump. Unrelated upgrades were deliberately left alone so a version move did not smuggle in unrelated risk — in particular one testing library has switched to a paid commercial licence, which is a decision for the owner rather than a side effect. Twenty-six warnings about database methods marked for future removal remain, noted as separate work. Nothing has been deployed.
Two rate limits that looked like they protected logins and file uploads were connected to nothing. The routing configuration declared a "10 logins per minute per address" limit and a "30 uploads per minute" limit. Neither was ever attached to a single route, so neither had any effect — searching the whole project found zero references to either. The danger was not the missing protection; it was the false reassurance. Anyone reading that file, or auditing how logins are defended, would reasonably conclude a 10-per-minute ceiling was in force. Both are now deleted. Login protection is what it always actually was, and this is now written down in the file next to where the dead limits used to be: a database-backed lockout counting failed attempts per email address and per network address, plus a genuine 30-requests-per-minute ceiling on the login route group. Two traps are recorded alongside it so the next reader does not create a new bug while tidying: the real login protection reads the visitor's true address through a helper that understands Cloudflare, whereas the deleted limit used a method that does not — the platform has no proxy-trust configuration, so those two are not interchangeable; and separately the word "auth" is also the name of a live login-required middleware in the same project, which has nothing to do with rate limiting and must not be confused with the deleted limit. The two tests that asserted these dead limits existed have been replaced by a test asserting they stay gone, so the configuration cannot quietly return without a route to attach it to, plus a test checking the opposite direction — that every rate limit a route does name is actually registered. That second test found nothing wrong today; every one of the 26 names in use resolves correctly. One unused entry in the separate table of numeric ceilings was left in place deliberately and labelled: that table is a catalogue of available limits rather than a claim about what is protected, and removing an entry would break any route that later asked for it. Verified by running the routing and rate-limit tests (28 passing) and the login tests (38 passing) in the container, plus static analysis on both changed files.
Written down: the ASP.NET backend runs on a version of .NET that stops receiving security patches on 10 November 2026. All five ASP.NET projects target .NET 8. It is a long-term-support version, but its support window closes on that date, and
api.project-nexus.netis a live site facing the internet. The replacement is .NET 10, supported until November 2028 — .NET 9 is not an option, having already gone out of support in May 2026. The size of the job is now recorded rather than guessed at: five version markers, eight locked Microsoft packages, the PostgreSQL database driver, two container recipes and three places in the automated checks. The 165 existing database migration files do not need regenerating, and the 3,386 existing tests are what make this a manageable change rather than a risky one. One ordering rule is recorded with it, and it matters: shipping the upgrade means restarting the ASP.NET container, and that container applies database changes to the live database every time it starts. There has been no successful backup of that database since 8 March 2026. A verified backup has to exist before any of this is deployed.Nobody would have been told if the ASP.NET or Web UK sites went down. Those two services were watched by an automated check that lived in the old separate repository. That repository was archived on 10 August 2026, and archiving a repository switches off its scheduled jobs — so the check stopped, while both services carried on running on their public web addresses. There is now a replacement in this repository that checks all six public addresses every fifteen minutes: the sales site, the React app, the Laravel API, the accessible site, the ASP.NET API and the Web UK site. It messages the same Telegram channel already used for deployment alerts, and only when something changes — when a site first goes down, when a different site goes down, or when everything comes back — because a message every fifteen minutes for the whole length of an outage is how people learn to ignore alerts. Three deliberate safeguards: one failed request is not treated as an outage (each address is retried three times before being called down), the check never reports a site being down as its own failure, and it does fail loudly if it cannot run at all — so "the monitor is broken" can never be mistaken for "everything is fine". Verified against the real live addresses: all six responded, and a deliberately broken configuration correctly reported a dead address and a wrong response code. One caveat worth stating plainly: scheduled jobs only run from the repository's main branch, so this starts running on its timer once the current branch is merged. Until then it can be started by hand from the Actions tab.
The Web UK accessible site was being built on a version of Node that stopped getting security patches in April. Node 20 reached the end of its supported life on 30 April 2026, and the recipe used to build the Web UK site image still asked for it, so for over three months that image was being assembled from a base with no security updates. It was also inconsistent in a way that hid the problem: the automated checks already installed Node 22 to run the Web UK tests, and this machine runs 22, so the entire 1,787-test suite had been passing on Node 22 all along while the shipped image contained 20. A third answer sat in the project's own
package.json, which claimed the minimum was Node 18. All three now say Node 22, the current long-term-support release, patched until April 2027. Verified by rebuilding the production image, starting it, and confirming it serves its health check, then running the full Web UK suite, the code-style check and the branding check. The test meant to catch this had pinned the exact text "node:20-alpine", so it kept passing after that version died; it now checks the version is at least the supported one, which fails if anyone drifts backwards but needs no editing for a future planned upgrade. That change was itself verified by temporarily putting Node 20 back and confirming the check fails. A stale Web UK release document was also corrected: it named Node 20 and a folder path that has not existed since the repositories were merged.Pushing a change no longer costs an hour of waiting. A Laravel or React change stopped triggering an unrelated 55-minute .NET test run. The checks that run on every push were taking around 50 minutes, and effectively all of that was one job. The main pipeline finished in 20 minutes; a second pipeline, added when the ASP.NET backend and Web UK frontend moved into this repository, took 59 — and five of its six jobs finished inside four minutes. The sixth ran the ASP.NET test suite: 3,386 tests, strictly one at a time, 54 minutes 38 seconds. Two things were wrong. First, that job was set to wake on
app/,routes/,config/,database/andreact-frontend/— so ordinary Laravel and React work, which cannot affect a .NET test, paid the full cost. Each of the six jobs now runs only when its own project changes, using the same shared path list the rest of the pipeline reads. Second, the job ran the suite as one unsplit command. The repository already contained the script for splitting it — it came across in the move but the new pipeline stopped calling it — so it is called again, now across six parallel runners instead of the four used previously. The script hands each runner its own throwaway database, so they never contend; it also keeps whole test classes together, which matters because most of this suite shares one database fixture. Expected effect: most pushes go back to the main pipeline's ~20 minutes, and pushes that genuinely touch the ASP.NET backend land around 15 rather than 58. To be clear about what has and has not been proven: the pipeline changes are verified as correct configuration, and this exact script and shard layout ran green on Linux in the original ASP.NET repository, but the six-shard timing here has not yet been observed on a real run.The experimental backend told administrators their actions had worked when nothing had happened. Any admin action it had not implemented — and it is only two-thirds finished — returned a plain "success" with a normal 200 response, while saving nothing at all. A real administrator could click "suspend member", be told it worked, and it would not have. Every log and every monitor would show a perfectly healthy result. That is worse than an error, because an error tells you something. It also meant any test of the form "did it respond successfully?" passed across the entire admin area without proving a single thing — and the remaining contract work is measured against exactly that kind of evidence, so this could quietly bank false progress at scale. It now answers honestly: not implemented, nothing was changed, use the Laravel backend for this. Verified by compiling the code and running 1,259 admin tests, all passing — nothing depended on the false success.
The experimental backend changed its own database every time it started. It no longer does. Starting the container applied any pending database changes automatically, in every environment including production. That made an ordinary restart a permanent, irreversible act: putting the old version back does not put the old database back, because these changes only run forwards. Two copies starting at once would race each other on the same changes. And it defeats the whole idea of building a spare copy and proving it before switching over, because the spare alters the shared database the moment it starts. Outside local development it now applies changes only when explicitly told to; if changes are waiting it refuses to start and names them, rather than quietly making them. The note in the code warns against the tempting shortcut of switching the environment to "test mode" to get past this — that also switches off the production security checks and falls back to a signing key derived from a value published in this public repository.
A safety check accepted signing keys that are published on the internet. Web UK refuses to start in production with placeholder secrets — but it recognised placeholders only by length and by one particular prefix. The two placeholder values actually shipped in this repository are both long enough and use a different prefix, so they passed. Anything started with them would have been signing real cookies and sessions with keys anyone can read, and the safety check would have reported the configuration valid. Both exact values are now rejected by name, along with the usual placeholder prefixes. The new test reads those values out of the file itself rather than copying them, so if the file changes the guard follows automatically instead of silently protecting something no longer there — and it confirms a genuine secret is not rejected merely for starting with similar characters. Also stopped the local production-mode setup from loading that public file's secrets at all.
A guard for the one thing keeping experimental code out of the live platform. The production image is built from an explicit list of folders, and that is what makes it impossible for the two experimental projects to reach production. Nothing was protecting that arrangement. One well-meant "just copy everything" edit — the sort of thing done to fix a missing-file error — would have shipped both experimental trees into the live image with no check objecting. There is now a check that refuses exactly that, proved by deliberately breaking it four ways: adding a copy-everything line, copying an experimental folder by name, and removing each of the two exclusions. It correctly still allows the legitimate multi-stage copies already in use.
The local ASP.NET setup used the exact same container and storage names as the dead live system — meaning one routine command could have destroyed a database with no backup. The project name, all four container names, the network and all three storage volumes were byte-for-byte identical to the ones running in production. Docker identifies things by name, so a perfectly ordinary "stop and clean up" command, run in that folder anywhere near the server, would have attached to the live system instead of the local one — and the storage-wipe version of that command would have destroyed the only copy of a database that has never once been backed up. Everything local is now renamed to a clearly-local set of names that cannot collide. Checked first that no local data existed to lose, and confirmed afterwards that both container setups still resolve correctly. The remaining mentions of the old name are folder paths that correctly refer to the dead system, not names that could clash with it.
The alarm that proves production is running the right code was about to start crying wolf every ten minutes, forever. It compares what production is running against the newest change in the repository. That was fine when the repository only held things production runs. Once this work merges, the repository also holds two experimental projects that production must never receive — so every change to those would make production look permanently out of date, and the alarm would fire every ten minutes about work that must not be deployed. Measured on real history: 9 of the last 14 changes would have triggered a false alarm. It now compares against the newest change that actually affects production, and ignores the rest. This matters more than the noise suggests: that alarm is the only thing proving production runs what the code says, and an alarm nobody believes gets switched off. Also fixed a trap that would have made the whole change do nothing — the check only downloads one commit of history by default, so the new logic would have silently found nothing and behaved exactly as before. Verified against real history, including that ordinary Laravel changes are still correctly treated as needing deployment.
A single misplaced line hid nearly a third of the platform's API from every comparison tool. The tools that check whether the other backend and the other frontend match Laravel first strip out the comments from the route file. They stripped block comments before line comments — and the route file contains ordinary comments that mention wildcard paths, like
/broker/moderation/*. That star-slash inside a normal comment was read as the start of a block comment, so everything up to the next one was deleted. Measured exactly: 808 of 2,681 routes — 30.1% — invisible, and 118,006 characters of real route declarations silently discarded. Nothing ever failed. The tools simply reported on a 70% view of the platform as though it were the whole thing, and every comparison figure produced so far was calculated against that view. Both copies of the fault are fixed, both affected reports regenerated. One immediate effect: five endpoints previously reported as "not present in Laravel" turned out to exist all along — that count is now zero.Correction: the two experimental projects are not "deployed nowhere". They are live on the public internet. The guidance document said otherwise and it was wrong, which sent several decisions down the wrong path — mine included. Both were confirmed by direct check: the ASP.NET backend answers as healthy, and the Web UK site serves real pages. They run from the old repository, which means this repository cannot update either of them — not for a feature, not for a bug, not for a security fix. The owner has now declared the old repository and everything it deployed dead, with the web addresses retained and this repository becoming the control panel. Two inherited hazards are now written down so nobody rediscovers them the hard way: the live ASP.NET database has had no successful backup since 8 March — 156 attempts, none succeeded, because the job runs with an empty server address and dies before it starts; and that application applies database changes automatically every time it starts, so an ordinary restart can permanently alter live data with nothing to restore from.
Guidance that told every AI assistant your own containers belonged to someone else. The instructions listed the ASP.NET and Web UK containers under "never touch — they belong to other projects." They are yours. The effect was that an assistant asked to look into them would politely refuse. Corrected, with the genuine reasons for caution put in place of the false one — and the rule against deploying or restarting them left fully intact.
Web UK could not reach the platform at all on a developer machine. It was configured to find Laravel on port 8088; this repository serves it on 8090. Nothing listens on 8088, so the first thing anyone tried would fail with "connection refused". Corrected across the code default, both environment files, the container setup, the Docker contract and the test expectations. All 1,779 Web UK tests still pass.
Two blind spots that let the accessible frontend drift apart unnoticed. Web UK exists to reproduce the Laravel accessible frontend's behaviour — yet changing that frontend triggered no Web UK check whatsoever. During the pause Laravel added 18 accessible pages and Web UK went from one page behind to nineteen, with nothing reporting it for over a week. Separately, 33 route files sat in a folder that no check watched at all. Both now wake the right checks.
The pre-push check said "all checks passed" when it had checked nothing. A change touching only the ASP.NET or Web UK folders matched none of the areas it knew about, so every check printed "skipped" and it finished by declaring success. It now names what it could not check and says plainly that nothing was verified, rather than showing a tick — and it does the same for any future folder it does not recognise, so this cannot quietly come back. On the current branch it now reports 1,677 ASP.NET files and 618 Web UK files as unchecked instead of staying silent.
The two per-project instruction files were one command away from being lost. Web UK's 32 KB guide existed only as an untracked file that a routine cleanup would have deleted, and the ASP.NET one had not been brought across at all. Both are now properly kept, the ASP.NET one recovered from the archived repository with its folder paths corrected.
The package-safety check can now actually block a bad dependency, instead of only mentioning it. It has been running in advisory mode because it needs a GitHub feature that was switched off; the owner switched that on, so the deliberate hold has been lifted as the workflow itself instructed. Checked before flipping it rather than after: the check had already run against this branch — which adds both imported projects' entire dependency lists at once, the largest set it will ever be asked to judge — and reported no vulnerable packages at high severity or above, and no banned packages. So it starts out passing rather than blocking work on day one. Combined with the ten extra file locations added earlier, it now watches every dependency list in the repository instead of six of sixteen.
The nightly security check has been failing on 522 secrets that do not exist. One faulty detector is now switched off. Every one of those findings came from a single detector looking for keys belonging to Lob, a direct-mail service this platform does not use. It was not a one-off: the same false alarm hit on 9 August, cleared, and came back on 10 August. Pinning the scanner to a fixed version — done last week for exactly this reason — could not help, because the pinned version predates the problem entirely. The cause is outside this project: "verified" means the scanner asked Lob's own service whether a key was real and was told yes, so when that service says yes to everything, everything looks like a leaked key. Proven rather than assumed, by running the scanner over the whole repository twice, identically: 530 verified secrets with the detector on, zero with it off. Also checked that no Lob key or address appears anywhere in the code, that no other detector reported anything at all, and that a mistyped detector name would be rejected rather than silently disabling nothing. Everything else the scanner looks for is untouched, and a note records that this must be reversed if the platform ever starts using Lob. This matters beyond the noise: a security check that goes red on its own teaches people to stop reading it.
Correction to an earlier note: switching on automatic C# security scanning is not possible yet, and nothing was missed. It was written up as a one-tick setting. In fact the option is not offered, because that setting only lists programming languages GitHub can see on the main line — and the ASP.NET code has not been merged there yet. Confirmed directly: the repository reports eleven languages and C# is not among them, and the change is refused outright when attempted. It becomes available the moment this work is merged, and the note now says so along with the exact steps.
Documentation that told people to run commands in folders that no longer exist has been sorted out — by fixing what is meant to be followed and labelling what is history. Roughly fifty documents came across still describing the old folder layout. Rewriting them all would have been the wrong move: most are dated records of decisions and audits, and quietly editing a record to match today's layout makes it a false record. So the split is deliberate. Anything someone would actually type or click was fixed: the testing guide's commands, the documentation index's links, the Web UK readme, four production safety notes that pointed at a document never brought across, and the guidance file that agents read on entering the Web UK folder — that last one told them to change into a directory that does not exist and then run eight commands with the wrong folder name, so every step failed. Everything genuinely historical instead got one line at the top saying it predates the move and how to read its paths, with the paths left untouched. Nothing that says "this was deleted" was altered, because that is still true. All documentation checks pass, and both link checkers still resolve every link.
The ASP.NET container image could not be built at all, and nothing in the pipeline was building it to notice. Its build instructions listed four of the five parts the project is made of, so the very first step failed. This was not caused by the move — the file is byte-for-byte identical to the one in the old repository, and the missing fifth part was added there months ago without anyone updating the list. It went unnoticed because no automated check has ever built this image. One line fixed it, confirmed by building the image successfully. Both container images — ASP.NET and Web UK — are now built automatically whenever their own project changes, so a broken build recipe is caught immediately instead of never. Both were built locally first so these checks start out passing rather than failing. Nothing is published; a successful build here still does not authorise any deployment.
Package security alerts now cover the parts of the project that had none. The nightly dependency check ran over four areas and silently missed Web UK, which came in with the move and had its own check in its old home. The separate check that reviews packages when they change was watching six files and missed ten, including everything belonging to Web UK and the ASP.NET project — and also, from before the move, the end-to-end tests and the phone app. All are now covered. Verified the Web UK check runs and passes today.
Two gaps we cannot close from inside the repository are now written down instead of being quietly absent. First: the old ASP.NET repository automatically scanned its C# for security problems. That scanning is switched on here for the JavaScript and Python, but not for C#, so the entire second backend gets none. It cannot be fixed by adding a file — GitHub does not permit the two ways of configuring it to coexist. It is a one-tick setting change, written up with the exact steps. Second: the ASP.NET end-to-end test suite came across, is kept up to date by the package updater, and is run by nothing; making it run needs a live server and database, which is a real piece of work for a project paused since 15 July. Both are recorded with the reason and with what would change the decision.
The translation comparison reported that the app had no translations at all. It has eleven languages. The same missing-folder fault: it looked for the app's translation files in a location deleted in August, found nothing, and produced a complete, confident report saying zero languages, zero files, zero phrases, and every single Laravel phrase missing. It finished successfully every time. Pointed at the real location it now reports eleven languages on both sides, all eleven matched, none missing. A missing folder is now a loud failure instead of a zero, confirmed by pointing it at one that does not exist. The document explaining how to read this report previously told people the empty result was expected and meant "there is no second app" — that instruction was wrong and has been replaced. The existing test for this tool still passes.
A report on which backend addresses the Web UK app calls was reading a completely different copy of the project. Its default source location was written into the script as a fixed path to another folder on this machine. That folder happens to exist, so nothing ever failed — the report simply described someone else's code, and had done since 15 July. It now reads this repository, and the record inside the regenerated report names this repository instead of the two dead locations it previously claimed. Its own tests pass, the full Web UK suite of 1,779 tests passes, and two consecutive runs produce identical content apart from the timestamp.
A report that claimed the React app had zero pages, and the Web UK app made zero backend calls, now tells the truth — and the numbers were far more wrong than the move explains. This report compares what each front end asks for against what each back end offers. Three of its inputs pointed at folders that no longer exist, and instead of complaining it recorded zero and carried on. Zero reads as a finding. It was a missing folder. Fixed, the same report now counts 566 pages in the React app (it was recording none) and 314 backend calls from the Web UK app (also none), of which 13 point at addresses that do not resolve — and those 13 are now listed with the file and line to look at, which is the first time that list has ever been produced. One of the three faults was not caused by the move at all: the count of React pages was taken from a single file that stopped holding the pages when they were split up into separate files some time ago, so it was reporting three out of roughly five hundred and sixty and had been quietly wrong far longer. It now reads the whole folder, and agrees with the separate comparison tool instead of contradicting it. A missing folder is now a loud failure, confirmed by pointing it at one that does not exist. One comparison was retired rather than repaired: it measured the shared React app against a second React app that was deliberately deleted in August, so every page came out as "missing". With one shared front end there is nothing to compare against, and reporting a comparison would be inventing evidence. The two reports that build on this one were re-run and still work.
Two checking scripts that could never pass again have been removed rather than left to mislead. One checked that documentation was internally consistent; 73 of its checks referred to folder locations from before the move, so it failed every time from a completely clean starting point. The other checked that the ASP.NET project was in a proper "paused and frozen" state — it required the work to be on a particular branch, to have exactly one copy checked out, and to carry eighteen specific saved markers. None of those are true here and none can be made true, because it was describing the old repository's final resting state, not anything about this one. Neither was run by any automated check, so removing them loses no coverage; what they were reaching for is already done by checks that genuinely run, including the link checker that now runs on every relevant change. Both originals remain in the archived copy of the old repository. The six places that told people to run them have been updated, and while those same lines were being edited the wrong folder name in them was corrected too.
The safety net that stops passwords and keys being committed is back. It had been gone since 9 August. The ASP.NET project brought its own guard that checked every commit for private keys, cloud access keys, server login strings with real addresses in them, and database dumps. The move did not bring that file across, and the command meant to install it pointed at a file that no longer existed — so it failed, and nobody had the protection. That matters more here than it did there, because this repository is public: a key committed by accident is a key published. The checks now live in this repository's own commit guard and run on every commit, needing nothing installed. Two of the original checks were deliberately left out: they looked for the word "password" in almost any form, which in a project like this matches test data, example settings and translated text constantly. A guard that keeps crying wolf teaches people to switch it off — and switching it off would also switch off the test check, which must never be bypassed. That kind of thing is still caught by two separate scanners in the pipeline. Tested seven ways: a fake cloud key, a private key, a server login string, and database dumps under two different names were each correctly blocked; ordinary project code containing the word "password" was correctly allowed; and editing a file after staging it does not sneak anything past, because the check reads what is actually being committed.
The one ASP.NET container recipe that was still broken now works. An earlier fix corrected the everyday container file but missed its production counterpart, which still looked for the Web UK frontend inside the ASP.NET folder where it lived before the move. Confirmed fixed by asking Docker to resolve both files together. Three pointers in it aimed at a safety document that was never brought across now say so plainly instead of pointing at nothing; the deployment-hold warnings around them are untouched.
Editing a pipeline setting no longer triggers the entire ASP.NET test suite. The file that decides which checks wake up for which change listed itself in every one of its nine entries. So touching that file — or the ASP.NET pipeline file — marked every area as changed and ran everything, including an hour and a half of machine time for the ASP.NET tests. For the five entries that feed the deployment safety check this self-listing is deliberate and has been left exactly as it was: if the rules themselves change, past results collected under the old rules must not be trusted. For the four entries covering the ASP.NET backend and Web UK frontend it bought nothing, because those are read by one pipeline and no deployment check looks at them. Verified afterwards that the five deployment entries are untouched, the four others are clean, and the deployment checker still loads the file without complaint. In place of it, those checks now run a full sweep once a week, so nothing can quietly rot by never being woken. Weekly rather than nightly on purpose: both those projects have been paused since 15 July, and paying every night for code nobody is changing is waste — worth revisiting the day that work restarts. Two smaller repairs came with it: the weekly sweep now runs in its own lane so an ordinary push cannot cancel it midway, and this pipeline now only runs for changes aimed at the main line, matching every other pipeline (before, a change aimed elsewhere got the expensive half of the pair and not the half that gates it).
Work-in-progress changes no longer run the three biggest test suites every time you push. A change that is still marked as a draft now skips the PHP test suite, the React test suite, and the ASP.NET test suite — the three heaviest things in the pipeline, together worth around five hours of machine time per push. Everything else still runs, so a draft still gets told immediately if it fails to build, fails a quick check, or breaks a rule. The moment you mark the work as ready, all three run in full, and merging is not possible until they have. Two further safety nets were already in place and are unchanged: the nightly sweep runs everything regardless, and the final release check refuses anything that failed. Checked rather than assumed: the two checks that collect these results already treat "did not run" as a pass and only object to an actual failure, and the separate check that guards deployment is unaffected because it only ever looks at the main branch, where this rule does not apply.
The check that was supposed to prove the frontends still match the backends was never actually being run — and could not have worked if it were. This was the central question of moving everything into one repository, and nothing was asking it. The comparison tool existed, was complete, and was called from nowhere at all. It also had two faults that would have made it useless: it looked for the Web UK frontend inside the ASP.NET folder, where it sat before the move, and for a second React app that was deliberately deleted back in August because there is now one React frontend serving both backends. On not finding either, it reported "nothing found" and finished successfully. A folder that has vanished now stops the check with a message naming what was missing, which was confirmed by deliberately pointing it at folders that do not exist. Every location it reads is now given to it explicitly instead of being guessed. It runs on every relevant change and publishes its report. Two honest limits: the React app is now counted rather than compared, because with a single shared frontend there is no second one to compare it against, and inventing a comparison would be inventing evidence; and the pipeline will not fail on the comparison's own numbers. Those numbers are a rough automated guess — they currently report several hundred differences in both directions — and Web UK's own documentation describes them as a backlog to work through, not a pass mark. Turning that into a pass mark would be making up a standard nobody agreed. What fails the pipeline is the fault that was actually found: a check quietly measuring nothing. The link checker, which was also failing and also run by nothing, now runs the same way.
A fresh copy of the repository can now start the ASP.NET and Web UK containers. It could not before. Both projects list a Docker settings file that has to exist before their containers will start, and both those files were left behind in the move — swallowed by the blanket "never commit environment files" rule that exists to stop anyone committing a password. The rule is right; these two files are not secrets. They hold a port number, a local web address, and two placeholder values whose only job is to be long enough to satisfy a length check in local development. They are named individually as exceptions, with the reasoning written next to them, and both now say in plain terms that they must never be used anywhere real. Actual environment files are still refused, which was checked. Separately, the ASP.NET container recipe was still looking for the Web UK frontend inside its own folder, where it used to live before the move — four separate paths, none of which existed. Confirmed by asking Docker to resolve the file: both projects now point at real directories.
47 broken links in the moved documentation are fixed, and the checker that finds them now runs automatically. The repository's own link checker was failing. Two kinds of breakage: pages pointing at shared files like the licence and security policy, which are now one folder further away; and pages pointing at each other, since the two projects used to be nested and are now side by side. 40 were repointed by resolving each one against the current layout rather than guessing. Seven were left deliberately unlinked: they name files that were genuinely not brought across in the move, and one of them would have quietly repointed to a same-named file that is a completely different thing. Those now say plainly that the file was not imported. Both projects' documentation now passes: 177 links checked in one, 22 in the other.
The local pre-flight script for the ASP.NET side could only ever fail. It ran checks against two folders — an admin app deleted before the move, and the Web UK frontend at its old nested path. Neither exists. Anyone following the testing documentation would have hit failures that said nothing about their own work. It now points at the real Web UK location, drops the deleted admin app, adds the lint check that the pipeline runs, and stops with a clear message if it is run outside a full checkout instead of failing obscurely.
Two contract inventories that vanished in the repository move are back, and were rebuilt rather than copied. The move brought across the ASP.NET backend and Web UK frontend, but a blanket "never commit spreadsheet files" rule — there to stop anyone committing an export of member data — silently swallowed two generated files on the way in: the row-by-row list of every API endpoint the React app calls, and the equivalent list of accessible-frontend routes. Their companion files survived, so the loss was invisible; the documentation still described one of them as the complete list. Copying the old ones back would have made things worse: they carried file paths from two different machines that no longer exist, one of which had never been this repository at all. Both were regenerated from the current tree instead. Doing that required fixing the tool that builds one of them, which had a developer's own folder path written into it as a fallback — so anywhere else it quietly produced an empty result rather than complaining. Every path it records is now written relative to the repository, so the files come out identical on any machine instead of churning in every comparison. The blanket rule is untouched; the two files are named individually as exceptions, and a test confirms other spreadsheet files in the same folders are still refused. All 1,779 Web UK tests pass.
Dependency security alerts now cover the ASP.NET backend and Web UK frontend, which had none. Both projects arrived with their own dependency-watching configuration, which was not merged into this repository's. Between the move on 9 August and now, nothing was watching their packages — including for published security advisories. Four things are now covered that were not: the .NET packages, the Web UK npm packages, the ASP.NET end-to-end test packages, and the base images both projects build from. That last one needed two separate entries, because the existing root entry only ever looked at the root folder and could not have seen them.
A deploy script that could have overwritten the whole platform has been deleted. The ASP.NET backend arrived with its own deployment script. Its upload step copied "the current folder" to the server in mirror mode — meaning it also deletes anything on the server that is not in the folder it is copying. In the old separate repository that folder was just the ASP.NET backend. In this combined repository it resolves to the repository root, so running it from the wrong place would have pushed the Laravel backend, the React frontend and the Web UK frontend into the ASP.NET server directory and deleted what was there. Its non-interactive path also skipped the typed
YESconfirmation. Nothing has replaced it, deliberately: the ASP.NET backend is development-only and has no production deployment. Laravel's own deploy script, at the repository root, is a different file and is untouched. The two other places that mentioned the removed script now say plainly that it is gone.The background worker's health check could never fail. It now can. The check asks whether the worker processes are running by searching the list of running programs for their names — but the search matched itself, because the command doing the searching contains those same names. So it always found a match and always reported the worker healthy, even with no workers running at all. This was measured, not guessed: in a container with zero workers, the old check reported healthy and the corrected one correctly reported unhealthy. It matters because of what that check was written for — the note above it records a 2026-06 incident where every worker died at startup and stayed dead for five days while the container kept reporting itself healthy. The check added to catch that had never been capable of catching it. The fix is a standard trick that stops the search matching its own text; it was proved to work in both directions, reporting healthy when workers are present and unhealthy when they are not. The startup grace period was also raised from 90 seconds to 4 minutes, because the new Redis wait can legitimately hold startup for up to two minutes and would otherwise have been reported as a fault.
The old single-container production setup has been deleted, along with the three deploy scripts and the validation check that kept it alive. Production has run on the blue/green setup since May; the old
nexus-php-*containers stopped then and were confirmed gone from the server. The setup file describing them, however, was not inert. A step in every deploy failed if that file was missing, the fallback deploy and rollback scripts began by copying it into place, and a security test read it. Anyone deleting the file on its own would have broken the next deployment immediately. All four are now removed together: the setup file, the three fallback scripts, and the deploy check, which now looks for the blue/green setup file it should have been checking all along. The fallback deploy and rollback commands no longer half-run against a missing file — they stop with a message pointing at the blue/green commands instead. The security test that proved the old containers only listened on the local machine now proves the blue/green ones do, and a new test fails if the deleted file ever reappears, because bringing it back would silently restore all three hidden dependencies. Verified by deliberately recreating the file, which made the new test fail as intended.The secret scanner is now pinned to a fixed version, so it can't turn the pipeline red overnight on its own. It was set to follow the scanner project's development branch, which meant every run downloaded whatever they had published most recently. On 9 August one of their builds reported 522 "verified secrets" that do not exist — all from a single detector, with no matching text anywhere in our code — and failed the nightly check on a commit that had passed a few hours earlier on identical code. It cleared itself by mid-morning when they fixed it. A security check that can fail for reasons unconnected to your code is worse than no check, because people learn to ignore it. It is now locked to a specific published version, identified by its exact fingerprint rather than a label, since labels can be moved by the publisher. Upgrading is deliberate: bump the fingerprint and the version together after reading the release notes.
The other two security tools are now pinned as well, so nothing in the security pipeline can change under us overnight. The vulnerability scanner and the dependency checker were both following development branches, exactly like the secret scanner. Both are now locked to an exact fingerprint. The two were handled differently on purpose: the vulnerability scanner is pinned to its current published version, ten changes behind its development branch. The dependency checker is pinned to the exact code that has been running and passing, not to its newest release — because that release is from 2021 and is five years and fourteen changes behind what we actually use. Pinning to it would have been a large behaviour change dressed up as a safety improvement. No tool in that pipeline now follows a moving branch.
The background worker now waits for Redis to answer before it starts, instead of crash-looping. Roughly once a week — most recently on 7 August — production logged a two-minute burst of about 600 identical background crashes, at three per second. No user ever saw them: these are background jobs, not page requests. The real cause has now been identified. Redis runs as a separate container that nothing tells the worker to wait for, so when Redis is briefly unreachable — a host reboot, the memory wedge on 23 July, the whole container fleet starting at once — the queue engine dies on "connection refused", the container restarts, and it does that a few times a second for a couple of minutes until Redis answers. Each restart rebuilds Laravel's list of web addresses, and the processes still dying from the previous attempt trip over that file mid-rebuild. That tripping-over is what filled the error log, so it was mistaken for the cause. The worker now checks that Redis is accepting connections before starting, retrying every two seconds for up to two minutes and then starting anyway with a warning rather than hanging. When Redis is already up this adds no delay at all. Both container setups were fixed, and both the "Redis is up" and "Redis is down" paths were tested in a real container.
Correction to the previous note on this crash: sharing one Laravel cache folder between containers was not the cause. The blue-green setup — the one production actually deploys — stopped sharing that folder on 21 June, and the bursts continued on 23 and 31 July and 1 and 7 August. The 7 August burst carries a build stamp that only the blue-green build produces, so it came from a container where the sharing problem cannot happen. The earlier bursts are stamped with a version number rather than a build, which looks like it identifies an older setup but does not: until 3 August no container stamped itself with a build at all, so every setup fell back to the version number. The shared-folder fix and its build check are still correct and are kept — a shared cache folder is a genuine bug — but they do not close this crash.
Linked-account and guardian authority now stays with the supported member throughout its lifecycle. Supporters can request help or reduce their access, but only the supported member can grant or expand activity, listing, credit, or message powers. Pending approvals show the exact requested levels on the main, accessible, and mobile interfaces. Prepared co-decide actions are cancelled when a relationship ends or its authority is reduced, and every confirmation channel rechecks the locked live relationship before acting. Guardian refusal or withdrawal clears all powers and message access. The main settings screens now separate staff-recorded guardian arrangements from member-controlled support, show each person's current access from the actual tiers, and require a named confirmation before ending all support on a card. Safeguarding screens now require explicit broker permissions, supporter message lists paginate beyond 20 conversations, transition and consequential proxy-action audit records are transactional, duplicate message-access requests are prevented at database level, and historical safeguarding-assignment conflicts are reconciled without destructive rollback.
Added
The experimental ASP.NET backend and shared Web UK accessible frontend now live beside the production Laravel source as isolated development applications. Laravel remains at the repository root and its production blue/green deployment is unchanged. The imported applications have separate build and contract checks, are excluded from Laravel image build contexts, and are not deployed. The former ASP.NET repository history and changelog were deliberately not imported; its exact source commit is recorded for provenance.
The rule that decides who counts as an administrator is now actually tested — it never was. One small piece of code answers "is this person an admin?" for the whole platform: every admin page and around twenty other places ask it. It had no test of any kind. That matters most for one rule it enforces deliberately: a broker or coordinator is refused administrator access even if an old admin marker is still sitting on their account from a previous role. Nothing was checking that rule held. There are now 38 checks covering it, and each one was proved capable of catching a real break — the rule was deliberately broken three different ways and the tests failed every time, as they should. The same treatment was given to the safeguarding decision object, where the rule being protected is that a safeguarding check which could not be completed must never be read as permission to make contact.
The two most destructive things the platform can do are now tested for real, against the real database. Deleting a member's personal data on request, and permanently wiping an entire community, were both covered only by tests that never touched a database — one checked a fake stand-in, the other checked that a file could be loaded. That is the exact blind spot that let a piece of this platform write to columns that did not exist for four months, unnoticed, because the code quietly swallows that kind of error and returns something that looks like success. There are now 51 checks that run the genuine operations end to end. The erasure test executes all 1,100 lines of the real deletion, so any table or column that has since been renamed makes it fail loudly instead of leaving personal data behind; it also proves the member's own words are removed from conversations while the other person's replies survive, that two people erased in the same community don't collide, and that a community can never erase someone who belongs to a different one. The community-wipe test proves the job wipes the community it was told to and leaves its neighbour completely untouched, refuses if that community was switched back on or gained a sub-community after the button was pressed, and never lets a failure be silently retried later against a reused community number. Alongside those, data-rights requests and consent are now checked for real: consent given in one community is invisible in another, one community cannot read or advance another's request, and a community's own version of the terms never leaks to its neighbours.
Nineteen tests that could never fail can now fail — and two of them were already broken. Some tests were written so that if anything at all went wrong, the test quietly marked itself skipped instead of failing. A skip shows a green tick, so those nineteen reported success no matter what broke underneath them. Two were genuinely broken and nobody could have known: the test for adding and removing a federation partner, and the test that adding a duplicate partner is refused. Both invented a web address like
test-partner-1234.example.com, which does not exist, and the platform's own safety check — which refuses to store an address it cannot look up, so nobody can point it at an internal machine — correctly rejected it. The failure was then reported as "that database table might not exist", which was never true. Both tests now use an address that needs no lookup, so they run properly everywhere, and two new tests pin the safety check itself: a made-up address and a private one are both refused. The seven presence tests kept their honest reason for skipping — they need Redis — but it is now checked once up front instead of wrapped around the whole test, so a real failure shows as a failure. Correction to an earlier note: a wider sweep suggested thirty-four tests had this problem; on inspection only these nineteen did. The other fifteen guard only their own setup data, and the checks that matter still run.And now they have been switched back on: 141 of them. The report below found 184 tests carrying a note like "that database table isn't there" when the table plainly does exist. 141 of those notes were the simple, identical kind, and they are now gone. Every one of the 776 affected tests was run against the real database first, and all passed, so nothing was quietly disabled to reach that number — and the ceiling that stops this creeping back up has been lowered from 281 to 140 to lock the gain in. The point is not the count. A test that skips itself still shows a green tick, so if one of those tables ever really did go missing, the platform would have said nothing; now it fails loudly and someone finds out. Two things were deliberately left: five notes name something genuinely absent, and 34 tests are written so that if the code throws an error the test marks itself skipped instead of failing — meaning those 34 can never fail, whatever breaks. That second group needs each test's behaviour checked by hand and is being tracked separately rather than stripped mechanically.
We can now tell which switched-off tests are switched off for no reason. Around 280 tests quietly skip themselves with a note like "that database table isn't there". A skipped test still shows a green tick, so this has been frozen at a ceiling to stop it growing — but nobody knew how many were still genuinely needed. A new report answers that by comparing each note against the actual database layout the tests run against. The answer: 184 of them name tables or columns that do exist, so those tests can run and the notes are simply out of date. Only 5 are still real. This does not switch anything on by itself — it produces the list to work through, and it can never fail a build.
Linked accounts got a round of plain usability care, on every screen that shows them. Every card now says what kind of link it is — family member, guardian, carer or organisation — which was always stored but never shown, so a member with several links can finally tell them apart at a glance. The people with the most at stake, those whose account someone else can help manage, now see exactly what each helper can do, worded from their side ("Your listings: prepare only — you approve each one"), instead of seeing nothing at all. A request you sent now shows how long it has been waiting and has a proper "Cancel request" button rather than only an unlabelled bin icon — and that icon can no longer fire twice if pressed twice. Adding a link now lets you say what kind of relationship it is up front (the server always accepted this; only the accessible site ever offered it). The support-level choices on the safeguarding page now use the same polished control as everywhere else. On the accessible site, the linked-accounts page now links straight to the "Waiting for your approval" queue, and in the phone app a coordinator-recorded arrangement now shows a plain note that the levels are the supported person's to set, instead of switches that looked changeable. All new labels are hand-translated in every language, reusing the accessible site's existing wording so no two screens disagree.
The accessible site now has the whole message-access loop too — plain HTML, no JavaScript needed for any of it. Everything the main app gained works identically on the accessible (GOV.UK-style) site: the helper's card shows ask / waiting / on-since states for messages; the supported person answers the request on their usual "Waiting for your approval" page; their own settings row says who can view, when they last looked, and offers the one-press stop; and the read-only viewer asks why you're looking first — the reason goes into the same permanent record — before showing conversations with no reply box anywhere on the page. Because web addresses end up in logs and history, the reason is never put in the address: it's held privately for half an hour and then must be given again. People messaging the supported person see the same carefully worded notice as on the main app (it never says whose helper it is), and the person themselves gets the standing reminder with a link to manage it — a notice the accessible site's conversations had been missing entirely, even for coordinator review. Every screen is in all eleven languages, reusing the same hand-checked translations as the main app so the two sites never say different things. Verified live in a browser: ask → purpose form → read-only list and conversation → permanent record written → stop → access refused. Eight new automated checks pin the loop, including that nothing leaks before a reason is given and that the person's unread counts show no trace of a helper's visit.
The message-viewing consent loop is now on screen, end to end — asked for, approved, visible, and revocable in the app. On Settings → Linked accounts, the helper's card now shows one of three honest states for messages: an "Ask to view their messages" button, "Waiting for their approval" while the person decides, or "On since…" once they agree. The supported person answers the request from the same "Waiting for your approval" card used for everything else, and from the moment they agree their own settings page says so in plain words — who can view their messages, when they last looked (or "never so far"), and a one-press "Stop them viewing my messages" that takes effect instantly. The reading screen itself opens from the helper's card: it first asks why they're looking (recorded permanently), then shows the conversations strictly read-only — no reply box exists on the page at all. Everyone else is told too: anyone messaging a person whose conversations a supporter can view sees a notice, worded so it never reveals whose helper it is or whether it's a coordinator instead, and the person themselves gets a standing reminder in their own conversations with a link to manage it. All of it in all eleven languages. Verified end to end in a live browser: ask → approve → view (with the reason recorded and the database refusing edits to that record) → withdraw → access refused.
Two faults were caught during that live walk and fixed before shipping: the conversation list arrived wrapped in a paging envelope the screen didn't expect (the page crashed; the test that should have caught it passed vacuously and now pins the real shape), and "last viewed" was reported in the wrong timezone, making a look from two minutes ago read as an hour old.
The message viewing itself is now built: strictly read-only, with a permanent record of every look. With the person's consent in place, their helper can open a read-only view of their conversations. Read-only is enforced at every layer: nothing can be sent, nothing gets marked as read (the person's unread counts show no trace a helper was ever there), messages the person deleted for themselves stay deleted, conversations with people on other timebank platforms are excluded entirely, and there is deliberately no sending route to attack. Before anything is shown, the helper must say why they're looking — and that reason goes into a permanent record that cannot be edited or deleted by anyone, including administrators; the database itself refuses. No reason, no access, no record — in that failure order. A safeguarding restriction placed on the pair instantly beats an existing grant. The supported person will see "last viewed" from this record on their settings page — accountability they can check, not just an admin artefact.
A helper can now ask to see the messages of the person they support — and it only ever happens with that person's own say-so. This reverses an earlier deliberate omission, on the owner's decision, and it is built around consent rather than a switch. When a helper turns the option on, nothing happens except that the supported person is asked — in the app and by an email with one-tap approve and decline. Only their yes switches it on; declining is always fine and never needs a reason. They can withdraw it at any moment with one press, withdrawal takes effect instantly, and turning it on again always requires a fresh yes. Asking twice while a request is open doesn't nag them again. Two protections are permanent: the old "view messages" tick-box that saved-and-did-nothing for years can never quietly activate the real thing — accounts that ticked it long ago get nothing until the person consents today — and coordinators or brokers can never hold this power at all. This piece is the foundation (the permission and consent machinery); the actual read-only viewing screen, the notice shown to people in those conversations, and the permanent record of every viewing are the next pieces of the same build.
You can now decide what a coordinator-recorded guardian may actually do for you. When a coordinator records that someone supports you, that record on its own grants nothing — and until now there was no way to give that person any real help, because the screen where support levels are set is driven by the helper, and the platform rightly refuses to let a helper grant themselves powers over you. The result was a dead end: for any pair a coordinator had recorded, the support levels were unreachable. Now, once you have agreed to an arrangement, your own Safeguarding settings offer the same two choices as any other linked account — whether that person may help with your listings and with your time credits, each set to nothing, "prepare only, you approve each one", or "do it on their own". It is your decision alone: the guardian cannot set it, cannot change it, and is simply told what you chose. You can take it back at any time, every change is written to the permanent history, and the helper only sees what you granted, marked as yours to change. Available on the main app and the accessible site, in all eleven languages.
Posting a listing for someone you support now uses the real listing form, not a cut-down version of it. The screen a helper used offered five boxes — title, offer or request, category, hours, description — against a real form with fourteen. There was no way to add a location, a photo, the skills involved, whether it can be done remotely, or any note about accessibility. So a helper acting for someone produced a visibly poorer listing than that person could have posted themselves, which is backwards: someone who needs help posting is the least likely to go back later and fill in what was missing. The screen now renders the very same form used everywhere else, so it has every field, the same checks, and the same writing assistance — and the next time that form gains a field, this screen gains it too, automatically.
Sending time credits for someone was thin in a different way: it never showed whose money it was. It now shows the supported person's balance and refuses an amount larger than they have, or larger than the community's single-transfer limit — the same guardrails the person gets on their own screen, checked against their wallet rather than the helper's.
Two things needed building behind the scenes to make this honest. Adding a photo, and attaching skills, previously failed for a helper because those actions are restricted to the account's owner — so the helper could fill the whole form in and then watch the photo silently fail. Both now work through a route that carries the helper's authority and confirms the listing really belongs to the person they support. One limit remains and the screen says so plainly rather than dropping it quietly: when a helper is only preparing something for approval, a photo can't be attached yet, because there is no listing to attach it to until the person approves.
A helper can now actually see the activity they were allowed to see — on every frontend. "View their activity" is the one linked-account permission that is switched on by default — and until now there was no screen behind it, on any frontend. A family could grant it, see it save, and nothing anywhere would ever show the activity. Now a "See their activity" button appears on the person's card in Settings → Linked accounts, opening a read-only summary: hours given and received, connections and groups, and their recent activity. The button only appears when the permission is actually on, the view offers no way to act — seeing and doing are deliberately different levels of trust — and if the permission is withdrawn while the window is open, it says so plainly instead of showing an empty page. Translated in all eleven languages, with tests pinning that the button tracks the permission and that no action buttons can appear in the view.
The same view exists on the accessible (GOV.UK) frontend as its own plain-HTML page — no JavaScript needed to read any of it, permission withdrawn mid-visit redirects back with a plain explanation — and in the native mobile app as an expandable section on the same card, translated in that app's seven languages. All three go through the identical server-side permission check, so what each one may show cannot drift apart.
A member's offers and requests now move with them when they are moved to another community — and no ghosts are left behind. Until now only the account itself moved. The member's listings stayed live in the old community, showing "Unknown" as the author; anyone who tried to message or pay that author got "Recipient not found", and a request sent against such a listing sat stuck forever because its owner could never see it. This is exactly the "scattered stuff" problem reported when members were moved in the past, and it was reproduced end-to-end on a running server before being fixed.
What now travels with the member, in the same all-or-nothing operation as the account itself: their offers and requests (each one's category is matched by name into the new community, or marked uncategorised if there is no equivalent), their skills, and their interests. The search index is refreshed for both communities, which also fixes a separate bug where a moved member kept appearing in the old community's member search. Any open exchange requests that involve the member but carry no credits yet are closed, and the other member gets a notification in their own language explaining why.
Two situations now stop a move with a clear explanation instead of quietly breaking things. If the member is the only owner of a group, the move asks the administrator to hand the group to someone else first — otherwise the group would become permanently unmanageable. If the member has an exchange that is underway, awaiting confirmation, or in dispute, the move waits until it is finished — cancelling it would destroy real work, and completing it after a move could not pay out correctly.
Three related faults found during this work are also fixed, because they can be triggered by members moved before this change existed. Completing an exchange with a vanished counterparty used to take the payer's credits and give them to no one — it now refuses loudly and no credits move. Requesting a listing whose owner has left the community now says exactly that, instead of creating a request nobody can answer. And a volunteer's impact certificate — the kind shared with employers — now keeps verifying after its holder moves community; previously the check silently began returning nothing.
Exchange history, messages, feed posts, group memberships, event records, reviews and volunteering hours deliberately stay in the community where they happened: each involves another member or that community's own record, and dragging them along would corrupt the books on both sides. The move dialogs state precisely what moves and what stays, in all eleven languages.
Added
A helper can now be given a middle level of trust: "Prepare only — they approve each one". Until now a linked account offered only all-or-nothing: a carer could either do nothing or act alone. The new middle level mirrors Ireland's co-decision-maker arrangement: the helper prepares a listing or a time-credit transfer, and it happens only when the account owner approves it. Approving takes one tap — a card appears on their dashboard and on the Linked accounts page — or one click on a link emailed to them, which works without signing in: the link is single-use, expires after 14 days, and just opening it does nothing (a button must be pressed), so an email scanner can never approve on someone's behalf. Declining is always offered, never needs a reason, and doing nothing is always safe — the request simply expires and both people are told. Every approval is carried out through the member's own code path, recorded as prepared by the helper, and written to the audit log. Available in all eleven languages.
The accessible (GOV.UK-style) frontend has the same approval queue at Settings → "Waiting for your approval" — plain forms, no JavaScript needed, approve/decline/withdraw all work identically, in all eleven languages.
The two "guardian" systems are now one system. For years the platform had two disconnected things called "guardian": a staff-written note that granted nothing, and a member-approved account link that granted real powers — stored separately, with nothing connecting them, and looking identical on screen. Staff-recorded arrangements now live in the same system as everything else. Nothing changes about what anyone sees or can do: the arrangement still grants nothing until the member themselves grants powers; members still see and answer arrangements in the same safeguarding screens (agree, refuse with no reason needed, or withdraw later); coordinators still create and end them from the same broker tab; and a member's "no" is still recorded as their own answer, never as an administrative removal. What changes underneath: one history, one set of screens per audience, no more parallel bookkeeping — and trying to record an arrangement between two members who already have a link is now refused with a clear message instead of silently creating a duplicate. All previous records were carried across; the old records remain untouched as a permanent archive.
Every change to a linked account now leaves a permanent record. Requesting a link, approving it, changing what a helper may do, and ending the link each write an entry — who acted, in what role, from where, and for permission changes the exact before and after — into a history that the database itself refuses to edit or delete. Until now these links, which grant real power over another member's listings and credits, had less audit history than the staff notes that grant nothing. Re-saving an unchanged setting deliberately records nothing, so the history stays meaningful. Groundwork for folding the two "guardian" systems into one.
Communities can now record what an act-alone power is based on. When a helper holds act-alone power over someone's listings or credits, a coordinator can record that they have personally seen the formal authority behind it — a decision-making representative court order, a power of attorney, or a registered support arrangement under Ireland's 2015 capacity law. The record deliberately refuses evidence: no document numbers, no dates, no uploads — the form rejects them outright, because the platform must never become a filing cabinet of court orders. What staff write is stored encrypted; marking a record as no longer valid uses a fixed list of reasons; and every change lands in a history that the database itself refuses to edit. This is a record, not a power switch — nothing is granted because of it; act-alone power is always granted by the member themselves. Lives under the broker panel's Support Actions tab, in all eleven languages.
A coordinator can record an approval given by phone, in person, or on paper. Some supported members will never click a link or open an app — this whole redesign exists for them. When such a member approves a prepared action offline, a coordinator can now record that approval, naming how it was given and who witnessed it. The record is honest about being weaker evidence than the member's own click — it is stored as "recorded offline by staff", never dressed up as the member's own action — and the member is always notified that it happened in their name, so a wrong record comes to light. Only coordinators and administrators can do this; an invalid channel is refused and nothing happens. The screen lives in the broker safeguarding panel under a new "Support Actions" tab, which lists everything awaiting an answer in the community, states the honesty rules before anything is submitted, and offers the witness field as optional.
Helpers can now prepare a listing or a transfer on screen. On Settings → Linked accounts, a helper with either action level sees "Prepare a listing" and "Prepare a transfer" buttons on the person's card. The form says plainly, before anything is filled in, what submitting will do: at the "prepare only" level, "Nothing happens yet — they will be asked to approve this"; at the "act alone" level, "This will happen immediately… the record shows you did it on their behalf, and they will be told." The submit button matches — "Send for their approval" versus "Do it now" — so nobody is surprised by what a click does. A test fails if a prepare-only helper's form could ever reach the immediate path.
Changed
Linked-account permissions now run on a three-tier support model under the hood. Following Ireland's Assisted Decision-Making (Capacity) Act 2015, each capability a supporter holds (see activity, manage listings, send credits) is now one of three levels: assist (see and help), co-decide (prepare, the supported member confirms), or represent (act alone). Every existing family link keeps exactly the power it had — the old on/off switches translate losslessly and no data conversion runs. Two rules are enforced in code with tests that fail if they are ever weakened: a co-decider can never pass a check meant for acting alone, and staff (brokers) are capped at co-decide — they can never be granted act-alone power over a supported member. Nothing changes on screen yet; the member-facing tier choices arrive together with the confirm step, so no option is ever shown before it works. One small visible correction: asking the API to enable the retired "view messages" permission used to store the setting (while granting nothing); it is now simply ignored and old rows that carried it are cleaned to "off" on their next save.
The word "ward" is retired from every screen. The platform now says "supported member". Wardship was abolished in Ireland by the Assisted Decision-Making (Capacity) Act 2015 (commenced 2023; the last wardship cases must be discharged by October 2027), so the term is legally obsolete — and it describes a person by their dependency rather than as a member. The broker and admin safeguarding dashboards (table columns, the "Consented Wards" statistic, the assignment form labels, the built-in help text) and three API error messages now use "supported member" phrasing, translated in all eleven languages. Database columns and code identifiers are unchanged — this is what people read, not how the system stores it. First step of the approved guardian-module redesign.
The deploy-time database safety check no longer raises false alarms on ordinary words. Before a release goes live, the server refuses any database change it judges risky while the current version is still serving. It was matching dangerous keywords as bare fragments of text, so a completely harmless change was blocked because the word "changed" appeared inside a list of event names. It now matches whole words only. This does not make the check any less strict: it was re-tested against nine genuinely destructive operations — dropping a column, renaming or truncating a table and the rest — and every one is still caught. Only the false alarms stopped.
Security
The security scan no longer hides its own failures behind each other — the gap that let advisories go unnoticed is closed. The scan runs several completely separate security checks in sequence: leaked-secret detection, two different dependency scanners, a PHP dependency check and a vulnerability database check. They have nothing to do with one another. But the system running them stops at the first failure, so whichever check went red first made every later check silently vanish — they showed as "skipped", which reads as nothing to see rather than never looked. This is not theoretical: in one recorded run a single dependency finding wiped out four blocking checks, including the one that looks for passwords and keys accidentally committed to the repository. So a real leaked secret could have gone undetected simply because an unrelated library advisory was published that week — which is exactly how the advisories fixed above sat unseen behind one another, revealing themselves one at a time as each was fixed. Every check now runs regardless of whether an earlier one failed, so one scan reports all the problems at once instead of the first one. This makes the scan stricter, not laxer: nothing was given permission to fail, and a check that runs and finds something still stops the build — only the hiding stopped. Checks are still abandoned if the run is superseded by a newer one, so no time is wasted on results nobody will read, and the reasoning is written into the file so a future check added there inherits it.
Three newly-published high-severity advisories cleared, restoring the security scan to green. The scan had been failing on every push to the main branch since the early hours of 8 August — not because anything in the platform changed, but because new flaws were published overnight against libraries we already had. Neither is code we wrote; both arrive indirectly.
nanoid(a short-ID generator reaching us through the stylesheet build tool and, in the phone app, the navigation library) could be sent into an infinite loop, and it had a proper fix, so every copy in the web frontend and the phone app was moved from 3.3.16 to 3.3.18 — a small patch update that moved nothing else with it.image-sizehad two flaws with no fixed release published at all, so there was nothing to upgrade to; it is recorded in the scan's ignore file with the reasoning written out and a dated note to revisit. It reaches us only through the phone app's development bundler, which runs on a developer's machine while building and is not part of the app anyone installs, and the only images it reads are our own. Verified by running the same scan locally: all four dependency lists now report zero findings.The security scan checks dependencies twice, with two different tools, and the second check had been invisible: it is skipped whenever the first one fails, so it had been silently sitting behind the failure. Fixing the first check revealed it was flagging the same
image-sizelibrary under a different reference number. Confirmed there is genuinely nothing to upgrade to — the tool reports every published version as affected, and the only upgrade it offers is a major downgrade of the phone app's React Native framework, from 0.81 back to 0.72, which would break the app without fixing the flaw. Both flags are recorded in that check's own reviewed exception list with the same reasoning and revisit date. All four dependency trees now pass both checks, verified locally before pushing.Closed a newly-published high-severity advisory in a small text-matching library used by our build and test tooling.
brace-expansion— a helper that expands patterns likefile.{js,ts}, pulled in indirectly by the test-coverage tool, the code linter and the performance checker — had a vulnerability published against it (CVE-2026-69152, rated 7.5). No platform code changed and nothing our members use was affected: the library is a development-only dependency and never reaches the shipped website, which was confirmed by checking the production dependency tree separately (it contains no copy of it at all). Every copy across the project was moved to a patched release — 1.1.16 to 1.1.18, 2.1.2 to 2.1.4, 5.0.8 to 5.0.9 — all small patch updates already permitted by the existing version rules, so nothing else moved with them.Updated the Markdown library the platform uses for formatted emails, closing six newly-published advisories. Six vulnerabilities in
league/commonmarkwere published on 6 August, four of them rated high: several ways a crafted message could tie the parser up in knots (a denial-of-service), and one that let a disguised link slip past the safety filter that is supposed to block dangerous links. The platform does not call this library directly — it arrives as part of the web framework and is used when rendering Markdown in emails — so no platform code changed. Upgraded from 2.8.2 to 2.9.0, which the framework already permitted; the security audit now reports nothing outstanding.
Fixed
When a helper acts on your account, you now hear about it properly — by email, not just a silent bell entry. If someone you allowed to act alone posted a listing in your name or sent time credits from your balance, the only trace was an entry in the notification bell that said "someone who manages your account" did it. No email, no phone notification, no name. Both events now arrive immediately on every channel and name the person — with a note that everything done on your behalf is recorded and a reminder that you can change what they may do at any time. Three more silent moments now speak as well: ending an account link tells the other person (previously they learned by absence); a change to a helper's levels tells the member whose account it is — immediately if the helper's power grew; and when a prepared action is withdrawn or expires unanswered, the member whose approval was pending is told, instead of being left with a prompt pointing at nothing. All in the recipient's own language, in all eleven languages.
The approvals list no longer shows an empty page when it fails to load, and the activity switch now visibly responds. If the list of things awaiting your approval failed to load, the page looked exactly like "nothing to approve" — dangerous for a member with a real transfer waiting. It now says the load failed and offers a retry. Separately, the "View their activity" switch saved correctly but never moved on screen until a reload; it now responds immediately. And on coordinator-recorded arrangements, the summary now shows all three granted levels instead of omitting the activity one.
Turning a helper permission "on" from the phone or the accessible site can no longer quietly upgrade it to full authority. The permission for a helper has three levels: off, "prepare only" (the helper sets things up and the member approves each one), and "act alone". The phone app and the accessible website only had on/off switches — and a "prepare only" arrangement showed up on them as an off switch. Flicking that switch "on" didn't restore what was there: it silently replaced "prepare only" with "act alone", handing the helper the exact power the middle level exists to withhold. Three layers now prevent this. The platform itself refuses to upgrade an existing level when an old-style on/off signal arrives — "on" means on, never "maximum". The accessible site now shows the real three-level choice (worded from the right perspective: "Their listings… they approve each one"). And the phone app's switches now read and write the true level, granting only "prepare only" when switched on — choosing "act alone" deliberately remains a web decision for now. Proven end-to-end: a "prepare only" grant was saved and re-saved through the accessible form and stayed exactly "prepare only"; automated tests pin all three layers so none can quietly regress.
Closed a newly published security advisory in the mobile app's build tooling. A YAML-parsing library used during mobile development and testing (never on members' phones) had a freshly disclosed flaw that could let a crafted file tie up a build machine. Both copies are now on the fixed version.
Creating a guardian assignment with an unknown email address failed silently. When a coordinator typed an email that matched no member (or paired someone with themselves, or repeated an existing arrangement), the platform refused correctly — but the screen showed nothing at all: no message, the dialog just sat there. The refusal's own explanation ("Supported member not found in this community", and so on) now appears, so staff know exactly what to correct. Found by the owner while testing; a regression test now fails if the message ever goes silent again.
The mobile app stopped offering the "View messages" switch that never did anything. The web and accessible frontends removed it on 5 August because no code anywhere checks that permission — it saved successfully and did nothing, so a family could believe a carer could read a dependent's messages when no such thing happened. The mobile app kept offering it until now. A test now pins that it cannot quietly return.
Being asked to let someone manage your account sent no email, and the notification led to the wrong page. Reported by the owner after receiving such a request. When a member asked to be linked to another member's account, the platform wrote a single entry in the bell menu and stopped there — no email and no device notification, because that code wrote the notification record directly instead of going through the dispatcher that also handles email and push. The bell entry then linked to
/settings, which opens the Profile tab, leaving the recipient on a page of ten tabs with nothing indicating which one held the request waiting for them. Both events now go through the dispatcher, and both the bell entry and the email button deep-link to the linked-accounts tab. The email is sent immediately rather than waiting for a digest, because being asked to let another person post listings and spend credits on your behalf is not digest material.Approving such a request used to notify nobody at all — the person who asked could only find out by going back to the settings page and noticing the status had changed. They now get a bell entry, an email and a push notification, naming who approved it. Both messages render in the recipient's own language, not the sender's, in all eleven languages.
One smaller fault fixed in passing: the bell text inserted the raw relationship code into the sentence, so a German reader saw "…als organization" — an English code word mid-sentence. It now inserts the translated label.
Each fix carries a test that fails if the fix is removed, including one asserting a failed approval notifies nobody.
The "Create Guardian Assignment" form claimed the guardian could read the member's messages. That was never true. The help text under the guardian email field on the broker safeguarding dashboard said "The guardian can view messages sent to and from this member" — but a guardian assignment is a written record for safeguarding staff and grants no access of any kind, and nobody on the platform can read another member's messages (that carer permission was deliberately removed on 2026-08-05 because it would expose the other person in the conversation, who never agreed). The danger ran one way: a broker could believe monitoring was in place and skip a protective step they would otherwise have taken. The text now says what the record actually is — a note for safeguarding staff that gives the guardian no access to the member's messages or account — in all eleven languages.
Moving a member to another community failed for almost every real member, with a blank "server error". An audit of the super-admin "Move to Different Tenant" feature found it worked in tests but not in practice. The reason: when someone signs in, the platform keeps a record of that sign-in session, and a database safety rule ties those records to the member's current community. Moving the member breaks that tie, so the database refused the move — for anyone who had ever signed in. Test accounts never sign in, which is exactly why every automated test passed while the real thing failed. The move now clears those session records first (they are already cancelled at that moment — moving a member signs them out everywhere, which is deliberate and unchanged), so the move goes through.
Three more ways the same button produced a bare "server error" now give a proper answer instead: moving a member into a community that already has an account with the same email address (or username) is refused with a message saying so; a destination community that does not exist or has been retired is refused with a message saying so; and pressing "move" to the community the member is already in says that, instead of erroring. One genuine limitation is now stated rather than crashed into: a member with event records — registrations, invitations, guardian consents — cannot be moved, because those records are deliberately locked to the community they happened in. The screen now says exactly that, and names what is blocking the move in the response for support to see.
The screens also told the administrator something untrue. The dialog said the member's "memberships will be updated for the target community" — they are not, and never were. What actually moves with the member: their profile, their sign-in, and their time-credit balance. What stays in the old community: their exchange history, listings, messages, group memberships and event records. The dialogs now say precisely that. The bulk-move screen had a worse version of the same problem: when every single member failed to move, it showed a green "moved (0)" success message — it now shows an error, and a partial move shows a warning with both counts.
Every fix carries a test that fails if the fix is removed, including the first test in the codebase that moves a member end-to-end through the real endpoint.
The new performance monitor could turn an unrelated request into a server error, if logging was misconfigured. The monitor is written so that its own failures never reach the person using the site — it records the problem and gives up quietly. But the act of recording the problem was itself unprotected, so if the log itself was broken, the very code meant to contain the failure became the failure. It then surfaced in whichever request happened to be in flight — in the case that exposed this, someone resolving a disputed exchange got a server error, and that page's own error handling broke the same way, so nothing about the real cause was ever written down.
A monitor with no safe way to fail is worse than no monitor, so writing the record is now protected too. That is the end of the line: if the log cannot be written, there is nowhere left to report to, and the request carries on unaffected.
Found because it happened: an automated test run had its log setting written in a way that produced an empty value, which made every attempt to write a log fail. That setting has been corrected — two neighbouring settings in the same file were already correct and this one was not — and the monitor no longer depends on it being right.