Skip to main content

blockworx/doc_ng/
reconcile.rs

1//! The reconciliation suite: one [`Host`](crate::doc_ng::session::Host),
2//! N [`ClientSession`](crate::doc_ng::session::ClientSession)s, and
3//! explicit message queues.
4//!
5//! Convergence alone proves nothing here: everyone folds one canonical
6//! linearization, so it holds however the merge rule compares. What can
7//! actually be wrong is the client's optimistic layer — rebuilds against
8//! a moving confirmed head, ack/broadcast interleavings, queue discipline
9//! — and the merge rule itself, which needs an independent oracle rather
10//! than agreement (see `assert_log_semantics`).
11//!
12//! Interleaving is randomized **across** queues and never within one:
13//! per-connection FIFO is the transport's guarantee, and the sessions
14//! assert contiguity against it.
15
16#[cfg(test)]
17mod tests {
18    use std::collections::VecDeque;
19
20    use proptest::prelude::*;
21    use uuid::Uuid;
22
23    use crate::doc_ng::{
24        block_model::{BlockInit, BlockUpdate, Icon, LabelInit, LabelUpdate},
25        commit::Commit,
26        document::{Document, TitleBlockUpdate},
27        fixtures::{commit, projection},
28        geometry::{FracVal, GridPoint, GridRect, GridSize},
29        id::BlockId,
30        opcode::{Crud, OpCodes},
31        rev::{Confirmed, Rev},
32        session::{ClientSession, Host, Nonce},
33        values::LabelSide,
34    };
35
36    const CLIENTS: usize = 3;
37
38    /// What the server sends. Modelled here rather than in a `protocol.rs`
39    /// so the suite does not pre-empt phase 3's transport design; the
40    /// shapes are the spec's §7 minus `Welcome`.
41    #[derive(Debug, Clone)]
42    enum Message {
43        Committed { nonce: Nonce, rev: Rev },
44        Rejected { nonce: Nonce },
45        Apply { rev: Rev, commit: Commit },
46    }
47
48    struct Client {
49        session: ClientSession,
50        /// Submitted, in flight toward the host.
51        outbox: VecDeque<(Nonce, Commit)>,
52        /// Sequenced, in flight toward this client. FIFO, always.
53        inbox: VecDeque<Message>,
54        /// The harness's own shadow of the session's pending queue. Kept
55        /// independently so the oracle never consults the thing it checks.
56        pending: VecDeque<(Nonce, Commit)>,
57        next_block: u8,
58    }
59
60    struct World {
61        host: Host,
62        clients: Vec<Client>,
63    }
64
65    /// Distinct per client, so concurrent creates cannot collide.
66    fn block_of(client: usize, nth: u8) -> BlockId {
67        let mut bytes = [0u8; 16];
68        bytes[0] = u8::try_from(client).expect("few clients");
69        bytes[1] = nth;
70        // Never the nil uuid, which means "the document" as a parent.
71        bytes[2] = 0xb1;
72        BlockId::from_uuid(Uuid::from_bytes(bytes))
73    }
74
75    fn rect(x: i32) -> GridRect {
76        GridRect {
77            top_left: GridPoint { x, y: 0 },
78            size: GridSize { w: 4, h: 4 },
79        }
80    }
81
82    fn label_init(name: &str) -> LabelInit {
83        LabelInit {
84            name: name.into(),
85            side: LabelSide::Bottom,
86            offset: FracVal::from(1.5),
87            hidden: false,
88        }
89    }
90
91    fn block_init(parent: BlockId, name: &str) -> BlockInit {
92        BlockInit {
93            parent,
94            rect: rect(0),
95            locked: false,
96            title: label_init(name),
97            type_label: label_init("kind"),
98            icon: Icon::default(),
99        }
100    }
101
102    fn create_block(id: BlockId, name: &str) -> OpCodes {
103        OpCodes::Block(id, Crud::Create(block_init(BlockId::NULL, name)))
104    }
105
106    fn rename(id: BlockId, name: &str) -> OpCodes {
107        OpCodes::Block(
108            id,
109            Crud::Update(BlockUpdate::Title(LabelUpdate::Name(name.into()))),
110        )
111    }
112
113    fn move_to(id: BlockId, x: i32) -> OpCodes {
114        OpCodes::Block(id, Crud::Update(BlockUpdate::Rect(rect(x))))
115    }
116
117    fn reparent(child: BlockId, parent: BlockId) -> OpCodes {
118        OpCodes::Block(child, Crud::Update(BlockUpdate::Parent(parent)))
119    }
120
121    impl World {
122        fn new() -> Self {
123            Self {
124                host: Host::default(),
125                clients: (0..CLIENTS)
126                    .map(|_| Client {
127                        session: ClientSession::default(),
128                        outbox: VecDeque::new(),
129                        inbox: VecDeque::new(),
130                        pending: VecDeque::new(),
131                        next_block: 0,
132                    })
133                    .collect(),
134            }
135        }
136
137        /// Seal, journal, predict, and put on the wire. Returns false if
138        /// the client refused its own edit, which the random pool is
139        /// built never to provoke.
140        fn edit(&mut self, client: usize, commit: Commit) -> bool {
141            let session = &mut self.clients[client].session;
142            let Ok(nonce) = session.submit(commit) else {
143                return false;
144            };
145            let outbound = session
146                .last_submission()
147                .expect("a submission is queued")
148                .1
149                .clone();
150            self.clients[client]
151                .outbox
152                .push_back((nonce, outbound.clone()));
153            self.clients[client].pending.push_back((nonce, outbound));
154            true
155        }
156
157        /// The host takes this client's oldest in-flight submission.
158        fn sequence(&mut self, client: usize) {
159            let Some((nonce, commit)) = self.clients[client].outbox.pop_front() else {
160                return;
161            };
162            match self.host.ingest(&commit) {
163                Ok(rev) => {
164                    self.clients[client]
165                        .inbox
166                        .push_back(Message::Committed { nonce, rev });
167                    for (ix, other) in self.clients.iter_mut().enumerate() {
168                        if ix != client {
169                            other.inbox.push_back(Message::Apply {
170                                rev,
171                                commit: commit.clone(),
172                            });
173                        }
174                    }
175                }
176                Err(_) => self.clients[client]
177                    .inbox
178                    .push_back(Message::Rejected { nonce }),
179            }
180        }
181
182        fn deliver(&mut self, client: usize, count: usize) {
183            for _ in 0..count {
184                let Some(message) = self.clients[client].inbox.pop_front() else {
185                    return;
186                };
187                let client = &mut self.clients[client];
188                match message {
189                    Message::Committed { nonce, rev } => {
190                        client
191                            .session
192                            .committed(nonce, rev)
193                            .expect("an ack for the front of the queue lands");
194                        let (shadow, _) = client.pending.pop_front().expect("a pending entry");
195                        assert_eq!(shadow, nonce, "answers arrive in submission order");
196                    }
197                    Message::Rejected { nonce } => {
198                        client.session.rejected(nonce).expect("the rejection lands");
199                        let (shadow, _) = client.pending.pop_front().expect("a pending entry");
200                        assert_eq!(shadow, nonce);
201                    }
202                    Message::Apply { rev, commit } => {
203                        client
204                            .session
205                            .apply_foreign(rev, &commit)
206                            .expect("a sequenced commit lands");
207                    }
208                }
209            }
210        }
211
212        fn quiesce(&mut self) {
213            for client in 0..CLIENTS {
214                while !self.clients[client].outbox.is_empty() {
215                    self.sequence(client);
216                }
217            }
218            for client in 0..CLIENTS {
219                let waiting = self.clients[client].inbox.len();
220                self.deliver(client, waiting);
221            }
222        }
223
224        /// **The oracle.** Rebuilt from the *host's* log rather than from
225        /// the session's incrementally maintained `confirmed`, so a session
226        /// that mistracks its head is caught rather than agreed with.
227        fn assert_oracle(&self) {
228            for (ix, client) in self.clients.iter().enumerate() {
229                let confirmed_count =
230                    usize::try_from(client.session.rev().get()).expect("the log fits in memory");
231                let mut replayed = Document::<Confirmed>::default();
232                for commit in &self.host.commits_after(Rev::ZERO)[..confirmed_count] {
233                    replayed = replayed
234                        .try_apply(commit)
235                        .expect("a sequenced commit folds on replay");
236                }
237                let mut predicted = replayed.predict();
238                for (_, commit) in &client.pending {
239                    predicted = predicted
240                        .try_apply(commit)
241                        .expect("the random pool never authors a commit that cannot fold");
242                }
243                assert_eq!(
244                    client.session.optimistic().content_hash(),
245                    predicted.content_hash(),
246                    "client {ix}: optimistic != confirmed-log ⊕ pending",
247                );
248            }
249        }
250
251        /// **The semantic oracle.** Convergence and the prediction oracle
252        /// are both blind to the merge *rule*: everyone folds one canonical
253        /// linearization, so `f(x) == f(x)` holds however `Register::apply`
254        /// compares. Replaying the log with dumb sequential last-wins
255        /// bookkeeping — plain assignment, no `WriteOrder`, no `Register` —
256        /// is an independent implementation to disagree with.
257        fn assert_log_semantics(&self) {
258            let mut title = String::new();
259            let mut blocks: std::collections::BTreeMap<BlockId, (GridRect, String, bool)> =
260                std::collections::BTreeMap::new();
261
262            for commit in self.host.commits_after(Rev::ZERO) {
263                for op in commit.ops() {
264                    match op {
265                        OpCodes::Document(TitleBlockUpdate::Name(name)) => title = name.clone(),
266                        OpCodes::Block(id, Crud::Create(init)) => {
267                            blocks.insert(*id, (init.rect, init.title.name.clone(), true));
268                        }
269                        OpCodes::Block(id, Crud::Update(BlockUpdate::Rect(to))) => {
270                            blocks.entry(*id).or_default().0 = *to;
271                        }
272                        OpCodes::Block(
273                            id,
274                            Crud::Update(BlockUpdate::Title(LabelUpdate::Name(name))),
275                        ) => {
276                            blocks.entry(*id).or_default().1 = name.clone();
277                        }
278                        OpCodes::Block(id, Crud::Delete) => {
279                            blocks.entry(*id).or_default().2 = false;
280                        }
281                        OpCodes::Block(id, Crud::Restore) => {
282                            blocks.entry(*id).or_default().2 = true;
283                        }
284                        _ => panic!("the random pool authored an op this oracle cannot replay"),
285                    }
286                }
287            }
288
289            let expected: Vec<_> = blocks
290                .into_iter()
291                .filter(|(_, (_, _, alive))| *alive)
292                .map(|(id, (rect, name, _))| (id, BlockId::NULL, rect, name))
293                .collect();
294            assert_eq!(
295                projection(self.host.state()),
296                expected,
297                "the fold disagrees with last-write-wins over the log",
298            );
299            assert_eq!(
300                self.host.state().title_block().name.as_ref(),
301                &title,
302                "the singleton's register disagrees with the log's last write",
303            );
304        }
305
306        fn assert_converged(&self) {
307            let host = self.host.state().content_hash();
308            for (ix, client) in self.clients.iter().enumerate() {
309                assert!(client.pending.is_empty(), "client {ix} is not quiesced");
310                assert_eq!(
311                    client.session.confirmed().content_hash(),
312                    host,
313                    "client {ix}'s confirmed document differs from the host's",
314                );
315                assert_eq!(
316                    client.session.optimistic().content_hash(),
317                    host,
318                    "client {ix}'s prediction differs from the host's document",
319                );
320            }
321        }
322
323        fn block_ids(&self, client: usize) -> Vec<BlockId> {
324            let mut ids: Vec<_> = self.clients[client]
325                .session
326                .optimistic()
327                .blocks()
328                .map(|(id, _)| id)
329                .collect();
330            ids.sort();
331            ids
332        }
333
334        fn title_of(&self, client: usize, id: BlockId) -> String {
335            self.clients[client]
336                .session
337                .confirmed()
338                .block(&id)
339                .expect("the block is in the document")
340                .as_ref()
341                .title
342                .name
343                .as_ref()
344                .clone()
345        }
346    }
347
348    #[derive(Debug, Clone, Copy)]
349    enum Edit {
350        Create,
351        Move {
352            which: usize,
353            x: i32,
354        },
355        Rename {
356            which: usize,
357            name: u8,
358        },
359        Delete {
360            which: usize,
361        },
362        Restore {
363            which: usize,
364        },
365        Title {
366            name: u8,
367        },
368        /// One commit writing one register twice — the intra-commit
369        /// ordering case, exercised inside the random drive as well as in
370        /// its own scenario.
371        MoveTwice {
372            which: usize,
373            first: i32,
374            second: i32,
375        },
376    }
377
378    #[derive(Debug, Clone, Copy)]
379    enum Step {
380        Edit { client: usize, edit: Edit },
381        Sequence { client: usize },
382        Deliver { client: usize, count: usize },
383    }
384
385    impl World {
386        /// Every edit the pool authors is valid against any state it could
387        /// meet: fresh ids, existing targets, and no reparenting — the one
388        /// op a foreign commit can invalidate. Rejection is provoked
389        /// deliberately in its own scenario instead.
390        fn apply_edit(&mut self, client: usize, edit: Edit) {
391            let ids = self.block_ids(client);
392            let pick = |which: usize| ids[which % ids.len()];
393            let ops = match edit {
394                Edit::Create => None,
395                _ if ids.is_empty() => None,
396                Edit::Move { which, x } => Some(vec![move_to(pick(which), x)]),
397                Edit::Rename { which, name } => {
398                    Some(vec![rename(pick(which), &format!("n{name}"))])
399                }
400                Edit::Delete { which } => Some(vec![OpCodes::Block(pick(which), Crud::Delete)]),
401                Edit::Restore { which } => Some(vec![OpCodes::Block(pick(which), Crud::Restore)]),
402                Edit::Title { name } => Some(vec![OpCodes::Document(TitleBlockUpdate::Name(
403                    format!("doc{name}"),
404                ))]),
405                Edit::MoveTwice {
406                    which,
407                    first,
408                    second,
409                } => {
410                    let id = pick(which);
411                    Some(vec![move_to(id, first), move_to(id, second)])
412                }
413            };
414
415            let ops = ops.unwrap_or_else(|| {
416                let nth = self.clients[client].next_block;
417                self.clients[client].next_block = nth.wrapping_add(1);
418                vec![create_block(block_of(client, nth), "fresh")]
419            });
420            assert!(
421                self.edit(client, commit("edit", ops)),
422                "the random pool must never author an edit the client refuses",
423            );
424        }
425
426        fn step(&mut self, step: Step) {
427            match step {
428                Step::Edit { client, edit } => self.apply_edit(client, edit),
429                Step::Sequence { client } => self.sequence(client),
430                Step::Deliver { client, count } => self.deliver(client, count),
431            }
432        }
433    }
434
435    fn any_edit() -> impl Strategy<Value = Edit> {
436        prop_oneof![
437            2 => Just(Edit::Create),
438            3 => (any::<usize>(), -50i32..50).prop_map(|(which, x)| Edit::Move { which, x }),
439            3 => (any::<usize>(), any::<u8>()).prop_map(|(which, name)| Edit::Rename { which, name }),
440            1 => any::<usize>().prop_map(|which| Edit::Delete { which }),
441            1 => any::<usize>().prop_map(|which| Edit::Restore { which }),
442            1 => any::<u8>().prop_map(|name| Edit::Title { name }),
443            2 => (any::<usize>(), -50i32..50, -50i32..50)
444                .prop_map(|(which, first, second)| Edit::MoveTwice { which, first, second }),
445        ]
446    }
447
448    fn any_step() -> impl Strategy<Value = Step> {
449        prop_oneof![
450            3 => (0..CLIENTS, any_edit()).prop_map(|(client, edit)| Step::Edit { client, edit }),
451            3 => (0..CLIENTS).prop_map(|client| Step::Sequence { client }),
452            3 => (0..CLIENTS, 0usize..4).prop_map(|(client, count)| Step::Deliver { client, count }),
453        ]
454    }
455
456    proptest! {
457        /// The two properties that matter, checked together: the oracle
458        /// after **every** step (a prediction is never allowed to drift,
459        /// not merely to end up right), and byte-identical convergence
460        /// once the wires drain.
461        #[test]
462        fn predictions_reconcile_and_everyone_converges(steps in prop::collection::vec(any_step(), 1..80)) {
463            let mut world = World::new();
464            for step in steps {
465                world.step(step);
466                world.assert_oracle();
467            }
468            world.quiesce();
469            world.assert_oracle();
470            world.assert_converged();
471            world.assert_log_semantics();
472        }
473    }
474
475    /// A semantic oracle: not "they agree" but *which value won*. A fold
476    /// that dropped every write would agree with itself perfectly.
477    #[test]
478    fn the_later_commit_in_server_order_wins_the_register_everywhere() {
479        let mut world = World::new();
480        let id = block_of(0, 0);
481        world.edit(0, commit("create", vec![create_block(id, "original")]));
482        world.quiesce();
483
484        // Both clients rename the same register, neither having seen the
485        // other — the concurrent write the whole design is about.
486        world.edit(0, commit("rename", vec![rename(id, "from client 0")]));
487        world.edit(1, commit("rename", vec![rename(id, "from client 1")]));
488        assert_eq!(
489            world.title_of(0, id),
490            "original",
491            "neither rename is confirmed yet",
492        );
493
494        // The host takes client 1's first, so client 0's is the later rev.
495        world.sequence(1);
496        world.sequence(0);
497        world.quiesce();
498
499        for client in 0..CLIENTS {
500            assert_eq!(
501                world.title_of(client, id),
502                "from client 0",
503                "client {client} must show the value the later rev wrote",
504            );
505        }
506        world.assert_converged();
507    }
508
509    /// The losing client's own value stands in its prediction until the
510    /// winning commit is confirmed — the Figma suppression rule, which is
511    /// the reason unacked writes sort above confirmed ones.
512    #[test]
513    fn an_unacked_local_write_outranks_an_incoming_confirmed_one() {
514        let mut world = World::new();
515        let id = block_of(0, 0);
516        world.edit(0, commit("create", vec![create_block(id, "original")]));
517        world.quiesce();
518
519        world.edit(1, commit("rename", vec![rename(id, "local")]));
520        world.edit(0, commit("rename", vec![rename(id, "remote")]));
521        world.sequence(0);
522        world.deliver(1, 1);
523
524        assert_eq!(
525            world.clients[1]
526                .session
527                .optimistic()
528                .block(&id)
529                .expect("the block is present")
530                .as_ref()
531                .title
532                .name
533                .as_ref()
534                .as_str(),
535            "local",
536            "the unacked local edit still suppresses the confirmed one",
537        );
538        assert_eq!(
539            world.title_of(1, id),
540            "remote",
541            "while the confirmed document has already taken the foreign value",
542        );
543
544        world.quiesce();
545        world.assert_converged();
546    }
547
548    /// Intra-commit ordering, end to end through a session: `seq` resolves
549    /// a commit that writes one register twice, and every client sees the
550    /// last write.
551    #[test]
552    fn a_commit_writing_one_register_twice_ends_on_its_last_write() {
553        let mut world = World::new();
554        let id = block_of(0, 0);
555        world.edit(0, commit("create", vec![create_block(id, "b")]));
556        world.quiesce();
557
558        world.edit(0, commit("drag", vec![move_to(id, 5), move_to(id, 9)]));
559        world.quiesce();
560
561        for client in 0..CLIENTS {
562            assert_eq!(
563                *world.clients[client]
564                    .session
565                    .confirmed()
566                    .block(&id)
567                    .expect("the block is present")
568                    .as_ref()
569                    .rect
570                    .as_ref(),
571                rect(9),
572                "client {client} must end on the commit's last write",
573            );
574        }
575    }
576
577    /// Re-delivery is refused by the contiguity check, not absorbed by the
578    /// fold — and refused *loudly*, because a repeated rev means the
579    /// transport broke a guarantee the sessions are built on.
580    ///
581    /// The rest of idempotence lives elsewhere: a re-folded commit mints a
582    /// *different* rev, so fold-level re-apply is not idempotent by design,
583    /// and the register keeps the original guarantee (an equal write order
584    /// loses, `register.rs`).
585    #[test]
586    #[should_panic(expected = "gap in the per-connection rev sequence")]
587    fn re_delivering_a_confirmed_commit_is_refused() {
588        let mut world = World::new();
589        let id = block_of(0, 0);
590        world.edit(0, commit("create", vec![create_block(id, "b")]));
591        world.quiesce();
592
593        let replayed = world.host.commits_after(Rev::ZERO)[0].clone();
594        let stale = world.clients[1].session.rev();
595        let _ = world.clients[1].session.apply_foreign(stale, &replayed);
596    }
597
598    /// Rejection safety. The cycle can only be seen by the host: each
599    /// client's reparent is valid against what it holds, and the second to
600    /// be sequenced closes the loop.
601    #[test]
602    fn a_commit_the_host_refuses_is_dropped_and_the_client_re_converges() {
603        let mut world = World::new();
604        let (first, second) = (block_of(0, 0), block_of(0, 1));
605        world.edit(
606            0,
607            commit(
608                "two blocks",
609                vec![create_block(first, "a"), create_block(second, "b")],
610            ),
611        );
612        world.quiesce();
613
614        world.edit(0, commit("nest a under b", vec![reparent(first, second)]));
615        world.edit(1, commit("nest b under a", vec![reparent(second, first)]));
616
617        world.sequence(1);
618        let host_after_first = world.host.state().content_hash();
619        world.sequence(0);
620        assert_eq!(
621            world.host.state().content_hash(),
622            host_after_first,
623            "the refused commit must leave the host untouched",
624        );
625        assert_eq!(
626            world.host.rev(),
627            Rev::new(2),
628            "and must consume no rev — the log cannot gap",
629        );
630
631        world.quiesce();
632        world.assert_converged();
633        assert_eq!(
634            *world.clients[0]
635                .session
636                .confirmed()
637                .block(&first)
638                .expect("the block is present")
639                .as_ref()
640                .parent
641                .as_ref(),
642            BlockId::NULL,
643            "client 0's refused reparent is gone from its document",
644        );
645    }
646
647    /// The undo round trip through a session, over the cases the journal's
648    /// rules turn on. The edit is asserted **observable** first, or an
649    /// inverse that restored nothing would pass vacuously.
650    #[test]
651    fn undo_restores_the_visible_document_through_the_wire() {
652        let id = block_of(0, 0);
653        let created = block_of(0, 1);
654        for edit in [
655            commit("move", vec![move_to(id, 7)]),
656            commit("rename", vec![rename(id, "renamed")]),
657            commit("delete", vec![OpCodes::Block(id, Crud::Delete)]),
658            commit(
659                "create",
660                vec![create_block(created, "new"), move_to(created, 3)],
661            ),
662            commit("drag", vec![move_to(id, 4), move_to(id, 8)]),
663            commit(
664                "multi",
665                vec![
666                    move_to(id, 2),
667                    rename(id, "multi"),
668                    create_block(created, "c"),
669                ],
670            ),
671        ] {
672            let mut world = World::new();
673            world.edit(0, commit("create", vec![create_block(id, "original")]));
674            world.quiesce();
675            let before = projection(world.clients[0].session.confirmed());
676
677            let label = edit.label().to_owned();
678            world.edit(0, edit);
679            world.quiesce();
680            assert_ne!(
681                projection(world.clients[0].session.confirmed()),
682                before,
683                "{label}: the edit must be observable or the round trip proves nothing",
684            );
685
686            let undone = world.clients[0]
687                .session
688                .undo()
689                .expect("something to undo")
690                .expect("the inverse folds");
691            let outbound = world.clients[0]
692                .session
693                .last_submission()
694                .expect("the undo is queued")
695                .1
696                .clone();
697            world.clients[0]
698                .outbox
699                .push_back((undone, outbound.clone()));
700            world.clients[0].pending.push_back((undone, outbound));
701            world.quiesce();
702
703            assert_eq!(
704                projection(world.clients[0].session.confirmed()),
705                before,
706                "{label}: undo must restore the visible document",
707            );
708            world.assert_converged();
709        }
710    }
711
712    /// A tombstoned block is restorable, and the restore travels: delete
713    /// and restore are inverses through the wire, not only in the fold.
714    #[test]
715    fn a_delete_and_its_restore_travel_to_every_client() {
716        let mut world = World::new();
717        let id = block_of(0, 0);
718        world.edit(0, commit("create", vec![create_block(id, "b")]));
719        world.quiesce();
720
721        world.edit(0, commit("delete", vec![OpCodes::Block(id, Crud::Delete)]));
722        world.quiesce();
723        for client in 0..CLIENTS {
724            assert!(
725                projection(world.clients[client].session.confirmed()).is_empty(),
726                "client {client} must not show a tombstoned block",
727            );
728        }
729
730        world.edit(
731            1,
732            commit("restore", vec![OpCodes::Block(id, Crud::Restore)]),
733        );
734        world.quiesce();
735        for client in 0..CLIENTS {
736            assert_eq!(
737                world.title_of(client, id),
738                "b",
739                "client {client} must see the restored block, inner values intact",
740            );
741        }
742        world.assert_converged();
743    }
744}