1.5.8
Released 2026-07-29
Added
Every test suite now runs in the pipeline, across eight parallel shards. The blocking test steps covered roughly 150 of the project's 1,283 suites, so about 88% of the suite could break and still produce a green build — the second of the two structural reasons breakage here is only ever discovered in large batches. A new job splits the whole suite across eight shards, each assigned by a hash of the file path so that adding one test file reshuffles only itself rather than moving every other file to a different shard and invalidating the timings.
Suites already failing for reasons that predate this job are listed in a quarantine file and skipped, so the job can cover everything else immediately rather than waiting until all 1,283 are green. That list may only shrink, and a quarantined path that no longer exists fails the run rather than sitting there indefinitely — a renamed test would otherwise quietly stay excluded forever. A visibility step runs the quarantined suites too, without gating on them, so a suite that gets fixed is noticed instead of remaining excluded because nothing ever runs it.
The job is deliberately non-blocking at first. Its first duty is to measure: the quarantine list was seeded from a local census covering 335 suites, of which 39 failed, and the rest of the suite — plus anything that fails only on a Linux runner — has to be catalogued before the job can gate anything. Shards do not cancel each other on failure, so one bad shard cannot hide the other seven's results, and each shard names every file as it starts so a shard that exhausts its time budget identifies the suite that hung. The comment above the job records the three changes that must happen together when it is switched to blocking, including adding it to the release gate's dependency list — a job missing from that list reads as enforced while being unable to fail the build.
The failures found so far are overwhelmingly stale queries and assertions left behind as the application changed underneath the tests, which is accumulated rot rather than new breakage. The runner also fixes a latent inconsistency: the pipeline's inline test invocations never raised Node's heap limit the way every package script does, so they silently ran with a quarter of the intended memory.
Fixed an infinite render loop in a test that looked like a component-library bug and was neither. A batch of admin smoke tests aborted with React's "maximum update depth exceeded", thrown from deep inside the component library's collection machinery — which read as a genuine defect in a live admin page. It was not: the page's own test suite passes completely. The cause was in the test's own setup, where the notification helper was rebuilt from scratch every time a component asked for it. Handing a component a brand-new object on every render invalidates anything that depends on its identity, so an effect re-ran, set state, re-rendered, received another new object, and looped until React gave up. The library frames appeared only because a dropdown re-registered itself on each pass.
Creating that helper once fixes it, and the file now passes completely and returns to the gate. The reasoning is recorded in the file, because the symptom points firmly at the wrong culprit — as it did here for a while.
I checked whether this was widespread before assuming: twelve other quarantined files build mocks the same way, but none of them fails with the loop signature, so the pattern is only dangerous when a component puts the object in a dependency list. Worth knowing rather than fixing blindly.
Translation setup no longer wipes itself in tests, removing a class of "passes on my machine" failures. The test environment deliberately loads the committed English translation files up front so components render the real strings users see. But the application's own translation setup re-initialised the library unconditionally when imported, replacing those loaded strings with a network loader that cannot fetch anything in a test environment — and it is pulled in indirectly by the API client and two core contexts, so it affected most page tests. Every lookup then fell through the missing-key path, whose output differs between an interactive developer session and the pipeline. That is why the same assertion could pass locally and fail in CI, or the reverse.
The setup now leaves an already-initialised instance alone. In a browser nothing initialises it first, so production behaviour is unchanged. This fixes no failing test on its own — it was pursued as a likely shared cause for a group of failures and turned out not to be one — but it removes a genuine source of environment-dependent results, which had already sent one investigation down the wrong path. Verified against roughly 3,600 tests across three independent selections with no change in outcome.
Forty admin test suites are back under the gate, from one root cause. The largest group of known-broken suites — 42 of them, failing because they could not find elements by their test markers — turned out to share a single defect rather than being 42 separate problems. Each stubs the whole group of shared admin building blocks, while the page under test loads the one it needs by its own path; the test runner keys replacements per module, so the replacement never took effect, the real component rendered, and the marker the test looked for was never there. Pointing each replacement at the paths the pages actually load fixed 40 outright: 489 of 491 tests across those files now pass, and the suites blocking a release rise from 1,179 to 1,219 of 1,283. The known-broken list drops from 104 to 64.
One trap is recorded in every file, because it bites immediately: the shared replacement must be declared as a
function, not assigned to aconst. Replacement registrations are lifted above the rest of the file, so aconstis still uninitialised when they run.The remaining two are fixed for this defect but held back by a second one — each asserts literal on-screen text that the component produces through translation, and no translations load in the test environment. That is the same environment-dependence found in the glossary component, and it is the next group to work through.
Two subtleties worth recording. Where a page loads several components from one file, the replacement must be bound to that file, not to the component's own name — one page takes its status badge from the data-table module, so binding the badge's own path would have done nothing. And where a replacement is bound to a file whose other exports the page also needs, it must pass the real module through, or those exports become undefined; two files were deliberately left partly unbound for exactly this reason rather than break a confirmation dialog.
The automated mock check was comparing text instead of resolving modules, and was blind to most of the defect it exists to find. It decided whether a test targeted one of the watched component groups by comparing the written import string. But a test can name the same group several ways — a path relative to its own folder is the identical module as the project-wide shortcut — and the tool that runs the tests resolves them to the same thing. So a test naming the group relatively was skipped outright.
This was not cosmetic: of the 42 admin suites failing for this exact reason, only 14 were visible to the check, because the other 28 spell the group relatively. It now compares the resolved module, falling back to the written string only when a path cannot be resolved, so nothing is silently dropped. A regression test covers the relative spelling.
Fixed an abbreviation component that could render a meaningless abbreviation, and a test whose result depended on which machine ran it. The admin glossary component wraps a term in an
<abbr>whose tooltip text comes from the translation files. Given a term it doesn't recognise it still emitted the element with an empty tooltip — worse than emitting nothing, because assistive technology announces an abbreviation and then offers no expansion for it. Unknown terms now render their content plainly. Real call sites are constrained to known terms, so this only bites if a term is removed from the dictionary while something still references it.Its test suite was failing for a subtler reason: no translation resources load in the test environment, so the component produced an empty tooltip locally, while the pipeline fell back to showing the raw translation key. The same assertion therefore passed or failed depending on where it ran. The test now pins the lookup so it means the same thing everywhere, checking the part that is actually this component's responsibility — that it looks the definition up under the right key — rather than whether a particular translation is present. Both suites are green and back under the gate.
A correction to yesterday's note in this file: the "file never loads at all" failure described previously was not a real category. Three suites appeared to fail that way, and all three load and run correctly on re-examination; the errors were a transient local build-cache artifact, not a defect in those files. One of the three had never been on the known-broken list at all. The genuine remaining failure in that group is an infinite render loop in a component library collection, which is a real and separate problem.
Repaired a course-creation test file that was absent from the suite entirely, and removed tests for a component that no longer exists. The course-creation file contained a genuine syntax error — an
awaitinside a non-async setup block — which meant the whole file failed to compile and all thirteen of its tests were silently missing rather than reported as failing. Fixing that exposed five real failures underneath, all the same weakness: the tests located the Save button by taking "the first button that isn't disabled" and clicking whatever that happened to be, so the save was never triggered. They now find it by its accessible name. Two of them also wrapped their assertions in a conditional, meaning a missing button would have passed silently. The file is now fully passing and back under the gate.Separately, tests and a stand-in for a legal-document version form were removed: that component was deleted months ago and replaced by the full-page editor (which has its own tests), but the references were left behind pointing at a file that no longer exists. An unresolvable import fails the entire file at compile time, so those references were taking every other test in their files down with them. Both files have a second, unrelated loading problem and remain on the known-broken list — the dead references were simply the first error, not the only one.
Worth noting: the type-checking gate independently flagged both the
awaitmisuse and the missing import. They were part of the recorded backlog rather than new, so the gate held its baseline instead of failing — but this is the class of defect that no longer accumulates unseen.Fixed the last blocking test step that had no retry, after it failed the build twice in one day on timing alone. Two suites — one for message threads, one for team chatrooms — failed in that step while passing locally on the first attempt and passing in the full-suite job, which does retry. In both cases the test queried the page while loading placeholders were still on screen. The step now retries a failing test once, matching the full-suite job; a genuinely broken test still fails both attempts and still fails the build, so only the timing lottery is removed. The reasoning is recorded next to the step, since the file that carries the setting cannot hold comments.
Grouped the 106 known-broken suites by cause, which turned up a blind spot in the check meant to catch this exact defect. Fixing them one file at a time would have been 106 unrelated edits; grouped by failure signature they collapse into a handful of causes, the largest by far being 42 suites that cannot find an element by its test marker. Those markers are overwhelmingly a few shared admin building blocks — a statistics card, a data table, a page heading, an empty-state panel — accounting for several hundred individual failures between them.
The cause is the same defect the automated mock check was built to find: the test replaces a whole group of admin components at once, but the page loads the one it needs by its own individual path, so the replacement never takes effect and the real component renders without the marker the test is looking for. The check only ever inspected two groups of components and not the admin one, so the single biggest instance of the defect it exists to catch was invisible to it. It now inspects the admin group too, which adds 22 findings across 14 files to the recorded total — measurement, not new breakage.
Worth recording why the obvious shortcut is wrong: none of those markers has ever existed on the real components, which the project history confirms. Adding them to make the tests pass would put test-only attributes into production components and preserve the deeper problem, which is that these tests currently assert against stand-ins rather than real behaviour. The fix is per-suite — point the replacement at the path the page actually loads, or rewrite the query against what the real component renders.
The check's own tests were updated to derive the number of inspected component groups instead of hard-coding it, so adding another group in future cannot break them the way it broke them this time.
Seventeen suites came off the known-broken list and back under the gate, taking it from 123 to 106 and the suites that block a release from 1,160 to 1,177. The step that runs the known-broken suites for visibility — added precisely so a suite that quietly starts working again gets noticed rather than sitting excluded forever — reported these passing on its first outing. Each was then re-checked locally with retries switched off, so none was included merely because a retry rescued it. The cap moved down to match; it can only ever move down.
The full-suite test job now gates the build. It spent its trial period unable to fail anything, on purpose, while the list of already-broken suites was catalogued. It was switched on after two consecutive runs had all eight shards green with zero genuine failures and no test needing its retry — so the pass was real, not retry-assisted. 1,160 of the 1,283 suites now block a release, up from roughly 150. The remaining 123 are the recorded known-broken list, capped and shrinkable only.
Three changes had to land together, because any one alone produces something that looks enforced and enforces nothing: removing the marking that let the job fail harmlessly, adding it to the release gate's dependency list (that gate decides from its dependencies, so a job missing from the list can conclude anything without consequence), and the cap on the skip list. The reasoning is recorded above the job so a future reader does not undo one part of it.
Two safeguards remain deliberately in place. A shard failure does not cancel the other seven, so one bad shard cannot hide the rest. And the step that runs the known-broken suites for visibility is still allowed to fail on its own terms — otherwise the 106 suites expected to fail there would have brought the whole job down the moment it started gating.
Groundwork for making the full-suite job actually gate the build: one retry per shard, and a cap on the skip list. Two things stood between the job and being able to fail a build, and neither was the job itself.
The first was flakiness. Across three trial runs, two suites failed once and passed on a re-run, so no run had all eight shards green on the first attempt. Turned blocking at that rate the job would fail builds semi-randomly and train everyone to ignore the pipeline — the exact habit this work exists to reverse. Each shard now retries a failing test once. That is deliberately retry rather than skip: adding a flaky suite to the skip list would buy a green build by throwing away the coverage, whereas one retry separates the two cases honestly — an intermittent test passes on its second attempt, while a genuinely broken one fails both and still fails the shard. The trade-off is written into the source: retries make intermittent failures quieter, not fixed, so a suite that keeps needing one is a bug to fix rather than a permanent arrangement.
The second was that the skip list is the obvious way to fake a green shard — one line turns a failing shard green. It is now capped at its current size and can only be lowered.
One detail worth recording, because it is the same trap the job's own instructions warn about: that cap is enforced in the main frontend job, not inside the full-suite job. The latter is currently marked to not fail the build, and that marking applies to everything inside it — so a check placed there would appear enforced while being incapable of failing anything.
All eight shards of the full-suite job now pass, so every one of the 1,283 test suites is either running in the pipeline or explicitly listed as known-broken. Fixing the shard that hung revealed it had only six genuine failures behind the hang; with those recorded the quarantine list settles at 123 of 1,283, and 1,160 suites run on every frontend change — up from roughly 150. The job stays non-blocking for a few more runs to confirm the result is stable rather than a single lucky pass, and the run in which seven shards passed and one failed also confirmed that a failing shard cannot redden the build while it is in this state.
First results from the full-suite job, and two fixes it paid for immediately. The initial run catalogued 78 further failing suites, taking the quarantine list from 39 to 117 of 1,283. The most useful finding is where they were: most of the failures are ones the local census did not see, because they fail only on a Linux runner. Running the census in the pipeline rather than on a developer machine was therefore the right call — a local sweep would have declared the suite far healthier than it is.
The one shard that neither passed nor failed was hanging, and the cause was the runner's own fault rather than a bad test. It was forcing every file in a shard through a single process — a setting borrowed from the fourteen-file smoke step, where it fixes an unrelated channel hang. At roughly 150 files per shard that setting causes a different hang: browser-environment state and memory accumulate across files until the run stalls, which is why the shared test setup already forces a garbage collection and clears the page between files. The suite's own configuration is tuned for exactly this workload and gives each file a clean environment, so the runner now defers to it. The suite named as the culprit passes on its own in twelve seconds, which is what pointed at the harness rather than the test.
Cleared the first batch of the remaining tests whose stand-in components silently did nothing, taking the recorded count from 129 to 115 and the files affected from 31 to 28. All three marketplace suites replaced form controls on the shared component group while the pages import each of those controls by its own individual path, so the replacements were never installed and the real components have always rendered.
Each block was commented as protecting the test from components that can loop endlessly in the test environment — a protection it never actually provided, since the stand-ins were never in place. The suites pass with the real components, so the loop being guarded against does not occur, and the comments were describing a safety that did not exist. The replacements were therefore deleted rather than pointed at the paths that would have made them live: switching fourteen stand-ins on for the first time would change what these tests actually exercise, which is the failure mode the previous pass hit and had to undo. Each file records which treatment it received and why. Verified by the 38 tests in those suites and the 817-check component contract gate.
Worth noting because it is the point of the exercise: the new test-file type gate caught its first real change immediately — deleting the dead code removed a type error along with it, and the gate refused to pass until that improvement was recorded, rather than letting the baseline quietly overstate the remaining debt.
The focused smoke-test list is no longer duplicated in two places. The same fourteen paths were written out inline in both the smoke step and the coverage step, free to drift apart with nothing to catch it. Both now read one file.
Test files are now type-checked, after being checked by nothing at all. The project's TypeScript configuration deliberately excluded every test file and the shared test harness, and the linter ignored the same paths — so renaming a prop or changing what a hook returns was a compile error in application code but completely invisible in the tests that exercise it. 1,951 type errors had accumulated across 650 of the 1,281 test files, none of them reported anywhere. This is one of the two structural reasons test breakage here is only ever discovered in large, painful batches; the other, that the automated pipeline runs only about one test file in seven, is addressed separately.
A second configuration checks the same source tree with the test exclusions lifted, and a new gate holds the error count to a baseline that can only shrink. The existing debt is therefore recorded rather than fixed in one heroic pass, while a new error fails the build on the commit that introduces it. The gate also fails when a baselined error is fixed without the baseline being regenerated — otherwise the record slowly becomes fiction and re-breaking that file would pass unnoticed. Its output distinguishes the two cases, so an improvement reads as "lock the win in", not as a failure.
The baseline records errors per file and per error code, which is the detail that decides whether a gate like this survives. Line numbers churn on every unrelated edit above an error, so a line-based record would cry wolf until people stopped believing it; a bare per-file total would let a brand-new error class slip into an already-erroring file as long as one old error was fixed in the same edit. Errors may move freely within a file, but one more of a given kind, or any kind not already recorded there, fails.
Two decisions worth recording. The gate reads the compiler's structured diagnostics rather than parsing its printed text, so nothing depends on compiler message formatting. And the test configuration deliberately does not declare an explicit list of global type packages: doing so replaces automatic inclusion, which silently dropped the Google Maps type definitions and produced 29 phantom errors in four application files that the normal type-check passes cleanly. Test files must be checked in the same type environment as the application, or the baseline records artefacts of its own configuration. Test globals are supplied by a single reference file instead, which carries that reasoning as a comment.
The gate refuses to run blind: if fewer than 1,000 test files reach the compiler, it fails rather than reporting a clean pass — an include rule that stopped matching the test suite would otherwise disarm the whole check with a green build. One genuine harness error was fixed in passing (a forced garbage-collection call in the shared setup file was typed as
unknownand therefore not callable).The Partner API now has its own kill switch, sitting beside the federation one. An audit of the federation kill switch turned up a second external system it never covered: the Partner API (AG60), ten endpoints under
/partner/v1that let approved third parties read members, listings and wallet balances, credit wallets, and subscribe to webhooks using their own bearer tokens — plus outbound webhook delivery. Switching external partner federation off did nothing to any of it, so "external access off" was not true.It gets a separate switch in Super Admin → Federation rather than being folded into the federation master, so each label keeps meaning exactly what it says: one governs federation protocols with other installations, the other governs third-party API access. Tests assert the two are independent in both directions.
The gate wraps the whole
/partner/v1block rather than sitting inside the partner auth middleware, because the OAuth token and revoke endpoints deliberately run without it — gating only the authenticated routes would have left the token mint open. Outbound partner webhook delivery is gated too. Blocked callers get HTTP 503 withRetry-After, matching the federation gate, and existing tokens and webhook subscriptions are preserved and resume on re-enable. It ships disabled, which matches the existing posture: thepartner_apitenant feature already defaults to off, so the API is opt-in.The Partner Timebanks panel now says when external federation is switched off, instead of presenting it as live. A panel-wide notice appears above every page while the kill switch is off, and the two sidebar sections that carry traffic to other installations — "External connections" and "Access & security" — are marked. Previously those pages gated only on the tenant's
federationfeature flag, so with external federation disabled an operator still saw external partners, protocol configuration, API keys and webhooks as fully working, with nothing to explain why actions silently failed.The pages stay reachable rather than being hidden: an operator needs them to inspect and reconfigure before switching federation back on. The notice states explicitly that federation inside this installation is unaffected — without that, someone reading only "federation disabled" may conclude same-install partnerships are broken and switch the gate back on to "fix" it. The status is read from the tenant-scoped federation settings endpoint rather than the platform one, because this panel is reachable by tenant super admins who are excluded from platform-super-admin routes. A failed read renders nothing rather than a false alarm.
A kill switch for external partner federation, so protocol traffic with other platforms can be switched off for safety review — one protocol at a time. Super Admin → Federation gains an "External Partner Federation" panel with a master switch plus an individual switch for each of the seven external protocols (Nexus native, Komunitin, Credit Commons, the legacy v1 API, partner webhooks, cross-platform hour transfers and aggregate reporting). It ships with external federation off, so each protocol is re-enabled deliberately as its audit passes.
This was not simply a new toggle. The existing "Federation" switch did not do what it appeared to do: turning it off left all 17 Credit Commons endpoints (including relayed and three-phase transactions), 16 of the 17 Komunitin endpoints, the whole legacy v1 API — including the endpoint that mints access tokens — the inbound hour-transfer endpoint and the public aggregates endpoint all answering external callers exactly as before. Outbound pushes to partners were similarly ungated. Fifty-nine external routes are now gated, verified by a test that enumerates each one individually.
The switch is deliberately a separate axis from the existing controls: it governs traffic with other installations only, and federation between communities inside this installation — including sub-communities and the Partner Timebanks panel — keeps working when it is off. The panel says so on screen, and a regression test asserts it, because the failure mode to guard against is a future operator "fixing" the disabled switch out of fear it had broken something internal.
Blocked callers receive HTTP 503 with
Retry-After, not 403 — their credentials are fine and the capability is temporarily withdrawn, and many federation clients treat a sustained 403 as permanent revocation. The response deliberately does not name the protocol, so an unauthenticated caller cannot enumerate which protocols this installation supports. Where the internal controls fail open on a database fault, so a brief outage cannot sever working same-install federation, the external switch fails closed: a missing configuration row, a missing column, an unrecognised protocol or any database error all resolve to "blocked".The panel also shows, per protocol, how many outbound pushes were blocked in the last 24 hours, so it is visible whether anything is still trying to reach partners. Blocked inbound attempts are recorded to the application log only — deliberately, since three of these endpoints are unauthenticated and writing an audit row per rejected request would turn the kill switch into a way to make the platform write unbounded rows.
Two further subtleties worth recording. Blocked outbound calls are not counted as partner failures, because doing so would trip the circuit breaker and leave partners unreachable for five minutes after the switch was turned back on — re-enabling takes effect immediately, and a test proves it. And
FEDERATION_ENABLED,FEDERATION_API_VERSIONandFEATURE_FEDERATIONhave been removed from the example environment file: they were documented as if they were switches but no code has ever read them, which is a plausible way to believe federation was off while it was on.
Changed
Notification text, safeguarding wording, and the event-management screens are now translated too. Notifications are the messages members read most often and were entirely English in every language; those, the safeguarding vocabulary, membership dues, and the ten event-management files are now translated — about 2,900 more values.
One family of files was being skipped silently and is not any more. Eleven translation files still open with an older array syntax, which the rewriting step did not recognise; rather than guess where the data began it refused to touch them, which was correct but meant those namespaces would have been passed over without anyone noticing. Both syntaxes are now handled, and the files come back in the modern form.
The accessible frontend is now translated into nine languages instead of being English throughout. All 24 of its translation files — the whole HTML-first accessible experience: wallet, volunteering, listings, events, groups, jobs, messages, goals, search, saved items, the marketplace and courses, the feed, member and organisation pages, federation, and the shared page furniture — held byte-identical English in every language but English. Around 32,400 values are now translated. This track exists specifically for people who need a plainer, more accessible interface; serving them English regardless of the language they picked was the part of it that did not work.
A defect in the translation tooling was found and fixed by the safety check rather than by anyone noticing broken text on screen. Placeholders are hidden behind a marker before being sent for translation, and the marker was initially a word — which the translation service duly translated:
nexuscame back asnexoin Spanish and Portuguese, and aslienin French. Those values could then not be reassembled, and 207 of them were refused and kept as English, exactly as intended. The marker is now a single meaningless letter, and reassembly identifies markers by position rather than by name, so a translated marker can no longer break anything. The 207 refused values were re-translated afterwards; one value platform-wide remains English by this rule, a multi-line AI prompt.Every message the API sends is now translated into nine languages instead of being English. All 1,977 server messages in the API's translation file — validation failures, refusals, "not found", rate limiting, wallet and volunteering errors, everything the server writes back during a request — were byte-identical English in every language except English. 17,777 values are now translated across Arabic, German, Spanish, French, Italian, Japanese, Dutch, Polish and Portuguese. This is the file behind roughly 4,900 places in the code that write a message to a member or an administrator, so it is the single largest piece of user-facing text the platform has.
Placeholders were the real hazard, not the words. A message like
:field must be :max characters or fewer.breaks visibly if a translation service moves, drops, or duplicates one of those markers — the member reads a literal ":max" mid-sentence. Every marker is therefore hidden before translation, restored after, and then counted: if the set of markers coming back does not match the English exactly, the English is kept and the value reported instead. That happened 17 times out of 17,777, each one a case where the service inserted a word inside a marker or ran two together. English that a reader can still understand is a far better outcome than a sentence with a broken placeholder in it.Every rewritten file is re-read by PHP itself and compared against what was meant to be written, so a formatting mistake cannot pass as valid-but-different.
All ten languages are included, Irish among them. Irish was initially left out on the strength of a note saying free machine translation of it was too poor to ship. That note was wrong: the existing tooling does treat Irish specially, but only because the paid translation service it prefers has no Irish at all — the free one does, and its Irish is of a piece with the other nine. About 5,900 Irish values are translated here alongside the rest.
On quality: this is machine translation. It is a large improvement on guaranteed English, and it is not finished work. It gets ordinary sentences right and gets domain terms wrong in predictable ways — "rate limit" has come back in several languages meaning a price or a speed rather than a request rate, and "broker" sometimes as an estate agent. Japanese and Arabic in particular would benefit from a native reader. The measurement added in this release makes that reviewable rather than invisible.
Untranslated text in the backend's translation files is now measured and can no longer get worse. The existing check on those files compares which keys exist in each language, so a file passed it while every line in it was a word-for-word copy of the English. That was not a corner case: 62.3% of all non-English values — 99,139 of them — were byte-identical English, and the check was green. Copied English is invisible to a key-based check by design; the key is there, and only its value is wrong.
A new check counts values instead, and holds the count as a ceiling per file. Adding English fails it. Removing English passes and says how much was removed. It is deliberately a ceiling rather than a pass/fail line: a debt of ninety-nine thousand lines cannot be repaid in the same change that starts measuring it, and a check that fails the day it is written gets switched off instead of fixed. Backfilling those values follows in this release.
Reading the files is done by asking the language they are written in — one process for all 462 of them, about a second — rather than by pattern-matching their text, which is how a value slips through unexamined. Where a value is genuinely the same in another language (a currency code, a product name, a borrowed technical term) it is listed by its text, so one entry covers everywhere that text appears rather than becoming a per-line list of exceptions nobody reads.
The app now tells the server which language it is being read in. Every API request carries the language selected in the app, so anything the server writes during that request comes back in it. Previously the app sent nothing of its own and the server had to fall back to the browser's language, which is often not the language the visitor chose — this was the one gap left open when API response language was fixed earlier in this release.
It changes most for people who are not signed in: registration, password reset, email verification, and the public pages have no saved preference for the server to read, so the browser's language was the only signal available. A signed-in member's saved preference still wins; this only replaces the guess underneath it, and an explicit language in the address still overrides both.
Only the eleven languages the platform actually has are sent, and regional variants are reduced to the language (
pt-BRbecomespt) rather than sent for the server to discard. File uploads assemble their request separately and are covered too, because an upload can be refused and its refusal is read like any other.Admin screens now show the server's own words when an action is refused, in the reader's language. Twenty-six places across the admin panel read the server's message from a field the server never fills — or from a
catchblock that can never run — so the message was silently dropped and replaced with a generic local one. On six of those, nothing checked whether the request had succeeded at all: saving the civic-digest cadence, editing an isolated-node item, sending an emergency alert, and loading the emergency-alert and survey lists all reported success, or showed an empty list, when the server had actually refused. Those are now reported.Two mistakes were repeated across the file: reading
messageon a failed response, which only ever carrieserror; and expecting the API client to throw on a rejected request, which it does not — it returns a failed result. Both read as correct code, and both meant the same thing in practice: the reason the server gave was thrown away.A related dead end was cleaned up on the sub-regions form. Per-field validation was being pulled out of an
err.response.data.errorsshape belonging to a different HTTP library — inside acatchthat never fires — so field-level messages never appeared and every rejection produced one flat toast. It now reads the errors the response actually carries.The check for untranslated admin text is now blocking, and can see through a type cast. Admin screens keep a lot of their wording in TypeScript objects and in server messages, where a JSX-based linter cannot see it, so a separate check covers those paths. It only ran when somebody remembered to type the command.
It also had a blind spot that hid this entire class of defect: it identified a server message by the text of what it was read from, so
(res as { message?: string }).messagedid not look like a server message to it. A cast is exactly what a developer writes when the property is not on the declared type — which is the case most worth flagging — so the check was blind precisely where it mattered. It now reads through casts, parentheses, and non-null assertions.Suppressing a line now also accepts the reason in the comment block directly above it, rather than only on the line itself. A one-line reason has to be terse, and the reason is the entire value of a suppression.
Translated URLs and other must-stay-literal text are now caught automatically. A check already existed for this and had found a real defect — the Irish admin copy had translated the route
/partner/v1into/comhpháirtí/v1, sending Irish-speaking administrators to an address that does not exist — but it only ran if someone remembered to type the command locally. It is now a blocking check on every relevant change.The existing translation check compares which keys exist in each language, so it cannot see this class of problem: the key is present and looks translated, and only its value is wrong. Since translations are often filled in by machine, and machines translate anything that resembles a word, this will recur. The check now also runs when the checking scripts themselves are edited, which the previous file-matching rule missed.
Fixed
API responses now follow the language the member chose, instead of their browser's language. Anything the server writes during a request — validation messages, refusals, service errors — was rendered in whatever language the browser asked for, ignoring the language the member had actually selected in the app. Someone who set the platform to French but browses with an English browser received English.
The locale is resolved from four things in order: an explicit override, the member's saved language, the browser's
Accept-Languageheader, then the platform default. The second of those never applied. Locale resolution runs early enough to cover every API route, which also means it runs before the request's login token has been checked — so at that moment there is no known member to read a preference from, and resolution quietly fell through to the browser. Three of the four tiers worked, which is why this held up under casual inspection.It also survived testing. The standard way to write an authenticated test happens to make the current member available earlier than a real request does, so the saved preference appeared to be honoured in tests and only failed in production. The new test deliberately signs in the way the app itself does, and additionally asserts that its own request was authenticated at all — an unauthenticated request would have passed the language check for the wrong reason and proved nothing.
The language is now applied again the moment the member is identified, which is the earliest point it can be known. An explicit override still wins. The
Content-Languageheader on the response is now read back from what was actually used, rather than from the earlier guess, so it no longer reports a language the body was not rendered in.One related gap is recorded but deliberately not addressed here: the frontend sends no language of its own with API calls, so requests made before signing in still depend entirely on the browser's header.
Sub-region errors in the Caring Community admin now appear in the admin's own language. Naming a sub-region with no usable characters, or reusing a web address already taken by another sub-region, produced English text — "Invalid sub-region slug." and "Sub-region slug already exists for this tenant." — for every administrator regardless of the language they had chosen. Both now use translation keys, translated into all ten other languages.
This is the same shape of defect as the federation one below, and it was found by looking for the shape rather than by a report: a message written in English deep in the service layer, handed outward unchanged by the controller, and displayed by an admin screen that already had a translated fallback string sitting next to it — a fallback that could never run, because a message is always supplied. The regression test that guarded the federation service has been generalised to cover every service whose admin-facing refusals have been cleaned, so none of them can quietly reacquire an English literal. Its scope is a deliberate list rather than a scan of every service: a larger tail of untranslated exception text still exists elsewhere, and a check that failed on the day it was written would have been switched off rather than fixed.
Federation partnership errors now appear in the admin's own language. Every refusal from the partnership lifecycle — requesting, approving, counter-proposing, rejecting, suspending, reactivating, ending, and changing permissions — was hardcoded English in the service layer. Forty-four of them. A French, German or Irish administrator got English text such as "Target tenant is not accepting federation requests" no matter what language they had chosen.
What made this hard to spot is that the admin screens looked correct. Each one already had a translated string sitting beside the server message as a fallback, so the code read as though it were covered. It was not: the server always supplies a message, so the fallback never ran. Every one of those translated strings was unreachable for real rejections. The path in between has no translation step anywhere — the service returns the text, the controller passes it through, and the API client copies it into the field the toast displays — so whatever the service writes is what the admin reads.
All forty-four now use translation keys, translated into all ten other languages. Refusals coming from the federation availability gate are handled slightly differently: that gate returns both an English diagnostic and a machine-readable level code, and the message is now chosen from the level. The English diagnostic deliberately stays English, because it is what operators read in logs and error reports, where a message that changes language with whoever triggered it is worse than useless. Admins get their own language; operators keep stable text.
A regression test guards all of it, and refuses any future hardcoded error string in that service, any translation key that does not exist, and any interpolated message whose placeholder does not match the key it is passed.
Turning external federation off no longer causes a retry storm. Four listeners that push to partners — reviews, messages, transactions and accepted connections — classify a failed push as retryable and throw so the queue retries it. A push refused by the new kill switch is reported with status code 0, which that rule read as a transient fault, so switching federation off made every queued push throw and retry until its attempts were exhausted, raising an alert each time. A deliberate operator action should be quiet. Blocked pushes are now terminal, while genuine connection failures and 5xx responses still retry; both halves are asserted.
Repaired the test suite's external-access posture, which the kill switch broke. Both switches ship disabled, and roughly 25 existing suites assert what the external surfaces do when reachable — protocol endpoints, push listeners, partner auth, rate limiting. With the production default in place they were asserting against HTTP 503 instead of the behaviour under test. The base test case now seeds both switches enabled, mirroring pre-switch behaviour so those assertions keep their meaning, and the tests that exercise the switches disable them explicitly.
It seeds
federation_enabledand clears the emergency lockdown as well as the external columns: the external switch is nested under both, so seeding only the child left the posture dependent on whatever an earlier test in the same process last committed to that singleton row — an order-dependent failure that surfaced only when suites ran together.Custom pages built in the page builder were rendering with no styling at all, and now render correctly. Every custom page lost its entire stylesheet before reaching the browser — the baseline rules that give the page its background, text colour and image sizing, the page's own styling from the builder, and the light/dark theme overrides. Pages fell back to whatever the surrounding app happened to apply, so anything laid out or coloured in the builder appeared plain.
The cause was a sequencing mistake in the sanitiser that cleans builder content before display. It handed the finished page, stylesheet included, to the HTML security library, and then looked for the stylesheet in what came back — but that library strips stylesheet blocks as a matter of course, even when explicitly told they are permitted. The stylesheet was therefore always gone by the time the code went looking for it, and the page was published without it. The fix separates the stylesheet from the page body before that step rather than after.
Nothing was loosened to achieve this. Styling safety was never that library's responsibility here: a dedicated policy confines every rule to the custom page container, discards rules that try to target the surrounding application, and strips attempts to break out of the container even when marked as high priority. That policy is unchanged and was re-verified in a real browser — a page trying to hide the whole site with a global rule still cannot, while its own legitimate styling now applies. Regression coverage already existed for all of this and had been failing; it passes now, and the eight failing checks are what led to the bug being found.
Repaired five core test suites that had been silently failing, and found out why tests here rot. Measuring the suite properly turned up two structural causes rather than bad luck. First,
tsconfig.jsonexcludes every test file from type checking, so renaming a prop or changing what a hook returns is a compile error in application code but invisible in the tests that exercise it — 1,957 such type errors have accumulated across 653 of the 1,282 test files, none of them reported anywhere. Second, the automated pipeline only ever runs about one test file in seven, so runtime breakage is silent too. Both safety nets are off, which is why breakage is only ever discovered in large, painful batches.The five repaired here — the two authentication context suites, the tenant context suite, and the
useApianduseMenushook suites — all failed for the same underlying reason: the application now shows localized messages where it used to pass the server's raw error text straight through, and the tests still asserted the old English wording. One of them also mocked the translation module without providing itstfunction at all, which crashed nine tests outright.Rather than paste the new wording in, each assertion now resolves its expected text through the same translation key the code uses, so rewording a message in a locale file cannot fail these tests again — only a real behaviour change can. Two tenant assertions were pointed at the stable error code the provider now exposes for the consuming screen to localize, which is the actual contract. 242 checks across the affected areas pass, and all five suites were added to the blocking pipeline step so they cannot quietly rot a second time. No application code changed.
Repaired the tests whose stand-in components were silently doing nothing in a way that changed what the test checked. The automated check added in 1.5.7 recorded 302 of these dead replacements, graded by how much damage each could do. This pass clears the whole top grade — all 114 of them, across 27 test files — and takes the total from 302 to 129 across 31 files, with the ratchet lowered so it cannot drift back. Rather less than half that reduction is the top grade itself; the rest follows automatically, because a replaced module is never executed and so stops dragging its own imports into the picture.
Three distinct faults sat behind the same symptom. Eleven suites believed they were holding the realtime, presence, tenant and toast layers still while the real ones loaded underneath them, so any assertion about live updates was checking nothing. Two of those also replaced a map component on the group import path while the page loads that map by its own individual path — so the real mapping library was loading in tests that appeared to have stubbed it out. The remaining sixteen did the same with dialogs, tab strips, dropdown menus and tooltips.
Where a test's expectations had quietly grown up around the real component, the real component won: five suites had their misleading replacement deleted rather than switched on, because their checks rely on genuine accessibility roles and on a real dialog marking the page behind it hidden — behaviour no stand-in reproduces. Switching those on would have turned a dead replacement into a live and wrong one, which is worse; one of them was caught doing exactly that, breaking a passing test, and was reverted. Each repair carries a note recording which of the two treatments it got and why.
All 327 tests across the 27 repaired suites pass, which is the point: these were silent gaps in what the tests covered, not visible failures, and nothing in the application changed. To stop the repairs decaying, all 25 repaired suites that were not already covered have been added to the blocking pipeline step that re-runs them, taking it from 72 suites to 97 and from around 800 checks to 1,046.
Fixed a test that only failed when the machine was busy, and had been quietly hiding a second fault. The prerender admin suite checks that using browser back/forward moves the visible tab, by telling the page the address changed and then waiting for the tab to catch up. The waiting was the bug: the page's address listener sits outside the part of the framework the test harness controls, so the resulting redraw was merely scheduled, not applied, and the check raced it against a one-second budget. Alone that always won. Running alongside ninety-odd other suites competing for the same processor cores, it lost — and because it passed in isolation, the fragility read as an unrelated infrastructure quirk. The test now applies the redraw before looking, so it is deterministic rather than usually-fast.
This also turned out to be the reason a stand-in for the realtime layer had appeared to break the same test earlier in this work: the stand-in only shifted the timing enough to lose the same race. With the race gone, that replacement is back in place, which is what clears the last two top-grade findings and lets the suite rejoin the blocking pipeline step it had been held out of. One root cause, two symptoms that looked unrelated.
Fixed an error that failed an entire test run while every check in it passed. The federation messages page scrolls a thread into view from inside a timer, so the scroll lands after the test that opened the thread has already finished. The browser stand-in used for tests has never implemented that scroll method, so it surfaced as an uncaught error, which the test runner reports as a failed run even though all eight of the suite's checks passed. It now carries the same one-line shim two other suites already use. This had been latent for as long as the suite existed and only became visible when the suite was added to a step whose result is actually enforced — until then, nothing was reading the exit code.
Security
Audited the three externally-reachable federation endpoints that require no login, and closed two gaps in what was proven about them. These are the first of the seven protocols switched off pending review, chosen first because they are the only ones an anonymous caller can reach at all. A correction worth recording: only one of the three is genuinely anonymous. The other two authenticate inside the request handler rather than at the boundary — one by a shared-secret signature, one by an API key or signature — which is why they looked unauthenticated from the routing table alone.
The genuinely public one, which returns an aggregate activity report for a community, holds up well. A community must opt in explicitly or the endpoint returns "not found" — and it returns exactly the same response whether the community does not exist or has opted out, so a name cannot be probed. Member and partner-organisation totals come back as ranges rather than exact numbers, and any activity category with fewer than five contributors is dropped entirely rather than reported, so a small group cannot be picked out of it. Responses are signed so a consumer can detect tampering, every query is recorded with its origin and pruned later, and requests are limited both per caller and across a whole network address, so hopping between communities cannot multiply the allowance.
Two things were true but untested, which is what the audit was for. First, the date range accepted from an anonymous caller: non-dates now provably fall back to a default window, a twenty-six-year request is provably clamped to a year, and a backwards range is provably corrected instead of quietly returning nothing — which would have read as "this community has no activity". Second, and more important: the endpoint that credits hours into a member's wallet is protected by a signature and nothing else, and while that rejection was tested deep in the service layer, nothing pinned the response the outside world actually sees. A forged signature must now return 401, name the reason, and leave the balance untouched.
No blocking findings; all three are candidates for switching back on. Nothing was switched on as part of this work — that is a deliberate production decision, and the recommended order is recorded with the audit.
Documentation
Renamed the federation protocol switches so they say who is on the other end and which way data flows. The old names actively misled — including the platform's own author, who reasonably read them as "one of these is for partners on another server, the other is for communities sharing this one". Both were for other servers. The real difference between them is direction, and no label mentioned it.
Two names did the damage. "Nexus native" reads as our own internal thing when it means traffic with a different NEXUS installation — the most external thing here; a product's own name inside a protocol name will always read as "ours, inside". And "Legacy v1 API", described as the "older v1 federation API", reads as retired. Nothing replaced it: it is the only way a partner reads your members, listings, messages and reviews in this platform's own format. That wording was persuasive enough that it nearly got the surface deleted.
Every switch is now named for its counterpart and its direction — "they send to us", "they read from us", "both directions", "they notify us" — and the descriptions say plainly what crosses the boundary. The public totals switch now also states that it is the only protocol answering without credentials, that counts are rounded to ranges, and that groups under five people are omitted. All of this is translated into the other ten languages, which until now still said "legacy" in their own words.
The federation manual gains an "inside versus outside" section built around the only two questions that matter for any endpoint: is it for people already here or for another installation, and does data flow in or out. It names the three traps directly — that one route prefix holds both internal and external surfaces so the path tells you nothing, that v1 did not become v2 because they serve different audiences, and that "native" does not mean internal. The README now distinguishes the two kinds of federation and warns self-hosters that external protocols ship switched off deliberately, so nobody spends an afternoon debugging a working kill switch. Matching notes sit in the route file for anyone reading the source.
Fixed an Irish translation that had translated a URL. The Partner API description rendered the literal route
/partner/v1as/comhpháirtí/v1, which is not an address that exists. Anyone following the Irish admin copy would have been sent to a path that does not resolve.The federation manual now says, up front, that every external protocol is switched off. All of the partner federation protocols are built and complete but deliberately disabled, because none is connected to a live partner yet. The manual described how to call them without mentioning that, so anyone following its examples received a bare "service unavailable" and no explanation — with nothing to distinguish a deliberate platform setting from wrong credentials or a broken integration. It now opens with a per-protocol status table, states that a disabled endpoint answers 503 whatever credentials are presented, and notes the two things people get wrong about the switch: federation between communities inside one installation is unaffected and keeps working, and the Partner API has its own separate switch that is not turned on or off with the others.
The entry describing the version 1 partner API was also misleading. It read as though that surface were superseded, when nothing has replaced it: it remains the only partner-facing read API in the platform's own native format. The newer families do different jobs — one accepts inbound pushes, two speak other platforms' formats, and the member-facing routes are for the platform's own logged-in users. The entry now says so, and records which of its endpoints sit outside the federation authenticator and why the token endpoint necessarily does.
The admin-facing API documentation page needed no change: it already inherits a banner announcing the disabled state.