2.0.0
Released 2026-09-16
Removed
- BREAKING: Removed the abandoned mobile web wrapper, its native bridge, push/update hooks, update modal, dependencies and
/api/app/versionand/api/app/check-versionendpoints. The Expo / React Native app inmobile/is the sole native Android/iOS client; its version enforcement and native push remain supported. Project documentation and admin descriptions now reflect this scope.
Added
safeguardingon the federated member profile response — the API now says whether contact with this member would be refused, before the member tries.GET /v2/federation/members/{id}returnscontact_allowed, the refusalcodeandstatus, a member-readabletitle/detail/message,can_request_coordinatorandretryable, derived from the sameSafeguardingInteractionPolicy::evaluateCrossTenantContact()decision thatsendMessage(),sendTransaction()andFederatedConnectionService::sendRequest()enforce. Wording comes fromMessageService::buildSafeguardingError(), so the sentence shown on the profile is the sentence that would have come back with the refusal, in the viewer's locale. It is advisory only — all three write paths re-evaluate the policy and remain the authoritative boundary, so a stale or absent value cannot let a refused interaction through. It fails closed: a policy lookup that throws reports not-allowed with the retryableSAFEGUARDING_POLICY_UNAVAILABLEcode rather than advertising an action the server is about to refuse. Deliberately not added to the member list endpoint, which would cost a preference lookup per row on a paginated page.npm run check:spa-shell— the SPA-shell contract guard, now wired into something that runs.scripts/test/test-spa-shell-fallback.mjshad existed since the shell contract was written and was invoked by nothing: notpackage.json, not a workflow, notpreflight.mjs. Run by hand it failed on five assertions, three of them stale. It also only grepped the nginx config as text, so it could not have detected the defect it was written to prevent (below). It now brace-matches the block that actually generates the response and asserts the marker there, rejects the marker in a block that hands off viareturn 421, reads the shell cache name fromvite.config.tsrather than pinning a superseded literal, and skipstry_fileschains that delegate to@spa_shell. Wired into CI (react-build, BLOCKING),scripts/preflight.mjs, and.github/ci-paths.yml— the guard's own path is named in thefrontendlist so editing the guard re-runs it, sincescripts/**wakes only the PHP cluster.npm run check:seo-delivery— an outside-in probe that asks the one question nothing else asked: would a crawler actually receive words? Every existing prerender check measures snapshot production (cache writable, queue moving, renders failing, coverage, scheduler liveness) and all of them were green from 2026-07-11 to 2026-09-13 while every crawler received a 1,950-byte empty shell — because the break sat between "snapshot exists" and "snapshot is served".scripts/check-prerender-delivery.mjsfetches public URLs over real HTTP as Googlebot, through whatever CDN and proxy stack is in front, and fails when the response is an empty SPA shell (under 10 KB, empty<div id="root">, no<h1>, no meta description). It is deliberately cause-agnostic: it would have caught the unterminated sidecar, the absent marker, the orphaned lock and the superseded rebuild equally, on day one. Exit 0 pass / 1 blank / 2 unavailable — never reported as a pass. Verified against production both ways: passes onhour-timebank.ieandtimebank.global, and correctly fails onapp.project-nexus.ie, which has no snapshots and genuinely does serve crawlers a blank shell. Runs non-blocking after a deploy whenNEXUS_DELIVERY_ORIGINSis set in the per-installation env file (hostnames deliberately are not hardcoded in the repo, so a new installation configures its own).
Changed
Ryde Time Bank community hostnames (
ryde.timebank.global,accessible-ryde.timebank.global) joined the platform on 2026-09-16. Both are now inscripts/uptime-targets.json(the accessible host twice: root, and/versionassertingnexus-webuk) and in the two deploy warnings that enumerate the accessible hostnames--without-webukwould take offline. The Apache vhosts and the origin-certificate binding live only on the server, as for every other community domain.Native app version 1.7.2, Android version code 15: the replacement internal-testing candidate keeps Request exchange actions fixed below the sheet title, shortens and labels the form, and supersedes build 14 after physical Android testing showed its bottom actions were still difficult to reach with the keyboard open. Production remains on build 12 / 1.6.0 pending a successful device walkthrough.
Native app version 1.7.1, Android version code 14: the replacement internal-testing build fixes Android keyboard handling in shared bottom-sheet forms and supersedes build 13. Production remains on the proven 1.6.0 build until this candidate passes a physical-device walkthrough.
Native app version 1.7.0, Android version code 13: the internal-testing build containing the completed mobile audit, polished federation and listing/message layouts, reliable exchange sheets, employer interview and offer actions, and the native date picker required by those hiring flows. The new runtime prevents this native dependency surface from being sent to the 1.6.0 binary by an over-the-air update.
Fixed
Prepare Android 1.7.4 (build 17) with the native-verified drawer viewport and long-note keyboard scrolling fix. Published to Internal testing and submitted for full Production rollout on 2026-09-16; Google review is pending.
Native form drawer keyboard scrolling: constrain the actual scroll viewport to the visible area above the keyboard, accounting for Android's status-bar coordinate offset. Keep focus while the drawer expands, refresh its bounds after snapping or keyboard dismissal, and provide expandable defaults for scrolling forms. Verified with long notes and normal/200% text on an Android emulator; physical Samsung and iOS verification remain outstanding.
Prepare Android 1.7.3 (build 16) for Internal testing with the shared bottom-sheet keyboard and long-note improvements.
Mobile bottom-sheet forms now use a constrained scrolling viewport and shared input-focus handling to reveal fields above the keyboard. Long notes use a bounded, internally scrolling editor; Request exchange removes duplicated placeholder labels. Physical keyboard verification remains required before promoting this follow-up.
Request exchange keeps its essential actions visible while typing on Android. Cancel and Send request now sit in a fixed action row directly below the sheet title, outside the scrolling form, so the keyboard and a long note cannot push them out of reach. The form removes its repeated prompt, gives both fields persistent labels and uses a shorter three-line note area.
Android internal-testing release records now include build 14 / 1.7.1. The release procedure records its exact source commit, signed artifact size and hash, completed CI, Play publication time and the physical-device gate that keeps production on build 12.
Native bottom-sheet fields no longer collapse when the Android keyboard opens. Shared inputs notify the sheet when they gain focus, and the sheet follows Android's existing resize behavior instead of adding the keyboard height a second time. This keeps the Request exchange title, hours field and note reachable; the separate fixed action-row correction above addresses the footer actions that physical testing showed were still difficult to reach.
Android release instructions now match the working Play Console upload path. The procedure records the successful 96 MB browser upload for build 13 and the Windows native-clean ordering failure, avoiding the obsolete 10 MB limitation and a reproducible failed build attempt.
Native hiring dates now follow the language selected inside the app. Interview summaries, the Android interview picker label and offer start dates use the app's regional locale instead of silently falling back to the phone's default language.
Fresh installations now include the complete durable hiring workflow schema. The canonical database snapshot includes the application-decision and hiring-delivery outboxes, interview and offer creation receipts, and their migration records, so clean environments match upgraded installations and the hiring delivery workers can start safely.
Native employers can now schedule real interviews and send complete job offers from the hiring pipeline. The pipeline exposes the server's interview and offer actions only when the community enables them, shows current action state, supports cancellation and withdrawal, and keeps translated, scrollable forms and responsive actions above the keyboard and system navigation. Unanswered submissions reuse a durable account- and community-scoped receipt identity; Laravel safely replays the same request and now persists the offer message, salary currency and pay type that its API accepted.
Job offers and interviews now retain their member notifications after a transient failure. Each committed offer or interview action writes a payload-free delivery fact in the same transaction; a scheduled worker resumes unfinished bell, push, realtime and interview-email steps with bounded retry and dead-letter state. Exact accepted/declined/cancelled/withdrawn replays retry the pending delivery, while vacancy, application and action locks prevent stale creation or withdrawal from racing a terminal hiring decision.
Listing-linked message threads and exchange requests now stay usable on Android. The compact thread context link stretches across the conversation instead of collapsing into a tall narrow block over the messages, and Request exchange keeps its wrapping Cancel and Send actions in the shared safe-area-aware sheet footer above the system navigation bar.
Native candidates can now see and safely answer real interview invitations and job offers. My Applications includes the current candidate action state without exposing private interviewer notes, and unanswered mobile writes use an exact authoritative readback before reporting success. Vacancy, application, offer and interview decisions now share a lock order so simultaneous employer and candidate actions have one outcome; terminal decisions also close stale offers and interviews, while accepted offers record application history.
Committed hiring decisions now retain a durable member-delivery record. The decision, audit history and delivery fact commit together; a scheduled worker retries unfinished bell, push and realtime dispatch with bounded backoff, resumes after each completed channel, and dead-letters repeated failures for investigation. Exact decision replays also retry any still-pending delivery without duplicating the member's bell notification.
Native hiring decisions now survive lost responses and simultaneous reviewers without duplicating or overwriting outcomes. Candidate updates include the exact stage the owner reviewed, Laravel serializes the transition, exact replays return the committed result without repeating history or notifications, and a stale different decision returns a translated conflict. Android and iOS read the applications back after an unanswered request and refresh stale cards after conflicts.
The native hiring pipeline now follows the active account and community. Replacing a signed-in account while a vacancy pipeline is open clears the previous owner’s selected stage and candidate view, starts the new route lifecycle, and discards late decision feedback or refreshes from the ended account.
Native job applications now close on an already-open screen at the server-defined deadline. The jobs API returns the exact cutoff and the server time used to evaluate it, so Android and iOS can disable an open application sheet at the correct instant without guessing from the phone's timezone. The screen preserves the member's draft while locking submission and editing after closure.
Native realtime now recovers after a transient configuration failure at startup. Returning the app to the foreground retries the full realtime setup when no client could be created, instead of attempting to reconnect a client that never existed and leaving live messages disabled until another sign-in.
Native realtime and unread badges now follow the active account through an in-place session replacement. A replacement account gets a fresh private channel and count refresh, late responses and events from the prior account are discarded, the prior launcher-badge count is never mirrored under the new identity, and logout fully disconnects the socket.
Automatic native community repair no longer outlives the account that started it. If a member signs out or another account replaces the session while the app is correcting a token/community mismatch, the stale repair stops before switching communities and cannot navigate the ended account back into the app.
Native session renewal now survives encrypted-storage failures without reporting a temporary session as durable. Rotated refresh credentials are saved before access credentials, both writes are mandatory, and a failed Keychain or Keystore write leaves renewal retryable instead of installing a memory-only token that can strand the next app launch.
Native course curriculum edits now recover from a lost creation response without duplicating content. Cohorts, sections, lessons, quiz scaffolds and questions retain an encrypted community/account/course/content-bound operation until confirmation. Laravel records each accepted child resource atomically, replays the original result for exact sequential or simultaneous retries, and rejects changed or cross-endpoint key reuse.
Native course screens now remain readable at enlarged system text. Course summaries, teaching actions, status badges, instructor controls, curriculum lesson controls, analytics tiles, lesson statistics, grading actions and learner navigation reflow into full-width rows at large text sizes, while long summary and lesson text is no longer clipped.
Native course drafts now survive a lost creation response without duplicating the course. The app retains an encrypted community/account/content-bound operation across restart and reuses it until the server confirms success. Laravel stores the course and its durable receipt atomically, returns the original course for exact sequential or simultaneous retries, and rejects reuse of the key for changed content.
Native course authoring now keeps later edits protected after the first draft save and ignores rapid duplicate creation gestures. Saving a new course no longer permanently disables the unsaved-changes warning, and synchronous in-flight guards prevent repeated presses from creating duplicate course drafts, cohorts, curriculum sections, lessons, quiz scaffolds or questions before the disabled state renders.
Completed native courses now repair certificates, notifications and XP after an interrupted backend integration. Course completion records durable delivery work in the same database transaction as learner progress, attempts it immediately, and retries pending work through the scheduler with bounded backoff. Replays are idempotent, so recovery cannot repeat the completion count, certificate, in-app notice, completion email or XP award.
Native course grading now protects instructor work and preserves the first accepted decision. Edited score, pass/fail and feedback fields warn before navigation, remain protected while saving, reset when the account, community or course changes, and ignore rapid duplicate taps. A lost response retries the exact desired grade once; Laravel serializes grading under a row lock, accepts that exact replay and returns a conflict instead of allowing a stale or simultaneous instructor decision to overwrite the stored result.
Opening the native app while already signed out no longer reports that the session expired. The protected Home tab now waits for authentication restoration before mounting, preventing its feed request from producing a misleading 401-driven warning on the sign-in screen.
Scheduled native-device checks now pass through mandatory administrator MFA instead of stopping before the emulator starts. The disposable E2E administrator is enrolled with a public test-only TOTP secret, and the live response-contract verifier completes the real challenge without weakening the platform policy or logging credentials.
The iOS launch smoke now uses Xcode 26.2, the minimum toolchain for Expo SDK 55. The workflow still selected a pre-26 compiler retained for the former SDK 54/Stripe combination, causing Expo Modules Core's Swift 6 actor-isolation code to fail before the simulator build could launch.
Native account-recovery links now survive first-run community selection. A new Android or iOS installation opened from a password-reset or email-verification link resumes that exact recovery screen after the member chooses their community instead of discarding the link and landing on Sign in.
Native authentication cards no longer collapse when the keyboard opens at enlarged text sizes. Sign-in, registration, password-reset and email-verification cards retain their full content height so headings and explanations remain readable and scrollable. The verification resend field scrolls into view with its action and now rejects malformed email addresses before making a request.
Native form guidance remains readable at enlarged text sizes. Registration now renders the minimum password length as wrapping guidance below the field instead of squeezing it into the fixed-height password box, where Android could split and clip it at 200% text.
Queued verification-email resends now use their declared retry policy. A temporary mail-provider refusal now fails the background job so Horizon retries it up to three times, while preserving the member's previous valid verification link. Unknown and already-verified addresses remain silent and enumeration-safe.
Queued password-reset emails now retry temporary delivery failures. A mail-provider refusal fails the background job instead of being recorded as successful, allowing Horizon to make the three configured attempts while retaining any previous valid reset link. Unknown addresses remain silent and enumeration-safe.
The canonical database schema now includes every receipt and outbox table used by the native reliability fixes. Fresh environments and schema-based CI checks see the same idempotency columns and creation/message-delivery tables that the Laravel migrations install, preventing valid mobile writes from being rejected against a stale bootstrap schema.
Native shift-swap requests now survive response loss without creating a second request. The app reserves an encrypted account/community/content-bound key before transport and reuses it after interruption or restart. Laravel stores the key and request hash on the swap row, returns that original row even after it was accepted, rejected, or cancelled, rejects changed content, serializes simultaneous keyed requests, and does not repeat the recipient notification.
Native shift-swap decisions now recover honestly from a lost response. Accept, reject, and cancel actions replay the exact row-locked decision once, using Laravel's idempotent same-action result. If both responses remain unreadable, the app refreshes the authoritative swap list and explains that the outcome is unknown instead of reporting an ordinary failure. A duplicate English volunteering translation key was also removed.
Competing native shift choices can no longer silently replace each other across devices. Signup now includes the assignment the member saw, and Laravel verifies that state while holding the application lock. If another device moved the member first, the later choice receives a translated conflict and refreshes authoritative state; same-target response-loss retries remain successful replays.
Native volunteer shift changes now recover from response loss without duplicate signup notifications. The opportunity screen verifies the application’s authoritative shift assignment after an indeterminate signup or cancellation, and the Volunteering hub does the same for cancellation. Laravel serializes signup on the approved application and treats an unchanged signup as a replay, so simultaneous retries leave one assignment and send one volunteer and organiser notification.
A native volunteering application withdrawal can no longer erase a competing organiser approval. Laravel now locks the application before validating and deleting it, so an approval and withdrawal have one authoritative winner. If the native app loses the withdrawal response or the organiser has already acted, it refreshes the application list before the member can act on stale status.
Native volunteer QR check-in and check-out now resist duplicate taps, account changes and simultaneous scans. The screen serializes actions before React re-renders and resets for a new account, community or token. Laravel preserves the one committed check-in timestamp when coordinators scan together, and returns the real validation reason instead of misreporting every refused check-in as missing.
Native volunteering opportunity creation now survives response loss without duplicating a role. The app keeps an encrypted account/community/content-bound replay key until the server confirms creation. Laravel serializes that key, returns the original opportunity for an exact retry, rejects changed content, and keeps the created role scoped to the active community. The creation summary and date fields also stack at enlarged system text.
The native Volunteering hub now remains readable at enlarged system text. Its hero, statistics, opportunity and organisation cards, application and shift states, swap decisions, certificates, expense and donation forms, campaign cards and history rows stack into full-width content instead of compressing copy, status chips, fields and paired actions into narrow columns.
Native organisation directories, profiles and organiser tools now remain readable at large text sizes. At enlarged system text, organisation identities, statistics, descriptions and actions use full-width rows. Organiser application and hours-review cards also stack their status and decision controls instead of squeezing labels into narrow columns.
Native volunteering detail now reflows identity, shift, organiser-decision and application controls at large text sizes. Long titles and metadata can expand, compact rows stack, and paired actions become full-width controls instead of narrowing until their labels become unreadable.
Native volunteering applications now recover safely from lost or duplicate responses and session replacement. Indeterminate and duplicate submissions read the authenticated opportunity back before reporting success; an unconfirmed result keeps the member's exact note with translated uncertainty guidance. Opportunity detail is keyed to account and community, so an earlier submission cannot refresh, notify or alter a replacement session.
Native job analytics and job forms now remain readable at large text sizes. At enlarged system text, analytics metrics and predictions use full-width tiles, chart labels become readable data rows, and stage summaries stack instead of squeezing content. Create/edit forms also stack summary, hours/credits and salary fields so labels and values retain usable width.
Native job applications now reconcile a lost submit response instead of trapping members in a duplicate-error loop. After an indeterminate write, the job screen reads the authenticated vacancy back and shows the normal confirmed state when the application exists. A later duplicate response also restores the existing applied state; if verification is unavailable, the draft remains intact with the honest uncertain-result warning.
Native podcast resume marks are now private to the signed-in account and community, and course-video failures offer a visible retry. Podcast positions use tenant-and-user-scoped encrypted keys, ignore the old shared blob, and serialize save, completion and start-over writes so a delayed save cannot resurrect a cleared mark. Native video now turns an SDK 55 player error into an accessible explanation and reload action instead of leaving a black rectangle.
Federated request cancellation can no longer erase a cross-community connection that another device just accepted. Federation accept, reject, cancel, and disconnect transitions now serialize on the relationship row. Native cancel and disconnect actions declare the pending or accepted state shown to the member; stale actions receive a conflict, and duplicate taps are suppressed before transport.
Sent connection requests can now be cancelled from the native member profile with accurate confirmation copy. The pending-request panel now exposes a full-size action, uses the pending-state server contract, and reconciles a competing acceptance before updating the profile. The Connections Sent tab now asks to cancel a request instead of misleadingly asking to remove an established connection.
Native connection cancellation can no longer erase a request that another device just accepted. Cancel-request and disconnect actions now declare the relationship state shown to the member, and Laravel checks that state while holding the same row lock used by acceptance. Exactly one competing action commits; the losing screen reads the authoritative relationship, removes a stale row from the old tab and reports the conflict instead of claiming both actions succeeded.
Committed native messages no longer lose their recipient delivery work when the event queue is temporarily unavailable. Every message now commits a payload-free delivery outbox row in the same database transaction. Laravel still attempts realtime and notification dispatch immediately, while an idempotent client replay or the scheduled consumer retries a failed enqueue without creating a second message. Repeated recovery dispatches are claimed once, transient errors use bounded backoff with sanitized diagnostics, and an exhausted delivery is dead-lettered without deleting the canonical conversation record.
Native wallet actions now give members a clear recovery path when an earlier result cannot be confirmed. Personal transfers and donations, federation transfers, and organisation deposits show a persistent translated warning, block an unchanged resend, and link to transaction history and community contact. Editing the amount, recipient, target or note allows a genuinely different action while the original uncertain operation remains protected from duplicate submission.
Federation partner-community cards now remain readable with large text. Compact metadata chips were replaced with full-size wrapping rows, and partner names, locations and descriptions can expand instead of being clipped. At large text the card identity and metadata stack vertically, keeping every label readable and tappable without squeezing words into narrow columns.
Native blank-note transfers now keep the same retry identity when the app language changes. The optional description is sent as an empty value and rendered with the viewer's localized transaction fallback, instead of storing a translated default inside the financial operation fingerprint. Retrying an uncertain transfer after changing language can no longer create a second debit key for the same intended payment.
Native authenticated responses can no longer update the wrong account after a session changes. Requests now re-check their session ownership after transport and again after asynchronous body parsing. A response dispatched for an earlier account is refused before its data reaches screen state, caches, success messages or navigation, while public requests remain usable and replacement credentials are preserved.
Native password recovery now gives expired, used and wrong-community reset links a usable exit, and isolates replacement links from stale state. A token refusal replaces the password form with the existing request-new-link action, while correctable password-policy errors retain the form. Opening a different reset link clears the prior secrets and status, and a late response for the replaced token cannot update the new route.
Native authentication status icons and the email-verification spinner now retain contrast on their actual tile color. Reset, forgot-password and verification states calculate foreground color from the tenant, success or error background currently rendered, preserving the symbol for pale community accents and state changes.
Native password-reset, forgot-password and email-verification links now open while the signed-out app is already running. Foreground account-recovery links are replayed after tenant selection, custom-scheme reset and forgot-password aliases map to their native screens with query tokens intact, and auth destinations replace the existing login route so cold-start replay cannot stack duplicate screens.
Native notification and deep-link capability checks now agree with the destination screen when a loaded community configuration omits a required switch. Missing feature/module keys are treated as unavailable after configuration loads, so a tap stays on the current screen with the existing explanation instead of opening a locked route. A wholly unavailable offline snapshot remains usable until configuration can be resolved.
Native App Links, Universal Links and notification destinations now reject short malformed percent encoding before it reaches navigation. Oversized and malformed inbound links share one bounded validation rule; direct links fail to the app root, notification and in-app destinations stay on their safe fallback, and unexpected redirect errors no longer return the same unvalidated path.
Native message, goal and poll drafts no longer fail silently when encrypted device storage is unavailable. A persistent accessible warning explains that the draft is not protected and disappears after a successful retry. Goal and poll composers remain open when a discard tombstone cannot be committed, while an accepted creation keeps its durable replay identity until local cleanup succeeds. Secure-store read failures return controlled results, and cleanup of unreachable old encrypted chunks no longer turns an already-committed manifest or tombstone into a false failure.
Failed native message drafts now survive interruption in the same order as they appear on screen. Send failure, retry cycling and edit transitions synchronously update the encrypted exact-conversation recovery state, so a failed message and the text typed immediately afterward both return after process termination. Photo-only failed drafts show their attachment names instead of an empty card.
Unsent native voice messages now survive Android backgrounding and process termination. Stopped recordings are moved out of temporary cache into app-owned draft storage, and their duration is encrypted for the exact account, community and conversation. Leaving the app while recording automatically finalizes the note before suspension. Relaunch restores the ready voice message; Cancel, confirmed discard and confirmed send remove the owned file, while an immediate send first seals its recovery record.
Unsent native message photos now survive Android process termination with their caption. Selected photos are copied into app-owned storage and their ordered metadata is encrypted for the exact account, community and conversation. Restore ignores missing files and cleans stale metadata; Remove, confirmed discard and confirmed send clean owned copies. An immediate send first seals the recovery manifest, so an interrupted upload retains both its exact payload and durable server-replay identity. If a durable copy cannot be made, the photo remains visible for the current session and the member receives a translated warning.
Unsent native message text now survives Android process termination. The active composer is encrypted and scoped to its account, community and exact conversation, restores without overwriting newer typing, and is cleared only after a confirmed send or confirmed discard. Draft writes use the existing manifest-last storage protocol, and a failed cleanup retains the message operation identity so a restored submitted draft replays the server receipt instead of creating a duplicate.
The native Polls screen and poll choices/results now remain readable at 200% Android text size. The screen introduction, create action, poll identity, question, standard choices, ranked choices and closed ranked-result rows reflow into full-width content when text is enlarged, and return to the compact layout when text size is restored without losing the route or session.
Ranked-poll results now stay hidden from non-creators until the poll closes, and the native app presents the available closed result clearly. Both the dedicated results endpoint and the response to a ranked ballot follow the poll's existing result-visibility rule. A closed ranked poll loads the authoritative voter count and first-choice totals, shows a visible loading or failure state, and lets the member retry a failed result load.
Native goal and poll creation drafts now survive Android process termination and ask before they are discarded. Drafts are encrypted and scoped to the active account and community, restore the composer after relaunch, and clear after successful creation or confirmed discard. Manifest-last chunk writes prevent partial drafts, ordered writes keep a late autosave from overwriting a discard, and only the focused route may save so an older stacked modal cannot resurrect stale content. Back and native-stack removal protect typed fields, explicit Cancel keeps the draft when the member chooses to continue editing, and every goal-composer exit, including switching to templates, uses the same decision. A dirty or still-saving goal sheet can no longer disappear through an overlay tap or downward swipe, and its Cancel button no longer mistakes the native press event for a post-close callback.
The native federation hub, populated partner/listing/group/event/message directories and details, and shared screen titles now remain readable when Android text size changes while the app is running. At 200% text, hero and directory explanations are no longer clipped, summary values and labels receive full-width rows, populated cards expose complete identity and description content, and route titles wrap beneath navigation controls. Detail identity rows and listing author actions also stack when needed, while returning to normal text restores compact columns without losing the current route or session.
Native registration no longer opens a session that encrypted storage failed to save. Registration now persists refresh and access credentials as required sequential writes before changing authenticated UI state, removes partial credentials on failure, and tells the member that the account exists and they should sign in again. This matches the established MFA session boundary and prevents a successful registration from becoming an unexplained logout on the next launch.
Native private media, large uploads, protected downloads and realtime subscriptions now use the active session consistently. These transports use a newly issued in-memory bearer while encrypted storage is still catching up, and refuse an old request if the account changes during credential reads. Authenticated downloads also prevent caller headers from replacing the trusted account or community identity.
Native polls now support the ranked and anonymous journeys their API advertised, and poll creation survives a lost response. The composer lets members choose single-choice or ranked voting and anonymous responses; Laravel now validates and persists those fields. Ranked polls arrive in the feed with their real type and the member’s saved order, render an accessible reorder-and-submit ballot, reject the single-choice endpoint, serialize concurrent submission, replay the same ballot safely and award vote XP once. Anonymous ballots no longer disclose the voter through creator notifications. Poll creation carries a durable account-, community- and exact-content-bound receipt and returns the original poll on retry. Requires the poll creation receipt migration before rollout.
Native goal creation and progress updates now survive lost responses without duplicate goals or double-counted progress. Goal and template creation carry a durable account-, community- and exact-intent-bound operation key; Laravel commits a receipt with the goal and returns the original result on unchanged replay. Progress updates send the exact value observed and desired, so retrying an accepted increment becomes a no-op while a conflicting concurrent change returns 409. Goal routes reset by account/community/goal identity, suppress late completion, serialize rapid actions, and verify uncertain progress and reminder writes from canonical state. Requires the goal creation receipt migration before rollout.
Native podcast authoring now survives lost responses and rapid actions without duplicate shows, episodes, hosted audio or subscriber announcements. Show and episode creation carries a durable account-, community- and exact-content-bound operation key; Laravel commits a receipt with the created row, returns it on replay and rejects changed intent. Studio routes reset by identity, suppress stale completion, serialize mutations and verify uncertain publish/archive/delete outcomes from the authored collection. Repeating a published episode transition no longer sends subscribers another announcement. Requires the podcast creation receipt migration before rollout.
Native paid-course progress and podcast preferences now recover safely after a lost response. Course enrolment and lesson completion read canonical server state before reporting an indeterminate request as failed, and their routes ignore completion after account, community or course replacement. Podcast follow and reaction requests now send the desired final state; Laravel applies it under a row lock, so retrying an accepted request cannot reverse it. Podcast audience actions also serialize rapid taps and respect route identity.
Native group content now survives lost responses, rapid taps and route replacement. Group discussion, announcement, question, answer, wiki-page, task and media creation carry a stable account-, community-, group- and content-bound operation key. Laravel commits a matching receipt with the content, returns the original result on unchanged retry and rejects changed intent. Media replay and conflict remove the duplicate staged file. Child loads and mutations suppress stale or late completion, wiki saves carry optimistic concurrency state, uncertain Q&A/wiki/task actions use exact server readback, and membership is no longer shown as changed before the server confirms it. Requires the group-content creation receipt migration before rollout.
Native event and group actions now respect the active account, community and route. Event RSVP, waitlist, publication, offered-place, poll, reminder, template and organizer-attendance actions acquire synchronous guards before rendered busy state can lag behind rapid taps. Indeterminate RSVP, waitlist, publication and offered-place responses read the canonical event before reporting failure. Event, attendance and group detail routes remount with tenant/account/content identity, suppress late completion after replacement, and group join/leave recover only when authoritative membership readback proves the intended state.
Native Marketplace orders and community delivery now complete their advertised journeys safely. Save, offer, order, payment, fulfilment and delivery-offer actions reconcile an exact authoritative state after a lost response, serialize rapid taps and ignore completion after account/community/route replacement. Community members can now discover privacy-safe paid delivery requests, submit an offer from the Marketplace hub and recover an accepted offer after its POST response is lost. Sellers can review delivery offers, while buyer-only accept/confirm controls now match the wallet authorization enforced by Laravel.
Native marketplace listing creation now survives lost responses, rapid taps and interrupted category loads. The Expo form persists one account-, community- and content-bound operation key before creating a listing. Laravel commits a matching receipt with the marketplace row, returns that original listing on an unchanged retry and rejects changed content under the same key; concurrent retries are serialized at the database boundary. Category and category-template failures now remain visible with Retry, server-marked required template fields are enforced before publishing, and late completion cannot navigate a replacement account, community or listing route. Requires the marketplace listing-creation receipt migration before rollout.
Native listing creation and detail actions now recover safely after lost responses and rapid taps. The Expo create form persists one account-, community- and content-bound operation key before sending. Laravel commits a matching receipt with the listing, returns that original listing on an unchanged retry, rejects changed content under the same key, and does not repeat creation events, XP or feed work on replay. Edit, exchange-request, bookmark, like, renew and delete failures read the authoritative listing/workflow state back before reporting failure. Detail actions acquire synchronous guards before React renders their pending state, and late results cannot update a replacement account, community or listing route.
Changing a password in the native app now ends the revoked device session with clear next steps. Laravel invalidates every session as part of the password transaction, so the app no longer reports success and returns to settings with credentials that cannot work. A confirmed change sends the member to sign in with the new password. If the network loses the response, the app explains that the result is unknown and asks the member to try the new password first, then the previous one. The password form now states the enforced 12-character minimum in every mobile language; corrupted Irish action copy and several visible translation errors are corrected.
Native text, photo and voice-message retries now return the message already accepted by the server. Each unchanged send carries a durable account-, community- and content-bound operation key. Laravel stores a content-free receipt in the same transaction as the message, rejects changed content under the same key, and returns the canonical message after response loss without storing another message or media file. Send-message XP is keyed to that message and can be repaired by the retry without being awarded twice.
Native text, photo, edit and voice-message sends now acquire a synchronous send lock. Two taps in the frame before React renders the sending state can no longer dispatch duplicate messages or voice uploads. The composer still allows the next text draft while an earlier send is pending, and its existing failed-draft recovery remains intact. Thread state resets by account, community and conversation, and late send completion cannot update a replacement thread.
Native notification actions now serialize and stop when their route is replaced. Mark all, mark read, group read and delete acquire a synchronous mutation guard before rendered busy state can lag behind a rapid tap. Opening an unread notification still navigates immediately, while its background mark-read can no longer refresh or show an obsolete error after account/community replacement. The inbox itself resets with session identity.
Native password, translation, notification, privacy, push and biometric settings now respect pending work and session identity. Password saves acquire a synchronous guard and lock all credential fields, preventing two same-frame password changes or edits beneath submitted credentials. These settings reset when the account or community changes, and results completing for a replaced route cannot navigate, toast, alter the replacement state or continue a multi-step push update. Biometric changes also serialize before React renders their busy state.
Native privacy and account settings no longer carry private state or late actions into a replacement session. Data-export history, blocked-user results, linked-account drafts and deletion state now reset when the account or community changes. Requests that finish after their route was replaced cannot repopulate the new screen, show an obsolete result, or sign the newly active account out after an earlier account deletion completed.
Editing a native profile no longer loses newer input or reports a committed update as failed. A delayed full-profile load preserves fields the member has already edited. Profile saves and avatar selection acquire synchronous guards, exclude one another, lock every submitted field and ignore completion after an account/community route replacement. The avatar update merges into the latest hydrated user rather than an older cached snapshot. Once Laravel accepts a profile or avatar update, failure to refresh the device cache is treated as local maintenance instead of showing an error and inviting a duplicate retry.
Native connection decisions now use the intended server workflow and recover after a lost response. Declining a received request calls the dedicated decline endpoint, so the requester receives the configured notification instead of the relationship being silently deleted through the generic remove route. Connect, accept, decline, cancel and disconnect actions read the authoritative relationship state after an uncertain failure and treat an already-committed matching outcome as success. Same-frame actions are serialized across connection rows and member profiles, route identity changes clear earlier state, and late completion cannot update a replacement route. Laravel now locks a pending request while declining it, so simultaneous devices produce one transition winner and one set of decline effects.
Partner venue passes and staff check-ins now recover safely from rapid taps, refresh failures and lost responses. Pass replacement is serialized, renders the returned QR immediately, and reads the current pass back after an uncertain result so an invalid old code is not left on screen. Staff can retry the same chosen-venue check-in, while a synchronous guard prevents two venue choices being recorded from one rapid interaction. Pass, check-in, venue and club routes reset on account/community/token changes; populated refresh failures remain visible with Retry. First-time pass creation now relies on the durable database uniqueness rule rather than becoming unavailable with the cache lock service.
Native ideation retries can no longer duplicate ideas or comments, reverse a vote, or submit after a challenge stops accepting ideas. The Expo client binds a stable operation key to each unchanged idea, comment and desired vote state; Laravel records the result durably and returns it when an accepted response is lost. Changed intent under the same key is refused. Challenge phase, deadline and per-member limits are now enforced under the server transaction and returned on challenge detail, while the native screen hides the form and explains the applicable refusal. Idea and comment drafts also warn before navigation, submitted fields lock while pending, and late results from a replaced account, community or route are ignored. The challenge-detail regression tests now exercise the joined, tenant-scoped response used by that screen.
A member who had not opted into federation was told a federated member did not exist.
GET /v2/federation/members/{id}gates the CALLER before it looks anything up, refusing a viewer who has not opted in with 403FEDERATION_NOT_ENABLED.FederationMemberProfilePagemapped every unsuccessful response tomember_profile.not_found_error, so that refusal rendered as "Member not found" — sending the member to look for a person who exists, is reachable, and is one switch in their own settings away. The page now keeps the error code and, for that one code, says federation is not switched on for their account, repeats the sentence the disabled buttons already use, and replaces Try Again (which cannot help) with a button to Federation settings. A genuine 404 is unchanged. Addsmember_profile.optin_required_headingandmember_profile.optin_required_actionin all eleven locales.The federated member profile offered Connect, Message and Send Credits to members whose community safeguarding policy refuses contact, so the member only found out after composing a transfer. The profile payload carried only
messaging_enabledandtransactions_enabled, both read fromfederation_user_settings, and the page enabled every action from those two fields alone. A recipient can have both settings switched on and still be unreachable, because the recipient tenant's safeguarding policy is evaluated separately at send time — the live case is a Timebanking UK member with two safeguarding options carryingrestricts_messaging, correctly refused with 403SAFEGUARDING_CONTACT_RESTRICTED. All three buttons are now disabled up front, with the server's explanation shown as visible text beneath them. That text is deliberately not phone-only: walking the page in a browser showed a disabled HeroUI Button computespointer-events: none, so it never receives hover or focus and its tooltip cannot open at all — the two older reasons on the same row (transactions_disabled_tooltipandoptin_required_tooltip) had the same flaw and are fixed alongside it, so every reason an action is unavailable is now readable on any screen. Fixing that also exposed a duplicate: the transfer-reason line fell back to whichever broader reason applied, printing the opt-in sentence twice whenever the viewer had not opted into federation. It now states only the recipient's own transfer setting. Connect is included becauseFederatedConnectionService::sendRequest()applies the same gate and failed the same way. The server-side checks are unchanged and stay authoritative; this only tells the member sooner.A refused federated credit transfer said only "Unknown error", hiding the one sentence that explained it.
FederationMemberProfilePagediscarded the API's error body and always renderedmember_profile.tx_unknown_error, so every distinct refusal looked identical: a safeguarding contact restriction on the recipient, a partnership with transfers switched off, a recipient who has disabled federated transactions, and a sender who has not opted in all produced the same dead-end toast. Observed on production sending from TimeBank Ireland to a Timebanking UK member who has two safeguarding options carryingrestricts_messaging, where the API correctly returned 403SAFEGUARDING_CONTACT_RESTRICTEDwith a member-readable message. The toast now shows the server's message and falls back to the generic string only when the response carries none. The refusal itself is correct and unchanged — this restores the explanation, it does not weaken the safeguarding boundary.The app failed to boot after every deploy for anyone whose browser had the site installed: the service worker was serving a previous build's
index.html, whose asset filenames the deploy had already deleted. Workbox only stores a navigation response as the reusable HTML shell when it carriesX-Nexus-Spa-Shell: 1(cacheableResponse.headerson the NetworkFirst navigation route).nginx.bluegreen.confdeclared that marker inlocation = /andlocation /, but both blocks end in an unconditionalreturn 421and the response is generated by the@spa_shellnamed location — and nginx does not inheritadd_headerinto a block that declares any of its own. The marker was therefore discarded on every request and never reached a browser. Verified on production before the fix:X-Nexus-Spa-Shellabsent fromhttps://timebank.global/, the response header set matching@spa_shell's exactly, and thenexus-public-html-shell-v3cache not present in a real browser after loading the site — onlyworkbox-precache-v2andnexus-immutable-assets-v1. With the runtime cache permanently empty,precacheFallbackhanded back the precachedindex.htmlof whichever build installed the worker whenever a navigation exceeded the 3-second network timeout, which is exactly what a blue/green switch produces. Old hashed assets hard-404 after a deploy (confirmed against production), so the app could not start and rendered the root error boundary inmain.tsx. The marker now lives on@spa_shell, along with theVary: User-Agentthat block also lacked despite serving bots and users different bodies at one URL; the dead declarations are replaced by comments explaining why they were dead. Verified by running the real nginx:nginx -tclean, andX-Nexus-Spa-Shell: 1present on/and on deep SPA routes, absent from every snapshot and maintenance block. Guarded bynpm run check:spa-shell.Stale-chunk recovery gave up after one attempt, so the deploy crash was shown to the user rather than repaired.
requestStaleChunkRecoveryallowed a single reload per route per 30 seconds and returnedfalsefor anything inside that window. A deploy reliably produces two failures in a row — the reload re-requests the same URL and the service worker hands back the same previous-build shell — so the second failure always fell through to the error boundary. Recovery now allows three attempts, and every attempt after the first deletes the caches that can hold HTML (workbox-precache*and any*html-shell*) before reloading, forcing the navigation to the network. A hung or rejecting Cache API cannot block the reload: a 2-second timeout guard reloads regardless. The service-worker registration is deliberately left alone — it owns the push subscription. The attempt budget resets after 60 quiet seconds, and the legacy bare-timestampsessionStoragerecord is upgraded rather than crashed on, so sessions open across this deploy still behave. Covered by nine tests insrc/routes/lazyWithRetry.test.ts.The platform master tenant was excluded from prerendering entirely, so
app.project-nexus.ie— the public front page that lists every community — had never had a single snapshot.PrerenderPlanRoutescarried->where('id', '<>', 1), skipping master in every route plan ever produced. That hostname takes roughly 156 crawler visits a day and served every one of them the empty SPA shell. Master is now planned like any other tenant, with one deliberate difference: it renders at the app host (FRONTEND_URL), never at its owntenants.domain. Master's domain isproject-nexus.ie, which Apache routes to the separate sales-site container — snapshots written under that hostname are read by nothing, and 48 such orphans had quietly accumulated. The reserved-slug guard is skipped for master because it is served at the host root with no slug prefix; without that, renaming master's slug to a reserved word would have aborted route planning for every tenant. Regression test inPrerenderPlanRoutesTest, verified to fail with the exclusion restored.ROOT CAUSE: the authoritative prerender publish could never complete, so crawler-facing prerendering was off platform-wide from 2026-07-11 to 2026-09-13. The publisher validates each staged page against its checksum sidecar with
read -r recorded_hash recorded_bytes < "$checksum". POSIXreadreturns non-zero at EOF when the final line has no trailing newline — even though it assigned both variables correctly — andprerender-worker.mjswrote those sidecars without one. Underset -euthe publish therefore aborted on the first page it validated, with no message, no partial state and exit code 1. Because.tenant-identity-v1is written only at the end of a successful authoritative publish, and nginx fails closed without it, every crawler received the 1,950-byte empty SPA shell: across the whole retained bot-access log, 0 snapshots were served to any bot out of ~4,400 visits. Google indexedhour-timebank.iewith the app's own "Unable to connect" error as its description. The reader now tolerates the EOF status (the malformed/mismatch checks immediately after still reject empty or wrong values) and the worker now terminates the sidecar, matchingsha256sumoutput.The regression test for that publish existed, passed, and proved nothing — and CI never ran it.
scripts/test/test-prerender-authoritative-publish.shwrote its checksum fixtures with a trailing newline while the real worker wrote them without, so the one byte that mattered was never exercised; and the script was not referenced by any workflow. The fixture now covers both shapes (verified: the test exits 1 with the reader fix reverted, 0 with it), and the test runs as a BLOCKING step in the existingMigration Safety Gatejob — no new required job, so the deploy verifier's job list is unchanged.The prerender health report could not see that prerendering was switched off. nginx fails closed on
.tenant-identity-v1: while that marker is absent it serves the empty SPA shell to every crawler regardless of how fresh or complete the snapshots are. Every existing check inPrerenderService::health()measures snapshot production — cache readable/writable, route planning, circuit breaker, queue age, recent failures, stuck jobs, coverage, scheduler liveness — and none measured delivery. The marker was absent in production from 2026-07-11 (commitf62b85fa4, which introduced the gate) to 2026-09-13, and across the whole retained bot-access log zero snapshots were served to any crawler while this endpoint reported green. A newserving_enabledcheck now reports red, with the exact remedy, whenever the marker is missing — and states that only a full authoritative rebuild writes it, so the condition cannot self-heal, and that the file must never be created by hand because its presence also activates strict missing-sidecar enforcement. Regression tests intests/Laravel/Feature/PrerenderServiceTest.php, both verified to fail without the check./terms,/privacyand/cookieshad no meta description, canonical link or Open Graph tags on any tenant that uploaded its own legal document. All three pages branch three ways — loading, tenant custom document, default content — but only the default branch rendered<PageMeta>. A tenant with a custom policy took the middle branch, which returned<CustomLegalDocument>bare, so the page reached crawlers with a title fromusePageTitle()and nothing else.AcceptableUsePagehas always renderedPageMetaon both branches and was used as the reference. Confirmed against a freshly rendered production snapshot ofhour-timebank.ie/terms:<title>Terms of Service</title>, zero description tags, zero canonical tags. Regression tests added toTermsPage.test.tsx,PrivacyPage.test.tsxandCookiesPage.test.tsx, each verified to fail without the fix.Every tenant domain advertised an empty sitemap to search engines.
location = /robots.txtrewrites the platform hostname to the requesting host viasub_filter, but ran withsub_filter_once on. robots.txt opens with the comment# robots.txt for app.project-nexus.ie (React frontend), so the single permitted substitution was spent on line 1 and theSitemap:directive on line 151 was never rewritten. Every community —hour-timebank.ie,pairc-goodman.com,uk.timebank.globaland the rest — therefore told crawlers its sitemap washttps://app.project-nexus.ie/sitemap.xml, which contains zero<url>entries, while its own correct sitemap (99 URLs forhour-timebank.ie) went unadvertised. Nowsub_filter_once off, so theSitemap:line and thellms.txtcomment lines are rewritten to the serving host as intended. Bothnginx.bluegreen.confandnginx.confcarry a comment pinning the value. Verified by byte count against production: the served file was exactly 4 bytes shorter than the file on disk — oneapp.project-nexus.ie→hour-timebank.iesubstitution, in the wrong place.Buying an item in the XP shop works again from the app already on members' phones. The purchase endpoint had begun requiring an operation key that only newer clients send, which would have refused every purchase made from a handset until a new store release. The key is now checked when it is sent and never demanded, so purchases that do send one keep their protection against a double tap buying the same item twice.
Android release builds no longer fail on Windows when Node crashes while shutting down after the update-manifest step: the Gradle build now runs Node through a wrapper that exits that one script cleanly the moment its work is done and retries it, bounded, if Node still crashes at teardown (
plugins/with-android-node-clean-exit,scripts/node-retrying.cjs). Seen in two of three builds of the first Expo SDK 55 bundle.A web sign-in page that is out of date no longer answers "Sign-in failed" when the server sends an answer it cannot read: the update reload is applied at once on sign-in, registration, password and two-factor pages instead of waiting for the cursor to leave a field, an unrecognised sign-in answer on a stale bundle says the page was out of date and refreshes it immediately, and a live session ended because the account now requires two-factor authentication is explained as such rather than as an expired session. Seen on the first web sign-in after mandatory administrator two-factor went live.
The Play upload-key signing configuration and the
-PplayVersionCodeoverride are now generated into the Android project by a config plugin (plugins/with-android-play-signing) instead of living only in one machine's git-ignored Gradle file, so a bundle built from a clean checkout is signed with the upload key rather than the debug key.XP-shop purchases from the web app and the accessible site would have failed once the current code shipped. The purchase endpoint started requiring an 8–191 character operation key (
c03aeefe6, so a retried request replays the original purchase instead of spending XP twice), but only the native app sent one; the React achievements page and the accessible shop form sent justitem_idand would have received 422 on every purchase. Both now send a per-purchase key (the accessible form carries a per-render key per item, and the route mints one if a cached form omits it). Tests:AchievementsPage.test.tsx, web-ukapi.test.jsandshared-accessible-shell.test.js. Found in the 2026-09-12 review before deployment; production was unaffected. (E-005, O-019; commit63b5f1134)Voting on a poll with an option from a different poll, or on a poll whose end date has passed, answered 500 instead of a proper status.
PollService::vote()threwInvalidArgumentException('Invalid poll option')andRuntimeException('This poll has closed'); neitherPollsController::vote()norSocialController::votePollV2()caught them, so both cases surfaced as a server error (with the exception class and file path in the body under debug). Both controllers now answer 422VALIDATION_INVALID_VALUEfor a foreign option and 409RESOURCE_CONFLICTfor a closed poll. The closed case is a new typedApp\Exceptions\PollClosedException(extendsRuntimeException, so nothing that caught the old type breaks); a broadcatch (\RuntimeException)was deliberately not used becauseQueryExceptionis one too. No new translation key — both messages reuseapi.invalid_input. Regression tests: four new cases intests/Laravel/Feature/Controllers/PollsControllerTest.php. Found by the same-community access sweep. (E-003, F-001; commita49671ea9)Native ideation submissions, votes, comments, edits and deletions now use synchronous pending guards, preventing rapid taps from dispatching conflicting or duplicate requests before React renders its loading state. Submitted idea, edit and comment fields lock for the duration of their write so a successful response cannot discard changes made after dispatch.
Federation hub, partner-detail, member-card and marketplace-order actions in the native app now use enforced responsive columns instead of compressing controls into unreadable rows. Labels keep a readable size and may wrap to two lines, large-text and narrow-screen layouts become full-width, partner and order chips use the medium size, and federation directory filters no longer cap partner names and translations to one 136dp line.
Android release builds no longer fail when Node crashes while shutting down after the JavaScript bundle step. The same Windows shutdown crash that the build already retried for the update-manifest step was seen in the Metro bundle step, after the bundle and source map had both been written in full. That step is now retried on the same bounded terms; an ordinary bundling failure is still reported on the first attempt rather than repeated, and other commands of the same tool are not retried.
Native goal completion now uses the canonical atomic lifecycle transition, preserving completion history, milestones and XP while making retries side-effect safe. Goal forms and actions prevent rapid conflicting edits. Poll votes now commit with XP, return the committed choice after response loss, reject a changed second choice, and serialize rapid native taps; poll creation locks its submitted draft.
Native paid-course, marketplace, daily-reward, challenge-reward and XP-shop actions now suppress rapid duplicate dispatches. Challenge awards commit with their claim marker, daily and challenge retries return committed results, and XP-shop retries use a durable content-bound identity so response loss cannot spend XP twice. Marketplace shipping no longer offers an ignored method override, and delivery copy now states the actual 14-day dispute period before escrow release.
Native volunteering now preserves expense, donation-pledge and hour drafts across tab changes, warns before leaving with unsaved or pending input, serializes rapid submissions, and retries certificate, expense, pledge and hour writes with the same content-bound operation identity. Laravel durably returns the original record after response loss, preventing duplicate records and duplicate hour credits.
Administrator shift-swap approve/reject decisions now have one atomic winner. Retrying the committed decision succeeds without repeating notifications or assignments, while the opposite decision returns a conflict.
Native organisation settings preserve dirty edits across refresh and tab changes, guard navigation while dirty or saving, and retain rejected clears for retry. Optional description, contact email and website fields can now be cleared through the Laravel update contract.
Native organisation dashboards now keep successful panel data visible when a later applications, hours, volunteers or wallet refresh fails, with a panel-specific Retry warning instead of a false empty state. Hour decisions, wallet inputs and settings fields also lock synchronously while their writes are pending, and the dashboard resets when the account, community or organisation changes.
Volunteer-hours decisions now have one atomic winner across concurrent approve/decline requests. Retrying the winning decision after response loss succeeds without paying twice, while the conflicting decision returns HTTP 409 instead of reporting contradictory success.
Native organisation directory/detail screens now reset on account/community changes and warn when a refresh fails instead of silently presenting stale records. Organisation details map the Laravel
volunteer_countandopportunity_countcontract, replacing misleading zero member/listing tiles and restoring the missing volunteer total.Native organisation registration now locks every field and its authority confirmation while saving, serializes rapid submissions, resets on account/community changes, and retries an unknown result with the same content-bound operation key. Laravel stores hashed operation identity so response loss and simultaneous retries return one pending organisation and one owner membership instead of a duplicate-name dead end or duplicate records.
Native job analytics now distinguish a failed prediction request from “not enough data,” offer a direct retry, refresh figures and predictions together, and reset on account/community changes. Prediction totals and comparisons use real tenant-scoped application rows instead of stale stored counters, and leftover English analytics labels are translated in German, Spanish, French, Italian and Portuguese.
Native candidate interview and offer decisions now serialize rapid taps, remain retryable after a failed request, and ignore completions after an account/community change or unmount. Laravel resolves conflicting decisions atomically, permits a response-loss retry of the decision that already won, and rejects the opposite decision.
Job-alert emails now render role type, commitment and closing dates in the recipient's language and regional date format instead of leaking English enum labels and month names.
Job-alert notifications now honour saved category filters and do not match location-specific alerts to vacancies with no location.
Native job-alert creation now locks the draft while saving, resets on account/community replacement, ignores completions from the prior identity, and retries the same intended alert with a content-bound idempotency key; Laravel stores hashed operation identity so lost responses and simultaneous retries return one subscription instead of creating duplicates.
Native job applications now read saved CV and cover-letter details from the Laravel response envelope, show an honest loading state, and expose failed lookups with Retry instead of claiming no saved CV exists.
Job creation retries carry a content-bound idempotency key, and Laravel stores it with the vacancy, so lost responses and simultaneous retries return the original job instead of publishing duplicates.
Job forms reset when the signed-in account or community changes, and completions from the previous identity cannot navigate or show success in the replacement session.
Job forms lock every field, choice and generation action while a save is pending, preventing accepted submissions from clearing edits that were never included in the request.
Job creation and editing prevent repeated save dispatches while a submission is pending.
Delayed AI job descriptions no longer overwrite employer edits or apply after their generation inputs change.
Switching job edit targets loads the correct vacancy and resets the form baseline instead of retaining the previous job's draft.
Job forms track unsaved changes across all fields and treat an unchanged loaded vacancy as clean, including when edits are reverted.
Hiring pipeline stage summaries use full-width rows so enlarged system text has room without relying on live font-scale notifications.
Hiring pipeline stage summaries use explicit container widths to prevent narrow, stretched controls in the native layout.
Job lists calculate application totals in a tenant-scoped batch query so stale stored counters no longer contradict job details and hiring pipelines.
Job detail application counts now reflect actual tenant-scoped applications rather than a potentially stale stored counter.
Mobile hiring pipeline actions prevent rapid conflicting moves for the same candidate while a request is pending.
Mobile hiring pipelines keep withdrawn candidates in their own stage and suppress forbidden actions for accepted, rejected and withdrawn applications, using the server's authoritative stage.
Mobile job candidate cards hide decision actions after acceptance, rejection or withdrawal, matching the server's terminal-status rules.
Mobile job candidate decisions prevent rapid conflicting updates while a status change is pending.
Mobile job applications respect server-derived vacancy availability, including expired deadlines and moderation restrictions, using the same end-of-day rule as submission.
Mobile job application drafts are scoped to the job, account and community, preventing a previous job's covering message or attachment from appearing in another application.
Mobile job application drafts retain their covering message when the sheet is closed and reopened after a failed submission; successful completion clears both message and attachment.
Mobile job owners no longer see an application action for their own vacancy; owner management tools remain available.
Mobile job applications wait for CV selection to finish before submission and recover when the document picker fails.
Mobile job applications lock the submitted message and CV controls while sending and preserve the message after rejection; pending submissions cannot be dismissed or dispatched twice from the form.
Mobile opportunity owners and organisation managers can include a decision note when reviewing volunteering applications. Tenant-required decline notes are explained and enforced before submission; failed decisions preserve the note for retry.
Mobile volunteering prevents conflicting approve/decline requests while an organiser's application decision is pending.
Mobile volunteering isolates application and form state by opportunity so navigating to another opportunity does not carry over a previous submitted status.
Mobile volunteering prevents overlapping shift sign-ups and cancellations while a shift change is being submitted.
Mobile volunteering pauses shift changes when existing registrations are loading or failed, offering retry instead of bypassing the confirmation required to move from another shift.
Mobile volunteering now permits a new application after a previous application was declined or withdrawn, matching the server's eligibility rules.
Mobile volunteering applications now show success only after server acceptance and lock the submitted note while sending, preserving it after failure for retry.
Opening another volunteer QR token resets the check-in confirmation and prevents the previous volunteer's status or delayed response from appearing for the new token.
Volunteer checkout retries return success for the original completed checkout without changing its timestamp or dispatching a duplicate shift-completed webhook.
Native volunteer check-in and checkout offer a same-token retry after temporary network or server failures, retaining the volunteer name and retrying the correct action.
Confirmation actions ignore duplicate events while an action is pending, preventing rapid presses from starting the same operation twice before the button disables.
Android confirmation dialogs disable portal animations that could crash the app when a confirmed action navigated away, including discarding an unsaved course.
Course completion checks the locked enrolment record before running completion integrations, preventing stale repeat requests from counting the same completion twice.
Instructor grading preserves fractional percentage scores instead of truncating them to whole numbers; invalid or out-of-range scores are rejected without replacing an existing grade.
Native course grading pauses score, pass/fail and feedback edits while saving, preserving the submitted draft when a grade is rejected.
Refreshing the native course grading queue preserves unsent feedback and keeps accepted grades hidden; refresh failures appear beside the retained forms instead of discarding them.
Native cross-community credit transfers save their retry key before sending, retain it after request failure, and prevent overlapping submissions or edits during a pending transfer.
Internal cross-community transfers with an explicit retry key retain their committed receipt in the database, preventing cache loss from causing a second debit and rejecting reuse of a key with changed transfer details. The wallet recovery endpoint can confirm these receipts for the original sender and transfer details.
Accepting a connection request no longer hides the member from the Connected tab if the user switches tabs before the request finishes.
Closing or unmounting an open native bottom sheet dismisses its keyboard, preventing it from covering the screen beneath the closed form.
Shared native top-bar labels remeasure after text-size changes, preventing the Back label and screen title from retaining clipped layout bounds.
Bottom-sheet titles and the group discussion helper text remeasure when text size changes, preserving the discussion draft while preventing stale clipped text.
Group discussion actions use full-width rows at larger text sizes to give translated and enlarged labels room to wrap.
The group discussion composer opens at its expanded height so Android keyboard users can reach the message and publishing actions without first expanding the sheet.
An accepted group answer is no longer reported as a failed post when its subsequent detail refresh fails; the question list refreshes and the UI reports the read failure separately.
Group questions and answers pause text editing while submitting, preventing successful saves from clearing newer unsent edits.
Group announcement title, body and pin controls pause during publishing so edits made during a pending save cannot be silently cleared; rejected saves retain the draft for retry.
Group discussion fields pause editing while publishing and unlock after a failed request, preventing successful submission from clearing text entered during the save.
Late native API authentication failures no longer refresh, retry under, or sign out a replacement session.
Native API requests awaiting credential reads are cancelled before dispatch if the session is replaced or cleared, preventing queued actions from using another account's bearer or stale sign-out credentials.
Native wallet reservations stop when the account or community changes during asynchronous recovery, preserving the original account's retry record.
Native wallet retries older than the legacy cache window can now check for a matching durable server record and replay the original key when confirmed. Unknown or unavailable results retain the existing safety block and never create a replacement debit.
Donation retry identities are persisted with the donation ledger, preventing a lost replay-cache result from debiting members twice. Member and community-fund paths retain atomic balance updates. Requires the credit donation retry-identity migration before rollout.
Explicit wallet transfer retries now use a durable receipt committed with the debit and credit, preventing duplicate transfers after replay-cache failure or eviction. Requires the wallet transfer receipt migration before rollout; legacy unresolved transfers and other credit-changing paths retain their existing recovery limits.
Organisation deposit retries now commit a durable receipt with both balances and ledger entries, preventing a replay-cache outage from charging the member twice. Requires the organisation deposit receipt migration before rollout.
Late duplicate wallet success responses no longer erase the saved retry identity of a newer intentional transfer, donation or organisation deposit.
Native text buttons grow to fit wrapped labels at larger accessibility text sizes. Android handles font-size changes without recreating the active screen and discarding its unsaved form state.
Cold native deep links retain the main navigation behind the destination, so Back and Cancel can leave the screen and invoke unsaved-draft confirmation.
Native image placeholders no longer persist when a failed image is replaced with a different URL. Saved event/group image recovery also uses the shared failure placeholder.
Native podcast playback failures now show a retry action and retain the last playback position instead of leaving the player showing Pause after a decoder error. Playback also supplies episode and show titles to native media controls.
Biometric unlock preserves its security boundary on failures. Native sessions stay locked when device authentication or the saved lock preference is unavailable; secure-storage failures no longer report successful enable/disable changes. Locked content is hidden from accessibility services, and hardware/enrolment errors use the existing translated messages.
Native biometric settings explain that the lock protects a saved session, permits biometrics enrolled on the device and may accept the device PIN. Enrolment guidance correctly requires fingerprint or face unlock; all seven native locales include the clarification.
Passkey cancellation and revocation remain under member control. Cancelling platform registration no longer opens a second prompt. Existing credentials remain manageable when tenant passkey sign-in is disabled.
ASP.NET passkeys require local user verification. Registration and sign-in enforce verification before issuing verified-passkey claims, including ceremonies started under the older policy. New credentials must be discoverable.
ASP.NET passkey removal preserves recovery and revokes sessions. Removing credentials cannot consume a passwordless member's final passkey, missing IDs cannot remove every key, and unknown IDs return a truthful failure. Successful removal invalidates access, refresh and security-confirmation tokens. Credential mutations, passkey sign-in and refresh rotation serialize against removal, and compatibility endpoints require security confirmation for enrolment and management.
Updated the ASP.NET WebAuthn verifier to the stable Fido2.AspNet 4.0.1 patch.
ASP.NET enforces the tenant biometric-login switch on registration and authentication, including assertions started before the switch was disabled, while retaining credential management.
Voice-message retries retain their duration. Retrying a failed native voice upload now sends the original recording length instead of zero seconds.
Marketplace media failures keep a recovery path. Saved listings retain failed photo/video operations for retry against the existing listing. Photos are tracked individually so a rejected file is not hidden by a successful batch response. Successful media steps are not repeated, and sellers can explicitly continue to the saved listing.
Saved events and groups retain failed cover images. A failed upload now opens recovery with retry, replacement-image selection and an explicit continue action, without repeating the saved event/group creation. Recovery controls use all seven native language catalogues.
Image recovery respects navigation and large-text attribution. Pending retries keep the recovery screen in place, completed retries do not redirect after unmount, and the native source-repository link wraps within the screen at large text sizes.
Lost photo-upload responses no longer duplicate marketplace photos on retry. Native retries retain an operation key, and the API commits a durable upload receipt with the image rows. Matching replays return the original result, conflicting content is refused, and ownership remains enforced. Requires the marketplace image-upload receipt migration before rollout.
Generated listing descriptions preserve newer edits. Delayed AI descriptions no longer overwrite text entered while generation is pending in native service and marketplace listing forms.
Stale sign-in responses cannot replace the current account. Delayed native password, MFA profile and session-restore responses are ignored after logout or a newer session is installed, including old credential refusals.
Delayed sign-out preserves a newer session. Native logout now stops obsolete cleanup after push unregister, server logout and local storage waits, avoiding deletion of credentials belonging to a subsequently signed-in account.
Late authentication replies preserve the active API identity. Login/logout responses cannot replace a newer bearer token, and mutation requests correctly recognize an isolated refresh-suppression option.
Credential cleanup follows pending writes. Native storage operations now preserve order per key, preventing a delayed write or cached read from restoring a token after logout removes it.
Confirmed wallet payments survive local cleanup failures. A failed secure-storage deletion no longer reports an accepted transfer or donation as failed. Completion is retained so intentional repeats can receive a new durable operation ID while uncertain requests keep duplicate protection.
Security
Corrected the authorization gate on an unused federation-controls handler so it can never become a platform-wide escalation. A disconnected admin handler that would write the installation-wide federation switches was guarded for any super-admin rather than platform super-admins only; it is now restricted to platform super-admins, matching the live equivalent. No route reaches it today, so there is no behavioural change — this closes a latent gap before it can be wired up.
"Resend my verification email" no longer reveals which addresses have an unverified account. The page already returned the same generic message either way, but it sent the verification email while the caller waited — and only for an account that exists and is not yet verified — so those addresses answered noticeably slower than unknown ones, which let them be identified by timing. The lookup and send now run on the same background email queue as the password-reset fix, so every address gets the same fast, identical response. Same class as the forgot-password fix above.
"Forgot password" no longer reveals which email addresses have an account. The page already returned the same message whether or not the address was registered, but it sent the reset email while the caller waited — and only when the account existed — so a registered address answered noticeably slower than an unknown one, which let the two be told apart by timing. The whole reset lookup and email now run on a dedicated background queue consumed by Horizon, so every address gets the same fast, identical response and the timing difference is gone. Nothing a member sees changes.
Native URL parsing now uses the fixed linear-time percent decoder without changing Expo Router's supported query-string contract. A maintained compatibility patch unwraps the decoder's ESM default export for the CommonJS router consumers, preventing crafted malformed links from tying up the app. Install-time patch application, hostile-input isolation, query-bearing Android navigation and dependency audits are covered by regressions. The mobile development toolchain also pins the corrected filesystem helper release.
Added
Two-factor recovery codes can now be saved. The enrolment screen warns that the codes are shown once, and offers a copy button and a plain-text download built entirely in the page, so the codes are never requested, never logged and never left behind in an address that could be fetched again. The date inside the file follows the member's community region, and the file uses Windows line endings so it stays readable in Notepad. Translated into all eleven languages.
Over-the-air mobile updates are now refused when the native surface (dependencies, Expo plugins, permissions, update channel) differs from the store build members have installed, recorded in
mobile/live-store-build.json;verify:releasealso refuses an Android version code below the live one. Added after the two-factor lockout fix was nearly published as an update that the SDK 54 store build could not have run.docs/SECURITY-ASSURANCE.md— how security assessment works here, and the register behind it. Project NEXUS hosts communities for public-sector bodies with supplier-assurance obligations, so security work is now a maintained record rather than a series of one-off exercises: a private Security Assurance Register holds every engagement, every finding with a permanent identifier and a status vocabulary that distinguishes "a fix exists" from "the fix works in production", the cross-document reconciliation, and the evidence-freezing rules. The public document describes the process and deliberately carries no findings. The rule that security work must read and update that register is binding inAGENTS.md(MANDATORY RULES) andCLAUDE.md, andSECURITY.mdnow points a discloser or a buyer at the process. (E-003; commitaa19256b7)tests/Laravel/Feature/Security/SameCommunityAccessSweepTest.php— can member A reach member B's records inside the SAME community? Every earlier sweep kept actor and record in different communities, whereTenantScopedoes the work; this one asks the question the per-endpoint ownership checks have to answer. Reads: every v2 GET with one path parameter, B's record vs A's own as control; a 200 carrying data must be registered as public by design (exact route, with a reason) or it fails, and B's e-mail address in any body always fails. Writes: every v2 non-GET with one path parameter, body grown from the API's own validation messages, B's row compared column by column after every request regardless of status; a changed or deleted row fails unless only counter columns moved, and a 2xx that leaves B's row alone must be a registered interaction. Consumed fixtures are re-seeded before the next endpoint (a control request is a real request). A third method covers multi-parameter routes (person routes with a third member seeded into both members' records; child routes with B's parent and child): first run 46 combinations probed, 25 refused with a working control, 7 refused at the permission gate, 0 served/accepted/changed. Shared machinery (fixture maps, seeding, id-type resolution, request helpers, multi-parameter enumerators) moved verbatim fromCrossCommunityAccessSweepTestintotests/Laravel/Feature/Security/Support/AccessSweepTestCase.php; the cross-community sweep's behaviour and figures are unchanged. (E-003; commits068dc7551,95cfeaab0)Published
docs/SECURITY-ASSURANCE.md, a public description of how security assessment works on the platform: what gets tested, how findings are recorded and tracked in the private register, how evidence is handled, and what a customer with a supplier-assurance obligation can ask for. It states process only and deliberately contains no findings. Linked from the documentation index and the published site navigation, with contributor and agent guidance pointing at it so security assessment is maintained as an ongoing record rather than a one-off audit.Repeatable real-backend MFA journeys for React, the accessible website (with JavaScript disabled), and the Android debug emulator, using dedicated synthetic accounts in the isolated end-to-end database. Browser tests cover enrollment, recovery acknowledgment, recovery sign-in and rejected code reuse; Android additionally checks session restoration after process restart.
MFA recovery and operational tooling. Members can replace recovery codes with a fresh authenticator code and revoke remembered devices from React and accessible security settings. OAuth/SSO sign-in can continue into local MFA without prematurely linking identities or issuing credentials. Read-only
security:mfa-readinessinventory and explicitly confirmedsecurity:recover-admin-mfahost recovery commands support rollout and emergency recovery; security changes send recipient-localized notifications.Native Android/iOS MFA sign-in and required enrollment. Password challenges now open native authenticator or recovery-code verification. Mandatory setup presents the setup key and recovery codes, preserves issued credentials while profile loading is retried, and handles expired challenges without silently refreshing an unrelated session.
Tenant member MFA enforcement with a mandatory platform administrator baseline. The authentication configuration panel now lets tenant administrators require MFA for every member, while the administrator requirement cannot be disabled. Restricted enrollment on React and the accessible website includes QR/manual setup, retries and recovery-code acknowledgment. Server checks cover existing sessions, role promotion and refresh; remembered devices cannot bypass required MFA, and required users cannot disable their factor. Existing passkey configuration permissions remain restricted to super administrators. Deployment activates the administrator baseline and requires the accompanying TOTP migration first.
tests/Laravel/Feature/Security/ExternalSurfaceKillSwitchSweepTest.php— the inbound partner surfaces, enumerated from the route table instead of a list. External partner federation and the partner v1 API are switched OFF in production and have been since 2026-07-27 with no partner connected. Both already have dedicated kill-switch tests and both pass. This adds a sweep because those tests drive hand-written lists, and these are the one authentication path that never runsAuthenticate— so they never see the cross-community checks proved inCrossCommunityTokenReplayTest.- 69 inbound partner route-and-method combinations: all 69 gated, all 69 refuse with
503while the switch is off, 0 reached. Surfaces covered: Credit Commons 17, Komunitin 17, Nexus ingest 7, legacy v1 15, partner v1 10, plus external webhooks, aggregates and hour-transfer inbound. - Control-verified. "Everything refused" proves nothing if the routes are broken, so the sweep runs again with the switch ON and requires the answer to change; a route that refuses identically either way is reported INCONCLUSIVE, not as a pass. All 69 changed: 62 →
401, 4 →400, 1 →404, and 2 →200. Those two areGET /v1/federation(a list of endpoint names) and/v1/federation/health(status and timestamp) — no member data, and both still behind the switch. - 🔴 The population is defined by PATH, not by which middleware is attached — deliberately. Selecting "routes carrying the kill-switch middleware" is circular: a new external route added without the gate would simply not be in the population, and the sweep would report a clean pass over the routes that were already safe. The sweep therefore asserts separately that every route on an inbound partner path carries a gate, against a shrink-only
KNOWN_UNGATEDlist that is currently empty. A new ungated external surface now fails this test on the day it lands. - Honest scope note: the existing hand-written lists were not materially incomplete — they cover comparable ground today. What this adds is that the coverage is now derived from the live route table and cannot drift, that an ungated route is detected rather than merely absent, that the refusal is proved to come from the switch, and that federation and partner v1 are checked in one pass.
- The member-facing
/v2/federation/*routes (connections, messages, opt-in, settings) are deliberately excluded: that is internal cross-community federation, live and ungated by design, and it runsAuthenticate, so it is covered by the token-replay sweep.
- 69 inbound partner route-and-method combinations: all 69 gated, all 69 refuse with
CrossCommunityAccessSweepTest::test_write_endpoints_refuse_a_foreign_id_even_with_a_valid_body— the write sweep's largest blind spot, closed by letting the API write its own request bodies. The write sweep sends an EMPTY body, so 108 of its 366 endpoints were rejected by validation before any community check could run — recorded as unproven, never as passes, and the single biggest gap in the assessment. Rather than hand-writing 108 bodies, the sweep now reads the API's own validation errors, which name the field that failed and often list the values it will accept ("Invalid reaction_type. Valid types: love, like, …"), supplies a plausible value, and asks again — up to eight rounds, stopping the moment validation objects twice to the same field, because that means the value was rejected rather than missing and guessing further would be dishonest.- 366 endpoints, 0 MUTATED. Refusals rose 245 → 271, and the unproven bucket fell 108 → 77: 31 endpoints that had never been exercised now are. The 77 that remain need structured bodies (
patch,permissions,tiers, a file upload) and are still recorded as unproven. - 🔴 The first run of this test scored a flawless 366 of 366 refusals in under six seconds, and it was completely worthless.
$e['uri']already begins withapi/, so prefixing another/apirequested/api/api/v2/…, which matches no route; every response was a router 404, and a router 404 scored as REFUSED. A dedicated assertion now fails the test outright if any response carriesNotFoundHttpException, so this cannot recur silently. Six seconds for what should have been thousands of requests was the only outward sign.
- 366 endpoints, 0 MUTATED. Refusals rose 245 → 271, and the unproven bucket fell 108 → 77: 31 endpoints that had never been exercised now are. The 77 that remain need structured bodies (
tests/Laravel/Feature/Security/CrossCommunityTokenReplayTest.php— a valid account from one community, used against another, across the whole authenticated API. Every sweep before this one kept the actor and the community aligned (a tenant-2 member asking for a tenant-999 record). None asked the opposite and more dangerous question: what if the CALLER is from somewhere else? That matters more than it first looks, because every community-owned query is restricted bytenant_id = TenantContext::getId()and the community is resolved from the REQUEST, not from the account — so a member of community A whose request resolved as community B would be handed community B's data by queries all working exactly as designed. The protection cannot come from the queries; it has to come from refusing the request.- 2,316 authenticated v2 routes swept, 2,303 refused, 0 reached, 13 inconclusive. Every single refusal carried the same code:
tenant_mismatch(2,303 of 2,303). The 13 are router-level 404s where the placeholder fails a pattern constraint ({kind}.csv,{providerKey},{provider},{action},{type}) — the same class as the 21 inRoleBoundarySweepTest. Authenticatecarries two independent checks — the user'stenant_id, and the token's owntenant_id(which still binds a platform super-admin whose account may legitimately cross communities). Both were already unit-tested inAuthenticateTest; what had never been established is that they cover the whole API, since they live in one middleware and 129 of the 2,503 v2 routes do not run it.- 🔴 This sweep proves check (1) only, and says so in the test. The actor is authenticated with
Sanctum::actingAs(), so no bearer token is present and the token-tenant check cannot fire. A real issued token was tried first and returns 401 for its own community inside the test harness — an artefact of middleware ordering under the test kernel, not production behaviour. Testing what can be tested honestly beat chasing it. - 🔴 The control caught the first version of this test proving nothing. Routes sort alphabetically, so the first 40 are all under
/admin/, where an ordinary member is refused regardless of community — every control "failed" and the sweep would have been meaningless. The control now draws only member-facing, parameterless GET routes.
- 2,316 authenticated v2 routes swept, 2,303 refused, 0 reached, 13 inconclusive. Every single refusal carried the same code:
CrossCommunityAccessSweepTest::test_no_endpoint_accepts_a_child_record_from_another_community— the rest of the multi-parameter routes, and eighteen new child fixtures to make them answerable. The companion to the foreign-person sweep: the 116 combinations whose deepest identifier is another RECORD (a lesson in a course, a version of a legal document, an answer to a question). Every identifier except the last is one of OUR OWN records owned by the acting user; only the deepest is another community's. 36 probed, 0 leaked, 0 mutated; 27 refused with a succeeding control (23 member, 4 community admin), 4 rejected by validation first, 5 inconclusive, 80 skipped.- 🔴 This could not be bolted onto the existing resolution, and measuring first is what showed it. 84 of the 86 candidate routes resolved BOTH identifiers to the same fixture type, because the child parameter fell back to matching its parent's path prefix — the sweep would have requested
courses/5/lessons/5, one row as both course and lesson, and the control would have rejected every one. It would have looked like coverage and proved nothing.CHILD_FIXTURES_BY_PREFIXresolves on the path segment before the parameter and is deliberately SEPARATE from the sharedPREFIX_FIXTURES, whose figures are published;{questionId}shows why a shared map could not work anyway — undercourses/…/quizzes/{quizId}/questionsit is a quiz question, undergroups/{id}/questionsa group Q&A question. - Eighteen child fixtures, each linked by a new
needsclause to the parent seeded for the SAME community, so a control uses a child that genuinely belongs to the parent in the URL: course lessons/sections/cohorts/quizzes/questions, group discussions/chatrooms/chatroom-messages/announcements/questions/answers/files/media/invites/challenges/scheduled-posts, podcast episodes, legal documents and their versions.insertRow()gainedneeds, an optional owner column, a conditionaltenant_id(legal_document_versionshas none — it is scoped through its parent document by join, which is whatLegalDocumentServicedoes), a{today}placeholder, and auniqueclause that reuses an existing row (legal_documentsis unique on(tenant_id, document_type)and every seeded tenant already holds atermsrow). - 🔴 A CONTROL REQUEST IS A REAL REQUEST, and one destroyed the preconditions of the endpoints after it. Endpoints run in sorted order, so the control for
DELETE .../versions/{versionId}genuinely deleted our own version and the controls for PUT, notify and pending-count on the same record then answered "Version not found" — three endpoints falsely INCONCLUSIVE. The identical trap hit the person sweep via group membership. Both sweeps now re-establish state per endpoint: this one re-seeds whenrecordsStillExist()says a needed row was consumed, which also makes it order-independent. Member refusals went 15 → 23 and the admin pass 1 → 4 from that fix alone. - 🔴 A disabled feature module is not a pass. The courses module is off for the test community, so twelve course endpoints answered 403 FEATURE_DISABLED to probe AND control. A gate firing first says nothing about community scoping, so the sweep enables
coursesandpodcastsfor both communities viatenants.features. - What it still does not reach: 80 combinations, 32 of them under
events. Those are the registration-product, offline check-in and guardian-consent families, whose tables require twenty-odd NOT NULL columns of tokens, hashes and ciphertext each; a hand-built row would not represent real state. Recorded as untested rather than fixtured badly.
- 🔴 This could not be bolted onto the existing resolution, and measuring first is what showed it. 84 of the 86 candidate routes resolved BOTH identifiers to the same fixture type, because the child parameter fell back to matching its parent's path prefix — the sweep would have requested
CrossCommunityAccessSweepTest::test_no_endpoint_accepts_a_person_from_another_community— the multi-parameter routes, which no sweep reached before. The two sweeps above take routes with exactly one path parameter. 141 route-and-method combinations take two or more, and none of them had been exercised — includingadmin/groups/{groupId}/members/{userId}/promote,conversations/{id}/participants/{userId}andevents/{id}/people/{userId}/history. That is where a scoping bug survives review, because the handler checks the FIRST id against the caller's community and forgets the second.- The outer record is deliberately OURS and legitimate; only the person is foreign. 25 combinations name a person (
{userId},{childId},{attendeeId},{guestId},{delivererId},{partnerId}, …); every other parameter is filled with a tenant-2 record owned by the acting user. 17 probed, 0 leaked, 0 mutated; 6 refused with a succeeding control (3 as member, 3 as community admin), 7 rejected by validation first (not a pass), 4 inconclusive, 8 skipped. - The detector is stronger than the single-parameter write sweep's, by design. That sweep compares the target row and states plainly that rows CREATED ELSEWHERE are invisible to it — which is precisely this sweep's subject, since "add this person to my group" writes a join row rather than altering the group. So this one counts, before and after every request, every row in the live schema referencing the foreign person across 302 person-shaped foreign keys. A count that moves in either direction is a confirmed cross-community write.
- The community-admin pass is fully conclusive: 3 probed, 3 refused, control-verified, 0 inconclusive — group member removal, promote and demote.
GroupService::updateMemberRole()scopes itsgroup_memberslookup bytenant_id, so a person outside the community has no row and the operation refuses. - 🔴 A test-ordering bug in the first version made seven results falsely inconclusive. Endpoints run in sorted order, so the control request for
DELETE .../members/{userId}genuinely removed the control person from the group, and every later control then failed withNOT_MEMBER. The relationships are now re-established idempotently before every endpoint, not once per pass. Nothing about the platform was wrong; the sweep was. - 🔴
group_memberscarriesis_federatedandsource_tenant_id, so a member row pointing at a user in another tenant is a DESIGNED federation state. Do not extend this sweep by planting a foreign user ingroup_membersand calling a successful promote a finding without reading the federation rules first. What is tested is narrower and sound: a person with no relationship to our record and no presence in our community must be refused. - Verb-shaped parameters (
{action},{state}) are recognised as operations rather than records, locally to this sweep so the other two sweeps' published figures are unaffected.
- The outer record is deliberately OURS and legitimate; only the person is foreign. 25 combinations name a person (
tests/Laravel/Feature/Security/PrivilegeFieldInjectionTest.php— a member sendsrole: admin, both super-admin flags,tenant_id: 999, a walletbalance,is_verifiedandidalongside a legitimate field onPUT /v2/users/me; every privileged column is read back with the query builder and must be unchanged. Passed first time:UserService::update()whitelists thirteen profile fields. The first thing a tester tries on a profile form, now pinned.CrossCommunityAccessSweepTest::test_no_write_endpoint_mutates_another_communitys_record— every non-GET single-parameter v2 route (796 route×methods, 691 routes) with a tenant-999 record id and an empty body, as a tenant-2 member and a tenant-2 community admin. 366 probed, 0 mutated the foreign record (snapshotted before/after so "accepted" and "changed" are not confused), 245 refused, 108 rejected by validation first (recorded as VALIDATION_FIRST — not a pass), 10 accepted no-ops pinned inKNOWN_ACCEPTED_NO_CHANGE. The first run listed 18 accepted writes; eight were fixed the same day (see Security).- 🔴
{day}is a day of the week, not a record id.PUT users/me/availability/{day}resolved by path prefix to amember_availabilityrow id, accepted it as a day number and answered 2xx — an eleventh accepted-no-change write. It failed only on a CI shard: the local run had failed to seed that fixture and skipped the endpoint, so the sweep was green locally and red in CI.dayis now named inNOT_AN_ID, which is what moves the probed count from 367 to 366.
- 🔴
Two route-table security sweeps, written for the Coventry City Council assurance request and kept as permanent regression tests.
TenantIsolationTestcovers three resources by hand; the v2 API registers 2,507 routes, 1,308 of which take a record id, so hand-written coverage was never going to keep pace. Both tests enumerateRoute::getRoutes()at runtime so a new endpoint is covered the day it lands. Per-request evidence (statuses, control statuses, response-body excerpts) is written to.local-docs-archive/security-evidence/(gitignored).tests/Laravel/Feature/Security/RoleBoundarySweepTest.php— every route reserved for a higher tier, requested by each of five actors with every declared method. anonymous 2,375 → 0 reached; member 1,121 → 0; broker 959 → 0; community admin 62 → 0; network admin 37 → 0. 4,554 requests, 0 reached a controller; 21 inconclusive (router-level 404s where the placeholder1fails a{provider}/{kind}.csv/{action}constraint — the gate was never exercised, so they are listed, not counted).- 🔴 A 422 is a REACHED, not a refusal. Form validation runs after route middleware, so a member sending an empty body to an admin write endpoint and getting a validation error proves the gate did not fire.
- 🔴 Gates are read from
Router::gatherRouteMiddleware(), notRoute::gatherMiddleware(). The declared stack still lists a gate that->withoutMiddleware(...)removed. The first version read the declared stack and reported the broker reaching/admin/listings(declaredadmin, effectivelybroker-or-admin— the broker's own panel) and the anonymous caller reaching/blog(declaredauth, effectively public) as 48 breaches. None were. - The first run did not disable rate limiting and the platform answered 429 to 2,097 of 2,397 anonymous requests after roughly 300 — recorded in the assessment as an observation that throttling engages; the test now removes
ThrottleRequestsbecause rate limiting has its own tests.
tests/Laravel/Feature/Security/CrossCommunityAccessSweepTest.php— every single-id GET endpoint whose record type can be resolved, requested with a tenant-999 record, in two passes: as a tenant-2 member for member routes and as a tenant-2 community ADMIN for/admin/routes. 371 endpoints in population, 220 probed, 124 refused, 0 returned another community's member data; 30 answered 200 with an empty body (correct); 62 inconclusive; 151 skipped (44 because the parameter is a slug/token/type, 102 because no fixture, 5 platform-tier routes covered by the role sweep).- 🔴 Every refusal is control-verified. Each probe is paired with the same request for a same-type record in tenant 2 owned by the actor; a refusal counts only if the control returned 200/201. Without this,
volunteering/shifts/{id}mapped to a VolOpportunity id "passes" with a 404 that means nothing. The control caught a wrong mapping during development —ideation-challengesis served byIdeationChallengesController, not the gamificationChallengemodel — which is exactly why it exists. - 🔴 A 200 is classified by reading the body. The status-only first version flagged 19 endpoints; 16 were correctly scoped empty lists.
- Id type is resolved from the parameter name first (
{userId},{childId},{caredForId}→ user;{groupId},{eventId},{courseId},{message}), then the longest matching path prefix (volunteering/organisations→ VolOrganization,admin/newsletters/segments→ NewsletterSegment). Slugs, tokens and type names are SKIPPED as "not an id", never probed with a number. Modules with no factory (courses,marketplace_listings,stories,podcast_shows) get a direct row insert filling only NOT NULL columns. KNOWN_SOFT_200holds four reviewed endpoints that answer 200 for a foreign record while disclosing nothing —connections/status/{userId},jobs/{id}/match,users/{id}/activity/dashboard, and (admin actor)admin/users/{id}/verification-badges. Each records the body read and the date. They should 404. Shrink-only in both directions.- Found in passing, not fixed here:
VolShiftFactorywritesupdated_atandvol_shiftshas no such column;ResourceItemFactorylikewise againstresources. Both factories cannot create a row.
- 🔴 Every refusal is control-verified. Each probe is paired with the same request for a same-type record in tenant 2 owned by the actor; a refusal counts only if the control returned 200/201. Without this,
Members are now asked whether they want notifications.
registerForPushNotifications()took its prompting argument as opt-in and only the Settings switch passed it, so on iOS and Android 13+ the system permission dialog was never raised in the ordinary flow: a new member signed up, was never asked anything, and silently received no message, exchange-request or event notifications unless they found the switch. A card on the feed offers it once. Either answer is stored, because the OS cannot tell "tapped Not now" from "never asked" — both read as undetermined — so a permission-only rule would re-offer on every launch.A Vibration switch in Settings, and the app stops buzzing on everything. The shared button fired a haptic on every press of every variant, and toasts and confirmations added their own, so plain navigation buzzed as hard as sending credits. The button impact is now limited to the primary and destructive variants, and the new switch silences all three kinds of feedback for members who do not want them.
Picked photos are shrunk before upload. Every picker passed
qualityonly, which re-encodes without resizing, so a 12-to-48-megapixel camera photo left the device at full size — several megabytes against an 8 MB server limit and a 60-second timeout. On mobile data the member waited out the minute and was told the upload had failed. Capped at 1600px on the longest edge (2048 for marketplace listings). Addsexpo-image-manipulator, so this needs a new store build. A PNG stays a PNG, so a transparent organisation logo does not gain a black background.Loading lists show the shape of what is coming. Notifications, marketplace, volunteering, jobs and the wallet transaction list showed a centred spinner on a blank screen while five other tabs already had card skeletons, so the same wait looked slower on half the app.
mobile/docs/PLAY_RELEASE_PROCEDURE.md— how a build actually reaches Google Play, written from an end-to-end release rather than from memory.PLAY_SUBMISSION.mdcovered signing, listing copy and assets but not the act of shipping, so the order of operations lived only in the owner's head and an agent shipped straight to production without it. Every timing and fingerprint in the document is measured.- The order is internal testing → phone → promote. Not production first.
- Play's live version code is the only source of truth. Measured 2026-09-09: Play was on 7 while EAS's remote counter said 4, so an EAS build with
autoIncrementwould have produced 5 and been rejected outright. The counter is stale because releases are built locally, which never touches it. - Review took 33 minutes (submitted 7:32 PM, published 8:05 PM), against a dialog that warns of up to 7 days. With managed publishing off, submitting for review is the decision to go live.
- Internal testing and a production review can run at once — verified, not assumed: version 8 was submitted to production at 7:32 PM, published to internal testing at 7:47 PM, and the production submission published normally at 8:05 PM.
- Also records the two non-interchangeable certificates, the silent debug-signing fallback, the emulator-address landmine, the single-architecture trap, and the fact that navigating during a 90 MB upload aborts it with no error.
- Three documents still listed "correct the false no-money claim in the Play description" as the top release blocker. Reading the live listing shows it was fixed some time ago — neither description contains that wording. Corrected in all three, with a note to read the live fields before re-raising it.
mobile/scripts/build-aab-play.sh— the signed Play bundle now has a checked-in recipe. There was none:build-apk-local.shbuilds a deliberately debug-signed APK for sideloading, and the artefact actually uploaded to Google was assembled by hand each time. Three ways that goes wrong silently, all of which produce a file that looks completely normal, are now refusals rather than warnings.- A debug-signed bundle.
android/app/build.gradle:133readsrelease { signingConfig playStoreFile ? playRelease : signingConfigs.debug }— with the signing values absent Gradle does not fail, it signs with the debug key and still writesapp-release.aab. The script reads the key and its passwords frommobile/credentials.json(never echoing them) and verifies the finished bundle's certificate against the Play upload key before reporting success. - 🔴 The wrong server baked in.
.env.production.localwas found holdingEXPO_PUBLIC_API_URL=http://10.0.2.2:8090— the address an Android emulator uses to reach the build machine, left behind by a test build on 2026-09-03. That value is compiled into the bundle permanently. A release built from it works flawlessly on an emulator and is dead in every real user's hand. The script regenerates the file and refuses any loopback, private-network or plain-http host. - One CPU architecture.
expo run:androidwritesreactNativeArchitectures=<one abi>intoandroid/gradle.properties; a later release build inherits it. On 2026-08-20 that shipped an x86_64-only artefact — perfect on the emulator, "App not installed" on a real phone. All four are passed explicitly. - It also requires
--version-coderather than defaulting, because Play refuses a version code it has already seen.
- A debug-signed bundle.
Changed
Repository Codex tasks now request live web search when current external evidence is needed.
Native app version 1.6.0, Android version code 12: the store binary for the completed mobile reliability and federation layout audit. The new runtime version is required because the audit added a native dependency surface that cannot be delivered safely to the 1.5.0 binary by an over-the-air update; the API now advertises the matching current version while preserving the existing minimum-version floor.
Android version code 11: the store release carrying the minified, resource-shrunk build. The version name stays 1.5.0 because no native dependency changed between the two builds — only how the release binary is compiled — so the over-the-air runtime version stays compatible with the build already on phones. This is the release that answers Google Play's "DEX code optimization is below our threshold" warning against version code 10.
Native app version 1.5.0, Android version code 10: the store release carrying the two-factor sign-in and the Expo SDK 55 upgrade. The bump also gives the build a new over-the-air runtime version, so an update built for SDK 55 can never be served to the SDK 54 build 9 still installed on phones.
The member directory's explanation of why it lists fewer people than have joined the community is now a single collapsible line. It shows the count on one row with a "Why?" control, and only opens the visibility rules, the closing note and the privacy-settings link when a member asks for them. As an always-open alert it pushed the first row of member cards below the fold on desktop and took several lines on phones.
The member directory now states its size once. The hero's member-count chip and the "Showing 24 of 25 members" line have gone: with the collapsible coverage note above them, the page carried three counts of almost the same thing, two of which used "of" to mean different things. The count while searching stays, because a match count is real information, and the Load more button already says how many members remain unloaded.
Android release builds are now minified and resource-shrunk (R8). Google Play reported "DEX code optimization is below our threshold — Obfuscation (2%)" against version code 10, with a February 2027 deadline. Minification had never been switched on: the Expo build-properties block set only legacy packaging, so the generated Gradle properties carried no minify setting and the release build compiled unobfuscated — which is also why every upload reported "no deobfuscation file associated with this App Bundle", because R8 never ran and there was no mapping file to include. The keep rules the app actually needs are checked in at
mobile/android-proguard-rules.proand carried into the generated Android project by a config plugin, since that project is regenerated on every build. Each rule cites the dependency and call site that requires it, and the file records which dependencies already ship their own so nothing is duplicated; notablyexpo-notificationsships a rules file that its own Gradle configuration never applies, andreact-native-gesture-handlerships none at all while two packages look its classes up by name. The build script now refuses a bundle that carries no R8 mapping file, and the release-config check refuses an app configuration that has lost either flag or the rules plugin.ASP.NET edition paused (owner decision, 11 September 2026) — not abandoned; it resumes when the rest of the platform is more mature. The four ASP.NET jobs in the platform-contracts workflow are pinned off so no runner minutes are spent on them, the ASP.NET markdown-link pass is removed from the shared inventory job, and
AGENTS.md,react-frontend/CLAUDE.md,docs/REACT-DUAL-BACKEND.mdand the ASP.NET README state the pause at the top. Code, ledger and earlier decision records are untouched; ADR-0005 records what was switched off and how to resume.Updated the native app to Expo SDK 55 and React Native 0.83, including the system-appearance API adjustment and explicit background audio configuration.
Native voice messages, podcasts and lesson/marketplace video now use Expo's separate audio and video modules. Audio loading has a bounded timeout and cancellation on leaving the screen; podcasts seek to their saved position before playing and activate lock-screen controls. Voice recording prevents overlapping starts while permission is pending and late starts after leaving the screen.
Updated the web passkey client to
@simplewebauthn/browser14.0.0, retaining the platform's existing Node 22 minimum and Laravel credential algorithms.🔴 The mutation detector had three blind spots, all now closed and all now proved closed by a test that tries to fool it. An external review of the assessment identified them, and it was right on every one. Every sweep in this file reports "0 mutated", and that number is worth exactly as much as the detector behind it.
- A mutation was classified only when the response was 2xx, so a write that changed another community's record and then returned an error escaped the detector entirely. Now classified from the record comparison alone, with the status reported alongside rather than gating it.
- An exception skipped the after-snapshot altogether, so a request that changed something and then threw was filed as merely inconclusive. The record is now read again on the exception path too, in both the write and person sweeps.
- The person sweep compared row COUNTS, so changing somebody's role in a group — same row, same count — was invisible. It now fingerprints row contents (count plus a hash), and columns that cannot be read are counted and reported as blind spots rather than silently dropped (currently 0 of 302).
- The leak detector scanned the 300-character excerpt kept for the report rather than the full response body. It now scans the whole body.
- New:
test_the_mutation_detector_sees_changes_it_is_supposed_to_see. The inverse of every other test here — it deliberately causes a field change, a deletion, a side row created elsewhere referencing the person, and a content change that leaves the row count identical, and fails if the detector misses any of them. A detector nobody has tried to fool is an assumption, not a control. - After hardening, every sweep still reports 0 mutated. The stricter detector did not reveal anything the weaker one had missed — which is the reassuring outcome, and now it rests on something demonstrated rather than assumed.
The pre-commit hook gained a third gate: the native push producer inventory. When
app/**.phpis staged it runsaudit-native-push-producers.php --checkin the app container — about a second — and blocks with the exact--writecommand if the inventory has gone stale. Added because that inventory pinsNotificationDispatchercall sites by file and line, so any edit adding or removing lines above one makes it stale even when no producer changed, and that redsPHP Tests (shard 1),PHP ChecksandRelease Gatetogether. It happened twice on 2026-09-10, the second time to someone who had already written the trap down in a handoff. Skips with a notice when Docker or the container is unavailable, as gate B does for phpunit; CI remains the backstop. Verified by deliberately shifting a call site and confirming the commit was blocked.The mobile Create Course, Create Job, Create Opportunity and Podcast Studio forms are laid out like Create Listing. The owner named Create Listing as the form that looks right and the others as "really badly formatted": a single undivided column of up to twenty fields, a save button buried in the middle of a card, and no summary of what has been chosen. Each now opens with a hero card (module icon, eyebrow, title, one-line purpose, and summary tiles that echo the choices as they are made), groups its fields into titled sections with an icon (
components/ui/FormSection), and keeps its primary action in the sticky footer that Create Listing and Create Event already had. Course titles that are missing are now also flagged under the field, not only in a toast that fades.
Fixed
Native wallet and message recovery. Transfers, donations and organisation deposits persist unresolved retry identities in encrypted storage across screen closure and app restart, scoped to account and community. Failed messages preserve the next draft and its attachments separately, with an unsent-draft switcher and navigation protection.
Native message editing preserves unsent work. Opening an older message for editing retains the current draft and photos in the unsent-draft area. Edit switching and cancellation are unavailable while a send or edit save is pending.
Native interaction and startup polish. Shared HeroUI buttons, tabs and interactive chips use 48dp targets; Messages has a compact header. Public configuration and preferences migrate from SecureStore to files, credential-write failures prevent false sign-in success, and browser-only Sentry replay is excluded from native bundles. iOS camera descriptions cover both QR journeys, and API/parity checks include MFA routes.
Native audit follow-through. Authentication screens suppress internal server exceptions and display source attribution, achievement navigation wraps into readable rows, and decorative status badges disable their press responders. API and parity check mode no longer rewrites generated evidence files.
Preserve pending two-factor enrollment when retrying setup, support the intended authenticator clock tolerance, and retain trusted-device choices through the accessible website. React now returns to sign-in after two-factor removal revokes the current session.
🔴 The mobile API-consumer ledger was stale, and a flaky test had been hiding it.
mobile/pins a fingerprint ofroutes/api.phpand verifies that every endpoint the app calls still exists. Adding the partner-analytics rate limit changed that file, sonpm run drift:checkfailed with "nothing was verified. This is NOT a pass." — the correct answer, and a good gate. It surfaced only after the jest flake below was fixed, because jest runs first in the same job and its failure stopped the job beforedrift:checkran. One red check can conceal another in the same job. Refreshed withnpm run api:routes: 2,240 API paths, 514 of 514 consumed endpoints verified, and the only diff is two fingerprints and two review dates — no endpoint was added or removed, because the change added middleware rather than a route.A flaky mobile test that turned
mainred without any mobile code changing.PushPermissionCard.test.tsx— "does not send the member to system settings when registration merely failed" — failed once on a CI Android job (1 of 3,854 tests) on a commit that touched onlyroutes/api.php, the changelog and a PHP test. It passes repeatedly on the developer machine.handleEnable()awaits two promises — the registration call, and the storage write recording that the member has now been asked — before it hides the card, so the defaultwaitForwindow is tight on a loaded runner. The component is deterministic (afailedresult always hides the card), so the wait was widened to 5s rather than the behaviour changed, with the reasoning recorded at the assertion.The native push producer inventory is current again (239 calls, unchanged in content) — twice. It went stale a second time when the poll-vote fixes added lines to
PollsControllerandSocialController, and was caught by CI rather than before the push, even though this exact trap is recorded in the working handoff. 🔴 Any edit above aNotificationDispatchercall in a tracked file redsPHP Tests (shard 1), and through itPHP ChecksandRelease Gate. Worth a pre-commit gate: the check isphp mobile/scripts/audit-native-push-producers.php --checkand takes about a second.First occurrence, same cause. The security fixes above added lines to
ConnectionsController,StoryControllerandStoryService, which moved fourNotificationDispatchercall sites down by a few lines. The committed inventory pins file-and-line ids, soNativePushProducerInventoryTestfailed on CI shard 1 — no producer was added or removed, only the line references were stale. Regenerated withmobile/scripts/audit-native-push-producers.php --write. Worth knowing: any edit above aNotificationDispatchercall in a tracked file reds this gate.🔴 The Android build on Google Play could never have received an over-the-air update. Found while trying to send one: the version code 8 bundle carried no update channel (Expo's cloud builds get it written in; our locally built Play bundle gets it only from
app.json, which set none) and a runtime version of 1.2.0 inside an app labelled 1.4.0 (the build script only regenerated the native project when the folder was missing, so an August value was reused). Either fault alone means the update service serves that build nothing.app.jsonnow bakes theproductionchannel into every build, the build script regenerates the native project every time, and it opens the finished bundle and refuses it if the channel or the runtime version is wrong. Version code 9 is the first build that can take an update; the fix cannot reach phones still on 8 or earlier. Confirmed on a real phone the same day: build 9, installed from Play, showed the app's "Update ready" prompt for the first update ever published, which is the first over-the-air update this app has received.🔴 Option pickers on eleven mobile screens were about 20dp tall and did not visibly show the chosen option. Create Job, Create Course, Podcast Studio, Create Event, Create Group, Create Marketplace Listing, Edit Listing, the support contact form and three marketplace filter bars all built their category / level / type / sort pickers on HeroUI Native's
TagGroup size="sm", whose selected state is a pale tint — over which every one of them painted the labelcontrastText(primary), white on most communities, so the chosen option was white text on a pale wash. Measured on a device: below the WCAG 2.2 minimum target and less than half Android's guidance. Create Listing never had the fault because it used HeroUI NativeButtons. That idiom is now the sharedcomponents/ui/ChoiceChips(44dp minimum, full accent fill when chosen, label colour picked by the library for the community's accent, wraps to new rows, announcesselectedto screen readers), used everywhere, andcomponents/choiceChipsMigration.test.tskeepsTagGroupout of the app so the small picker cannot come back one screen at a time.🔴 The Goals "Add goal" drawer could not be scrolled and had no buttons. Reproduced on the emulator after the owner reported it: the drawer opened with the keyboard on the title field, the description and target fields sat underneath the keyboard, and Cancel / Create goal were not on screen at all —
HeroCard.Footerhas no row layout, so twoflex-1buttons in a column collapsed to zero height. There was no scroll container either. Three Podcast Studio drawers, the job application drawer and five marketplace drawers had a plain React NativeScrollViewinstead, which HeroUI Native's own documentation says the drawer intercepts, so dragging moved the drawer and never the content. The sharedBottomSheetnow takesscrollable(renders the gorhom scroll view the drawer does not swallow) andfooter(actions pinned above the keyboard and home indicator; on Android the wrapper measures the keyboard itself, because neither the window nor the drawer moves for it under this app's root). Twenty-two drawers migrated.components/ui/sheetContentRules.test.tsfails on any React Native scroll view inside a drawer and on any drawer holding a text field that is notscrollable.🔴 Form fields were hidden behind the keyboard on iPhone, on fifteen screens — including the wallet transfer amount and the marketplace offer amount, where the member could not see the figure they were entering before sending credits or money. Android is protected by the manifest's
adjustResize; iOS has no equivalent and each scroll view must make room itself, which is why the gap survived every Android device test.app/keyboardAvoidance.test.tsnow scans every screen with a text field.🔴 Paged lists could show a row twice or lose one, and the warning that would have revealed it was switched off.
usePaginatedApiappended pages without de-duplicating, so cursor pagination over a list the server re-orders handed React two children with the same key. Both of React's duplicate-key warnings were inLogBox.ignoreLogs, which is why it went unnoticed. The hook de-duplicates; the suppressions are gone, along with the nested-list one, which was checked rather than assumed to be needed.🔴 Large system text clipped the tab bar and could squeeze a screen title's own Back button off the row. The app had no font-scale handling anywhere. Caps are applied only where the container cannot grow — 1.6 for single-line chrome, 1.3 for tab labels, whose bar height now grows with the same capped scale. Body text is deliberately left uncapped.
🔴 The iOS permission explanations described a narrower app than the one we ship, which Apple's guideline 5.1.1 checks against the running app: the camera text named only marketplace codes though it also scans event check-in codes; the photo-library text said "profile or post" though photos are picked for listings, events, groups and marketplace items, and videos are picked too; the location text named only the marketplace though the Listings tab uses it as well.
A cold start showed three screens instead of one.
expo-splash-screenwas not installed, so the native splash hid as soon as React rendered — and the first thing React renders is a bare spinner. The splash is now held until the redirect has landed, with a five-second backstop and a release from the crash boundary so nothing can strand a member on it. The splash also gains a dark-mode background and stops letterboxing on other aspect ratios."You're offline" now appears on every screen. The banner was mounted by hand on fourteen screens out of a hundred and seventy; on the rest a lost connection was indistinguishable from a server fault. It is mounted once in the shell and adds its own measured height to the safe-area inset, so it never covers a screen's own content.
Every external link now opens through one checked helper.
Linking.openURLwas called from twenty-two files; six had no error handling at all, so a device with nothing able to open the link produced an unhandled rejection and, to the member, a button that did nothing. One opened an empty string when a parcel had no tracking link, and two opened a member-typed website with no scheme check.A picture that fails to load no longer leaves a blank hole. No image in the app had an
onError, so a photo that 404s rendered as a blank rectangle the size of the picture. Seventeen server-image sites now use the component written for this.Harmless questions were being asked in red. The confirmation dialog defaulted to the danger variant, so enrolling on a course, confirming a purchase or a delivery, completing a group exchange, sending credits and even unblocking somebody all asked with a red button.
Numbers now read the way the member's language writes them. Ratings and file sizes were built with
toFixed, which always produces a full stop, so a French or German member saw "4.5" among numbers that otherwise read "4,5".A screen reader can jump by heading. The app had twenty heading roles in total and the screen title was not one of them anywhere; one line in the shared top bar covers 134 screens.
The same screen no longer looks different depending on how you reached it. Groups and members are reachable at two routes that present them differently, and links were split between the two.
A half-filled seller profile now asks before it is lost — four steps of business details with nothing between a stray Back gesture and losing all of it.
🔴 Android App Links have never verified, because
assetlinks.jsonpublished the wrong certificate. The file listedF5:0D:87:55…, which is the upload key — the one used to sign what we send to Google. Play App Signing is enabled, so Google re-signs the app with its own key (79:38:E8:06…), and that is the fingerprint a phone checks. Everyhttps://app.project-nexus.ie/…link therefore opened a browser or a chooser for everyone who installed from Play, whileapp.json'sautoVerify: truehalf looked correct. Both fingerprints are now published — Google's app signing key first, the upload key retained so a locally signed release build verifies too. Confirmed against the Play Console's own ready-made snippet, which names the app signing key.verify-release-config.mjsalready anticipated this in a comment and accepts multiple fingerprints; it passes.🔴 Sign-up reported one mistake at a time and would not say which box it meant.
RegistrationService::registerended its validation at$validator->errors()->first()— a single string with no field — andRegistrationController::registerpassednullas the field, so a member who got their email, phone and password wrong was told about one of the three, in a banner, across an eight-input form. Every failure is now returned, each naming the input it belongs to, throughrespondWithErrors(). The native app already places a message on whichever field the API names and simply had nothing to place.- Backwards compatible by construction. The first entry keeps the code and message the single-error response carried, because both first-party frontends branch on
errors[0].codeto tellTERMS_REQUIREDandLOCATION_NOT_VERIFIEDfrom a generic failure. Those two special cases no longer hide the other bad inputs, though — they lead, then the rest follow. - The single-error refusals that belong to a visible input now name it too: duplicate account and the two email-domain checks →
email, breached and mismatched passwords →password/password_confirmation, invite-code failures →invite_code. Rawlatitude/longituderows are deliberately dropped, being fields no member can see or correct. - Prevention: two tests in
RegistrationControllerTestassert every failed input is present and attributed, and that the special-cased code still leads. A control run confirms both fail against the old code — one because only a single error came back, the other because the terms branch hid the email failure. No new translation keys: the fallback reuses the existingapi.validation_failed.
- Backwards compatible by construction. The first entry keeps the code and message the single-error response carried, because both first-party frontends branch on
Two volunteering lists could only ever serve their first page, so the app has no "Load more" on either.
- Certificates:
VolunteerCertificateService::getUserCertificates()has always paginated and always returned a cursor, butVolunteerCertificateController::myCertificates()called it with no filters and so threw the caller's page request away. It now readscursorandlimit, acceptingper_pageas an alias because that is what the app already sends. The{ items, cursor, has_more }shape is unchanged — both the app andCertificatesTab.tsxreaddata.items. - Shift swaps:
ShiftSwapService::getSwapRequests()returned everything it found behind a hardlimit(50)with no cursor at all. It now takeslimit(default 20, max 50) and a cursor. Ordering moved fromcreated_attoid, both descending: a cursor oncreated_atcannot be stable, because two requests made in the same second tie and a tie makes rows repeat or vanish between pages. The endpoint answers throughrespondWithCollection(), sodatastays a flat array — whatVolunteerShiftSwapsResponsealready expects — and the cursor rides inmeta. - 🔴 Found while doing it: filtering swaps by direction has never worked from the app. It sends
direction=sent/received; the service only tested forincoming/outgoing, so both fell through to the catch-all branch and returned every swap in both directions. The service now accepts the app's vocabulary alongside the original, which fixes it without needing a new store build.
- Certificates:
🔴
scripts/release.mjscorruptedreact-frontend/package-lock.jsonwhen cutting v1.8.0, and turnedmainred. It bumped the version with a blind1.7.0→1.8.0string replace across every file carrying the version, and the lockfile carried that string in twenty-one places that had nothing to do with the platform version:esquery's own version (there is noesquery@1.8.0), the^1.7.0ranges for@emnapi/runtimeandes-module-lexer, and seventeen Node engine ranges, where^21.7.0silently became^21.8.0. Every job that runsnpm cithen died withETARGET No matching version found for esquery@1.8.0— all eight React shards, React Build & Tests, Translation Drift Detection, the Release Gate, Security Scan, Lighthouse CI and the Platform-contracts React job. Four red workflows, one cause.- Root cause: a blind string replace applied to dependency manifests. It had only ever worked by luck — no earlier platform version happened to collide with a dependency's, and
1.7.0is a very common third-party version. The lockfile is repaired to the two root version keys only, sonpm ciresolves again. - Prevention: manifest versions are now set by key, never by text —
scripts/lib/set-json-version.mjswrites.versionand.packages[""].versionand leaves every other byte alone, re-serialising with the file's own indentation (npm writes 2 spaces, composer 4) so a release diff stays reviewable.scripts/test/test-release-manifest-rewrite.mjspins it in both directions, with decoys for each of the four collision shapes; it fails 4/4 against the old string replace. It is BLOCKING in CI (npm run test:release-manifest) and runs in local preflight.
- Root cause: a blind string replace applied to dependency manifests. It had only ever worked by luck — no earlier platform version happened to collide with a dependency's, and
scripts/release.mjscould not finish a release, because it ran the semver gate one step too early. It wrote all thirty-six version files, then rancheck-semver-policy.mjsbefore committing and tagging — and that gate asserts every release at or above its1.7.0enforcement floor has avX.Y.Ztag. The tag cannot exist yet at that point, so the check failed by construction, the script exited without committing, and the release had to be completed by hand. It was latent until now:1.7.0was tagged before it became the floor, so1.8.0was the first cut to hit it.- Root cause: a tag-dependent check placed in a pre-commit self-check block.
check-version-consistency.mjs(tag-independent) still runs there and still blocks the commit;check-semver-policy.mjsnow runs after the tag, and on failure prints thegit tag -d/git reset --softundo pair rather than leaving the state unexplained. - Prevention:
--no-tagand--no-commitnow say in words that the semver gate did not run and what to run once the tag exists, instead of reporting checks as passed when one was skipped.
- Root cause: a tag-dependent check placed in a pre-commit self-check block.
Changed
- 🔴
CrossCommunityTokenReplayTestnow exercises the REAL login credential path, because its previous claim about how authentication works was wrong. An external review of the assessment caught it and was right.Authenticatereadsforeach ($request->bearerToken() === null ? $guards : [] as $guard), so the two tenant checks inside that loop run only when a request carries no bearer token — on the production login path, which always carries one, that branch never executes and the token-tenant check inside it is unreachable. The isolation on the real path is enforced byTenantContext::resolve(), which validates the JWT's signature and compares the community inside it with the community requested, exempting platform administrators.- The test now mints a token exactly as login does (
TokenService::generateToken($userId, $tenantId)), proves it works against its own community first, then replays it. 2,316 authenticated v2 routes, 2,316 refused, 0 reached, 0 inconclusive — every refusal attributed, not assumed: the mismatch handler is the only thing that sets the resolved community to0, and the 13 routes whose URL pattern makes them raise rather than return carry theTENANT_MISMATCHcode in the thrown body. Control: 6 of 25 sampled member endpoints served the token in its own community, reported as measured rather than rounded into an impression of universal success. - 🔴 A second invented explanation is also corrected. The previous version said a genuinely issued token failed "because of middleware ordering under the test kernel". That was a guess written up as fact. The real reasons: user-login personal access tokens were deliberately retired (the middleware docblock says so, and refuses them so an old seven-day token cannot bypass JWT lifetime and revocation), and
TenantContext::resolve()reads$_SERVERdirectly while the test harness clears it. Both verified in code. - Platform-tier prefixes are excluded from the population and the document now says so: the total is of selected routes, not of every route.
- The test now mints a token exactly as login does (
Security
Two volunteering endpoints could echo raw database error text to the caller. Recording an offline donation (
POST /v2/volunteering/donations) and submitting an expense (POST /v2/volunteering/expenses) mapped everyRuntimeExceptionto a 400 that repeated the exception message. Laravel'sQueryExceptionis aRuntimeException, and both services rethrow it when the request carries no idempotency key, so a database failure (reachable in practice only through a same-key race) would have answered with the full SQL statement, its bindings and the connection name. Both controllers now answer a generic 500SERVER_ERRORand log the detail server-side. Regression test:tests/Laravel/Feature/Security/VolunteeringDatabaseErrorDisclosureTest.php. (E-005, F-016; commita304ef718)A member could rebuild and download their personal-data export without limit by replaying an idempotency key. The 5-per-24-hours cap counted only new audit rows, and a replayed
Idempotency-Keywrites none, so every replay rebuilt the archive with no bound and the admin GDPR notice fired once. Every build now also counts against a per-member daily limit, replays included. Regression test intests/Laravel/Feature/MemberDataExportTest.php. (E-005, F-017; commit453aeaa27)The SVG logo sanitiser closes two bypass classes. A CSS escape (
\75rl(reads asurl(to the browser) slipped past the forbidden-token check in<style>bodies andstyleattributes, and a SMIL<set>/<animate>aimed at an event handler,hreforstylecould re-create what the attribute scrub removed. Backslashes are now refused in CSS, and animation elements targeting those attributes are removed. Admin-only upload, served as an image, so defence in depth. Two new cases intests/Laravel/Unit/Core/SvgUploaderTest.php. (E-005, F-018; commit657810aa0)The last CSV writer goes through the formula-neutralising sanitiser. The administrator import-template download (
AdminUsersController::importTemplate) was the one remaining barefputcsv(), deferred on 2026-09-11 because the file was being edited; its content is constant, so this closes F-005 rather than fixing an exposure. The coverage test's pending allowlist entry is removed. (E-005; commita3750df1f)A script file placed in the web-served uploads folder would have run as PHP; the web server now refuses it. Proven in the dev container: a
.phpwritten intohttpdocs/uploads/answered 200 and executed, while the same request under/storage/answered 403, because the root.htaccessguarded^storage/only. Upload validation (which checks content) was the only layer.httpdocs/.htaccessnow carries an<If>block denying.php/.php[0-9]/.phtml/.pht/.phar/.phpsunder/uploads/, written as<If>rather than aRewriteRulebecauseuploads/.htaccessenables its own rewrite engine and per-directory rules are not inherited. It sits in the root file because production mounts a volume overhttpdocs/uploads. Re-probed: every script variant 403, images and the API unaffected. Guard:tests/Laravel/Feature/Security/WebRootHardeningTest.php. Re-checked on the live API host on 2026-09-12 after release:GET /uploads/anything.phpanswers 403. (E-003, F-003; commit61fa20fc3)Every CSV export now neutralises spreadsheet formulas. Nineteen exporters wrote member-supplied text with a bare
fputcsv()and one only quoted cells, while the sharedCsvExportSanitizerwas used by twelve. NewCsvExportSanitizer::put()is a drop-in forfputcsv(); all 32 exporting files use it.CsvExportSanitizerCoverageTestfails on any future barefputcsv()inapp/. The one file deferred at the time (AdminUsersController, off-limits during the audit) was converted on 2026-09-12 — see the E-005 entry above. (E-003, F-005; commit259be739c)The accessible site resolved the community from a client-controllable
X-Forwarded-Host. Apache merges a client-sent value in front of the real one; web-uk took the first.requestHost()andnormalizeRequestHost()now take the last (proxy- written) value, falling back toHost. Test:web-uk/tests/tenant-routing-forwarded-host.test.js. Recommended at host level:RequestHeader unset X-Forwarded-Hostin the Plesk vhosts. (E-003, F-006; commit8733cb941)Turning two-factor authentication off now requires a current authenticator code as well as the account password (
POST /v2/auth/2fa/disabletakescode; 422 without it, 403 withfield: codewhen refused). The code is checked with the same single-use 30-second step as login, so a code already spent on any other proof is refused, and it is only looked at after the password is accepted. The React settings dialog and the accessible site's two-factor page ask for both. Owner decision, 12 September 2026 (security register E-004 §7).Session validation no longer accepts the token from the URL query string; only the
Authorizationheader or the request body is read, so tokens cannot end up in proxy, CDN or web-server logs (two-factor review F-010).Password-login answers that carry a two-factor challenge token, and the answers that complete login with credentials, are now sent
Cache-Control: private, no-store(two-factor review F-014).Accessible site: the two-factor page (setup key, QR code, one-time recovery codes) is sent
no-store; the remembered-device cookie is cleared with the sameSecureattribute it was set with; a failed remembered-device revocation shows a translated message instead of an error page; the enrolment code is checked for six digits before it is forwarded; and/profileforms are rate-limited like every other form route (two-factor review F-015).Added 31 adversarial PHPUnit tests under
tests/Laravel/Feature/Security/TwoFactor/that pin the two-factor properties end to end (challenge boundaries, single-use authenticator steps, recovery-code lifecycle, remembered-device scope, disable and administrator-reset tiers, refresh and forged-claim and delegated-session enforcement, per-account limits, secret leakage), plus review tests for the React setup page, the native sign-in screen and the accessible two-factor page.Close MFA enforcement for operational-role accounts carrying platform-administrator authority, and rotate accessible-site sessions after password acceptance before storing MFA challenges. Recheck enrollment and administrator-reset authority under the user lock, using current revocation reads for security decisions.
Scope restricted MFA setup and verification rate limits to the validated account, preventing unrelated members behind the accessible server or a shared network from exhausting each other's allowance. Replacement challenges share the same account bucket; invalid challenges remain IP-limited and the broad IP abuse ceiling remains enforced.
BREAKING: Privileged SSO sign-in now requires a host-controlled
SSO_PRIVILEGED_PROVIDERSJSON allowlist binding the exact tenant ID, provider key, issuer URL and client ID. Tenant-editable identity-provider settings cannot authorize administrator sign-in by themselves. Verified upstream authentication time is preserved rather than refreshed at callback, and provider trust is rechecked when pending MFA completes.Recheck passkey-removal authority and confirmation under the account lock; bind impersonation to its originating refresh session; prevent delegated message reads from changing read state and reject delegated data-export creation. Current database reads prevent stale transaction snapshots from accepting revoked authentication.
BREAKING: Administrator impersonation now requires verified actor MFA and is read-only; revoking the actor's authentication invalidates delegated access. Administrator MFA resets require proof verified within five minutes, an identity-check reason and a durable audit record. Mandatory administrator MFA activates with this release; apply the accompanying TOTP migration before serving updated code.
Harden two-factor enrollment and recovery with atomic session revocation, pending-setup invalidation and single-use authenticator timesteps and recovery codes. SSO requires verified upstream assurance or local MFA continuation. Upgrade React Router to 7.18.3 and validate login return paths to address unsafe navigation paths.
Production served two of every security header, with two contradicting
Permissions-Policyvalues. The production images now bake HSTS and nothing else.Dockerfile.prodandDockerfile.bluegreenwrote an Apache conf withHeader always setforStrict-Transport-Security,X-Frame-Options,X-Content-Type-Options,Referrer-PolicyandPermissions-Policy;App\Http\Middleware\SecurityHeaderssets the last four as well, so every application response carried both. Measured live on 2026-09-10:camera=(), microphone=(), geolocation=(self), payment=(self)from the image alongsidecamera=(self), microphone=(self), geolocation=(self), fullscreen=(self), payment=(), usb=(), browsing-topics=()from the middleware. 🔴 TheHeader setifemptyguard added tohttpdocs/.htaccesson the same day could never have worked:always setwrites to Apache'serr_headers_outwhilesetifemptyreadsheaders_out, and the two tables cannot see each other. 🔴 A browser intersects duplicatePermissions-Policyheaders, so the live pair deniedcamera,microphone,payment,usbandbrowsing-topicsoutright —payment=()breaks browser payment integrations. Each header now has exactly one source: the middleware for application responses,Header setifemptyinhttpdocs/.htaccessfor files Apache serves itself, and the image for HSTS only (the container is reached over HTTP from the host proxy, so$request->secure()is false and the middleware does not emit it). Reproduced and fixed in the dev container across three states — no conf (one each), old conf (two each, matching production exactly), HSTS-only conf (one each). 🔴 The devDockerfilebakes no such conf, which is why the original fix was "verified on the local origin" and still shipped broken; header changes must be re-checked against the live service.scripts/check-duplicate-security-headers.mjs— a guard so that cannot come back. Fails if either production Dockerfile bakes an Apache directive for a header the middleware owns, if the middleware stops setting one of them (which would leave production with none), ifhttpdocs/.htaccessswitches fromsetifemptytoalways set, or if the image stops setting HSTS. Runs in the always-onDockerfile Drift Detectionjob so no path filter can let it sleep. Verified in both directions: passes on the fixed tree, fails when the old directive is reintroduced.Fifteen write endpoints that answered "success" for a record belonging to another community now answer 404. Each was an idempotent no-op — the scoped query touched nothing — so the sweep classed them as
ACCEPTED_NO_CHANGE(Finding 6) or, when only a valid body reached them, as an accepted valid-body write (Finding 18), and pinned them in two shrink-only lists. Both lists are now empty. Every fix is the same shape: resolve the record (or the person) insideTenantContext::getId()before acting, and refuse with the endpoint's existing not-found key when it is absent. Endpoints:DELETE events/{id}/waitlist,POST events/{id}/attendance/bulk,DELETE feed/posts/{id}/share,DELETE goals/{id}/reminder,DELETE jobs/{id}/save,DELETE listings/{id}/save,PUT messages/{id}/read,DELETE stories/close-friends/{friendId},POST stories/{id}/analytics,DELETE users/me/availability/{id},DELETE users/me/sub-accounts/{id},DELETE courses/{id}/enroll,DELETE members/{id}/endorse,PUT admin/volunteering/giving-days/{id},DELETE admin/courses/instructors/{userId}.CrossCommunityAccessSweepTestre-run twice: write sweep 366 probed, 0 accepted-unchanged, 0 mutated; valid-body pass 0 accepted. No translation key was added — every message reuses a key already inlang/en/api.phporapi_controllers_2.json.security-scan.yml: an unavailable Semgrep run no longer looks like a clean one. The step ended in|| trueand, when Semgrep failed to run (auth, network, install), wrote an EMPTY SARIF so the upload step never crashed — so a scan that never happened was indistinguishable from a scan that found nothing. The step now fails visibly (continue-on-error: truekeeps the rest of the job running) and the upload runs only when Semgrep actually wrote a file (hashFiles('semgrep-results.sarif') != ''). Raised by external review of the Coventry assessment (audit item A8), which also noted the container scan runs on push only, not on the nightly schedule — now stated as such in the assessment.CrossCommunityAccessSweepTest: every accepted-but-unchanged write is now also sent with an identifier that exists nowhere (GHOST_ID= 2,000,000,000), and the two answers compared on status and on the body with digit runs normalised. Findings 6 and 18 in the assessment had called these fifteen endpoints "existence disclosures" without ever running that third case; measured, all 15 answer a nonexistent id identically — they are misleading success replies and disclose nothing about the record. Relabelled in the assessment (audit item A10). Recorded per endpoint asghostin the evidence files and as a line in both write-pass summaries.Both poll-vote endpoints stopped answering
500for a poll in another community (twenty-one endpoints fixed in total).POST /v2/polls/{id}/voteandPOST /v2/feed/polls/{id}/vote.PollService::vote()is correctly scoped — it looks the poll up withWHERE id = ? AND tenant_id = ?and throwsRuntimeException('Poll not found')— but both controllers catch onlySafeguardingPolicyException, so the exception escaped as a server error instead of a not-found. Exactly the shape of the giving-days and event-RSVP fixes earlier the same day. Both controllers now resolve the poll through the tenant-scopedPollService::getById()first and answer404. Found only by the valid-body pass: an empty body was rejected at validation before the vote was ever attempted.- Five further endpoints answer success for another community's identifier and are visible only with a valid body —
DELETE courses/{id}/enroll,POST events/{id}/attendance/bulk,DELETE members/{id}/endorse,POST stories/{id}/analytics,DELETE admin/courses/instructors/{userId}. Each was read before being pinned inKNOWN_VALID_BODY_ACCEPTED; all are inert.StoryService::trackAnalytics()is worth naming: it answerstracked: truebut looks the story up scoped by tenant and returns before inserting, so no analytics row is created for another community's story. That check mattered — the one genuine cross-community side effect in this whole assessment was an endpoint of precisely this shape that did create a row (POST jobs/{id}/referral).
- Five further endpoints answer success for another community's identifier and are visible only with a valid body —
The three paid regional-analytics partner endpoints are now rate-limited (
throttle:nexus-route-30-per-1m); they had no limit at all./api/partner-analytics/me/dashboard,/me/reportsand/me/reports/{id}/downloadauthenticate by resolving a per-subscription token inside the controller rather than through middleware, so no middleware ran on them: no authentication, no kill switch and no throttle. The rate limit was therefore the only thing between an unauthenticated caller and unlimited token guesses, and there wasn't one. Found by enumerating every route that does not runAuthenticate— 129 of 2,503 — while checking which authentication paths bypass the cross-community token guard.- Worth recording, because it is the reassuring half: the controller itself is soundly built. Tokens are stored hashed (SHA-256, with a plaintext fallback only for rows predating the hash column), read from the
Authorization: Bearerheader and nowhere else, and every query — includingdownloadReport— is scoped by report id andsubscription_idandtenant_id, so there is no cross-subscription read. Every access is written to an access log. - The route comment claimed the token could also arrive as
?token=. The controller has never accepted that, and must not: a credential in a query string is recorded in web-server access logs, browser history andRefererheaders. Comment corrected to match the stricter code.
- Worth recording, because it is the reassuring half: the controller itself is soundly built. Tokens are stored hashed (SHA-256, with a plaintext fallback only for rows predating the hash column), read from the
Four more endpoints stopped acknowledging another community's CHILD RECORD (nineteen in total). All found by the new foreign-child sweep, all fixed the same day, and in every case the query was already community-scoped so no data crossed and none was disclosed — the responses were simply untrue, and a success for a foreign identifier confirms that identifier exists.
DELETE courses/{courseId}/cohorts/{cohortId}—CourseCohortService::delete()already scopes the cohort to the guarded course and returnsfalsewhen there is nothing of ours to delete; the controller discarded that and answereddeleted: true. Now 404.DELETE courses/{courseId}/quizzes/{quizId}/questions/{questionId}— the delete is scoped to the quiz, which is scoped to the guarded course, but the row count was discarded and the answer wasdeleted: trueregardless. Now 404 when nothing was removed.POST admin/legal-documents/{docId}/versions/{versionId}/notify— answerednotified: true, count: 0for another community's version.notifyUsersOfUpdate()is scoped and correctly notified nobody, but it returns 0 for several legitimate reasons too (not the current version, still a draft, acceptance not required), so the count cannot tell them apart. The controller now resolves the version in this community first, exactly aspublishVersion()already did.GET admin/legal-documents/{docId}/versions/{versionId}/pending-count— same shape:count: 0is indistinguishable from "nobody is pending". Same fix.- All four reuse
api.not_found/api.version_not_found, keys that already exist and are translated in all eleven locales, so no i18n work and no new gate debt.
A fifteenth endpoint stopped acknowledging a PERSON from another community:
DELETE jobs/{id}/team/{userId}. Found by the new multi-parameter foreign-person sweep.JobTeamService::removeMember()checks the vacancy is in this community and owned by the caller, and its delete is tenant-scoped — so nothing could ever cross — but it returnedtrueregardless of how many rows it removed, so a member of another community was answeredsuccess: truefor a delete that did nothing. A new tenant-scopedJobTeamService::isTeamMember()decides, and the controller answers404withapi.not_found(an existing key, already translated in all eleven locales). The 422 for a non-owner is unchanged and is still returned first, so membership is not disclosed to anyone who cannot manage the team.Fourteen API endpoints stopped acknowledging records that belong to another community. All found by the two route-table sweeps on 2026-09-10 and fixed the same day; none disclosed member data, all confirmed a foreign id exists, and one created a side row against it.
- Read side (5).
connections/status/{userId},jobs/{id}/match,users/{id}/activity/dashboardandadmin/users/{id}/verification-badgesanswered 200 with zeros/empty lists for a foreign id — indistinguishable from "no data", so a caller could confirm the id existed elsewhere. Each now checks the record is in the current tenant and answers 404.volunteering/giving-days/{id}/statsanswered 500: the service throws for a foreign id and the controller expectednull. Caught → 404. - 🔴
POST jobs/{id}/referralminted a referral token for a vacancy in ANOTHER community — a real cross-community side effect,tenant_id2 pointing at a tenant-999 vacancy. Now 404 unlessgetById()resolves the vacancy in this tenant. - Write side, acknowledged but harmless (7 more, now 404).
stories/{id}/view(service was scoped and returned void; controller saidviewed: trueregardless — service now returns bool),jobs/alerts/{id}delete/unsubscribe/resubscribe (scoped to the caller's own alerts; now report not-found when nothing was theirs),admin/jobs/{id}/unfeature(model-scoped zero-row update reported as success),admin/newsletters/{id}andadmin/newsletters/templates/{id}(tenant-scopedDELETEreported 204 for zero rows). DELETE events/{id}/rsvp500'd for a foreign event.EventRegistrationExceptionextendsRuntimeExceptionand was caught by the generic clause first, which rethrew it. Catch order swapped.- Ten further write endpoints still answer 2xx for a foreign id without touching anything (idempotent unsave / leave / mark-read / delete of a row the actor never had). Pinned in
KNOWN_ACCEPTED_NO_CHANGE, shrink-only in both directions, and listed in the assessment as an open Low. StoryControllerTest::test_view_silently_handles_nonexistent_storyasserted the old 200 and is nowtest_view_of_nonexistent_story_is_not_found.
- Read side (5).
web-uk: a protocol-relative open redirect on custom accessible domains. On a community's own accessible hostname,
/{slug}/accessible//evil.exampleredirected toLocation: //evil.example, because the remainder after the prefix was used verbatim. Leading slashes are now collapsed (tenant-routing.js);tests/tenant-routing-protocol-relative-redirect.test.jspins it and confirms the/alpha/redirect (which prepends the prefix and was never affected) still works. Found by CodeQLjs/server-side-unvalidated-url-redirection; the other nine alerts under that rule are internally builturlFor()paths and were dismissed with that reason.web-uk: federation banner lookups now use
Object.hasOwn.banners[status]resolvedconstructor/toStringthroughObject.prototypeto a function that is not a banner (CodeQLjs/unvalidated-dynamic-method-call, two sites).Production API responses carried every security header twice, with two DIFFERENT
Permissions-Policyvalues.httpdocs/.htaccessusedHeader always set, which adds to the error table alongside the middleware's headers on the success table. NowHeader setifemptywith values identical toSecurityHeadersmiddleware, so the middleware wins on PHP responses and Apache still covers static files; the obsoleteX-XSS-Protectionis no longer sent from.htaccess;X-Powered-Byis unset. Verified on the local origin: one of each. 🔴X-Powered-By: PleskLinon production comes from the HOST Apache and cannot be removed from this repository — recorded as a recommendation.Dependency advisories closed:
morgan1.11.0→1.12.0 (CVE-2026-15603),qs6.15.3→6.16.0 viaoverrides(CVE-2026-82417/82562 — Express pins the range),sanitize-html2.17.5→2.17.7 (CVE-2026-63670 textarea mXSS, CVE-2026-84371 SVG SMIL URI bypass),dompurify3.4.11→3.4.15 (GHSA-55q2-fjhq-7xh7, GHSA-c2j3-45gr-mqc4),react-router/react-router-dom6.30.4→6.30.6 (GHSA-jjmj-jmhj-qwj2, open redirect leading to XSS — the v6 line's own patch; the two advisories needing v7.18 stay open). The react-frontend lock was regenerated with npm 10.9.3 on the host and passes thenode:20-alpinestrictnpm ci --dry-run;tsc --noEmitclean.- Dependabot alerts and security updates were switched ON for the repository on 2026-09-10 (owner-authorised), and
maingained branch protection — required checkRelease Gate, no force-push, no deletion, administrators not blocked so the commit-to-main workflow is unchanged; no required reviews. Dependabot immediately reported 52 alerts: 13 in shipped code, 39 in build/test tooling that is never deployed. Seven of the thirteen are closed by the upgrades above;image-size×2 (mobile) dismissed asnot_used— it is pulled in only by the Metro bundler at build time and no fixed version exists; react-router ×2 (v7-only fix) anddecode-uri-componentremain open and documented. The 39 tooling alerts are left for Dependabot's now-enabled security-update pull requests, each gated byRelease Gate. - 🔴
sanitize-html≥2.17.6 requireshtmlparser2^12, which is ES-module-only. Node 22 (the runtime) loads it; Jest's loader cannot, so 12 web-uk suites failed with "Cannot use import statement outside a module".web-uk/tests/shims/htmlparser2.cjs, wired viamoduleNameMapper, hands that one package to the real Node loader throughprocess.getBuiltinModule('node:module').createRequire()— NOTrequire('node:module'), which inside Jest is Jest's ownmoduleand mapshtmlparser2back to the shim (circular,{}). Verified:require('sanitize-html')succeeds in thenode:22web-uk container and the</textarea/>mXSS probe now sanitises to an empty string. Full web-uk suite: 148 suites, 3,049 tests. - Left open, with reasons recorded on the alerts: react-router CVE-2026-53669 (fix is the v7 major);
decode-uri-componentCVE-2026-45822 underexpo-router(fix is ESM-only and needs a store build); CVE-2026-53668 dismissed — its range is 7.9.6–7.12.0 and 6.30.4 is outside it; CVE-2026-53666 dismissed — needs SSR hydration the SPA does not do.
- Dependabot alerts and security updates were switched ON for the repository on 2026-09-10 (owner-authorised), and
GitHub code-scanning backlog triaged: 78 open alerts. 46 dismissed in-session with a written reason each (34 in
web-uk/testsfixtures, 2 in a react-frontend test file, 4 in developer scripts, a vendor docsrequirements.txt, a Stripe publishable key, 3 intentionally public nginx proxy locations). 19 more reviewed and prepared in.local-docs-archive/security-assessment/dismiss-reviewed-alerts.sh— the agent's permission classifier blocked the second batch, so a person runs it. 13 left open deliberately: the two genuine advisories above, and alerts on lines fixed today that CodeQL/Trivy will close on their next scan.