diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 8b10da0..8a7e109 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -181,6 +181,8 @@ Exit gate: **MEMBERS-DIRECTORY-001I current source boundary:** [#635](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/635) changes only the preserved connected Account recovery state. An unknown visibility, upload, or removal outcome, or a failed authoritative post-mutation readback, retains the fixed uncertain-change warning through failed and repeated **Reload settings** attempts in the same application and account lifetime. Photo and finder mutation controls stay hidden, and discarded draft bytes or data URLs do not return. Only a successful authoritative profile read clears uncertainty and shows the returned current photo and finder setting. Initial profile-load failures and failed confirming reads after definitive mutation rejection remain generic unavailable states, while the generic load message no longer promises globally that no setting changed. Reload creates no request ID, sends no visibility, upload, or removal mutation, and starts no automatic mutation retry; application change, account change, and unmount reset the component lifetime and fence older completions. Availability stays `false`, so the default source branch and live #623 preview remain inert. #635 changes no data movement, page structure, service contract, Function, Rule, index, package, workflow, backend, provider, account, sign-in, production data, deployment, or connected/live behavior. #507 still owns privacy approval, scoped authorization, isolated staging, backend-first deployment/readback, the reviewed availability flip, connected publication, and live proof. +**MEMBERS-DIRECTORY-001J current source boundary:** [#637](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/637) changes only keyboard-focus recovery in the preserved connected Account branch. A user-initiated visibility, upload, or removal failure that becomes uncertain, a completed mutation followed by failed authoritative readback, or a definitive rejection followed by failed confirming read records one recovery-focus intent for the current mounted application-and-account lifetime and focuses the rendered **Reload settings** action. Choosing Reload records a replacement same-lifetime intent that is bound by the next load effect to its exact load identity; a guarded failure of that load focuses the replacement recovery action, including across repeated failures. Initial and background load failures have no intent and do not steal focus. A successful authoritative reload clears the intent and does not focus a removed recovery action or an unrelated ready control. Application change, account change, unmount, and stale load completion fail the explicit lifetime-and-load checks and remain focus-inert. The focus handoff creates no request ID, additional service call, mutation, automatic retry, draft restoration, or data URL. Existing uncertainty truth, generic failure truth, profile-read count, mutation/read/render/context fences, photo-draft handling, and ready-state focus rules remain unchanged. Availability stays `false`, so the default source branch and live #623 preview remain inert. #637 changes no data movement, page structure, service contract, Function, Rule, index, package, workflow, backend, provider, account, sign-in, production data, deployment, or connected/live behavior. #507 still owns privacy approval, scoped authorization, isolated staging, backend-first deployment/readback, the reviewed availability flip, connected publication, and live proof. + ### Phase 5 — End-to-end qualification **Issue:** TEST-001 plus final closure evidence from all prior phases diff --git a/SECURITY.md b/SECURITY.md index 07a5849..6ad3b91 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -105,6 +105,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Source-only rendered-result containment for RISK-042 | MEMBERS-DIRECTORY-001G [#631](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/631) makes the preserved connected People finder distinguish an absent photo from a supplied thumbnail that the browser cannot display. `null` renders **No photo**; a failed non-null render removes the image/data URL from the page and renders **Photo unavailable**; a different later version receives a fresh render attempt. After completed validation, empty results, fixed failure, or result cards, **Clear search and result cards** empties the query, removes the prior message, headings, cards, names, and images from component state and the rendered page, announces **Search field and displayed result cards cleared.**, and focuses the name input. It creates no request ID and makes no directory-service call. Generated-only tests use made-up names and non-face thumbnails to cover the fallbacks, version reset, four clearable outcomes, focus, zero-call behavior, keyboard/44-pixel/contrast/320-pixel layout, and the unchanged unavailable default. | This local rendered-page disposal is not cancellation, browser-memory erasure, cache removal, audit rollback, or recall of a response already returned, seen, or captured. Clear is absent while idle or pending; existing pending, stale-context, guard, projection, cap, and fixed-error behavior remains. The source-controlled availability value stays `false`, and live #623 remains inert. No service/server contract, Firebase, provider, account, sign-in, production data, or live behavior changes. #507 still owns notice/retention approval, scoped authorization, protected authority, isolated staging, backend-first deployment/readback, the availability flip, connected publication, and live proof. Never add a photo query, face recognition, embedding, similarity, biometrics, total, export, or roster authority; use no real name or photo. | | Source-only reviewed-replacement containment for RISK-042 | MEMBERS-DIRECTORY-001H [#633](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/633) renames the destructive control **Remove current saved photo** and makes a confirmed remove plus authoritative readback preserve the exact current reading or ready component-memory draft identity while updating only the current saved-photo state and revision. The replacement creates no upload request or service call until a later explicit Save creates a new request ID, sends the same content type and bytes once, and uses the refreshed revision. Definitive rejection plus successful readback preserves the draft and associates the fixed error with Remove. Unknown outcome or failed post-mutation readback clears the draft identity, bytes, and data URL, hides photo and finder mutation controls, retains only the existing **Reload settings** recovery, and exposes no Save or duplicate-mutation retry. Confirmed readback moves focus to a still-current Remove action, otherwise an eligible Save action, otherwise the persistent Add/Replace input. Generated-only tests cover exact-byte/revision reuse, reading and ready drafts, no-draft and concurrent-photo focus, rejection, unknown/readback failure, unchanged visibility, stale contexts, reselection, unmount, and the unchanged unavailable default. | Intentionally preserving an unsaved replacement keeps private image bytes in component memory after the current saved photo is removed; it is not an upload, durable draft, server state, cancellation, or deletion proof outside the authoritative saved-photo readback. Context and generation fences limit stale restoration, while unknown state fails closed by discarding the draft. The source-controlled availability value stays `false`, and live #623 remains inert. No service/server contract, photo query, face recognition, matching, embedding, similarity, biometric processing, roster authority, membership proof, Firebase, provider, account, sign-in, production data, or connected/live behavior changes. #507 still owns notice/retention approval, scoped authorization, protected authority, isolated staging, backend-first deployment/readback, the availability flip, connected publication, and live proof. Use only generated non-face test images; do not inspect or upload a real photo. | | Source-only uncertain-change recovery containment for RISK-042 | MEMBERS-DIRECTORY-001I [#635](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/635) gives the preserved connected Account branch a component-lifetime uncertainty marker for an ordinary mutation failure or failed authoritative post-mutation readback. Failed and repeated **Reload settings** reads retain the fixed **We could not confirm that change** warning, keep photo and finder mutation controls hidden, and cannot restore discarded draft bytes or data URLs. A successful guarded authoritative profile read clears uncertainty and displays the returned state. Initial load failures and failed confirming reads after definitive mutation rejection remain generic unavailable; that generic message no longer globally promises **No setting was changed**. Reload creates no request ID or visibility, upload, or removal mutation. Generated-only tests cover repeated failures, eventual authoritative success, generic failures, zero mutation retries, discarded bytes, and stale application/account reload fences. | The marker preserves truthful uncertainty only within one mounted application-and-account component lifetime; it is not provider acknowledgement, durable mutation evidence, reconciliation, deletion proof, or authorization. Generic unavailable state deliberately does not infer whether a prior change occurred. The source-controlled availability value stays `false`, and live #623 remains inert. No data movement, page structure, service/server contract, Firebase, provider, account, sign-in, production data, deployment, or connected/live behavior changes. #507 still owns notice/retention approval, scoped authorization, protected authority, isolated staging, backend-first deployment/readback, the availability flip, connected publication, and live proof. Use no real name or photo. | +| Source-only recovery-focus containment for RISK-042 | MEMBERS-DIRECTORY-001J [#637](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/637) gives the preserved connected Account branch one explicit recovery-focus intent for a user-initiated uncertain mutation, failed authoritative post-mutation readback, or failed confirming read after definitive rejection. The intent is tied to the current mounted application-and-account lifetime. A Reload-created intent is additionally bound by the next load effect to that exact load identity. Only the matching current unavailable or unknown transition focuses the rendered **Reload settings** action. Repeated failed reloads focus each replacement action. Initial or background load failure has no intent and does not steal focus; successful authoritative reload clears the intent without redirecting focus to an unrelated ready control. Application change, account change, unmount, and stale completion remain focus-inert. Generated-only tests cover all three mutations, both readback-failure classes, repeated uncertain and generic reload failures, successful reload, outside focus, application/account changes, unmount, zero retry or request-ID creation, and the unchanged unavailable default. | Programmatic focus is accessibility state, not provider acknowledgement, authorization, durable reconciliation, deletion proof, or evidence that a stale load became current. It creates no service call, mutation, automatic retry, draft restoration, data URL, or new data flow. The source-controlled availability value stays `false`, and live #623 remains inert. No page structure, service/server contract, Function, Rule, index, Firebase or provider configuration, account, sign-in, production data, deployment, or connected/live behavior changes. #507 still owns notice/retention approval, scoped authorization, protected authority, isolated staging, backend-first deployment/readback, the availability flip, connected publication, and live proof. Use no real name or photo. | | Immediate Product-binding containment part of RISK-031 | PAY-PRODUCT-001A is tracked in live [#353](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/353). One dependency-free projection is used by the paid race and Shop checkout paths. A present stored Product link must be an own primitive non-empty string from 1 through 255 JavaScript code units and is copied without trimming, conversion, character restrictions, or `prod_` inference. Only a genuinely missing own field retains the compatibility Product-create path. Before any mapping write, a resolved Product must provide a bounded custom ID, exact Product kind, expected declared mode, and an installed-SDK 2xx response marker. Malformed stored links stop before token, registration/order identifier, Product-link or business-record write, or Stripe work; earlier access and request-count checks and their safety-counter writes may already have run. Malformed created results stop after at most one Product attempt but before mapping, Checkout Session, or business-record writes. | This structural containment does not prove provider origin, Stripe account ownership, intended catalog identity, metadata binding, Product status, price, dispatch, delivery, or reconciliation. A rejected create result may leave an orphaned Product; do not retry automatically. Anonymous clean-missing creation remains concurrency-prone and reachable from public traffic. Complete #113 inventory/disposition, authenticated idempotent catalog synchronization, Product-specific plan/pre-send/result/lost-acknowledgement/reconciliation controls, isolated staging, protected Firebase deployment, and provider proof before live commerce. | | Immediate current-handler containment part of RISK-003 | PAY-SESSION-001A is tracked in live [#357](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/357). The current paid race and Shop handlers build one immutable expectation before the Session call, catch rejection without opening it, and immediately project only a closed installed-SDK result. A result must match declared test/live mode, payment/open/unpaid state, exact cents, USD, buyer email, exact closed metadata, exact callbacks, a mode-compatible bounded Session ID, one canonical HTTPS capability at the hard-coded default `checkout.stripe.com` origin, and an installed-SDK 200 marker. Only copied ID/URL values continue; records store the ID but never the URL. Invalid results create no registration/order record and return one fixed unknown-result message. The website makes rejection or a missing paid URL terminal for that page visit, retains the form, and blocks a second direct handler call. | This is a narrow legacy compatibility stop, not trusted provider/account origin, deterministic business idempotency, dispatch/delivery proof, durable result evidence, payment proof, or adoption of the unused C4 chain. The Session call occurs first, so a rejected result may leave a payable orphan; earlier rate-counter, token/identifier, and lazy Product-mapping effects may remain. Reload, another tab/device, or a scripted caller bypasses the page lock. A configured Stripe custom checkout domain is blocked until #113 and a protected configuration boundary approve it. Complete PAY-002C/D persistence-first sagas, trusted C4 controller/result persistence, reconciliation, protected staging/deployment, provider readback, and exact website/Firebase/live proof. | | Immediate pre-render browser containment part of RISK-003 | PAY-SESSION-001B is tracked in live [#503](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/503). Each current public race and Shop page now keeps one component-local in-memory marker. After existing local checks admit a request, the handler sets that marker synchronously before analytics or its first Checkout promise can settle. A same-action or later submission on that mounted page is inert, including the gap before React renders the existing pending disabled button. Focused actual-route tests prove one service/analytics attempt across same-action and post-render repeats; preserve free-participant, volunteer-without-price-tier, and both paid-link navigation branches; and prove local waiver/native-disabled paths do not consume an attempt. | This browser marker is not a server boundary, durable idempotency key, provider dispatch/result record, business-state lock, payment proof, or reconciliation. It never releases during the mounted page visit because the admitted result must navigate or enter #357's terminal unknown-result state. Existing form controls other than the submit button remain editable. Reload, a remount, another tab/device, a script, or a direct service caller can bypass it. A same-mounted route change instead stays locked, and this slice does not fence an older pending result from that changed route. Complete PAY-002C/D persistence-first deterministic commands, durable replay/reconciliation, protected deployment, and exact website/Firebase/Stripe/live proof. | diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 135b5fd..e987f7b 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -697,6 +697,8 @@ WEB-002C [#623](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/623) publi **MEMBERS-DIRECTORY-001I uncertain-change reload truth — SOURCE ONLY:** [#635](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/635) changes only the preserved connected Account recovery state. An ordinary visibility, upload, or removal failure with an unknown outcome, or a mutation whose authoritative readback fails, sets a component-lifetime uncertainty marker and displays the fixed **We could not confirm that change** warning. Each failed **Reload settings** preserves that warning, keeps the photo and finder mutation controls hidden, and cannot restore discarded draft bytes or data URLs. Only a successful guarded authoritative profile read clears uncertainty and renders the returned current photo and finder setting. An initial profile-load failure and a failed confirming read after a definitive mutation rejection remain generic unavailable states; the generic load message no longer makes the global promise **No setting was changed**. Reload creates no request ID, performs no visibility, upload, or removal mutation, and starts no automatic mutation retry. The existing application/account key and lifetime/load fences reset uncertainty on application change, account change, or unmount and make an older reload completion inert. This is state and copy hardening only: it changes no data movement, page structure, service contract, Function, Rule, index, package, workflow, backend, provider, account, sign-in state, production data, deployment, or connected/live behavior. Availability remains byte-for-byte `false`; the default source branch and live #623 preview remain inert, and #507 retains every privacy, authorization, staging, deployment, connection, publication, and live-proof gate. +**MEMBERS-DIRECTORY-001J profile-recovery focus — SOURCE ONLY:** [#637](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/637) changes only the preserved connected Account recovery focus. When a user-initiated visibility, upload, or removal mutation has an unknown outcome, when a completed mutation's authoritative readback fails, or when a definitive rejection cannot be confirmed by a profile read, the component records one recovery-focus intent tied to the current mounted application-and-account lifetime before rendering **Reload settings**. The rendered recovery button receives focus after either the uncertain-change or generic unavailable transition. Choosing Reload replaces that intent with a same-lifetime reload intent, and the next load effect binds it to that exact load identity; only a guarded failure of that current load focuses the replacement Reload button. Repeated failed reloads repeat that exact-load handoff. An initial or background load has no recovery intent and does not steal focus. A successful authoritative reload clears the intent, removes the recovery action, and does not redirect focus to an unrelated ready control. Application change, account change, unmount, or an obsolete load clears or fails the lifetime-and-load checks, so an older completion cannot move focus in a new context. Focus creates no request ID, service call, mutation, automatic retry, draft restoration, or data URL beyond the existing profile read. Existing uncertainty copy, mutation/read/render fences, draft rules, and ready-state focus behavior otherwise remain unchanged. This accessibility-only source change adds no data movement, page structure, service contract, Function, Rule, index, package, workflow, backend, provider, account, sign-in state, production data, deployment, or connected/live behavior. Availability remains byte-for-byte `false`; the default branch and live #623 preview remain inert, and #507 retains every privacy, authorization, staging, backend-first deployment/readback, availability-flip, publication, and live-proof gate. + ### 8.0a Provider-neutral membership authority and entitlement — SOURCE ONLY, UNUSED MEMBERS-IDENTITY-001A [#208](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/208), with its command-order correction in MEMBERS-IDENTITY-001H [#451](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/451), defines one unused pure contract that keeps a stable MPRC membership separate from the Firebase account used to sign in. A membership record can exist and receive monotonic term decisions without a UID. Such a record grants no website entitlement. Google, WhatsApp, Strava, email equality, a profile role, and any browser field remain projections or inputs to future reviewed workflows; none is membership authority. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index ac4c4e6..8d4452f 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -2472,7 +2472,7 @@ flowchart TD Connected -. "never photo search or proof" .-> Official["Membership, role, payment, or official records"] ``` -Text alternative: the published #623 artifact keeps the Account and administrator-guarded People-finder controls disabled; source-only #627 preserves the accessible connected layouts behind the unchanged false availability value; source-only #629 adds local photo review where Cancel sends nothing, Save alone sends the existing upload command, and the current saved photo remains authoritative with a version-reset unavailable fallback; source-only #631 distinguishes a `null` **No photo** result from an unrenderable supplied **Photo unavailable** result, retries a different later photo version, and lets completed local states clear the query, messages, cards, names, and images with an announcement, input focus, and no new request or service call; that Clear action does not cancel work, erase memory or cache, roll back an audit, or recall a seen result; source-only #633 makes **Remove current saved photo** preserve the same local reading or ready replacement through a confirmed authoritative remove, use the refreshed revision only when the person later chooses Save, focus a remaining Remove action before a ready Save action before the persistent file input, keep the draft after a definitive rejection with successful readback, and after an unknown outcome or failed readback discard its bytes, hide photo and finder mutation controls, and retain only the existing Reload settings recovery with no Save retry; source-only #635 keeps that uncertain-change warning through failed Reload settings attempts until one authoritative profile read succeeds, while generic load failures make no global no-change promise and reload sends no mutation; and only #507 may later connect name search plus voluntary thumbnails after privacy, authorization, staging, and backend-first readback, without photo search, face recognition, or official-record authority. +Text alternative: the published #623 artifact keeps the Account and administrator-guarded People-finder controls disabled; source-only #627 preserves the accessible connected layouts behind the unchanged false availability value; source-only #629 adds local photo review where Cancel sends nothing, Save alone sends the existing upload command, and the current saved photo remains authoritative with a version-reset unavailable fallback; source-only #631 distinguishes a `null` **No photo** result from an unrenderable supplied **Photo unavailable** result, retries a different later photo version, and lets completed local states clear the query, messages, cards, names, and images with an announcement, input focus, and no new request or service call; that Clear action does not cancel work, erase memory or cache, roll back an audit, or recall a seen result; source-only #633 makes **Remove current saved photo** preserve the same local reading or ready replacement through a confirmed authoritative remove, use the refreshed revision only when the person later chooses Save, focus a remaining Remove action before a ready Save action before the persistent file input, keep the draft after a definitive rejection with successful readback, and after an unknown outcome or failed readback discard its bytes, hide photo and finder mutation controls, and retain only the existing Reload settings recovery with no Save retry; source-only #635 keeps that uncertain-change warning through failed Reload settings attempts until one authoritative profile read succeeds, while generic load failures make no global no-change promise and reload sends no mutation; source-only #637 focuses the recovery action after user-initiated mutation or readback failure, binds a Reload focus intent to the exact current load before focusing a replacement after failure, never steals focus on an initial load failure, and keeps stale application, account, and unmounted completions focus-inert; and only #507 may later connect name search plus voluntary thumbnails after privacy, authorization, staging, and backend-first readback, without photo search, face recognition, or official-record authority. Officer review steps for the #621 frontend preview: @@ -2954,6 +2954,93 @@ Officer source-review procedure for MEMBERS-DIRECTORY-001I [#635] uncertain-chan **Escalation:** membership lead plus privacy and platform/security owners. Use the private incident path if a real name or photo appeared, a failed Reload hid uncertain-change truth, Reload sent a mutation, stale state crossed accounts, or connected behavior became available. +Officer source-review procedure for MEMBERS-DIRECTORY-001J [#637] profile-recovery keyboard focus — connected source only, **NOT LIVE**: + +**Purpose:** let a backup officer verify from specialist-prepared evidence that the preserved connected Account interface moves keyboard focus to the available recovery action after a user-initiated profile-setting failure, without connecting the feature, repeating a mutation, using a real account or photo, or changing production. + +**Approvers:** membership lead, privacy owner, and platform/security owner. + +**Prerequisites:** #635 is reviewed and merged. Ask the platform owner or testing specialist for the exact #637 source candidate, the named generated-only test output, the trustworthy old-source failure, and a redacted written synthetic-behavior report. The specialist runs the tests and records the evidence. The backup officer reviews that written evidence without a terminal or test harness. Keep the source-controlled availability value `false`. Do not sign in to production, use a real name or photo, call production Firebase, or change production data. + +1. Keep the complete profile-photo and People-finder feature marked **NOT AVAILABLE YET**. +2. Ask the platform owner for the exact #637 issue. +3. Ask the platform owner for the reviewed pull request. +4. Ask the platform owner for the candidate or merge commit. +5. Ask the testing specialist for the named MEMBERS-DIRECTORY-001J test output. +6. Ask the testing specialist for the trustworthy old-source failure. +7. Ask the testing specialist for the redacted written synthetic-behavior report. +8. Confirm the report names the specialist who ran the tests. +9. Confirm the evidence uses only a made-up account. +10. Confirm the evidence uses only generated non-face image bytes. +11. Confirm the source-controlled availability value remains byte-for-byte `false`. +12. Confirm the last verified production deployment remains inert #623 deploy `6a7e072f8f346b0008510d29`. +13. Confirm an ordinary unknown visibility outcome focuses **Reload settings**. +14. Confirm an ordinary unknown upload outcome focuses **Reload settings**. +15. Confirm an ordinary unknown removal outcome focuses **Reload settings**. +16. Confirm a completed mutation followed by failed authoritative readback focuses **Reload settings**. +17. Confirm a definitive rejection followed by failed confirming read focuses generic **Reload settings**. +18. Confirm the uncertain paths retain **We could not confirm that change**. +19. Confirm the definitive-rejection read failure retains the generic unavailable message. +20. Confirm the first failed uncertain-state Reload focuses its replacement **Reload settings** action. +21. Confirm a repeated failed uncertain-state Reload focuses its replacement **Reload settings** action. +22. Confirm a failed generic Reload focuses its replacement **Reload settings** action. +23. Confirm an initial profile-load failure does not move focus from an outside control. +24. Confirm an initial profile-load failure does not focus **Reload settings** automatically. +25. Confirm a successful authoritative Reload removes **Reload settings**. +26. Confirm a successful authoritative Reload does not focus an unrelated ready control. +27. Confirm each mutation-created focus intent belongs to the current mounted application-and-account lifetime. +28. Confirm each Reload-created focus intent is bound to its exact new load identity. +29. Confirm an older load completion cannot use a newer load's focus intent. +30. Confirm an application change makes the earlier recovery focus intent inert. +31. Confirm an account change makes the earlier recovery focus intent inert. +32. Confirm unmount makes the earlier recovery focus intent inert. +33. Confirm Reload creates no directory request number. +34. Confirm Reload calls no visibility mutation. +35. Confirm Reload calls no photo upload mutation. +36. Confirm Reload calls no photo removal mutation. +37. Confirm Reload starts no automatic mutation retry. +38. Confirm recovery focus restores no selected-photo draft. +39. Confirm recovery focus restores no discarded data URL. +40. Confirm the default Account branch obtains no directory context. +41. Confirm the default Account branch creates no directory request number. +42. Confirm the default Account branch calls no directory service. +43. Confirm the source diff changes no Account wiring. +44. Confirm the source diff changes no People-finder page. +45. Confirm the source diff changes no data movement. +46. Confirm the source diff changes no page structure. +47. Confirm the source diff changes no client service contract. +48. Confirm the source diff changes no Function. +49. Confirm the source diff changes no Rule. +50. Confirm the source diff changes no index. +51. Confirm the source diff changes no schema. +52. Confirm the source diff changes no package. +53. Confirm the source diff changes no workflow. +54. Confirm the source diff changes no release control. +55. Confirm the source diff adds no photo query. +56. Confirm the source diff adds no face recognition. +57. Confirm the source diff adds no biometric processing. +58. Record the source change as its own state. +59. Record the named test results as their own state. +60. Record whether the change merged as its own state. +61. Record whether any website artifact was published as its own state. +62. Record the exact `runmprc.com` revision as its own state. +63. Record whether Firebase was deployed as its own state. +64. Record whether an outside provider was configured as its own state. +65. Record whether an account or sign-in state changed as its own state. +66. Record whether production data changed as its own state. +67. Record whether connected behavior became available as its own state. +68. Stop before changing availability, Firebase, a provider, an account, production data, or the live website. + +**Expected result:** the reviewed connected source focuses **Reload settings** after an ordinary unknown visibility, upload, or removal outcome; failed authoritative post-mutation readback; or failed confirming read after definitive rejection. A Reload-created focus intent belongs to the same mounted application-and-account lifetime and is bound to that exact load. Each guarded failed Reload focuses its replacement recovery action. Initial load failure does not steal focus. Successful authoritative reload clears the intent without redirecting focus to an unrelated ready control. Application change, account change, unmount, and stale completion remain focus-inert. The focus handoff creates no request ID, extra service call, mutation, automatic retry, restored draft, or data URL. Existing uncertainty and generic failure truth remain unchanged. Availability remains `false`; the default branch obtains no directory context, creates no request ID, and calls no directory service; and live #623 remains inert. The backend and connected behavior remain **NOT AVAILABLE YET**. + +**Stop conditions:** a real account, name, or photo; production sign-in; direct production Firebase access; a user-initiated mutation or readback failure whose rendered **Reload settings** action is not focused; a failed Reload whose replacement **Reload settings** action is not focused; an initial load failure that steals unrelated focus; a successful Reload that redirects focus to an unrelated ready control; an older load, application, account, or unmounted completion that moves focus; a request number or visibility, upload, or removal mutation caused by Reload; an automatic mutation retry; a restored draft or data URL; changed uncertainty or generic failure wording; a data-flow or page-structure change; a Function, Rule, index, schema, service-contract, provider, account, sign-in, production-data, or website change; an availability flip; use of a terminal or test harness by the backup officer; or a claim that source, tests, or merge means the feature is live. + +**Success proof:** record the exact #637 issue, reviewed pull request and commit; trustworthy old-source failure; green separately named MEMBERS-DIRECTORY-001J focused tests; green full frontend tests; type-checking; scoped lint; diagnostic production build; unchanged lint baseline; workflow checks; diff-check; independent privacy/security, frontend/accessibility, focus/race, and backup-officer reviews; and exact-main CI if merged. Record source, tests, merge, website publication, exact `runmprc.com` revision, Firebase deployment, provider configuration, account/sign-in change, production-data action, and connected/live behavior separately. Record the unchanged `false` availability value and unchanged #623 deploy separately. Source and tests do not prove merge; merge does not prove publication; publication does not prove Firebase, provider, account, data, or connected-live behavior. Final connection and live proof remain #507 work. + +**Undo:** use one reviewed frontend revert or safe roll-forward. Confirm the default disabled branch still makes zero directory calls. No Firebase, provider, account, or production-data undo is needed because #637 changes source only. Undo must not strand focus after a user-initiated recovery transition or permit a stale completion to move focus. + +**Escalation:** membership lead plus privacy and platform/security owners. Use the private incident path if a real name or photo appeared, recovery focus moved across applications or accounts, Reload sent a mutation, an initial failure stole unrelated focus, or connected behavior became available. + ## Admin screens — NOT AVAILABLE YET Admin event and product editors exist in source, but their live permissions, backup, preview, and rollback behavior have not been approved. Saving can write directly to production Firestore. Officers must not use these screens as a continuity procedure yet. diff --git a/src/pages/account/MemberDirectoryProfile.test.tsx b/src/pages/account/MemberDirectoryProfile.test.tsx index 2285c14..7056f02 100644 --- a/src/pages/account/MemberDirectoryProfile.test.tsx +++ b/src/pages/account/MemberDirectoryProfile.test.tsx @@ -1804,6 +1804,271 @@ describe('My Account member directory profile', () => { }); }); + describe('MEMBERS-DIRECTORY-001J profile recovery focus', () => { + async function selectReadyUpload(bytes: string) { + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File([bytes], 'fixture-001j.png', { type: 'image/png' })], + }, + }); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + fireEvent.load(preview); + return screen.getByRole('button', { name: 'Save profile photo' }); + } + + test.each([ + ['visibility', 'visibility'], + ['upload', 'upload'], + ['removal', 'remove'], + ])('focuses Reload settings after an ordinary unknown %s outcome', async ( + _label, + action, + ) => { + if (action === 'visibility') { + (setMyMemberDirectoryVisibility as jest.Mock) + .mockRejectedValueOnce(new Error('synthetic private visibility detail')); + } else if (action === 'upload') { + (setMyMemberDirectoryPhoto as jest.Mock) + .mockRejectedValueOnce(new Error('synthetic private upload detail')); + } else { + (getMyMemberDirectoryProfile as jest.Mock).mockResolvedValueOnce(PROFILE_WITH_PHOTO); + (removeMyMemberDirectoryPhoto as jest.Mock) + .mockRejectedValueOnce(new Error('synthetic private removal detail')); + } + renderProfile(); + + let control: HTMLElement; + if (action === 'visibility') { + control = await screen.findByRole('checkbox'); + } else if (action === 'upload') { + control = await selectReadyUpload('unknown upload bytes'); + } else { + control = await screen.findByRole('button', { + name: 'Remove current saved photo', + }); + } + control.focus(); + expect(control).toHaveFocus(); + fireEvent.click(control); + + const reload = await screen.findByRole('button', { name: 'Reload settings' }); + expect(reload).toHaveFocus(); + expect(document.activeElement).not.toBe(document.body); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryVisibility).toHaveBeenCalledTimes( + action === 'visibility' ? 1 : 0, + ); + expect(setMyMemberDirectoryPhoto).toHaveBeenCalledTimes(action === 'upload' ? 1 : 0); + expect(removeMyMemberDirectoryPhoto).toHaveBeenCalledTimes(action === 'remove' ? 1 : 0); + expect(document.body).not.toHaveTextContent('synthetic private'); + if (action === 'upload') { + expect(document.body.innerHTML).not.toContain(btoa('unknown upload bytes')); + } + }); + + test.each([ + ['resolved mutation readback failure', false], + ['definitive rejection confirming-read failure', true], + ])('focuses Reload settings after a %s', async (_label, definitive) => { + const rejected = { code: 'functions/failed-precondition' }; + if (definitive) { + (setMyMemberDirectoryVisibility as jest.Mock).mockRejectedValueOnce(rejected); + (isDefinitiveMemberDirectoryRejection as jest.Mock).mockImplementation( + (error) => error === rejected, + ); + } + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockRejectedValueOnce(new Error('synthetic private readback detail')); + renderProfile(); + const checkbox = await screen.findByRole('checkbox'); + checkbox.focus(); + + fireEvent.click(checkbox); + + const reload = await screen.findByRole('button', { name: 'Reload settings' }); + expect(reload).toHaveFocus(); + expect(screen.getByRole('alert')).toHaveTextContent(definitive + ? /could not load your profile photo/i + : /could not confirm that change/i); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryVisibility).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(removeMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent('synthetic private'); + }); + + test('refocuses every replacement Reload after repeated unknown-state failures without retrying the mutation', async () => { + (setMyMemberDirectoryVisibility as jest.Mock) + .mockRejectedValueOnce(new Error('synthetic private outcome detail')); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockRejectedValueOnce(new Error('synthetic private first reload detail')) + .mockRejectedValueOnce(new Error('synthetic private second reload detail')); + renderProfile(); + fireEvent.click(await screen.findByRole('checkbox')); + const firstReload = await screen.findByRole('button', { name: 'Reload settings' }); + expect(firstReload).toHaveFocus(); + + fireEvent.click(firstReload); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + const secondReload = await screen.findByRole('button', { name: 'Reload settings' }); + expect(secondReload).not.toBe(firstReload); + expect(secondReload).toHaveFocus(); + + fireEvent.click(secondReload); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(3)); + const thirdReload = await screen.findByRole('button', { name: 'Reload settings' }); + expect(thirdReload).not.toBe(secondReload); + expect(thirdReload).toHaveFocus(); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryVisibility).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(removeMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + }); + + test('refocuses Reload after a generic reload failure but does not focus an initial background failure', async () => { + const initialLoad = deferred(); + (getMyMemberDirectoryProfile as jest.Mock) + .mockReturnValueOnce(initialLoad.promise) + .mockRejectedValueOnce(new Error('synthetic private generic reload detail')); + const view = render( + <> + + + , + ); + const outside = screen.getByRole('button', { name: 'Outside control' }); + outside.focus(); + expect(outside).toHaveFocus(); + + await act(async () => initialLoad.reject(new Error('synthetic private initial detail'))); + + const initialReload = await screen.findByRole('button', { name: 'Reload settings' }); + expect(outside).toHaveFocus(); + expect(initialReload).not.toHaveFocus(); + initialReload.focus(); + fireEvent.click(initialReload); + + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + const replacementReload = await screen.findByRole('button', { + name: 'Reload settings', + }); + expect(replacementReload).not.toBe(initialReload); + expect(replacementReload).toHaveFocus(); + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryVisibility).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(removeMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent('synthetic private'); + view.unmount(); + }); + + test('clears recovery focus intent after a successful authoritative reload', async () => { + (getMyMemberDirectoryProfile as jest.Mock) + .mockRejectedValueOnce(new Error('synthetic private initial detail')) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO); + renderProfile(); + const reload = await screen.findByRole('button', { name: 'Reload settings' }); + reload.focus(); + + fireEvent.click(reload); + + expect(await screen.findByRole('img', { name: 'Your current profile thumbnail' })) + .toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Reload settings' })) + .not.toBeInTheDocument(); + expect(document.activeElement).toBe(document.body); + expect(screen.getByRole('checkbox')).not.toHaveFocus(); + expect(screen.getByLabelText('Replace profile photo')).not.toHaveFocus(); + expect(screen.getByRole('button', { name: 'Remove current saved photo' })) + .not.toHaveFocus(); + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryVisibility).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(removeMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + }); + + test.each([ + ['application', otherApp, 'synthetic-user'], + ['account', app, 'other-synthetic-user'], + ])('makes a failed old Reload focus-inert after the %s changes', async ( + _label, + nextApp, + nextUid, + ) => { + const oldReload = deferred(); + (setMyMemberDirectoryVisibility as jest.Mock) + .mockRejectedValueOnce(new Error('synthetic private outcome detail')); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockReturnValueOnce(oldReload.promise) + .mockResolvedValueOnce(DEFAULT_PROFILE); + const view = renderProfile(); + fireEvent.click(await screen.findByRole('checkbox')); + fireEvent.click(await screen.findByRole('button', { name: 'Reload settings' })); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + + view.rerender( + , + ); + const currentInput = await screen.findByLabelText('Add profile photo'); + currentInput.focus(); + await act(async () => oldReload.reject(new Error('synthetic private late detail'))); + + expect(currentInput).toHaveFocus(); + expect(screen.queryByRole('button', { name: 'Reload settings' })) + .not.toBeInTheDocument(); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryVisibility).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(removeMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent('synthetic private'); + }); + + test('makes a failed old Reload focus-inert after unmount', async () => { + const oldReload = deferred(); + (setMyMemberDirectoryVisibility as jest.Mock) + .mockRejectedValueOnce(new Error('synthetic private outcome detail')); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockReturnValueOnce(oldReload.promise); + const view = renderProfile(); + fireEvent.click(await screen.findByRole('checkbox')); + fireEvent.click(await screen.findByRole('button', { name: 'Reload settings' })); + await waitFor(() => expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2)); + view.unmount(); + const outside = document.createElement('button'); + document.body.appendChild(outside); + try { + outside.focus(); + + await act(async () => oldReload.reject(new Error('synthetic private late detail'))); + + expect(outside).toHaveFocus(); + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryVisibility).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(removeMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + } finally { + outside.remove(); + } + }); + }); + test('reloads settings before allowing a retry after an unknown outcome', async () => { const current = { ...DEFAULT_PROFILE, revision: 1, searchableByOfficers: true }; (getMyMemberDirectoryProfile as jest.Mock) diff --git a/src/pages/account/MemberDirectoryProfile.tsx b/src/pages/account/MemberDirectoryProfile.tsx index 3871950..2d3f139 100644 --- a/src/pages/account/MemberDirectoryProfile.tsx +++ b/src/pages/account/MemberDirectoryProfile.tsx @@ -221,6 +221,12 @@ type MutationConfirmation = ( profile: MemberDirectoryProfileData, ) => string; +type RecoveryFocusIntent = { + lifetime: symbol; + source: 'mutation' | 'reload'; + load: symbol | null; +}; + function visibilityConfirmation( requested: boolean, ): MutationConfirmation { @@ -320,9 +326,11 @@ function MemberDirectoryProfileAttempt({ const loadRef = useRef(null); const mutationRef = useRef(null); const uncertainChangeRef = useRef(false); + const recoveryFocusIntentRef = useRef(null); const photoReadRef = useRef(null); const photoInputRef = useRef(null); const removePhotoButtonRef = useRef(null); + const reloadButtonRef = useRef(null); const savePhotoButtonRef = useRef(null); const postMutationFocusRef = useRef<'photo-input' | 'remove-result' | null>(null); @@ -334,11 +342,27 @@ function MemberDirectoryProfileAttempt({ loadRef.current = null; mutationRef.current = null; uncertainChangeRef.current = false; + recoveryFocusIntentRef.current = null; photoReadRef.current = null; postMutationFocusRef.current = null; }; }, []); + useEffect(() => { + if (state.phase !== 'unknown' && state.phase !== 'unavailable') return; + const intent = recoveryFocusIntentRef.current; + if ( + intent === null + || intent.lifetime !== lifetimeRef.current + || (intent.source === 'reload' + && (intent.load === null || intent.load !== loadRef.current)) + ) return; + const target = reloadButtonRef.current; + if (target === null) return; + recoveryFocusIntentRef.current = null; + target.focus(); + }, [state.phase]); + useEffect(() => { if (state.phase !== 'ready' || postMutationFocusRef.current === null) return; const focusIntent = postMutationFocusRef.current; @@ -368,6 +392,15 @@ function MemberDirectoryProfileAttempt({ const preserveUncertainChange = uncertainChangeRef.current; let active = true; loadRef.current = load; + const focusIntent = recoveryFocusIntentRef.current; + if ( + focusIntent !== null + && focusIntent.lifetime === lifetime + && focusIntent.source === 'reload' + && focusIntent.load === null + ) { + recoveryFocusIntentRef.current = { ...focusIntent, load }; + } mutationRef.current = null; photoReadRef.current = null; postMutationFocusRef.current = null; @@ -385,6 +418,7 @@ function MemberDirectoryProfileAttempt({ || loadRef.current !== load ) return; uncertainChangeRef.current = false; + recoveryFocusIntentRef.current = null; setState({ phase: 'ready', profile, confirmation: null }); } catch { if ( @@ -447,6 +481,7 @@ function MemberDirectoryProfileAttempt({ if (!mutationIsCurrent(start)) return; mutationRef.current = null; uncertainChangeRef.current = false; + recoveryFocusIntentRef.current = null; setState({ phase: 'ready', profile, confirmation: null }); setActionError({ control: start.action === 'upload' ? 'file' : start.action, @@ -459,6 +494,11 @@ function MemberDirectoryProfileAttempt({ photoReadRef.current = null; setPhotoDraft(null); setActionError(null); + recoveryFocusIntentRef.current = { + lifetime: start.lifetime, + source: 'mutation', + load: null, + }; setState({ phase: 'unavailable' }); } return; @@ -468,6 +508,11 @@ function MemberDirectoryProfileAttempt({ setPhotoDraft(null); setActionError(null); uncertainChangeRef.current = true; + recoveryFocusIntentRef.current = { + lifetime: start.lifetime, + source: 'mutation', + load: null, + }; setState({ phase: 'unknown' }); return; } @@ -485,6 +530,7 @@ function MemberDirectoryProfileAttempt({ postMutationFocusRef.current = 'remove-result'; } uncertainChangeRef.current = false; + recoveryFocusIntentRef.current = null; setState({ phase: 'ready', profile, confirmation: confirmation(profile) }); } catch { if (!mutationIsCurrent(start)) return; @@ -493,6 +539,11 @@ function MemberDirectoryProfileAttempt({ setPhotoDraft(null); setActionError(null); uncertainChangeRef.current = true; + recoveryFocusIntentRef.current = { + lifetime: start.lifetime, + source: 'mutation', + load: null, + }; setState({ phase: 'unknown' }); } } @@ -689,7 +740,16 @@ function MemberDirectoryProfileAttempt({ } function reloadSettings() { - if (state.phase !== 'unknown' && state.phase !== 'unavailable') return; + const lifetime = lifetimeRef.current; + if ( + (state.phase !== 'unknown' && state.phase !== 'unavailable') + || lifetime === null + ) return; + recoveryFocusIntentRef.current = { + lifetime, + source: 'reload', + load: null, + }; setReloadAttempt((attempt) => attempt + 1); } @@ -723,7 +783,7 @@ function MemberDirectoryProfileAttempt({

{state.phase === 'unknown' ? UNKNOWN_CHANGE_MESSAGE : LOAD_FAILURE_MESSAGE}

-