diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md
index fca9af4..bfa4f07 100644
--- a/GITHUB_ISSUES.md
+++ b/GITHUB_ISSUES.md
@@ -168,6 +168,8 @@ Do not redesign routing or hosting in this issue; WEB-001 owns that. WEB-001A1 [
## CI-001 — Repair test gates and secure the deployment pipeline
+**COMMUNITY-STATUS-001 #724 — source-only staging child:** correct member free-event availability/cancellation-history presentation and stale UI after uncertain status reads. Existing #707/#711/#719/#722 dependencies are closed. Additive response-only enum/after-read clock; no stored/mutation/policy/authorization/schema/migration change or new service/dependency/IAM/Rules/polling. Existing transactional checks and exact saved retries remain authoritative. Backend-first exact staging source/archive, resource/profile/security, Hosting and disposable real UI/cleanup are required before availability; real refunds/transfers/dues/production and full backend completion stay separate.
+
**CI-STAGING-VERIFY-001 #722:** one script-only child corrects the final community success output's undefined digest, adds closed redacted failure phases and fixed read-only native verification. Local reproduction proves the reporting bug, not the caught exception from every historical failure. Clean exact green current-main source, native ancestor provenance/exact archive, scoped owner/runtime/config/resource/invoker and unchanged profile inventory remain mandatory. No new deployment, provider/IAM/data/payment mutation, dependency, workflow or production authority. See the [officer review](./docs/officers/STAGING_COMMUNITY.md#check-an-uncertain-staging-release--722). This does not close #105/#133/#136 or full backend readiness; current command/unchanged-marker evidence must be recorded in #722 before claiming hosted verification.
**CI-001C2 owner decision, 2026-10-11:** [#697](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/697) applies Dave's direct approval to continue Firebase backend setup without a backup owner. Dave (`daliu`) is the sole release/recovery owner. Backup appointment and two-person ownership are no longer backend prerequisites; absence of backup remains documented risk. #113/#133/#136 retain their other inventory, authority, exact-source, staging, policy and rollback requirements. Jeff's #667 read-only staging role does not expand. This decision is not Firebase deployment or live payment approval.
diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md
index 40a1f4b..1154360 100644
--- a/OPERATIONS_RUNBOOK.md
+++ b/OPERATIONS_RUNBOOK.md
@@ -1,5 +1,7 @@
# MPRC Platform Operations Runbook
+**#724 current-status correction — SOURCE ONLY:** same scoped staging service/runtime, response-only availability and no migration. Deploy only exact reviewed communityActions after green main, verify its native archive/settings/IAM and unchanged profiles, then publish exact staging Hosting. Supply only owned disposable member/event fixtures. Check all six availability states, stored confirmed history after event cancellation, own cancellation/count preservation, failed refresh clearing and exact saved retry; sample term/window boundaries after reads. Follow the [no-terminal review](./docs/officers/STAGING_COMMUNITY.md#current-member-status--724-source-prepared-not-available-yet). Remove only owned fixtures with identity/update-time fences and sign out; no extra service/polling, new permissions, real policy/payment or production action. Fresh native acceptance is pending #724.
+
## Read-only community release verification — #722
The former success output used an undefined `digest` identifier; a provider-free regression reproduces `ReferenceError` and verifies serialization of `bundle.digest`. Historical generic failures remain their original reported status; this does not prove which exception every old run caught. New failures expose only a closed phase enum, never exception text, provider responses, tokens or signed URLs.
diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md
index 2096c10..de555b5 100644
--- a/SYSTEM_DESIGN.md
+++ b/SYSTEM_DESIGN.md
@@ -1,5 +1,7 @@
# MPRC Platform System Design
+**#724 member-status correction — SOURCE ONLY:** the existing staging status read adds a response-only closed `signupStatus` enum (open/not_open_yet/closed/ended/full/cancelled), derived from validated event/window/capacity with server time sampled after reads. No stored schema, mutation authorization, count, registration/waiver/audit, membership policy, IAM/Rules/service/dependency or production change. UI distinguishes cancelled events from stored signups, fails closed on missing/unknown availability and hides old membership/event status/actions on uncertain reads while preserving exact saved retry and account/provider fences. No polling. Backend-first staging archive/resource/readback, Hosting and disposable UI/failure/cleanup proof remain required under #724; source/tests are not hosted acceptance.
+
**#719 fictional account linking — STAGING VERIFIED 2026-10-12:** `admin_dues_link` deliberately connects one immutable fictional receipt to one owner-marked verified/enabled disposable pending account. Exact v3 association permission is separate from recording/approval/event permissions; native recent TOTP/App Check, exact caller/input/revision and <=24-hour disposable marker are required. One transaction creates a separate link and derived authority preserving the original current term, preventing duplicate receipt/account activation, reassignment and renewal. Saved retries recheck permission first; cancellation keeps the original term and server-clock expiry removes test membership. Receipt review adds only a linked boolean, no UID/contact data. No Auth claim/role, real policy/data/payment, dependency/service/schedule/IAM expansion/migration or production change. Default-deny browser Rules cover both new collections. PR720 merged b6a8fd57d9add466da73df1d33b69e131e3fecbd after six exact PR/main jobs and every step passed. Native communityActions version 4 and staging Hosting match that source; the downloaded 91,793-byte archive matches SHA-256 47f11b8bec7f5ae58281c9029a015e9cc751a04e5ad3eafe3234441fa3a963db. Forty-four hosted cases (32 denial cases), atomic races, immutable retries, actual officer/member UI, offline retry, phone-size DOM layout, cancellation and natural expiry passed. Six disposable users and 47 exact owned documents were removed; native absence and late profiles were verified. Both capabilities were disabled first; browser sign-out, Apps-on-device OFF, normal networking/viewport, closed tab/receiver and memory disposal complete the test handoff. Profiles v2/v1/2843388, App Check/MFA and the six-permission min0/max2 runtime remain unchanged. The release command reported its existing generic post-deploy failure; independent native checks qualified the result without repeating deployment or bypassing a guard. Reporting cause remains unqualified. Production/runmprc.com and real payments were untouched.
**Status:** Target architecture and current-state assessment
diff --git a/docs/officers/STAGING_COMMUNITY.md b/docs/officers/STAGING_COMMUNITY.md
index 8dedf49..6414e5b 100644
--- a/docs/officers/STAGING_COMMUNITY.md
+++ b/docs/officers/STAGING_COMMUNITY.md
@@ -62,6 +62,23 @@
## Existing membership and free-event actions
+### Current member status — #724 source prepared, NOT AVAILABLE YET
+
+**Purpose:** distinguish the event's current signup availability from the member's stored signup, and stop displaying old status after an uncertain read. **Approver/escalation:** Dave, sole staging owner. **Prerequisites:** the specialist supplies the verified #724 staging release, disposable account and made-up events. New behavior is not hosted proof until #724 records exact deployment, actual UI and cleanup.
+
+1. Open the supplied staging `/community` link and confirm **Staging test only**.
+2. Sign in with the supplied disposable account.
+3. Read the event's signup message before choosing an action.
+4. Stop a new signup if it is cancelled, closed, full, not open yet, ended or unknown.
+5. Keep **Event cancelled** separate from **Your signup** history. An event cancellation does not itself rewrite your stored signup or issue a refund.
+6. If the specialist asks, cancel your own confirmed fictional signup using the existing button.
+7. Select **Refresh status** after an event change or term expiry.
+8. If the refresh fails, expect old membership/event status and new-action controls to disappear.
+9. Use **Retry request** only for an uncertain saved action, or **Refresh status** for a new read.
+10. Confirm only a successful fresh read restores current controls, then sign out and request owned-fixture cleanup.
+
+**Expected result/proof:** the server projects existing event window/capacity/status constraints using its clock after reads; the browser cannot turn a display value into authorization. Counts, registration/waiver evidence and paid-term cancellation policy are unchanged. A confirmed stored signup on a cancelled event is history, not proof that the event will take place. Existing transactional join checks still decide the result if availability changes after the read. Missing/unknown projection fails closed. No polling or extra service is added. **Stop:** production, real data/payment, unclear availability/count/history, stale status surviving failure or request for secrets/record edits. **Undo:** sign out; Dave/specialist uses the reviewed staging rollback if required. Account/fixture supply, deployment/readback/cleanup and rollback remain specialist-only; no officer uses a terminal or edits records. Real refunds, bib transfers and production remain unavailable.
+
- Request membership: records a pending request. It does not prove dues or grant membership.
- Withdraw membership request: withdraws a pending application.
- Cancel membership: records a cancellation request for an already-approved synthetic membership. Access lasts through its original paid term. No refund occurs.
diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md
index ff26ae1..ea68d40 100644
--- a/docs/officers/SYSTEM_MAPS.md
+++ b/docs/officers/SYSTEM_MAPS.md
@@ -2,6 +2,20 @@
These diagrams show where pages, information, and deployments go. Each diagram includes a one-sentence text version.
+## Member status freshness — #724 source only
+
+```mermaid
+flowchart LR
+ Member["Disposable staging member"] --> Read["Current status read; no polling"]
+ Read --> Server["Validated event/capacity; clock sampled after reads"]
+ Server --> Display["Signup availability and separate stored signup"]
+ Read -- "Failure or uncertain action" --> Hide["Hide old status/new actions; keep exact saved retry"]
+ Hide --> Read
+ Display --> Mutation["Existing transactional join/cancel checks decide again"]
+```
+
+Text alternative: the staging member page displays server-derived availability separately from stored signup history, hides old status on uncertainty and restores it only after a fresh read, while existing transactions—not the display—continue deciding joins and cancellations; hosted proof remains pending #724.
+
## Uncertain staging release review — #722
```mermaid
diff --git a/functions/communityActions.emulator.test.js b/functions/communityActions.emulator.test.js
index acddc31..f6c2893 100644
--- a/functions/communityActions.emulator.test.js
+++ b/functions/communityActions.emulator.test.js
@@ -17,6 +17,7 @@ describeLocal('community lifecycle real Firestore transactions (emulator only)',
now = Date.now(); service = createCommunityService(db, () => now);
});
beforeEach(async () => {
+ now = Date.now();
for (const root of roots) await db.recursiveDelete(db.collection(root));
await db.doc('communityConfig/events').create({ schemaVersion: 1, eventIds: ['fixture-event'] });
await db.doc('communityEvents/fixture-event').create(freeEvent(now));
@@ -51,6 +52,20 @@ describeLocal('community lifecycle real Firestore transactions (emulator only)',
expect((await service.status(uid)).membership.entitlement).toBe('current_member');
now = paid.authority.term.endsAtMs;
expect((await service.status(uid)).membership.entitlement).toBe('not_entitled');
+ }, 45000); // Concurrent native transactions may outlast Jest's five-second unit default.
+ test('cancelled-event availability is a read-only projection and preserves own confirmed signup evidence', async () => {
+ const uid = 'fixture-status-member';
+ await service.mutate(uid, join());
+ const event = db.doc('communityEvents/fixture-event');
+ const registration = event.collection('registrations').doc(uid);
+ await event.update({ status: 'cancelled' });
+ const before = await db.getAll(event, registration);
+ const view = await service.status(uid);
+ expect(view.events[0]).toMatchObject({ status: 'cancelled', signupStatus: 'cancelled', confirmedCount: 1, registration: { status: 'confirmed', revision: 1 } });
+ expect((await service.status('fixture-other')).events[0].registration.status).toBe('none');
+ const after = await db.getAll(event, registration);
+ expect(after.map(doc => doc.data())).toEqual(before.map(doc => doc.data()));
+ expect(after.map(doc => doc.updateTime.toMillis())).toEqual(before.map(doc => doc.updateTime.toMillis()));
});
test('officer approval/withdrawal, capacity/closure concurrency and live revocation use real transactions', async () => {
const uid = 'fixture-officer'; const runner = 'fixture-member';
diff --git a/functions/communityActions.js b/functions/communityActions.js
index 97ed314..36d711e 100644
--- a/functions/communityActions.js
+++ b/functions/communityActions.js
@@ -99,13 +99,23 @@ function createCommunityService(db, clock = Date.now) {
const memberRef = (uid) => db.doc(`communityMemberships/${uid}`);
const emptyMember = { schemaVersion: 1, revision: 0, applicationStatus: 'none', authority: null };
async function status(uid) {
- const now = clock();
const [member, manifest] = await db.getAll(memberRef(uid), db.doc('communityConfig/events'));
const ids = manifest.exists ? manifest.data().eventIds : [];
check(Array.isArray(ids) && ids.length <= 20 && new Set(ids).size === ids.length && ids.every((id) => typeof id === 'string' && ID.test(id)));
const refs = ids.flatMap((id) => [db.doc(`communityEvents/${id}`), db.doc(`communityEvents/${id}/registrations/${uid}`)]);
const docs = refs.length ? await db.getAll(...refs) : [];
- const events = ids.map((id, index) => ({ ...eventView(docs[index * 2].data(), id), registration: registrationView(docs[index * 2 + 1].data()) }));
+ // Read completion, not request start, determines time-based presentation.
+ // Mutations still independently enforce their transactional constraints.
+ const now = clock(); check(integer(now, 8_640_000_000_000_000));
+ const events = ids.map((id, index) => {
+ const event = eventView(docs[index * 2].data(), id);
+ const signupStatus = event.status === 'cancelled' ? 'cancelled'
+ : event.status === 'closed' ? 'closed'
+ : now < event.opensAtMs ? 'not_open_yet'
+ : now >= event.closesAtMs ? 'ended'
+ : event.confirmedCount >= event.capacity ? 'full' : 'open';
+ return { ...event, signupStatus, registration: registrationView(docs[index * 2 + 1].data()) };
+ });
return { membership: memberView(member.exists ? member.data() : emptyMember, uid, now), events };
}
diff --git a/functions/communityActions.test.js b/functions/communityActions.test.js
index 9b350ea..aac12a2 100644
--- a/functions/communityActions.test.js
+++ b/functions/communityActions.test.js
@@ -127,6 +127,48 @@ describe('transactional membership and free-event lifecycle', () => {
await service.mutate('runner-a', joining());
expect((await service.status('runner-b')).events[0].registration.status).toBe('none');
});
+ test.each([
+ [{}, 'open'],
+ [{ opensAtMs: 1_800_000_000_001 }, 'not_open_yet'],
+ [{ opensAtMs: 1_800_000_000_000 }, 'open'],
+ [{ closesAtMs: 1_800_000_000_000 }, 'ended'],
+ [{ status: 'closed' }, 'closed'],
+ [{ confirmedCount: 1 }, 'full'],
+ [{ status: 'cancelled', confirmedCount: 1 }, 'cancelled'],
+ ])('status projects existing signup constraints without writes %#', async (extra, expected) => {
+ docs.set('communityEvents/one-seat', freeEvent(now, extra));
+ const before = JSON.stringify([...docs]);
+ expect((await service.status('runner-a')).events[0].signupStatus).toBe(expected);
+ expect(JSON.stringify([...docs])).toBe(before);
+ });
+ test('cancelled event status retains confirmed signup history and another UID sees none', async () => {
+ await service.mutate('runner-a', joining());
+ docs.get('communityEvents/one-seat').status = 'cancelled';
+ const before = JSON.stringify([...docs]);
+ expect((await service.status('runner-a')).events[0]).toMatchObject({
+ status: 'cancelled', signupStatus: 'cancelled', confirmedCount: 1,
+ registration: { status: 'confirmed', revision: 1 },
+ });
+ expect((await service.status('runner-b')).events[0].registration.status).toBe('none');
+ expect(JSON.stringify([...docs])).toBe(before);
+ });
+ test('server status samples time after reads so a term/window ending during I/O is not shown active/open', async () => {
+ const memory = memoryDb(); const began = now; let current = began;
+ memory.docs.set('communityMemberships/runner-a', approvedMember('runner-a', began));
+ memory.docs.set('communityConfig/events', { schemaVersion: 1, eventIds: ['one-seat'] });
+ memory.docs.set('communityEvents/one-seat', freeEvent(began, { closesAtMs: began + 60000 }));
+ const original = memory.db.getAll;
+ memory.db.getAll = async (...refs) => { const value = await original(...refs); current = began + 60000; return value; };
+ const read = createCommunityService(memory.db, () => current);
+ const before = JSON.stringify([...memory.docs]);
+ const result = await read.status('runner-a');
+ expect(result.membership.entitlement).toBe('not_entitled');
+ expect(result.events[0].signupStatus).toBe('ended');
+ expect(JSON.stringify([...memory.docs])).toBe(before);
+ });
+ test('display availability cannot be injected into a signup request', () => {
+ expect(() => parseRequest({ ...joining(), signupStatus: 'open' })).toThrow('Request is invalid.');
+ });
test('write failure rolls back all effects and a retry remains safe', async () => {
const memory = memoryDb(); const command = request('membership_join');
const original = memory.db.runTransaction;
diff --git a/src/pages/community/Community.test.tsx b/src/pages/community/Community.test.tsx
index 4da4ad9..de9a669 100644
--- a/src/pages/community/Community.test.tsx
+++ b/src/pages/community/Community.test.tsx
@@ -25,6 +25,22 @@ const state = (applicationStatus = 'none', revision = 0) => ({
},
events: [],
});
+const testEvent = (extra = {}) => ({
+ id: 'fixture-event',
+ title: 'Synthetic event',
+ location: 'Synthetic place',
+ startAtMs: 1800003600000,
+ opensAtMs: 1799999999000,
+ closesAtMs: 1800001800000,
+ status: 'open',
+ signupStatus: 'open',
+ capacity: 1,
+ confirmedCount: 0,
+ waiverText: 'Synthetic waiver only',
+ waiverVersion: 'synthetic-v1',
+ registration: { revision: 0, status: 'none' },
+ ...extra,
+});
const show = () => (
{signupMessage(event)}
{event.confirmedCount} {' '} @@ -175,14 +192,14 @@ export default function Community() { <>
{event.waiverText}