| 1 | { |
| 2 | "description": "Chat event vectors: ensures chat.js (web) and native (iOS/Mac) fold message/reaction/edit/delete events identically. Reactions are now encrypted events (type='reaction' stored like messages). Used by test/event-vectors.test.mjs and ios/.../EventVectorTests.swift.", |
| 3 | "app": "chat", |
| 4 | "source": "web/static/apps/chat.js", |
| 5 | "vectors": [ |
| 6 | { |
| 7 | "name": "plain_text_message", |
| 8 | "description": "A simple text message with no attachments or reply.", |
| 9 | "decrypted_payload": { |
| 10 | "name": "Alice", |
| 11 | "text": "Hello everyone", |
| 12 | "ts": 1700000000000, |
| 13 | "uid": "user-alice-1" |
| 14 | }, |
| 15 | "server_message": { |
| 16 | "id": 12345, |
| 17 | "created_at": 1700000000, |
| 18 | "blob": "encrypted(decrypted_payload)" |
| 19 | }, |
| 20 | "expected_fold_event": { |
| 21 | "type": "chat.msg", |
| 22 | "name": "Alice", |
| 23 | "text": "Hello everyone", |
| 24 | "ts": 1700000000000, |
| 25 | "uid": "user-alice-1", |
| 26 | "attachments": [] |
| 27 | }, |
| 28 | "expected_native_fields": { |
| 29 | "name": "Alice", |
| 30 | "text": "Hello everyone", |
| 31 | "uid": "user-alice-1", |
| 32 | "isRenderableChat": true |
| 33 | } |
| 34 | }, |
| 35 | { |
| 36 | "name": "message_with_attachments", |
| 37 | "description": "A message with an image and a file.", |
| 38 | "decrypted_payload": { |
| 39 | "name": "Bob", |
| 40 | "text": "Check these out", |
| 41 | "ts": 1700000001000, |
| 42 | "uid": "user-bob-1", |
| 43 | "attachments": [ |
| 44 | { |
| 45 | "id": "blob-img-1", |
| 46 | "name": "photo.jpg", |
| 47 | "type": "image/jpeg", |
| 48 | "w": 1920, |
| 49 | "h": 1080, |
| 50 | "size": 524288 |
| 51 | }, |
| 52 | { |
| 53 | "id": "blob-file-1", |
| 54 | "name": "report.pdf", |
| 55 | "type": "application/pdf", |
| 56 | "size": 2097152 |
| 57 | } |
| 58 | ] |
| 59 | }, |
| 60 | "server_message": { |
| 61 | "id": 12346, |
| 62 | "created_at": 1700000001, |
| 63 | "blob": "encrypted(decrypted_payload)" |
| 64 | }, |
| 65 | "expected_fold_event": { |
| 66 | "type": "chat.msg", |
| 67 | "name": "Bob", |
| 68 | "text": "Check these out", |
| 69 | "ts": 1700000001000, |
| 70 | "uid": "user-bob-1", |
| 71 | "attachments": [ |
| 72 | { |
| 73 | "id": "blob-img-1", |
| 74 | "name": "photo.jpg", |
| 75 | "type": "image/jpeg", |
| 76 | "w": 1920, |
| 77 | "h": 1080, |
| 78 | "size": 524288 |
| 79 | }, |
| 80 | { |
| 81 | "id": "blob-file-1", |
| 82 | "name": "report.pdf", |
| 83 | "type": "application/pdf", |
| 84 | "size": 2097152 |
| 85 | } |
| 86 | ] |
| 87 | } |
| 88 | }, |
| 89 | { |
| 90 | "name": "message_with_reply", |
| 91 | "description": "A reply-to message with quoted context — a plain quote reply (no thread flag), which renders inline in the main feed exactly as it always has.", |
| 92 | "note": "replyTo.thread is ABSENT here: that is the wire form of every reply from before the flag existed and of the ordinary Reply verb. Clients fold it as thread:false and list the message in the main feed (expected_main_feed_visible). The plain verb targets the EXACT message clicked — replyTo.id names the bubble the member tapped Reply on, never a resolved root (web quoteTarget in chat.js; native ChatFeedModel.quoteTarget), so replying to a reply stacks another quote inline and starts no thread. Its quote card jumps to the quoted message; it wears no thread link and contributes nothing to any 'N Replies' tally. Contrast message_in_thread.", |
| 93 | "decrypted_payload": { |
| 94 | "name": "Charlie", |
| 95 | "text": "I agree!", |
| 96 | "ts": 1700000002000, |
| 97 | "uid": "user-charlie-1", |
| 98 | "replyTo": { |
| 99 | "id": 12345, |
| 100 | "name": "Alice", |
| 101 | "snippet": "Hello everyone" |
| 102 | } |
| 103 | }, |
| 104 | "server_message": { |
| 105 | "id": 12347, |
| 106 | "created_at": 1700000002, |
| 107 | "blob": "encrypted(decrypted_payload)" |
| 108 | }, |
| 109 | "expected_fold_event": { |
| 110 | "type": "chat.msg", |
| 111 | "name": "Charlie", |
| 112 | "text": "I agree!", |
| 113 | "ts": 1700000002000, |
| 114 | "uid": "user-charlie-1", |
| 115 | "replyTo": { |
| 116 | "id": 12345, |
| 117 | "name": "Alice", |
| 118 | "snippet": "Hello everyone" |
| 119 | }, |
| 120 | "attachments": [] |
| 121 | }, |
| 122 | "expected_main_feed_visible": true |
| 123 | }, |
| 124 | { |
| 125 | "name": "message_in_thread", |
| 126 | "description": "A reply composed IN a thread (the docked pane's or mobile overlay's composer, or the 'Reply in thread' verb): replyTo carries thread:true and points DIRECTLY at the thread's root. The message lives only in its thread — the main feed shows just the root's 'N Replies' tally, never this bubble (Slack model).", |
| 127 | "note": "The thread flag is ADDITIVE and rides INSIDE replyTo ({ id, name, snippet, thread: true }) — the same shape old clients already decode, which ignore the extra key and show the reply inline (harmless degradation). Absent/false = an inline quote reply (message_with_reply). THE FLAG IS THE WHOLE DEFINITION OF A THREAD: membership is the flag plus a direct pointer at the root (web threadRootOf; native ChatFeedModel.threadRoots), never a walk up a quote chain — a thread exists only because a member chose 'Reply in thread', so nothing is absorbed into one by accident. Web: chat.msg fold sets reply.thread (default false); inMainFeed(m, bySid) is the predicate, viewItems(msgs, { mainFeed: true }) the transcript filter — the reply counts in its root's replyCount (only thread replies do) and lists in threadItems. The hide is ROOT-AWARE: expected_main_feed_visible is evaluated WITH the quoted root (replyTo.id) present in the loaded window — the test seeds it from the snapshot — because the tally lives on the root's bubble. With the root paged out the reply renders inline like a quote reply, and collapses into the tally when the root loads (thread_reply_root_unloaded). Native mirrors: MessagePayload.replyTo.thread, the root-aware feed filter, the tally, and the thread view. The flag is set at the compose seam (thread view / verb), never by the pure targets (quoteTarget/threadTarget), and survives edits ({ ...raw, text }).", |
| 128 | "decrypted_payload": { |
| 129 | "name": "Bea", |
| 130 | "text": "In the thread", |
| 131 | "ts": 1700000006000, |
| 132 | "uid": "user-bea-1", |
| 133 | "replyTo": { |
| 134 | "id": 100, |
| 135 | "name": "Ann", |
| 136 | "snippet": "Root text", |
| 137 | "thread": true |
| 138 | } |
| 139 | }, |
| 140 | "server_message": { |
| 141 | "id": 12351, |
| 142 | "created_at": 1700000006, |
| 143 | "blob": "encrypted(decrypted_payload)" |
| 144 | }, |
| 145 | "expected_fold_event": { |
| 146 | "type": "chat.msg", |
| 147 | "name": "Bea", |
| 148 | "text": "In the thread", |
| 149 | "ts": 1700000006000, |
| 150 | "uid": "user-bea-1", |
| 151 | "replyTo": { |
| 152 | "id": 100, |
| 153 | "name": "Ann", |
| 154 | "snippet": "Root text", |
| 155 | "thread": true |
| 156 | }, |
| 157 | "attachments": [] |
| 158 | }, |
| 159 | "expected_native_fields": { |
| 160 | "name": "Bea", |
| 161 | "text": "In the thread", |
| 162 | "uid": "user-bea-1", |
| 163 | "replyTo": { |
| 164 | "id": 100, |
| 165 | "name": "Ann", |
| 166 | "snippet": "Root text", |
| 167 | "thread": true |
| 168 | }, |
| 169 | "isRenderableChat": true |
| 170 | }, |
| 171 | "expected_main_feed_visible": false |
| 172 | }, |
| 173 | { |
| 174 | "name": "thread_reply_root_unloaded", |
| 175 | "description": "A reply made IN a thread whose root is older than the loaded history: the sequence holds ONLY the flagged reply (replyTo.id = 100 with its name/snippet snapshot) and no message 100. It must render in the main feed as an ordinary inline bubble, quote card and all.", |
| 176 | "note": "The thread hide is ROOT-AWARE. The 'N Replies' tally lives on the root's bubble, so with the root paged out a hidden reply would render NOWHERE — cold-open a busy chat and its newest message would be invisible until the reader blind-scrolled back past the root. So a thread-flagged reply is hidden only when the root it points at (replyTo.id / replyRoot) is present in the loaded list; otherwise it degrades to an inline bubble, and it collapses into the tally the moment the root loads — the same message flips from expected_main_feed true to false purely on what is loaded. This is the ONE case where a card wearing a thread link renders in the transcript: a thread reply shown inline keeps its way back into the thread (threadLinkCount), while a plain quote reply's card only ever jumps to what it quotes. Web: inMainFeed(m, bySid) over bySidIndex(msgs); viewItems(msgs, { mainFeed: true }). Native mirrors the same rule in its feed filter.", |
| 177 | "message_sequence": [ |
| 178 | { |
| 179 | "server_message": { |
| 180 | "id": 105, |
| 181 | "created_at": 1700000005 |
| 182 | }, |
| 183 | "decrypted_payload": { |
| 184 | "name": "Finn", |
| 185 | "text": "Replying in a thread whose root is older than the loaded history", |
| 186 | "ts": 1700000005000, |
| 187 | "uid": "user-finn-1", |
| 188 | "replyTo": { |
| 189 | "id": 100, |
| 190 | "name": "Alice", |
| 191 | "snippet": "Hello everyone", |
| 192 | "thread": true |
| 193 | } |
| 194 | } |
| 195 | } |
| 196 | ], |
| 197 | "expected_thread_list": { |
| 198 | "note": "The root paged out, so the row is headed by the snapshot its FLAGGED reply carries (root_loaded false) — the same stand-in the thread view's own header uses. The thread is still listed: it is exactly what a member came looking for.", |
| 199 | "rows": [ |
| 200 | { "root": 100, "root_loaded": false, "name": "Alice", "snippet": "Hello everyone", "count": 1 } |
| 201 | ] |
| 202 | }, |
| 203 | "expected_main_feed": { |
| 204 | "105": true |
| 205 | } |
| 206 | }, |
| 207 | { |
| 208 | "name": "reply_in_thread_continues_the_thread", |
| 209 | "description": "'Reply in thread' on a message that is ALREADY in a thread continues that thread: the composer stages the thread's ROOT — never the tapped reply — and the compose seam flags it thread:true.", |
| 210 | "note": "Compose-side contract for the thread verb (web threadTarget in chat.js; native ChatFeedModel.threadTarget), and the reason a thread stays flat: every reply in it points straight at the root, so there is never a chain to walk. Here Dana tapped 'Reply in thread' on Erin's thread reply, which belongs to root 12345 — so the staged replyTo names 12345 with thread:true, and Dana's reply lands in the thread, not the transcript. RENAMED from reply_in_thread_targets_root, whose premise (plain Reply root-resolving through an inline chain) is exactly what the two-verb split removed: the plain verb now targets the tapped bubble (message_with_reply), and a message NOT already in a thread roots a new thread on itself (inline_reply_chain_is_not_a_thread).", |
| 211 | "decrypted_payload": { |
| 212 | "name": "Dana", |
| 213 | "text": "Same here!", |
| 214 | "ts": 1700000004000, |
| 215 | "uid": "user-dana-1", |
| 216 | "replyTo": { |
| 217 | "id": 12345, |
| 218 | "name": "Alice", |
| 219 | "snippet": "Hello everyone", |
| 220 | "thread": true |
| 221 | } |
| 222 | }, |
| 223 | "server_message": { |
| 224 | "id": 12349, |
| 225 | "created_at": 1700000004, |
| 226 | "blob": "encrypted(decrypted_payload)" |
| 227 | }, |
| 228 | "expected_fold_event": { |
| 229 | "type": "chat.msg", |
| 230 | "name": "Dana", |
| 231 | "text": "Same here!", |
| 232 | "ts": 1700000004000, |
| 233 | "uid": "user-dana-1", |
| 234 | "replyTo": { |
| 235 | "id": 12345, |
| 236 | "name": "Alice", |
| 237 | "snippet": "Hello everyone", |
| 238 | "thread": true |
| 239 | }, |
| 240 | "attachments": [] |
| 241 | }, |
| 242 | "expected_main_feed_visible": false |
| 243 | }, |
| 244 | { |
| 245 | "name": "reply_thread_grouping", |
| 246 | "description": "Inline replies and threads are SEPARATE. A thread is its root plus the replies flagged thread:true that point at it — nothing else — so the root's 'N Replies' tally counts thread replies ONLY. Quote replies (101, 103) and a quote-of-a-quote chain (102) stay inline in the feed, tally nothing, and belong to no thread.", |
| 247 | "note": "Multi-message vector: fold the sequence, then derive the thread rollup (web viewItems replyRoot/replyCount + threadItems in chat.js; native ChatFeedModel.threadRoots/refreshReplyCounts/threadItems). THIS VECTOR CHANGED with the two-verb split (plain Reply vs Reply in thread): it used to assert that 101/102/103 resolved transitively into root 100's thread and that the root tallied 4. They no longer do. Message 102 quotes reply 101 — a chain of inline quotes is a conversation in the feed, not a thread nobody opened — so it has NO thread root and adds nothing to any tally; the same goes for the direct quote replies 101 and 103. Only 104 carries replyTo.thread:true, so it alone is in root 100's thread: expected_threads gives root 100 a tally of ONE, first_reply 104, and expected_thread_items lists the thread as [100, 104]. expected_main_feed says 104 is HIDDEN from the main feed (web inMainFeed / viewItems mainFeed; native's feed filter) while every other message shows — a thread-flagged reply is hidden iff the root it points at is present in the sequence; rootless it shows inline (thread_reply_root_unloaded). Separators and sender runs in the main feed derive over the shown messages only. expected_reply_targets pins the two compose verbs against this same state.", |
| 248 | "message_sequence": [ |
| 249 | { |
| 250 | "server_message": { |
| 251 | "id": 100, |
| 252 | "created_at": 1700000000 |
| 253 | }, |
| 254 | "decrypted_payload": { |
| 255 | "name": "Alice", |
| 256 | "text": "Hello everyone", |
| 257 | "ts": 1700000000000, |
| 258 | "uid": "user-alice-1" |
| 259 | } |
| 260 | }, |
| 261 | { |
| 262 | "server_message": { |
| 263 | "id": 101, |
| 264 | "created_at": 1700000001 |
| 265 | }, |
| 266 | "decrypted_payload": { |
| 267 | "name": "Bob", |
| 268 | "text": "Hi Alice", |
| 269 | "ts": 1700000001000, |
| 270 | "uid": "user-bob-1", |
| 271 | "replyTo": { |
| 272 | "id": 100, |
| 273 | "name": "Alice", |
| 274 | "snippet": "Hello everyone" |
| 275 | } |
| 276 | } |
| 277 | }, |
| 278 | { |
| 279 | "server_message": { |
| 280 | "id": 102, |
| 281 | "created_at": 1700000002 |
| 282 | }, |
| 283 | "decrypted_payload": { |
| 284 | "name": "Charlie", |
| 285 | "text": "Welcome!", |
| 286 | "ts": 1700000002000, |
| 287 | "uid": "user-charlie-1", |
| 288 | "replyTo": { |
| 289 | "id": 101, |
| 290 | "name": "Bob", |
| 291 | "snippet": "Hi Alice" |
| 292 | } |
| 293 | } |
| 294 | }, |
| 295 | { |
| 296 | "server_message": { |
| 297 | "id": 103, |
| 298 | "created_at": 1700000003 |
| 299 | }, |
| 300 | "decrypted_payload": { |
| 301 | "name": "Dana", |
| 302 | "text": "Good to be here", |
| 303 | "ts": 1700000003000, |
| 304 | "uid": "user-dana-1", |
| 305 | "replyTo": { |
| 306 | "id": 100, |
| 307 | "name": "Alice", |
| 308 | "snippet": "Hello everyone" |
| 309 | } |
| 310 | } |
| 311 | }, |
| 312 | { |
| 313 | "server_message": { |
| 314 | "id": 104, |
| 315 | "created_at": 1700000004 |
| 316 | }, |
| 317 | "decrypted_payload": { |
| 318 | "name": "Erin", |
| 319 | "text": "Replying from inside the thread", |
| 320 | "ts": 1700000004000, |
| 321 | "uid": "user-erin-1", |
| 322 | "replyTo": { |
| 323 | "id": 100, |
| 324 | "name": "Alice", |
| 325 | "snippet": "Hello everyone", |
| 326 | "thread": true |
| 327 | } |
| 328 | } |
| 329 | } |
| 330 | ], |
| 331 | "expected_threads": { |
| 332 | "roots": { |
| 333 | "101": 0, |
| 334 | "102": 0, |
| 335 | "103": 0, |
| 336 | "104": 100 |
| 337 | }, |
| 338 | "reply_counts": { |
| 339 | "100": 1, |
| 340 | "101": 0, |
| 341 | "102": 0, |
| 342 | "103": 0, |
| 343 | "104": 0 |
| 344 | }, |
| 345 | "first_reply": { |
| 346 | "100": 104 |
| 347 | } |
| 348 | }, |
| 349 | "expected_thread_items": { |
| 350 | "note": "The thread view's list for each root: the root plus its thread replies, in server order. Root 100's thread holds only 104 — the inline replies are not in it. 101 has no thread of its own until someone starts one on it.", |
| 351 | "100": [100, 104], |
| 352 | "101": [101] |
| 353 | }, |
| 354 | "expected_inline_chain": { |
| 355 | "100": [ |
| 356 | 101, |
| 357 | 102, |
| 358 | 103 |
| 359 | ], |
| 360 | "104": [] |
| 361 | }, |
| 362 | "expected_thread_list": { |
| 363 | "note": "The Threads list's rows, in the order it shows them (web threadListItems(msgs); native ChatFeedModel.threadListItems(_:)). One row per thread ROOT: the line you recognise it by, how many replies it holds, and whether that root is in the loaded window. Membership is exactly threadRootOf/threadRootID(of:) — the same read the root's 'N Replies' tally makes — so an inline quote chain contributes NO row. Order is by the thread's LAST message under the feed's own comparator (confirmed by id, a still-pending optimistic copy last), newest thread first. Here only 104 is in a thread, so there is ONE row (root 100, one reply) — the three inline replies 101/102/103 list nothing.", |
| 364 | "rows": [ |
| 365 | { "root": 100, "root_loaded": true, "name": "Alice", "snippet": "Hello everyone", "count": 1 } |
| 366 | ] |
| 367 | }, |
| 368 | "expected_main_feed": { |
| 369 | "100": true, |
| 370 | "101": true, |
| 371 | "102": true, |
| 372 | "103": true, |
| 373 | "104": false |
| 374 | }, |
| 375 | "expected_reply_targets": { |
| 376 | "note": "Two verbs, two targets, both pure. `quote` is plain Reply (web quoteTarget(m); native ChatFeedModel.quoteTarget) — the EXACT message clicked, no resolution, no flag, so it renders inline. `thread` is Reply in thread (web threadTarget(msgs, m); native ChatFeedModel.threadTarget) — the root of the thread the message is already in, or the message ITSELF when it is in none, because that message becomes the new thread's root. The compose seam adds thread:true to the `thread` target on the wire; the helpers stay flag-free.", |
| 377 | "quote": { |
| 378 | "100": { "id": 100, "name": "Alice", "snippet": "Hello everyone" }, |
| 379 | "102": { "id": 102, "name": "Charlie", "snippet": "Welcome!" }, |
| 380 | "104": { "id": 104, "name": "Erin", "snippet": "Replying from inside the thread" } |
| 381 | }, |
| 382 | "thread": { |
| 383 | "100": { "id": 100, "name": "Alice", "snippet": "Hello everyone" }, |
| 384 | "102": { "id": 102, "name": "Charlie", "snippet": "Welcome!" }, |
| 385 | "104": { "id": 100, "name": "Alice", "snippet": "Hello everyone" } |
| 386 | } |
| 387 | } |
| 388 | }, |
| 389 | { |
| 390 | "name": "inline_reply_chain_is_not_a_thread", |
| 391 | "description": "Three messages, each an ordinary quote reply to the one before: a root, a reply, and a reply to that reply. No thread flag anywhere, so there is NO thread — every bubble shows inline in the feed and NOTHING wears an 'N Replies' tally.", |
| 392 | "note": "The regression this vector exists to catch. Before the two-verb split, replying to a reply silently root-resolved and joined (or created) a thread, so a chain like this collapsed into 'root + 2 replies' with a tally on 300 and the two replies hidden from the feed — a thread nobody chose. Now: reply_counts are all 0, roots are all 0, threadItems(300) is just [300], and expected_main_feed shows all three. expected_reply_targets pins that Reply on 302 quotes 302 (not 300), and that Reply in thread on 302 roots a NEW thread on 302 itself — the only way a thread ever begins.", |
| 393 | "message_sequence": [ |
| 394 | { |
| 395 | "server_message": { |
| 396 | "id": 300, |
| 397 | "created_at": 1700000000 |
| 398 | }, |
| 399 | "decrypted_payload": { |
| 400 | "name": "Alice", |
| 401 | "text": "Anyone up for lunch?", |
| 402 | "ts": 1700000000000, |
| 403 | "uid": "user-alice-1" |
| 404 | } |
| 405 | }, |
| 406 | { |
| 407 | "server_message": { |
| 408 | "id": 301, |
| 409 | "created_at": 1700000001 |
| 410 | }, |
| 411 | "decrypted_payload": { |
| 412 | "name": "Bob", |
| 413 | "text": "I'm in", |
| 414 | "ts": 1700000001000, |
| 415 | "uid": "user-bob-1", |
| 416 | "replyTo": { |
| 417 | "id": 300, |
| 418 | "name": "Alice", |
| 419 | "snippet": "Anyone up for lunch?" |
| 420 | } |
| 421 | } |
| 422 | }, |
| 423 | { |
| 424 | "server_message": { |
| 425 | "id": 302, |
| 426 | "created_at": 1700000002 |
| 427 | }, |
| 428 | "decrypted_payload": { |
| 429 | "name": "Charlie", |
| 430 | "text": "Me too — one o'clock?", |
| 431 | "ts": 1700000002000, |
| 432 | "uid": "user-charlie-1", |
| 433 | "replyTo": { |
| 434 | "id": 301, |
| 435 | "name": "Bob", |
| 436 | "snippet": "I'm in" |
| 437 | } |
| 438 | } |
| 439 | } |
| 440 | ], |
| 441 | "expected_threads": { |
| 442 | "roots": { |
| 443 | "301": 0, |
| 444 | "302": 0 |
| 445 | }, |
| 446 | "reply_counts": { |
| 447 | "300": 0, |
| 448 | "301": 0, |
| 449 | "302": 0 |
| 450 | }, |
| 451 | "first_reply": {} |
| 452 | }, |
| 453 | "expected_thread_items": { |
| 454 | "note": "Every root's thread is just itself — an inline chain creates none.", |
| 455 | "300": [300], |
| 456 | "301": [301], |
| 457 | "302": [302] |
| 458 | }, |
| 459 | "expected_inline_chain": { |
| 460 | "300": [ |
| 461 | 301, |
| 462 | 302 |
| 463 | ], |
| 464 | "301": [ |
| 465 | 302 |
| 466 | ], |
| 467 | "302": [] |
| 468 | }, |
| 469 | "expected_thread_list": { |
| 470 | "note": "No thread anywhere, so NO rows — and with no rows the Threads entry is not offered at all (web hides the ▤ row; native hides the menu item). A chat full of inline replies looks exactly as it did before threads existed.", |
| 471 | "rows": [] |
| 472 | }, |
| 473 | "expected_main_feed": { |
| 474 | "300": true, |
| 475 | "301": true, |
| 476 | "302": true |
| 477 | }, |
| 478 | "expected_reply_targets": { |
| 479 | "quote": { |
| 480 | "302": { "id": 302, "name": "Charlie", "snippet": "Me too — one o'clock?" } |
| 481 | }, |
| 482 | "thread": { |
| 483 | "302": { "id": 302, "name": "Charlie", "snippet": "Me too — one o'clock?" } |
| 484 | } |
| 485 | } |
| 486 | }, |
| 487 | { |
| 488 | "name": "action_line", |
| 489 | "description": "A /me-style action line (e.g., 'added milk' from a to-do app).", |
| 490 | "decrypted_payload": { |
| 491 | "name": "Todo Bot", |
| 492 | "text": "added 'milk'", |
| 493 | "ts": 1700000003000, |
| 494 | "uid": "app:todo", |
| 495 | "action": true |
| 496 | }, |
| 497 | "server_message": { |
| 498 | "id": 12348, |
| 499 | "created_at": 1700000003, |
| 500 | "blob": "encrypted(decrypted_payload)" |
| 501 | }, |
| 502 | "expected_fold_event": { |
| 503 | "type": "chat.msg", |
| 504 | "name": "Todo Bot", |
| 505 | "text": "added 'milk'", |
| 506 | "ts": 1700000003000, |
| 507 | "uid": "app:todo", |
| 508 | "action": true, |
| 509 | "attachments": [] |
| 510 | } |
| 511 | }, |
| 512 | { |
| 513 | "name": "message_with_video", |
| 514 | "description": "A message with an embedded video (dimensions + duration for layout before blob loads).", |
| 515 | "decrypted_payload": { |
| 516 | "name": "Diana", |
| 517 | "text": "Check this video", |
| 518 | "ts": 1700000004000, |
| 519 | "uid": "user-diana-1", |
| 520 | "attachments": [ |
| 521 | { |
| 522 | "id": "blob-vid-1", |
| 523 | "name": "clip.mp4", |
| 524 | "type": "video/mp4", |
| 525 | "w": 1280, |
| 526 | "h": 720, |
| 527 | "dur": 45.5, |
| 528 | "poster": "blob-vid-1-poster", |
| 529 | "size": 5242880 |
| 530 | } |
| 531 | ] |
| 532 | }, |
| 533 | "server_message": { |
| 534 | "id": 12349, |
| 535 | "created_at": 1700000004, |
| 536 | "blob": "encrypted(decrypted_payload)" |
| 537 | }, |
| 538 | "expected_fold_event": { |
| 539 | "type": "chat.msg", |
| 540 | "name": "Diana", |
| 541 | "text": "Check this video", |
| 542 | "ts": 1700000004000, |
| 543 | "uid": "user-diana-1", |
| 544 | "attachments": [ |
| 545 | { |
| 546 | "id": "blob-vid-1", |
| 547 | "name": "clip.mp4", |
| 548 | "type": "video/mp4", |
| 549 | "w": 1280, |
| 550 | "h": 720, |
| 551 | "dur": 45.5, |
| 552 | "poster": "blob-vid-1-poster", |
| 553 | "size": 5242880 |
| 554 | } |
| 555 | ] |
| 556 | } |
| 557 | }, |
| 558 | { |
| 559 | "name": "reaction_add", |
| 560 | "description": "A member reacting to a message with a single emoji. Reactions are encrypted events (type='reaction') that fold into the target message's reactions array.", |
| 561 | "decrypted_payload": { |
| 562 | "type": "reaction", |
| 563 | "targetSid": 12345, |
| 564 | "targetUid": "user-eve-1", |
| 565 | "emoji": "👍", |
| 566 | "targetText": "buy milk", |
| 567 | "name": "Alice", |
| 568 | "uid": "user-alice-1", |
| 569 | "ts": 1700000010000, |
| 570 | "on": true |
| 571 | }, |
| 572 | "server_message": { |
| 573 | "id": 12353, |
| 574 | "created_at": 1700000010, |
| 575 | "blob": "encrypted(decrypted_payload)" |
| 576 | }, |
| 577 | "expected_fold_event": { |
| 578 | "type": "chat.reaction", |
| 579 | "targetSid": 12345, |
| 580 | "emoji": "👍", |
| 581 | "uid": "user-alice-1", |
| 582 | "on": true |
| 583 | }, |
| 584 | "expected_native_fields": { |
| 585 | "type": "reaction", |
| 586 | "targetSid": 12345, |
| 587 | "targetUid": "user-eve-1", |
| 588 | "emoji": "👍", |
| 589 | "targetText": "buy milk", |
| 590 | "uid": "user-alice-1", |
| 591 | "on": true |
| 592 | }, |
| 593 | "note": "Reactions are encrypted events stored as messages. Clients decrypt and fold them into the target message's reactions array. Count and mine-flag are derived client-side from the accumulated reaction events. on=true adds the reaction; on=false removes it. The mine-flag is true if uid===me, false otherwise. targetUid is the reacted-to message's author, stamped by the reacting client so push handlers (sw.js, the iOS NSE) can show the notification only to the author; the fold ignores it. Omitted when the target is a legacy uid-less message — then no one is notified. targetText is a replySnippet of the reacted-to message, likewise stamped by the reacting client and likewise ignored by the fold: it exists so the chat list can say 'Alice reacted 👍 to “buy milk”' for a thread built from last_blob alone (format.js reactionLine / the Swift twin). Optional — reactions from clients that predate it carry none, and the line falls back to 'Alice reacted 👍'." |
| 594 | }, |
| 595 | { |
| 596 | "name": "reaction_remove", |
| 597 | "description": "A member removing their reaction from a message.", |
| 598 | "decrypted_payload": { |
| 599 | "type": "reaction", |
| 600 | "targetSid": 12346, |
| 601 | "targetUid": "user-eve-1", |
| 602 | "emoji": "❤️", |
| 603 | "targetText": "shipped it 🎉", |
| 604 | "name": "Bob", |
| 605 | "uid": "user-bob-1", |
| 606 | "ts": 1700000015000, |
| 607 | "on": false |
| 608 | }, |
| 609 | "server_message": { |
| 610 | "id": 12354, |
| 611 | "created_at": 1700000015, |
| 612 | "blob": "encrypted(decrypted_payload)" |
| 613 | }, |
| 614 | "expected_fold_event": { |
| 615 | "type": "chat.reaction", |
| 616 | "targetSid": 12346, |
| 617 | "emoji": "❤️", |
| 618 | "uid": "user-bob-1", |
| 619 | "on": false |
| 620 | }, |
| 621 | "expected_native_fields": { |
| 622 | "type": "reaction", |
| 623 | "targetSid": 12346, |
| 624 | "targetUid": "user-eve-1", |
| 625 | "emoji": "❤️", |
| 626 | "targetText": "shipped it 🎉", |
| 627 | "uid": "user-bob-1", |
| 628 | "on": false |
| 629 | }, |
| 630 | "note": "Removal is signaled by on=false. Clients fold both add and remove into the target message's reaction array; the current state is derived by filtering for on=true and counting. Removals never notify anyone (push handlers suppress on=false) — but they DO speak in the chat list ('Bob removed ❤️ from “shipped it 🎉”'), because on reload this event is all the list has to render the row from." |
| 631 | }, |
| 632 | { |
| 633 | "name": "edit", |
| 634 | "description": "A message edited by its author within the edit window.", |
| 635 | "original_payload": { |
| 636 | "name": "Eve", |
| 637 | "text": "typo here", |
| 638 | "ts": 1700000005000, |
| 639 | "uid": "user-eve-1" |
| 640 | }, |
| 641 | "edited_payload": { |
| 642 | "name": "Eve", |
| 643 | "text": "no typo here", |
| 644 | "ts": 1700000005000, |
| 645 | "uid": "user-eve-1" |
| 646 | }, |
| 647 | "server_message": { |
| 648 | "id": 12350, |
| 649 | "created_at": 1700000005, |
| 650 | "edited_at": 1700000010, |
| 651 | "blob": "encrypted(edited_payload)" |
| 652 | }, |
| 653 | "expected_fold_event": { |
| 654 | "type": "chat.edit", |
| 655 | "msgId": 12350, |
| 656 | "text": "no typo here" |
| 657 | }, |
| 658 | "note": "History/catch-up folds of an edited row (edited_at set) emit this SAME event, text included (#457): over a pre-seeded local store the chat.msg fold dedupes against the cached text, so this event is what carries an edit made while the device was away." |
| 659 | }, |
| 660 | { |
| 661 | "name": "delete", |
| 662 | "description": "A message deleted by its author (hard-deleted server-side).", |
| 663 | "server_delete_frame": { |
| 664 | "type": "delete", |
| 665 | "id": 12351 |
| 666 | }, |
| 667 | "expected_fold_event": { |
| 668 | "type": "chat.delete", |
| 669 | "msgId": 12351 |
| 670 | } |
| 671 | }, |
| 672 | { |
| 673 | "name": "tombstone_delete", |
| 674 | "description": "A stored delete tombstone (#581/#583): the server hard-deleted message 12345 and left a fresh empty row naming it in tombstone_of. History pages and reconnect catch-up must fold it to the SAME event the live delete frame produces — one delete path, no gap-inference.", |
| 675 | "server_message": { |
| 676 | "id": 12360, |
| 677 | "created_at": 1751900000, |
| 678 | "blob": "", |
| 679 | "tombstone_of": 12345 |
| 680 | }, |
| 681 | "expected_fold_event": { |
| 682 | "type": "chat.delete", |
| 683 | "msgId": 12345 |
| 684 | }, |
| 685 | "note": "The tombstone's own id (12360) is deliberately fresh — above every client watermark — so forward-only after: sync always delivers it. Clients with no local copy of 12345 fold it as a no-op. Old clients that predate tombstone_of skip the row entirely (an empty blob fails decrypt)." |
| 686 | }, |
| 687 | { |
| 688 | "name": "legacy_message_no_uid", |
| 689 | "description": "An older message (before uid was added) — both web and native fall back to matching by name.", |
| 690 | "decrypted_payload": { |
| 691 | "name": "Frank", |
| 692 | "text": "This is old", |
| 693 | "ts": 1699000000000 |
| 694 | }, |
| 695 | "server_message": { |
| 696 | "id": 12352, |
| 697 | "created_at": 1699000000, |
| 698 | "blob": "encrypted(decrypted_payload)" |
| 699 | }, |
| 700 | "expected_fold_event": { |
| 701 | "type": "chat.msg", |
| 702 | "name": "Frank", |
| 703 | "text": "This is old", |
| 704 | "ts": 1699000000000, |
| 705 | "attachments": [] |
| 706 | }, |
| 707 | "note": "Both implementations should treat missing uid as valid and fall back to name-based matching for reply attribution." |
| 708 | }, |
| 709 | { |
| 710 | "name": "message_with_transport_gossip", |
| 711 | "description": "A message whose payload carries the host-level _ckpt tamper-evidence gossip field (docs/tamper-evidence.md). _ckpt is transport metadata injected/stripped by the web host: folds must drop it (never render or store it), and native decode must tolerate and ignore it — it is not app or chat content and must never be re-emitted.", |
| 712 | "note": "Added with #573 slice 2. The web host strips _ckpt before folding; this vector pins that even an unstripped payload folds without it (the reduce allow-lists fields). Native: Codable ignores unknown keys — this vector documents that as contract, not accident.", |
| 713 | "decrypted_payload": { |
| 714 | "name": "Alice", |
| 715 | "text": "Hello with gossip", |
| 716 | "ts": 1700000000000, |
| 717 | "uid": "user-alice-1", |
| 718 | "_ckpt": [ |
| 719 | 9, |
| 720 | "b95fd584ded27920b2b8aa67add5b59269ac572f2924d1fb5a1387e650b5344e" |
| 721 | ] |
| 722 | }, |
| 723 | "server_message": { |
| 724 | "id": 12399, |
| 725 | "created_at": 1700000000, |
| 726 | "blob": "encrypted(decrypted_payload)" |
| 727 | }, |
| 728 | "expected_fold_event": { |
| 729 | "type": "chat.msg", |
| 730 | "name": "Alice", |
| 731 | "text": "Hello with gossip", |
| 732 | "ts": 1700000000000, |
| 733 | "uid": "user-alice-1", |
| 734 | "attachments": [] |
| 735 | }, |
| 736 | "expected_native_fields": { |
| 737 | "name": "Alice", |
| 738 | "text": "Hello with gossip", |
| 739 | "uid": "user-alice-1", |
| 740 | "isRenderableChat": true |
| 741 | } |
| 742 | }, |
| 743 | { |
| 744 | "name": "admin_warning_message", |
| 745 | "description": "An admin warning (issue #578): an ordinary E2EE DM message whose payload carries warn:true. Clients render the same bubble with a distinct 'notice from an admin' framing; the flag rides inside the ciphertext, so the server never knows a warning from a hello.", |
| 746 | "decrypted_payload": { |
| 747 | "name": "Root", |
| 748 | "text": "A report was filed about your behaviour here. Please keep this space welcoming.", |
| 749 | "ts": 1700000300000, |
| 750 | "uid": "user-admin-1", |
| 751 | "warn": true |
| 752 | }, |
| 753 | "server_message": { |
| 754 | "id": 12399, |
| 755 | "created_at": 1700000300, |
| 756 | "blob": "encrypted(decrypted_payload)" |
| 757 | }, |
| 758 | "expected_fold_event": { |
| 759 | "type": "chat.msg", |
| 760 | "name": "Root", |
| 761 | "text": "A report was filed about your behaviour here. Please keep this space welcoming.", |
| 762 | "ts": 1700000300000, |
| 763 | "uid": "user-admin-1", |
| 764 | "warn": true, |
| 765 | "attachments": [] |
| 766 | }, |
| 767 | "expected_native_fields": { |
| 768 | "name": "Root", |
| 769 | "text": "A report was filed about your behaviour here. Please keep this space welcoming.", |
| 770 | "uid": "user-admin-1", |
| 771 | "warn": true, |
| 772 | "isRenderableChat": true |
| 773 | } |
| 774 | }, |
| 775 | { |
| 776 | "name": "convert_inline_chain_to_thread", |
| 777 | "description": "'Convert to thread': an inline reply chain that got long is moved out of the transcript into a thread rooted at the message it hangs off. The conversion is its own encrypted event (type='threadconvert', {root, ids}) — no message is rewritten — and every client folds it into the same feed: 401 and 402 leave the transcript, 400 keeps its place wearing a '2 Replies' tally, and the unrelated 403 never moves.", |
| 778 | "note": "The messages CANNOT be rewritten to carry replyTo.thread after the fact (only their author may edit one, only for 15 minutes, and it would churn N authorships to change a display rule), so the conversion is a separate fold event, exactly like a reaction. The fold is a pure claim map (sid → {root, at}) projected onto the loaded window, so it is order-independent and idempotent — see convert_folds_before_its_members_arrive and convert_overlapping_and_repeated. expected_main_feed_unconverted is the SAME sequence with the convert step dropped, which is precisely what a client too old to know the payload type does: it keeps showing the chain inline, the behaviour before this shipped. expected_inline_chain pins what the verb would move NEXT — empty here, because the chain now lives in a thread, which is also why the verb stops being offered on 400.", |
| 779 | "message_sequence": [ |
| 780 | { |
| 781 | "server_message": { |
| 782 | "id": 400, |
| 783 | "created_at": 1700000400 |
| 784 | }, |
| 785 | "decrypted_payload": { |
| 786 | "name": "Alice", |
| 787 | "text": "Where should we do dinner?", |
| 788 | "ts": 1700000400000, |
| 789 | "uid": "user-alice-1" |
| 790 | } |
| 791 | }, |
| 792 | { |
| 793 | "server_message": { |
| 794 | "id": 401, |
| 795 | "created_at": 1700000401 |
| 796 | }, |
| 797 | "decrypted_payload": { |
| 798 | "name": "Bob", |
| 799 | "text": "The place by the bridge?", |
| 800 | "ts": 1700000401000, |
| 801 | "uid": "user-bob-1", |
| 802 | "replyTo": { |
| 803 | "id": 400, |
| 804 | "name": "Alice", |
| 805 | "snippet": "Where should we do dinner?" |
| 806 | } |
| 807 | } |
| 808 | }, |
| 809 | { |
| 810 | "server_message": { |
| 811 | "id": 402, |
| 812 | "created_at": 1700000402 |
| 813 | }, |
| 814 | "decrypted_payload": { |
| 815 | "name": "Charlie", |
| 816 | "text": "They shut on Mondays", |
| 817 | "ts": 1700000402000, |
| 818 | "uid": "user-charlie-1", |
| 819 | "replyTo": { |
| 820 | "id": 401, |
| 821 | "name": "Bob", |
| 822 | "snippet": "The place by the bridge?" |
| 823 | } |
| 824 | } |
| 825 | }, |
| 826 | { |
| 827 | "server_message": { |
| 828 | "id": 403, |
| 829 | "created_at": 1700000403 |
| 830 | }, |
| 831 | "decrypted_payload": { |
| 832 | "name": "Dana", |
| 833 | "text": "Unrelated: who has the keys?", |
| 834 | "ts": 1700000403000, |
| 835 | "uid": "user-dana-1" |
| 836 | } |
| 837 | }, |
| 838 | { |
| 839 | "server_message": { |
| 840 | "id": 410, |
| 841 | "created_at": 1700000410 |
| 842 | }, |
| 843 | "decrypted_payload": { |
| 844 | "type": "threadconvert", |
| 845 | "root": 400, |
| 846 | "ids": [ |
| 847 | 401, |
| 848 | 402 |
| 849 | ], |
| 850 | "name": "Bob", |
| 851 | "uid": "user-bob-1", |
| 852 | "ts": 1700000410000 |
| 853 | } |
| 854 | } |
| 855 | ], |
| 856 | "expected_threads": { |
| 857 | "roots": { |
| 858 | "400": 0, |
| 859 | "401": 400, |
| 860 | "402": 400, |
| 861 | "403": 0 |
| 862 | }, |
| 863 | "reply_counts": { |
| 864 | "400": 2, |
| 865 | "401": 0, |
| 866 | "402": 0, |
| 867 | "403": 0 |
| 868 | }, |
| 869 | "first_reply": { |
| 870 | "400": 401 |
| 871 | } |
| 872 | }, |
| 873 | "expected_thread_items": { |
| 874 | "400": [ |
| 875 | 400, |
| 876 | 401, |
| 877 | 402 |
| 878 | ], |
| 879 | "401": [ |
| 880 | 401 |
| 881 | ], |
| 882 | "note": "The converted chain reads in the thread in server order, root first — and 401, itself converted, has no thread of its own." |
| 883 | }, |
| 884 | "expected_thread_list": { |
| 885 | "note": "A converted chain lists like any other thread: one row on root 400, counting the two messages the conversion moved.", |
| 886 | "rows": [ |
| 887 | { "root": 400, "root_loaded": true, "name": "Alice", "snippet": "Where should we do dinner?", "count": 2 } |
| 888 | ] |
| 889 | }, |
| 890 | "expected_main_feed": { |
| 891 | "400": true, |
| 892 | "401": false, |
| 893 | "402": false, |
| 894 | "403": true |
| 895 | }, |
| 896 | "expected_main_feed_unconverted": { |
| 897 | "400": true, |
| 898 | "401": true, |
| 899 | "402": true, |
| 900 | "403": true |
| 901 | }, |
| 902 | "expected_inline_chain": { |
| 903 | "400": [], |
| 904 | "403": [] |
| 905 | }, |
| 906 | "expected_reply_targets": { |
| 907 | "note": "Conversion changes where a message renders, never what the two compose verbs quote: plain Reply still quotes the exact bubble, and Reply in thread on a converted message now continues the thread it was moved into.", |
| 908 | "quote": { |
| 909 | "402": { |
| 910 | "id": 402, |
| 911 | "name": "Charlie", |
| 912 | "snippet": "They shut on Mondays" |
| 913 | } |
| 914 | }, |
| 915 | "thread": { |
| 916 | "402": { |
| 917 | "id": 400, |
| 918 | "name": "Alice", |
| 919 | "snippet": "Where should we do dinner?" |
| 920 | }, |
| 921 | "403": { |
| 922 | "id": 403, |
| 923 | "name": "Dana", |
| 924 | "snippet": "Unrelated: who has the keys?" |
| 925 | } |
| 926 | } |
| 927 | } |
| 928 | }, |
| 929 | { |
| 930 | "name": "convert_folds_before_its_members_arrive", |
| 931 | "description": "The conversion folds FIRST and the messages it names arrive afterwards (an older history page paging in, an out-of-order live frame, a reconnect catch-up). The result is identical to folding them the other way round: 501 and 502 land in 500's thread the moment they arrive.", |
| 932 | "note": "Order-independence is a hard requirement, not a nicety: history pages arrive newest-first while live frames arrive oldest-first, so no client can rely on seeing a conversion after its members. The claim map is state; where each message renders is DERIVED from it on every fold, so a message that arrives after its own conversion is stamped on arrival.", |
| 933 | "message_sequence": [ |
| 934 | { |
| 935 | "server_message": { |
| 936 | "id": 510, |
| 937 | "created_at": 1700000510 |
| 938 | }, |
| 939 | "decrypted_payload": { |
| 940 | "type": "threadconvert", |
| 941 | "root": 500, |
| 942 | "ids": [ |
| 943 | 501, |
| 944 | 502 |
| 945 | ], |
| 946 | "name": "Charlie", |
| 947 | "uid": "user-charlie-1", |
| 948 | "ts": 1700000510000 |
| 949 | } |
| 950 | }, |
| 951 | { |
| 952 | "server_message": { |
| 953 | "id": 500, |
| 954 | "created_at": 1700000500 |
| 955 | }, |
| 956 | "decrypted_payload": { |
| 957 | "name": "Alice", |
| 958 | "text": "Standup notes?", |
| 959 | "ts": 1700000500000, |
| 960 | "uid": "user-alice-1" |
| 961 | } |
| 962 | }, |
| 963 | { |
| 964 | "server_message": { |
| 965 | "id": 501, |
| 966 | "created_at": 1700000501 |
| 967 | }, |
| 968 | "decrypted_payload": { |
| 969 | "name": "Bob", |
| 970 | "text": "Pushed the router fix", |
| 971 | "ts": 1700000501000, |
| 972 | "uid": "user-bob-1", |
| 973 | "replyTo": { |
| 974 | "id": 500, |
| 975 | "name": "Alice", |
| 976 | "snippet": "Standup notes?" |
| 977 | } |
| 978 | } |
| 979 | }, |
| 980 | { |
| 981 | "server_message": { |
| 982 | "id": 502, |
| 983 | "created_at": 1700000502 |
| 984 | }, |
| 985 | "decrypted_payload": { |
| 986 | "name": "Charlie", |
| 987 | "text": "Reviewing it now", |
| 988 | "ts": 1700000502000, |
| 989 | "uid": "user-charlie-1", |
| 990 | "replyTo": { |
| 991 | "id": 501, |
| 992 | "name": "Bob", |
| 993 | "snippet": "Pushed the router fix" |
| 994 | } |
| 995 | } |
| 996 | } |
| 997 | ], |
| 998 | "expected_threads": { |
| 999 | "roots": { |
| 1000 | "500": 0, |
| 1001 | "501": 500, |
| 1002 | "502": 500 |
| 1003 | }, |
| 1004 | "reply_counts": { |
| 1005 | "500": 2, |
| 1006 | "501": 0, |
| 1007 | "502": 0 |
| 1008 | }, |
| 1009 | "first_reply": { |
| 1010 | "500": 501 |
| 1011 | } |
| 1012 | }, |
| 1013 | "expected_thread_items": { |
| 1014 | "500": [ |
| 1015 | 500, |
| 1016 | 501, |
| 1017 | 502 |
| 1018 | ] |
| 1019 | }, |
| 1020 | "expected_thread_list": { |
| 1021 | "note": "The conversion folded before its members arrived, and the row still counts both — the list reads the same projected state the feed does.", |
| 1022 | "rows": [ |
| 1023 | { "root": 500, "root_loaded": true, "name": "Alice", "snippet": "Standup notes?", "count": 2 } |
| 1024 | ] |
| 1025 | }, |
| 1026 | "expected_main_feed": { |
| 1027 | "500": true, |
| 1028 | "501": false, |
| 1029 | "502": false |
| 1030 | }, |
| 1031 | "expected_main_feed_unconverted": { |
| 1032 | "500": true, |
| 1033 | "501": true, |
| 1034 | "502": true |
| 1035 | }, |
| 1036 | "expected_inline_chain": { |
| 1037 | "500": [] |
| 1038 | }, |
| 1039 | "expected_reply_targets": { |
| 1040 | "quote": { |
| 1041 | "502": { |
| 1042 | "id": 502, |
| 1043 | "name": "Charlie", |
| 1044 | "snippet": "Reviewing it now" |
| 1045 | } |
| 1046 | }, |
| 1047 | "thread": { |
| 1048 | "502": { |
| 1049 | "id": 500, |
| 1050 | "name": "Alice", |
| 1051 | "snippet": "Standup notes?" |
| 1052 | } |
| 1053 | } |
| 1054 | } |
| 1055 | }, |
| 1056 | { |
| 1057 | "name": "convert_overlapping_and_repeated", |
| 1058 | "description": "Two people convert overlapping parts of the same chain, and one conversion is sent twice. The fold settles the same way whatever the arrival order: the OLDEST conversion owns a message (lowest server id wins), a repeat changes nothing, and a claim whose root was itself converted flattens onto the outermost root — threads never nest.", |
| 1059 | "note": "620 moves 601 under 600; 621 moves 602 and 603 under 601 — which is itself now inside 600's thread, so both flatten onto 600 and thread 601 stays empty (expected_thread_items pins [601] alone, not [601, 602, 603]). 622 repeats 620 verbatim and is a no-op because 620 is older. Every claim names its root directly, so this is a pure map merge: no walk over the message graph, nothing to re-derive when a message is deleted.", |
| 1060 | "message_sequence": [ |
| 1061 | { |
| 1062 | "server_message": { |
| 1063 | "id": 600, |
| 1064 | "created_at": 1700000600 |
| 1065 | }, |
| 1066 | "decrypted_payload": { |
| 1067 | "name": "Alice", |
| 1068 | "text": "Bug in the composer", |
| 1069 | "ts": 1700000600000, |
| 1070 | "uid": "user-alice-1" |
| 1071 | } |
| 1072 | }, |
| 1073 | { |
| 1074 | "server_message": { |
| 1075 | "id": 601, |
| 1076 | "created_at": 1700000601 |
| 1077 | }, |
| 1078 | "decrypted_payload": { |
| 1079 | "name": "Bob", |
| 1080 | "text": "Which build?", |
| 1081 | "ts": 1700000601000, |
| 1082 | "uid": "user-bob-1", |
| 1083 | "replyTo": { |
| 1084 | "id": 600, |
| 1085 | "name": "Alice", |
| 1086 | "snippet": "Bug in the composer" |
| 1087 | } |
| 1088 | } |
| 1089 | }, |
| 1090 | { |
| 1091 | "server_message": { |
| 1092 | "id": 602, |
| 1093 | "created_at": 1700000602 |
| 1094 | }, |
| 1095 | "decrypted_payload": { |
| 1096 | "name": "Charlie", |
| 1097 | "text": "Latest edge", |
| 1098 | "ts": 1700000602000, |
| 1099 | "uid": "user-charlie-1", |
| 1100 | "replyTo": { |
| 1101 | "id": 601, |
| 1102 | "name": "Bob", |
| 1103 | "snippet": "Which build?" |
| 1104 | } |
| 1105 | } |
| 1106 | }, |
| 1107 | { |
| 1108 | "server_message": { |
| 1109 | "id": 603, |
| 1110 | "created_at": 1700000603 |
| 1111 | }, |
| 1112 | "decrypted_payload": { |
| 1113 | "name": "Dana", |
| 1114 | "text": "Same here", |
| 1115 | "ts": 1700000603000, |
| 1116 | "uid": "user-dana-1", |
| 1117 | "replyTo": { |
| 1118 | "id": 602, |
| 1119 | "name": "Charlie", |
| 1120 | "snippet": "Latest edge" |
| 1121 | } |
| 1122 | } |
| 1123 | }, |
| 1124 | { |
| 1125 | "server_message": { |
| 1126 | "id": 620, |
| 1127 | "created_at": 1700000620 |
| 1128 | }, |
| 1129 | "decrypted_payload": { |
| 1130 | "type": "threadconvert", |
| 1131 | "root": 600, |
| 1132 | "ids": [ |
| 1133 | 601 |
| 1134 | ], |
| 1135 | "name": "Bob", |
| 1136 | "uid": "user-bob-1", |
| 1137 | "ts": 1700000620000 |
| 1138 | } |
| 1139 | }, |
| 1140 | { |
| 1141 | "server_message": { |
| 1142 | "id": 621, |
| 1143 | "created_at": 1700000621 |
| 1144 | }, |
| 1145 | "decrypted_payload": { |
| 1146 | "type": "threadconvert", |
| 1147 | "root": 601, |
| 1148 | "ids": [ |
| 1149 | 602, |
| 1150 | 603 |
| 1151 | ], |
| 1152 | "name": "Charlie", |
| 1153 | "uid": "user-charlie-1", |
| 1154 | "ts": 1700000621000 |
| 1155 | } |
| 1156 | }, |
| 1157 | { |
| 1158 | "server_message": { |
| 1159 | "id": 622, |
| 1160 | "created_at": 1700000622 |
| 1161 | }, |
| 1162 | "decrypted_payload": { |
| 1163 | "type": "threadconvert", |
| 1164 | "root": 600, |
| 1165 | "ids": [ |
| 1166 | 601 |
| 1167 | ], |
| 1168 | "name": "Dana", |
| 1169 | "uid": "user-dana-1", |
| 1170 | "ts": 1700000622000 |
| 1171 | } |
| 1172 | } |
| 1173 | ], |
| 1174 | "expected_threads": { |
| 1175 | "roots": { |
| 1176 | "600": 0, |
| 1177 | "601": 600, |
| 1178 | "602": 600, |
| 1179 | "603": 600 |
| 1180 | }, |
| 1181 | "reply_counts": { |
| 1182 | "600": 3, |
| 1183 | "601": 0, |
| 1184 | "602": 0, |
| 1185 | "603": 0 |
| 1186 | }, |
| 1187 | "first_reply": { |
| 1188 | "600": 601 |
| 1189 | } |
| 1190 | }, |
| 1191 | "expected_thread_items": { |
| 1192 | "600": [ |
| 1193 | 600, |
| 1194 | 601, |
| 1195 | 602, |
| 1196 | 603 |
| 1197 | ], |
| 1198 | "601": [ |
| 1199 | 601 |
| 1200 | ], |
| 1201 | "note": "601's own thread is just 601: the messages a later conversion aimed at it flattened onto 600, because 601 had already been moved there." |
| 1202 | }, |
| 1203 | "expected_thread_list": { |
| 1204 | "note": "Overlapping conversions flatten onto the outermost root, so this is ONE row of three replies, never a row per conversion.", |
| 1205 | "rows": [ |
| 1206 | { "root": 600, "root_loaded": true, "name": "Alice", "snippet": "Bug in the composer", "count": 3 } |
| 1207 | ] |
| 1208 | }, |
| 1209 | "expected_main_feed": { |
| 1210 | "600": true, |
| 1211 | "601": false, |
| 1212 | "602": false, |
| 1213 | "603": false |
| 1214 | }, |
| 1215 | "expected_main_feed_unconverted": { |
| 1216 | "600": true, |
| 1217 | "601": true, |
| 1218 | "602": true, |
| 1219 | "603": true |
| 1220 | }, |
| 1221 | "expected_inline_chain": { |
| 1222 | "600": [] |
| 1223 | }, |
| 1224 | "expected_reply_targets": { |
| 1225 | "quote": { |
| 1226 | "603": { |
| 1227 | "id": 603, |
| 1228 | "name": "Dana", |
| 1229 | "snippet": "Same here" |
| 1230 | } |
| 1231 | }, |
| 1232 | "thread": { |
| 1233 | "603": { |
| 1234 | "id": 600, |
| 1235 | "name": "Alice", |
| 1236 | "snippet": "Bug in the composer" |
| 1237 | } |
| 1238 | } |
| 1239 | } |
| 1240 | }, |
| 1241 | { |
| 1242 | "name": "convert_cycle_leaves_messages_inline", |
| 1243 | "description": "A malformed or hostile pair of conversions pointing at each other (700 into 701's thread, 701 into 700's). Neither resolves: both messages stay exactly where they were, in the feed, and the fold terminates.", |
| 1244 | "note": "A real chain can never loop — a reply's server id is always higher than the message it quotes — so this can only come from a client that made it up. The resolver walks a claimed root through to the thread it ended up in, and a cycle resolves to no conversion at all rather than hanging or picking arbitrarily. expected_inline_chain still offers the ordinary chain under 700, because nothing moved.", |
| 1245 | "message_sequence": [ |
| 1246 | { |
| 1247 | "server_message": { |
| 1248 | "id": 700, |
| 1249 | "created_at": 1700000700 |
| 1250 | }, |
| 1251 | "decrypted_payload": { |
| 1252 | "name": "Alice", |
| 1253 | "text": "Anyone seen the cat?", |
| 1254 | "ts": 1700000700000, |
| 1255 | "uid": "user-alice-1" |
| 1256 | } |
| 1257 | }, |
| 1258 | { |
| 1259 | "server_message": { |
| 1260 | "id": 701, |
| 1261 | "created_at": 1700000701 |
| 1262 | }, |
| 1263 | "decrypted_payload": { |
| 1264 | "name": "Bob", |
| 1265 | "text": "On the roof again", |
| 1266 | "ts": 1700000701000, |
| 1267 | "uid": "user-bob-1", |
| 1268 | "replyTo": { |
| 1269 | "id": 700, |
| 1270 | "name": "Alice", |
| 1271 | "snippet": "Anyone seen the cat?" |
| 1272 | } |
| 1273 | } |
| 1274 | }, |
| 1275 | { |
| 1276 | "server_message": { |
| 1277 | "id": 730, |
| 1278 | "created_at": 1700000730 |
| 1279 | }, |
| 1280 | "decrypted_payload": { |
| 1281 | "type": "threadconvert", |
| 1282 | "root": 701, |
| 1283 | "ids": [ |
| 1284 | 700 |
| 1285 | ], |
| 1286 | "name": "Charlie", |
| 1287 | "uid": "user-charlie-1", |
| 1288 | "ts": 1700000730000 |
| 1289 | } |
| 1290 | }, |
| 1291 | { |
| 1292 | "server_message": { |
| 1293 | "id": 731, |
| 1294 | "created_at": 1700000731 |
| 1295 | }, |
| 1296 | "decrypted_payload": { |
| 1297 | "type": "threadconvert", |
| 1298 | "root": 700, |
| 1299 | "ids": [ |
| 1300 | 701 |
| 1301 | ], |
| 1302 | "name": "Charlie", |
| 1303 | "uid": "user-charlie-1", |
| 1304 | "ts": 1700000731000 |
| 1305 | } |
| 1306 | } |
| 1307 | ], |
| 1308 | "expected_threads": { |
| 1309 | "roots": { |
| 1310 | "700": 0, |
| 1311 | "701": 0 |
| 1312 | }, |
| 1313 | "reply_counts": { |
| 1314 | "700": 0, |
| 1315 | "701": 0 |
| 1316 | }, |
| 1317 | "first_reply": {} |
| 1318 | }, |
| 1319 | "expected_thread_items": { |
| 1320 | "700": [ |
| 1321 | 700 |
| 1322 | ], |
| 1323 | "701": [ |
| 1324 | 701 |
| 1325 | ] |
| 1326 | }, |
| 1327 | "expected_thread_list": { |
| 1328 | "note": "A cycle resolves to no conversion at all, so nothing is in a thread and the list is empty.", |
| 1329 | "rows": [] |
| 1330 | }, |
| 1331 | "expected_main_feed": { |
| 1332 | "700": true, |
| 1333 | "701": true |
| 1334 | }, |
| 1335 | "expected_main_feed_unconverted": { |
| 1336 | "700": true, |
| 1337 | "701": true |
| 1338 | }, |
| 1339 | "expected_inline_chain": { |
| 1340 | "700": [ |
| 1341 | 701 |
| 1342 | ], |
| 1343 | "701": [] |
| 1344 | }, |
| 1345 | "expected_reply_targets": { |
| 1346 | "quote": { |
| 1347 | "701": { |
| 1348 | "id": 701, |
| 1349 | "name": "Bob", |
| 1350 | "snippet": "On the roof again" |
| 1351 | } |
| 1352 | }, |
| 1353 | "thread": { |
| 1354 | "701": { |
| 1355 | "id": 701, |
| 1356 | "name": "Bob", |
| 1357 | "snippet": "On the roof again" |
| 1358 | } |
| 1359 | } |
| 1360 | } |
| 1361 | }, |
| 1362 | { |
| 1363 | "name": "convert_conflicting_claims_oldest_wins", |
| 1364 | "description": "Two conversions genuinely disagree about where a message belongs: 821 moves 802 into 810's thread, 822 moves the same message into 800's. The OLDEST claim wins — 802 lands in 810's thread — and every client settles there whatever order the events arrive in.", |
| 1365 | "note": "The vector that pins the tie-break itself. It has to be a disagreement about the ROOT, and the two roots have to be in different threads: when the loser's root sits inside the winner's, the flatten in convertedRoots makes both rules produce the same feed — which is why convert_overlapping_and_repeated (where 620 and 622 name the SAME root) cannot pin this. Mutation check: flipping the merge to newest-wins moves 802 into 800's thread and fails expected_threads.roots, reply_counts, first_reply, expected_thread_items and expected_main_feed here. Both claims are legitimately shaped — 802 quotes 801, which the conversions claimed — so the receiver's relationship check (convert_unrelated_ids_are_ignored) accepts either and the tie-break alone decides.", |
| 1366 | "message_sequence": [ |
| 1367 | { |
| 1368 | "server_message": { |
| 1369 | "id": 795, |
| 1370 | "created_at": 1700000795 |
| 1371 | }, |
| 1372 | "decrypted_payload": { |
| 1373 | "name": "Dana", |
| 1374 | "text": "Standup notes", |
| 1375 | "ts": 1700000795000, |
| 1376 | "uid": "user-dana-1" |
| 1377 | } |
| 1378 | }, |
| 1379 | { |
| 1380 | "server_message": { |
| 1381 | "id": 800, |
| 1382 | "created_at": 1700000800 |
| 1383 | }, |
| 1384 | "decrypted_payload": { |
| 1385 | "name": "Alice", |
| 1386 | "text": "Shall we move the standup?", |
| 1387 | "ts": 1700000800000, |
| 1388 | "uid": "user-alice-1" |
| 1389 | } |
| 1390 | }, |
| 1391 | { |
| 1392 | "server_message": { |
| 1393 | "id": 801, |
| 1394 | "created_at": 1700000801 |
| 1395 | }, |
| 1396 | "decrypted_payload": { |
| 1397 | "name": "Bob", |
| 1398 | "text": "Half an hour later suits me", |
| 1399 | "ts": 1700000801000, |
| 1400 | "uid": "user-bob-1", |
| 1401 | "replyTo": { |
| 1402 | "id": 800, |
| 1403 | "name": "Alice", |
| 1404 | "snippet": "Shall we move the standup?" |
| 1405 | } |
| 1406 | } |
| 1407 | }, |
| 1408 | { |
| 1409 | "server_message": { |
| 1410 | "id": 802, |
| 1411 | "created_at": 1700000802 |
| 1412 | }, |
| 1413 | "decrypted_payload": { |
| 1414 | "name": "Charlie", |
| 1415 | "text": "Same, and I'll bring the notes", |
| 1416 | "ts": 1700000802000, |
| 1417 | "uid": "user-charlie-1", |
| 1418 | "replyTo": { |
| 1419 | "id": 801, |
| 1420 | "name": "Bob", |
| 1421 | "snippet": "Half an hour later suits me" |
| 1422 | } |
| 1423 | } |
| 1424 | }, |
| 1425 | { |
| 1426 | "server_message": { |
| 1427 | "id": 820, |
| 1428 | "created_at": 1700000820 |
| 1429 | }, |
| 1430 | "decrypted_payload": { |
| 1431 | "type": "threadconvert", |
| 1432 | "root": 800, |
| 1433 | "ids": [ |
| 1434 | 801 |
| 1435 | ], |
| 1436 | "name": "Bob", |
| 1437 | "uid": "user-bob-1", |
| 1438 | "ts": 1700000820000 |
| 1439 | } |
| 1440 | }, |
| 1441 | { |
| 1442 | "server_message": { |
| 1443 | "id": 821, |
| 1444 | "created_at": 1700000821 |
| 1445 | }, |
| 1446 | "decrypted_payload": { |
| 1447 | "type": "threadconvert", |
| 1448 | "root": 795, |
| 1449 | "ids": [ |
| 1450 | 802 |
| 1451 | ], |
| 1452 | "name": "Dana", |
| 1453 | "uid": "user-dana-1", |
| 1454 | "ts": 1700000821000 |
| 1455 | } |
| 1456 | }, |
| 1457 | { |
| 1458 | "server_message": { |
| 1459 | "id": 822, |
| 1460 | "created_at": 1700000822 |
| 1461 | }, |
| 1462 | "decrypted_payload": { |
| 1463 | "type": "threadconvert", |
| 1464 | "root": 800, |
| 1465 | "ids": [ |
| 1466 | 802 |
| 1467 | ], |
| 1468 | "name": "Charlie", |
| 1469 | "uid": "user-charlie-1", |
| 1470 | "ts": 1700000822000 |
| 1471 | } |
| 1472 | } |
| 1473 | ], |
| 1474 | "expected_threads": { |
| 1475 | "roots": { |
| 1476 | "795": 0, |
| 1477 | "800": 0, |
| 1478 | "801": 800, |
| 1479 | "802": 795 |
| 1480 | }, |
| 1481 | "reply_counts": { |
| 1482 | "795": 1, |
| 1483 | "800": 1, |
| 1484 | "801": 0, |
| 1485 | "802": 0 |
| 1486 | }, |
| 1487 | "first_reply": { |
| 1488 | "795": 802, |
| 1489 | "800": 801 |
| 1490 | } |
| 1491 | }, |
| 1492 | "expected_thread_items": { |
| 1493 | "795": [ |
| 1494 | 795, |
| 1495 | 802 |
| 1496 | ], |
| 1497 | "800": [ |
| 1498 | 800, |
| 1499 | 801 |
| 1500 | ], |
| 1501 | "note": "802 is in 795's thread because 821 claimed it first — not in 800's, which 822 asked for a beat later." |
| 1502 | }, |
| 1503 | "expected_thread_list": { |
| 1504 | "note": "The ordering rule, in one vector: two threads, and the row for root 795 comes FIRST even though 800 is the newer root — 795's thread holds 802, which is the newer message. The list answers 'where is the conversation', not 'which root is newest'.", |
| 1505 | "rows": [ |
| 1506 | { "root": 795, "root_loaded": true, "name": "Dana", "snippet": "Standup notes", "count": 1 }, |
| 1507 | { "root": 800, "root_loaded": true, "name": "Alice", "snippet": "Shall we move the standup?", "count": 1 } |
| 1508 | ] |
| 1509 | }, |
| 1510 | "expected_main_feed": { |
| 1511 | "795": true, |
| 1512 | "800": true, |
| 1513 | "801": false, |
| 1514 | "802": false |
| 1515 | }, |
| 1516 | "expected_main_feed_unconverted": { |
| 1517 | "795": true, |
| 1518 | "800": true, |
| 1519 | "801": true, |
| 1520 | "802": true |
| 1521 | }, |
| 1522 | "expected_inline_chain": { |
| 1523 | "795": [], |
| 1524 | "800": [] |
| 1525 | }, |
| 1526 | "expected_reply_targets": { |
| 1527 | "quote": { |
| 1528 | "802": { |
| 1529 | "id": 802, |
| 1530 | "name": "Charlie", |
| 1531 | "snippet": "Same, and I'll bring the notes" |
| 1532 | } |
| 1533 | }, |
| 1534 | "thread": { |
| 1535 | "802": { |
| 1536 | "id": 795, |
| 1537 | "name": "Dana", |
| 1538 | "snippet": "Standup notes" |
| 1539 | } |
| 1540 | } |
| 1541 | } |
| 1542 | }, |
| 1543 | { |
| 1544 | "name": "convert_unrelated_ids_are_ignored", |
| 1545 | "description": "A conversion names three ids, but only 903 actually hangs off root 900 — 901 and 902 are unrelated messages that quote nothing. Only 903 moves; the other two stay exactly where they are, in the feed.", |
| 1546 | "note": "The receiver checks the sender's id list against the messages themselves: a conversion moves a reply CHAIN, so a claimed message's own quote must be the root or another claimed message. Without this the event would be 'move any N messages under any root' — and since a conversion renders no bubble, the oldest claim wins forever, and deleting the convert row is not an undo, one crafted payload could quietly hide unrelated messages under an old root, unattributably. A claimed message that isn't loaded yet has no quote to check and simply stays inline until it arrives, the same degradation as a thread reply whose root is paged out.", |
| 1547 | "message_sequence": [ |
| 1548 | { |
| 1549 | "server_message": { |
| 1550 | "id": 900, |
| 1551 | "created_at": 1700000900 |
| 1552 | }, |
| 1553 | "decrypted_payload": { |
| 1554 | "name": "Alice", |
| 1555 | "text": "Kicking off the migration", |
| 1556 | "ts": 1700000900000, |
| 1557 | "uid": "user-alice-1" |
| 1558 | } |
| 1559 | }, |
| 1560 | { |
| 1561 | "server_message": { |
| 1562 | "id": 901, |
| 1563 | "created_at": 1700000901 |
| 1564 | }, |
| 1565 | "decrypted_payload": { |
| 1566 | "name": "Bob", |
| 1567 | "text": "Coffee run — anyone?", |
| 1568 | "ts": 1700000901000, |
| 1569 | "uid": "user-bob-1" |
| 1570 | } |
| 1571 | }, |
| 1572 | { |
| 1573 | "server_message": { |
| 1574 | "id": 902, |
| 1575 | "created_at": 1700000902 |
| 1576 | }, |
| 1577 | "decrypted_payload": { |
| 1578 | "name": "Charlie", |
| 1579 | "text": "The invoice is paid", |
| 1580 | "ts": 1700000902000, |
| 1581 | "uid": "user-charlie-1" |
| 1582 | } |
| 1583 | }, |
| 1584 | { |
| 1585 | "server_message": { |
| 1586 | "id": 903, |
| 1587 | "created_at": 1700000903 |
| 1588 | }, |
| 1589 | "decrypted_payload": { |
| 1590 | "name": "Dana", |
| 1591 | "text": "Which box first?", |
| 1592 | "ts": 1700000903000, |
| 1593 | "uid": "user-dana-1", |
| 1594 | "replyTo": { |
| 1595 | "id": 900, |
| 1596 | "name": "Alice", |
| 1597 | "snippet": "Kicking off the migration" |
| 1598 | } |
| 1599 | } |
| 1600 | }, |
| 1601 | { |
| 1602 | "server_message": { |
| 1603 | "id": 920, |
| 1604 | "created_at": 1700000920 |
| 1605 | }, |
| 1606 | "decrypted_payload": { |
| 1607 | "type": "threadconvert", |
| 1608 | "root": 900, |
| 1609 | "ids": [ |
| 1610 | 901, |
| 1611 | 902, |
| 1612 | 903 |
| 1613 | ], |
| 1614 | "name": "Bob", |
| 1615 | "uid": "user-bob-1", |
| 1616 | "ts": 1700000920000 |
| 1617 | } |
| 1618 | } |
| 1619 | ], |
| 1620 | "expected_threads": { |
| 1621 | "roots": { |
| 1622 | "900": 0, |
| 1623 | "901": 0, |
| 1624 | "902": 0, |
| 1625 | "903": 900 |
| 1626 | }, |
| 1627 | "reply_counts": { |
| 1628 | "900": 1, |
| 1629 | "901": 0, |
| 1630 | "902": 0, |
| 1631 | "903": 0 |
| 1632 | }, |
| 1633 | "first_reply": { |
| 1634 | "900": 903 |
| 1635 | } |
| 1636 | }, |
| 1637 | "expected_thread_items": { |
| 1638 | "900": [ |
| 1639 | 900, |
| 1640 | 903 |
| 1641 | ], |
| 1642 | "note": "Only the message that really replied to 900 is in its thread; naming the other two moved nothing." |
| 1643 | }, |
| 1644 | "expected_thread_list": { |
| 1645 | "note": "Only the id whose claim actually held (903) is in a thread, so the row counts one reply, not the three the event named.", |
| 1646 | "rows": [ |
| 1647 | { "root": 900, "root_loaded": true, "name": "Alice", "snippet": "Kicking off the migration", "count": 1 } |
| 1648 | ] |
| 1649 | }, |
| 1650 | "expected_main_feed": { |
| 1651 | "900": true, |
| 1652 | "901": true, |
| 1653 | "902": true, |
| 1654 | "903": false |
| 1655 | }, |
| 1656 | "expected_main_feed_unconverted": { |
| 1657 | "900": true, |
| 1658 | "901": true, |
| 1659 | "902": true, |
| 1660 | "903": true |
| 1661 | }, |
| 1662 | "expected_inline_chain": { |
| 1663 | "900": [], |
| 1664 | "901": [], |
| 1665 | "902": [] |
| 1666 | }, |
| 1667 | "expected_reply_targets": { |
| 1668 | "quote": { |
| 1669 | "901": { |
| 1670 | "id": 901, |
| 1671 | "name": "Bob", |
| 1672 | "snippet": "Coffee run — anyone?" |
| 1673 | } |
| 1674 | }, |
| 1675 | "thread": { |
| 1676 | "901": { |
| 1677 | "id": 901, |
| 1678 | "name": "Bob", |
| 1679 | "snippet": "Coffee run — anyone?" |
| 1680 | }, |
| 1681 | "903": { |
| 1682 | "id": 900, |
| 1683 | "name": "Alice", |
| 1684 | "snippet": "Kicking off the migration" |
| 1685 | } |
| 1686 | } |
| 1687 | } |
| 1688 | } |
| 1689 | ] |
| 1690 | } |