Skip to content

Commit ec55fbe

Browse files
committed
Prevent stale chat delivery downgrades
1 parent f6feaab commit ec55fbe

4 files changed

Lines changed: 84 additions & 0 deletions

File tree

docs/release-live-stress-report.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4015,3 +4015,44 @@ Result:
40154015
canary: all six current targets reached `sent: propagated resource`.
40164016
- Phone inbox receipt remains unproven in this run because ADB had no attached
40174017
phones by the final check, and `/api/rem/peers` remained empty.
4018+
4019+
## 2026-06-23 Chat Store Propagated State Downgrade Guard
4020+
4021+
Trigger:
4022+
4023+
- The operator again reported broadcast message
4024+
`2a2892b3227b427487308d53712dd163` as `failed` / `propagated` /
4025+
`broadcast_direct_timeout_fallback` with `send_error`.
4026+
- Live DB and `/Chat/Messages?limit=500` both returned that ID as
4027+
`State=propagated`, `dispatch_status=accepted`, and
4028+
`delivery_policy_reason=broadcast_direct_timeout_fallback`.
4029+
- The older `f011f23619fc4d0b9dcd9bf51462629e` report also returned
4030+
`State=propagated` / `dispatch_status=accepted`.
4031+
4032+
Fix:
4033+
4034+
- Added a chat-store merge guard so a late `failed` update for a message ID
4035+
cannot downgrade an already successful `sent`, `delivered`, or `propagated`
4036+
chat row.
4037+
- Recovery still works in the other direction: queued/failed rows can still be
4038+
replaced by later successful delivery states.
4039+
4040+
Verification:
4041+
4042+
- `npm --prefix ui run test -- chat.spec.ts` failed before the guard and passed
4043+
after the fix.
4044+
- `npm --prefix ui run test -- chat.spec.ts dashboard.spec.ts` passed.
4045+
- `npm --prefix ui run build` passed and the running local server served the
4046+
rebuilt `index-DKA-mRkx.js` entry.
4047+
- Focused backend fallback checks still passed:
4048+
- `cargo test -p r3akt-rch-server propagated_broadcast_fallback_send_error_continues_after_fifth_attempt`.
4049+
- `cargo test -p r3akt-rch-server internal_delivery_failure_retries_propagated_broadcast_fallback_send_error`.
4050+
- In-app browser `/chat` loaded the rebuilt bundle and rendered the
4051+
communications grid without a visible failed delivery state in the broadcast
4052+
list.
4053+
4054+
Result:
4055+
4056+
- Pass for the client-side stale-update path: the UI can no longer turn an
4057+
already accepted propagation fallback into a visible terminal `failed` row
4058+
when an older failure update arrives late.

docs/user-story-status.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Last updated: 2026-06-23
5555

5656
| Run | Stories touched | Scope | Result | Errors / next action |
5757
| --- | --- | --- | --- | --- |
58+
| 2026-06-23 chat store propagated downgrade guard | RCH-US-009, RCH-US-010, RCH-US-030 | User again reported `2a2892b3227b427487308d53712dd163` as `failed` / `propagated` / `broadcast_direct_timeout_fallback` with `send_error`. Rechecked the DB/config-backed local server, `/Chat/Messages?limit=500`, served UI bundle, and in-app browser `/chat`. | Fixed a client-side stale message merge edge case. Live `/Chat/Messages` returns both `2a289...` and older `f011...` as `State=propagated`, `dispatch_status=accepted`, and `delivery_policy_reason=broadcast_direct_timeout_fallback`; each has six `sent: propagated resource` targets. Added a chat-store guard so a late `failed` update cannot downgrade an already `sent`, `delivered`, or `propagated` row. The regression failed before the guard and passed after it; `npm --prefix ui run test -- chat.spec.ts dashboard.spec.ts`, focused backend fallback tests, and `npm --prefix ui run build` passed. The running server serves rebuilt `index-DKA-mRkx.js`, and in-app `/chat` rendered without a visible failed broadcast row in the current list. | Final phone/deck receipt proof remains open. This closes the stale client-side failed-row presentation path for already accepted propagation fallback messages. |
5859
| 2026-06-22 local UI route smoke | RCH-US-003, RCH-US-005, RCH-US-007, RCH-US-008, RCH-US-009, RCH-US-014, RCH-US-015, RCH-US-016, RCH-US-017, RCH-US-018, RCH-US-019, RCH-US-021, RCH-US-022, RCH-US-025, RCH-US-030 | In-app browser against `http://127.0.0.1:18080/` using the live local `RTH_Store` DB/config. Hard-refresh loaded `/`, `/missions`, `/missions/legacy`, `/missions/domain`, `/missions/assets`, `/missions/logs`, `/checklists`, `/webmap`, `/topics`, `/files`, `/chat`, `/users`, `/users/teams/members`, `/configure`, `/about`, `/connect`, and representative `/missions/57942bdb0269482ea76f3b2530dea6f9/*` deep links. | Pass for shell load, SPA fallback, route guards on loopback, main navigation visibility, and console-error smoke. Dashboard reached `ONLINE`/`LIVE` with PID `7748`; chat, files, topics, checklists, users, team roster, configure, about, mission legacy, and representative mission-domain pages rendered without browser console errors. | No confirmed product error in this slice. A short 1.5s hard-refresh wait produced false boot-screen failures; future route smoke should wait until `INITIALIZING RETICULUM BACKEND` disappears or a real timeout is reached. Action-level CRUD, destructive flows, WebMap canvas behavior, remote-auth redirect behavior, and mobile/responsive layout remain untested. |
5960
| 2026-06-23 in-app browser reload recovery | RCH-US-030 | Reused the in-app browser controller after earlier navigation timeouts and navigated to `http://127.0.0.1:18080/` while the DB/config-backed server was running as PID `2548`. Captured a rendered full-page screenshot, then removed the local screenshot artifact. | Pass for current shell load. The page loaded with title `RCH UI` and rendered the dashboard, sidebar navigation, `ONLINE`/`LIVE` header, backend control panel showing PID `2548`, event feed, telemetry chart, and local timing panel. | This proves the shell is loading again in the current browser session. It does not close action-specific browser proof for feature rows such as Users, Files, Topics, Checklists, WebMap, or mission audit controls. |
6061
| 2026-06-22 remote auth and connection retest | RCH-US-003 | In-app browser Connect flow against the live server using `http://127.0.0.1.nip.io:18080` and `ws://127.0.0.1.nip.io:18080` as remote-looking URLs that resolve to the local RCH instance. Used API key `manual-test`; restored settings to `http://127.0.0.1:18080` afterward. | Pass. Connect switched to Remote Connection mode, displayed `Remote backend requires authentication.`, and protected `/chat` redirected to `/connect?redirect=/chat` while unauthenticated. API Key mode displayed `API key is required for remote backend authentication.` until a key was entered. Login with `manual-test` redirected back to `/chat`; local restore showed Local Connection Settings and `Connection settings saved`. | No product error found. This validates remote gating and login mechanics without a true external hub; real remote TLS/DNS deployment should still be covered during staging or deployment tests. |

ui/src/stores/chat.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,35 @@ describe("chat store", () => {
4646
scope: "broadcast"
4747
});
4848
});
49+
50+
it("does not downgrade propagated broadcast fallback messages to failed", () => {
51+
const chat = useChatStore();
52+
53+
chat.upsertMessage({
54+
message_id: "2a2892b3227b427487308d53712dd163",
55+
direction: "outbound",
56+
scope: "broadcast",
57+
state: "propagated",
58+
content: "broadcast",
59+
attachments: [],
60+
created_at: "2026-06-23T02:00:00.000Z",
61+
updated_at: "2026-06-23T02:01:00.000Z"
62+
});
63+
chat.upsertMessage({
64+
message_id: "2a2892b3227b427487308d53712dd163",
65+
direction: "outbound",
66+
scope: "broadcast",
67+
state: "failed",
68+
content: "broadcast",
69+
attachments: [],
70+
created_at: "2026-06-23T02:00:00.000Z",
71+
updated_at: "2026-06-23T02:02:00.000Z"
72+
});
73+
74+
expect(chat.messages).toHaveLength(1);
75+
expect(chat.messages[0]).toMatchObject({
76+
message_id: "2a2892b3227b427487308d53712dd163",
77+
state: "propagated"
78+
});
79+
});
4980
});

ui/src/stores/chat.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ const fromApiMessage = (payload: ChatMessagePayload): ChatMessage => ({
5151
updated_at: payload.UpdatedAt
5252
});
5353

54+
const successfulDeliveryStates = new Set(["delivered", "propagated", "sent"]);
55+
56+
const isStaleFailureDowngrade = (current: ChatMessage, incoming: ChatMessage): boolean => {
57+
const currentState = String(current.state ?? "").trim().toLowerCase();
58+
const incomingState = String(incoming.state ?? "").trim().toLowerCase();
59+
return successfulDeliveryStates.has(currentState) && incomingState === "failed";
60+
};
61+
5462
export const useChatStore = defineStore("chat", () => {
5563
const messages = ref<ChatMessage[]>([]);
5664
const loading = ref(false);
@@ -112,6 +120,9 @@ export const useChatStore = defineStore("chat", () => {
112120
}
113121
const index = messages.value.findIndex((entry) => entry.message_id === message.message_id);
114122
if (index >= 0) {
123+
if (isStaleFailureDowngrade(messages.value[index], message)) {
124+
return;
125+
}
115126
messages.value[index] = { ...messages.value[index], ...message };
116127
} else {
117128
messages.value.push(message);

0 commit comments

Comments
 (0)