Skip to main content

helix_im/sync/
session.rs

1//! SyncSession 聚合 + EventEnvelope
2//!
3//! ## 源码印证(2026-06-08 实测)
4//!
5//! - /sync/notify(from_seq) 返回 4 种响应
6//! - 分桶顺序铁律:type1 upsert → type2 edit → type3 revoke → type6 read
7//! - ≥500 events 需续拉(needs_continuation)
8//! - TooLong:cursor = reset_to-1 + Emit(SyncTooLong) + getLatestPost 覆盖式重拉;不物理删 message
9
10use crate::state::{ChannelId, Seq};
11
12pub(crate) const POST_FIELD_ID: u64 = 1 << 0;
13pub(crate) const POST_FIELD_CHANNEL_ID: u64 = 1 << 1;
14pub(crate) const POST_FIELD_USER_ID: u64 = 1 << 2;
15pub(crate) const POST_FIELD_TYPE: u64 = 1 << 3;
16pub(crate) const POST_FIELD_MESSAGE: u64 = 1 << 4;
17pub(crate) const POST_FIELD_SIMPLE_MESSAGE: u64 = 1 << 5;
18pub(crate) const POST_FIELD_PROPS: u64 = 1 << 6;
19pub(crate) const POST_FIELD_USER_SNAPSHOT: u64 = 1 << 7;
20pub(crate) const POST_FIELD_CREATE_AT: u64 = 1 << 8;
21pub(crate) const POST_FIELD_UPDATE_AT: u64 = 1 << 9;
22pub(crate) const POST_FIELD_READ_BITS: u64 = 1 << 10;
23pub(crate) const POST_FIELD_SNAPSHOT_ID: u64 = 1 << 11;
24pub(crate) const POST_FIELD_VIEWERS: u64 = 1 << 12;
25pub(crate) const POST_FIELD_MENTIONS: u64 = 1 << 13;
26pub(crate) const POST_FIELD_EXPEDITE_MAP: u64 = 1 << 14;
27pub(crate) const POST_FIELD_QUICK_REPLY: u64 = 1 << 15;
28pub(crate) const POST_FIELD_TOPIC: u64 = 1 << 16;
29pub(crate) const POST_FIELD_REPLY_ID: u64 = 1 << 17;
30pub(crate) const POST_FIELD_REPLY_ROOT_ID: u64 = 1 << 18;
31pub(crate) const POST_FIELD_REPLY_FIRST_LEVEL_ID: u64 = 1 << 19;
32pub(crate) const POST_FIELD_REPLIED_MESSAGE: u64 = 1 << 20;
33pub(crate) const POST_FIELD_REPLY_MESSAGES: u64 = 1 << 21;
34pub(crate) const POST_FIELD_REPLY_COUNT: u64 = 1 << 22;
35
36/// 落库所需的 owned typed 视图(HX-C005:字段提取上移 parser,热路径零再解析)。
37///
38/// parser(`parse_inbound` / `parse_channel_events`,`Value` 已在手)一次解析填充此结构;
39/// `channel::event_to_upsert_op` 退化为零再解析的 Row 拼装——不再每事件
40/// `serde_json::from_slice(raw)` 全量重解析 + ~14 次 String 堆分配。
41///
42/// 字段映射真源 = cses-client `From<types::Post> for Message`(PK 三级回退、snake/camel 兼容
43/// 已在 parser 提取阶段处理)。`props` 保留服务端原始对象(业务扩展 / event_seq / read map 下游对账)。
44#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
45pub struct PostFields {
46    /// `temporary_id` ?? `temporaryId`(PK 首选,空字符串=缺省,由 event_to_upsert_op 走三级回退)。
47    pub temporary_id: String,
48    pub id: String,
49    /// post 内的 channel_id(缺省回退到 envelope 权威 channel_id,在拼装时处理)。
50    pub channel_id: String,
51    pub user_id: String,
52    /// Company owning the sender directory entry; identity only, not a profile.
53    pub team_id: String,
54    /// Wire `type` 字段在内部 typed view 中使用 `msg_type`,日志仍映射回协议名称。
55    #[serde(rename = "type")]
56    pub msg_type: String,
57    pub message: String,
58    /// 短预览文本(Go wire `simpleMessage` / `simple_message`),用于 Dialog lastMessage。
59    pub simple_message: String,
60    /// 服务端原始 props 对象的字符串形态(业务扩展透传)。
61    pub props: String,
62    /// 旧调用方兼容字段;生产入站与持久化始终为空,姓名由宿主用户目录提供。
63    pub user_snapshot: String,
64    pub create_at: i64,
65    /// 服务端消息更新时间;缺省保持 0,禁止用本地墙钟伪造远端权威值。
66    pub update_at: i64,
67    /// 服务端权威已读位(C2 / type=6 真源 post.rs:1087-1089)。
68    /// 缺省空串=该 post 无已读信息;落库时**单调覆盖** message.read_bits 列(非累加)。
69    pub read_bits: String,
70    /// 创建时成员快照权威键;缺省空串表示服务端未提供,禁止本地合成。
71    pub snapshot_id: String,
72    /// 可见性受众(A3/CAP-9:sync 应用未读 +1 的 `visible_to_user` 谓词真源
73    /// message_service.rs:323-333——`viewers` 含 `"all"` 或当前 auth_id 即可见)。
74    /// 缺省空 Vec(无堆分配);NOTICE 类型恒可见不依赖此字段。
75    pub viewers: Vec<String>,
76    /// `mentions` 用户 id 列表,供 Rust 侧计算 Dialog mention patch。
77    pub mentions: Vec<String>,
78    /// 服务端原始 expediteMap / expedite_map 对象字符串,供 urgent 判定和存储透传。
79    pub expedite_map: String,
80    /// Go `post_update.quickReply` 规范化数组的 JSON 文本,独立落 message.quick_reply。
81    pub quick_reply: String,
82    /// 消息的话题映射对象(Go wire `topic`),原样 JSON 持久化并投影给 UI。
83    pub topic: String,
84    /// 被回复消息 id,以及服务端归一后的回复根/一级回复锚。
85    /// 这些字段由 Helix parser 原样持久化,UI 只消费投影,不计算回复层级。
86    pub reply_id: String,
87    pub reply_root_id: String,
88    pub reply_first_level_id: String,
89    /// 被引用消息与回复摘要由服务端直接给出,Helix 只做一次解析/投影。
90    /// JSON 字段保留字符串形态落库,避免 UI 再探测 snake/camel 或重组树。
91    pub replied_message: String,
92    pub reply_messages: String,
93    pub reply_count: i64,
94    /// JSON key presence mask; missing fields must not overwrite an existing readback row.
95    #[serde(skip)]
96    pub(crate) present_fields: u64,
97}
98
99impl PostFields {
100    /// Reports whether the source payload explicitly contained the given field.
101    pub(crate) fn has_field(&self, field: u64) -> bool {
102        self.present_fields & field != 0
103    }
104}
105
106/// 解析后的强类型入站事件(14 类 WS type 的产物)
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct EventEnvelope {
109    /// per-channel ID(channel_id 的内部引用,两个字段保持同步)
110    pub id: ChannelId,
111    pub channel_id: ChannelId,
112    pub seq: Seq,
113    pub kind: EventKind,
114    /// 落库所需 owned typed 视图(parser 一次解析填充;HX-C005 热路径零再解析)。
115    pub fields: PostFields,
116    /// 事件 wire 的 `msgId`(C2 phantom 判定真源 post.rs:1064-1069)。
117    ///
118    /// sync `events` 路径:事件本身只带 `eventSeq/eventType/msgId/actorId`,消息内容在
119    /// 分离的 `messages` map(key=msgId)。`msg_id ∈ messages` → 落内容行;`∉` → phantom
120    /// (cursor 推进但不落 message 行,HX-C008 §1.6)。`None` = 无 msgId(read 事件可空 / legacy)。
121    pub msg_id: Option<String>,
122    /// 服务端事件行 identity。撤回投影使用这些字段生成 viewer-ready 系统事件;它们与原消息
123    /// 作者完全独立,缺失时保持空值/0,绝不从 `fields.user_id` 猜 actor。
124    pub event_id: String,
125    pub actor_id: String,
126    pub occurred_at: i64,
127    /// ChannelEvent.payload 原样 JSON;当前撤回合同为空也保留,防后续系统事件扩展被 parser 丢弃。
128    pub event_payload: String,
129    /// 同一业务效果在 stream 与 member projection 间的关联键;不参与排序或幂等。
130    pub effect_id: String,
131    /// ACL marker 只占据 stream 位置,不得物化消息或泄露 actor/payload。
132    pub redacted: bool,
133    /// ⑤ 未读 +1 决策(在边界 handler 用**原始帧 + auth + 完整 echo 判定**算定,gate 在
134    /// apply/flush **两路**统一发出)。`Some` = 该 PostUpsert 落库时一并 `GuardedBump` unread
135    /// (已含 sender 豁免 + 可见性 + echo 跳过);`None` = 不 bump(echo / 非新消息 / 其它 kind /
136    /// sync 路径——sync 未读由 CAP-9 apply 自管,故默认 None 不重复 +1)。
137    ///
138    /// **乱序进 gate buffer 的事件同样携带本决策**,flush 时由 gate 发出——修复「乱序 post 经
139    /// buffer flush 不补未读」偏差(gate 成为未读 bump 的**单一发出点**,立即 apply 与 flush 同口径)。
140    pub unread_bump: Option<crate::channel_write::PostChannelUpdate>,
141    /// 当前投影视角用户;仅用于生成 `isSelf` 与消息动作 capability,不参与存储。
142    pub viewer_user_id: String,
143    /// Trusted action causation copied from the WS envelope, never from post content.
144    pub causation_id: Option<String>,
145}
146
147impl EventEnvelope {
148    /// 构造 EventEnvelope(统一入口,确保 id 与 channel_id 始终一致)。
149    ///
150    /// parser 经此入口传入已提取的 `fields`(owned typed 视图),落库路径直接读取,
151    /// 不再持有 / 重解析原始 JSON 字节(HX-C005)。
152    ///
153    /// `msg_id` 缺省从 fields 派生(`id` ?? `temporary_id`,皆空=None)——既有调用方
154    /// (WS 路径 / driver-host 测试)无须改签名;sync 路径再用 `with_msg_id` 覆盖为 wire 权威值。
155    pub fn new(channel_id: ChannelId, seq: Seq, kind: EventKind, fields: PostFields) -> Self {
156        let msg_id = if !fields.id.is_empty() {
157            Some(fields.id.clone())
158        } else if !fields.temporary_id.is_empty() {
159            Some(fields.temporary_id.clone())
160        } else {
161            None
162        };
163        Self {
164            id: channel_id,
165            channel_id,
166            seq,
167            kind,
168            fields,
169            msg_id,
170            event_id: String::new(),
171            actor_id: String::new(),
172            occurred_at: 0,
173            event_payload: String::new(),
174            effect_id: String::new(),
175            redacted: false,
176            unread_bump: None,
177            viewer_user_id: String::new(),
178            causation_id: None,
179        }
180    }
181
182    /// 覆盖 wire 权威 `msgId`(sync `events` / WS 路径,C2 phantom 判定)。
183    ///
184    /// 仅当传入 `Some(非空)` 时覆盖——`None` / `Some("")` 保留 `new` 派生的 fallback
185    /// (`id` ?? `temporary_id`),不抹掉。边界零信任,helix-im 不变量 4。
186    pub fn with_msg_id(mut self, msg_id: Option<String>) -> Self {
187        if let Some(id) = msg_id.filter(|s| !s.is_empty()) {
188            self.msg_id = Some(id);
189        }
190        self
191    }
192
193    /// 附加 wire 权威事件 identity。空 actor 保持 unavailable,调用方不得回退为消息作者。
194    pub fn with_event_identity(
195        mut self,
196        event_id: Option<String>,
197        actor_id: Option<String>,
198        occurred_at: i64,
199        event_payload: String,
200    ) -> Self {
201        self.event_id = event_id.filter(|id| !id.is_empty()).unwrap_or_default();
202        self.actor_id = actor_id.filter(|id| !id.is_empty()).unwrap_or_default();
203        self.occurred_at = occurred_at.max(0);
204        self.event_payload = event_payload;
205        self
206    }
207
208    pub fn with_effect(mut self, effect_id: Option<String>, redacted: bool) -> Self {
209        self.effect_id = effect_id.filter(|id| !id.is_empty()).unwrap_or_default();
210        self.redacted = redacted;
211        self
212    }
213
214    /// 附加 ⑤ 未读 bump 决策(仅 `post` handler 对新消息算定后调用)。
215    /// gate 在 apply/flush 两路读取并发出——确保乱序经 buffer 的消息 flush 时也补未读。
216    pub fn with_unread_bump(
217        mut self,
218        bump: Option<crate::channel_write::PostChannelUpdate>,
219    ) -> Self {
220        self.unread_bump = bump;
221        self
222    }
223
224    pub fn with_viewer_user_id(mut self, viewer_user_id: &str) -> Self {
225        self.viewer_user_id = viewer_user_id.to_string();
226        self
227    }
228
229    pub fn with_causation_id(mut self, causation_id: Option<String>) -> Self {
230        self.causation_id = causation_id.filter(|value| !value.is_empty());
231        self
232    }
233}
234
235/// `increment_channel` 帧 data(`IncrementChannel.ToMap()`)的解析结果(B-rest 契约)。
236///
237/// 真 Go data 含 id/teamId/displayName/lastEventSeq/unreadCount/needSync/mentionList/
238/// urgentPostList(设计文档 §1.3);helix 另保留当前 viewer 的未读 anchor/read watermark/
239/// projection revision,供 member projection strict guard 使用;`raw` 供 emit
240/// im:channel:increment 透传。
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct IncrementChannel {
243    pub channel_id: ChannelId,
244    pub last_event_seq: Seq,
245    pub need_sync: bool,
246    /// Viewer-scoped unread anchor from the same snapshot as the optional projection revision.
247    pub unread_post_id: Option<String>,
248    /// Viewer-scoped read boundary, when supplied by the versioned member projection.
249    pub last_read_seq: Option<i64>,
250    /// Strict member projection revision; zero remains a legacy snapshot marker.
251    pub projection_revision: Option<u64>,
252    pub raw: bytes::Bytes,
253}
254
255/// IM 事件类型(对应 WS 协议 type 字段,源码 14 类分桶)
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub enum EventKind {
258    /// type1:新消息 upsert
259    PostUpsert,
260    /// type2:消息编辑
261    PostEdit,
262    /// type3:消息撤回
263    PostRevoke,
264    /// type6:已读
265    PostRead,
266    /// type7:频道终态 tombstone。
267    ///
268    /// 该事件只允许经 `/channel/sync/notify` 的严格 wire 进入;它不是 post,不能落到
269    /// message 表或沿用普通 cursor 提交链。`sync::flow` 会把 tombstone marker 与 cursor
270    /// 放进同一个 `PersistAtomic`,收到成功回执后才关闭本地 channel 并发布终态事件。
271    ChannelTerminalClosed,
272    /// 其他类型(暂不处理)
273    Other(u8),
274}
275
276impl EventKind {
277    /// 返回对应的 WS 协议 type 数字(用于落库和分桶排序)
278    pub fn type_num(&self) -> u8 {
279        match self {
280            EventKind::PostUpsert => 1,
281            EventKind::PostEdit => 2,
282            EventKind::PostRevoke => 3,
283            EventKind::PostRead => 6,
284            EventKind::ChannelTerminalClosed => 7,
285            EventKind::Other(n) => *n,
286        }
287    }
288}
289
290/// sync 响应的 4 种形态
291#[derive(Debug, Clone, PartialEq, Eq, Default)]
292pub struct SyncPersona {
293    pub membership_state: String,
294    pub epoch_start_seq: Option<Seq>,
295    pub epoch_end_seq: Option<Seq>,
296    pub member_projection: Option<serde_json::Value>,
297}
298
299#[derive(Debug)]
300pub enum SyncResponse {
301    /// 无新事件(cursor 已是最新)
302    NoChange { next_seq: Seq, persona: SyncPersona },
303    /// 有事件(列表,≥500 时 needs_continuation=true 需续拉)
304    Events {
305        events: Vec<EventEnvelope>,
306        /// C2:服务端按可见性过滤的消息内容快照(key=msgId,真源 post.rs:1047 `messages` 参数)。
307        ///
308        /// 落库时:type1/2 的 `event.msg_id ∈ messages` → 用此 map 内容落 message 行;
309        /// `∉` → phantom(不落行,cursor 照推)。type6 → 读 `messages[msg_id].read_bits`
310        /// 单调覆盖。空 map = 全 phantom(仅推 cursor)。owned `PostFields`(HX-C005)。
311        messages: std::collections::HashMap<String, PostFields>,
312        /// 服务端权威水位(Go `SyncEntry.nextSeq`):cursor 应推进到此值,**不要**用
313        /// `max(event.seq)` 推导。当前契约下二者恒等(Go `next = events[末].EventSeq`),
314        /// 但显式锚 nextSeq 防契约漂移(尾部 phantom seq 不进 events 数组时不落后)。
315        next_seq: Seq,
316        needs_continuation: bool,
317        persona: SyncPersona,
318    },
319    /// 服务端返回快照(覆盖式 upsert,不先物理删除既有 message)
320    Snapshot(ChannelSnapshot),
321    /// cursor gap 过大,保留旧 message 并覆盖式重拉
322    TooLong { reset_to: Seq },
323}
324
325/// 频道快照(用于 Snapshot 响应的全量重建)
326#[derive(Debug)]
327pub struct ChannelSnapshot {
328    pub channel_id: ChannelId,
329    pub reset_to: Seq,
330    pub messages: Vec<EventEnvelope>,
331}
332
333/// SyncSession 状态(一次 /sync/notify 请求的生命周期)
334pub struct SyncSession {
335    pub channel_id: ChannelId,
336    pub from_seq: Seq,
337    pub corr: helix_core::Correlation,
338}
339
340/// One reconnect/relogin authority window.  It deliberately contains only
341/// correlation-free state: the actual network and storage work is still
342/// represented by Effects and their matching PortReply contexts.
343#[derive(Debug, Clone, PartialEq, Eq)]
344pub struct RecoverySession {
345    pub phase: RecoveryPhase,
346    pub session_epoch: u64,
347    pub actor_id: String,
348    pub pending_commits: std::collections::BTreeMap<ChannelId, Seq>,
349    completion_published: bool,
350}
351
352impl Default for RecoverySession {
353    fn default() -> Self {
354        Self {
355            phase: RecoveryPhase::Idle,
356            session_epoch: 0,
357            actor_id: String::new(),
358            pending_commits: std::collections::BTreeMap::new(),
359            completion_published: false,
360        }
361    }
362}
363
364impl RecoverySession {
365    /// Start a new trust boundary.  Epoch zero is intentionally never emitted
366    /// on the V2 wire, so the first usable session is one.
367    pub fn begin(&mut self, actor_id: &str) {
368        self.session_epoch = self.session_epoch.saturating_add(1).max(1);
369        self.actor_id.clear();
370        self.actor_id.push_str(actor_id);
371        self.pending_commits.clear();
372        self.completion_published = false;
373        self.phase = RecoveryPhase::Comparing;
374    }
375
376    pub fn invalidate(&mut self) {
377        self.pending_commits.clear();
378        self.completion_published = false;
379        self.phase = RecoveryPhase::Idle;
380        self.actor_id.clear();
381    }
382
383    pub fn compare(
384        &mut self,
385        local: CommittedRecoveryHead,
386        authority: AuthorityHead,
387    ) -> RecoveryComparison {
388        let comparison = compare_committed_recovery(local, authority);
389        self.phase = match comparison {
390            RecoveryComparison::Equal => RecoveryPhase::Recovered,
391            RecoveryComparison::Pull { .. } => RecoveryPhase::Pulling,
392            RecoveryComparison::AuthorityReloadRequired => RecoveryPhase::Blocked,
393        };
394        comparison
395    }
396
397    pub fn await_commit(&mut self, channel_id: ChannelId, committed_to: Seq) {
398        self.pending_commits.insert(channel_id, committed_to);
399        self.phase = RecoveryPhase::AwaitingCommit;
400    }
401
402    /// A storage acknowledgement is meaningful only when it matches exactly
403    /// the batch that this session registered.  Late A-session acknowledgements
404    /// are therefore a no-op rather than a renderer-visible completion.
405    pub fn commit_ok(&mut self, channel_id: ChannelId, committed_to: Seq) -> bool {
406        if self.pending_commits.remove(&channel_id) != Some(committed_to) {
407            return false;
408        }
409        self.phase = if self.pending_commits.is_empty() {
410            RecoveryPhase::Recovered
411        } else {
412            RecoveryPhase::AwaitingCommit
413        };
414        true
415    }
416
417    pub fn commit_failed(&mut self, channel_id: ChannelId) {
418        self.pending_commits.remove(&channel_id);
419        self.phase = RecoveryPhase::Failed;
420    }
421
422    pub fn is_active_for(&self, actor_id: &str) -> bool {
423        self.session_epoch != 0 && self.actor_id == actor_id && !self.actor_id.is_empty()
424    }
425
426    pub fn is_collecting_for(&self, actor_id: &str) -> bool {
427        self.is_active_for(actor_id) && !self.completion_published
428    }
429
430    pub fn is_collecting(&self) -> bool {
431        self.session_epoch != 0 && !self.actor_id.is_empty() && !self.completion_published
432    }
433
434    pub fn has_pending_commits(&self) -> bool {
435        !self.pending_commits.is_empty()
436    }
437
438    pub fn mark_completion_published(&mut self) {
439        self.completion_published = true;
440    }
441}
442
443/// Recovery is deliberately a pure comparison model. The caller obtains both
444/// inputs through ports, persists any chosen batch, and only then advances the
445/// in-memory head; this type cannot manufacture a UI completion on its own.
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub enum RecoveryPhase {
448    Idle,
449    Comparing,
450    Pulling,
451    AwaitingCommit,
452    Recovered,
453    Failed,
454    Blocked,
455}
456
457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
458pub struct AuthorityHead {
459    pub event_seq: Seq,
460}
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463pub struct CommittedRecoveryHead {
464    pub cursor: Seq,
465    pub ledger_to_seq: Seq,
466    pub coverage_to_seq: Seq,
467}
468
469impl CommittedRecoveryHead {
470    pub const fn is_coherent(self) -> bool {
471        self.cursor.0 == self.ledger_to_seq.0 && self.cursor.0 == self.coverage_to_seq.0
472    }
473}
474
475#[derive(Debug, Clone, Copy, PartialEq, Eq)]
476pub enum RecoveryComparison {
477    Equal,
478    Pull {
479        from_exclusive: Seq,
480        to_inclusive: Seq,
481    },
482    AuthorityReloadRequired,
483}
484
485/// A normalized sync batch whose sequence evidence has already been checked.
486/// The parser stays responsible for wire decoding; this type owns the
487/// cross-entry invariants shared by reconnect and incremental recovery.
488#[derive(Debug, Clone)]
489pub struct SyncBatchFacts {
490    pub channel_id: ChannelId,
491    pub from_exclusive: Seq,
492    pub authority_head: AuthorityHead,
493    pub events: Vec<EventEnvelope>,
494}
495
496impl SyncBatchFacts {
497    pub fn from_events(
498        channel_id: ChannelId,
499        from_exclusive: Seq,
500        authority_head: Seq,
501        events: Vec<EventEnvelope>,
502    ) -> Result<Self, &'static str> {
503        if events.is_empty() {
504            return Err("sync batch facts require at least one event");
505        }
506        let mut previous = from_exclusive;
507        for event in &events {
508            if event.channel_id != channel_id || event.id != channel_id {
509                return Err("sync batch event channel differs from request channel");
510            }
511            // sync/notify is viewer-filtered: invisible channel events create
512            // legitimate gaps. The returned facts must still be strictly
513            // increasing and newer than the committed cursor.
514            if event.seq <= previous {
515                return Err("sync batch event sequence is not strictly increasing");
516            }
517            previous = event.seq;
518        }
519        let last = events.last().map(|event| event.seq).unwrap_or(Seq(0));
520        if authority_head < last {
521            return Err("sync batch authority head precedes final event");
522        }
523        Ok(Self {
524            channel_id,
525            from_exclusive,
526            authority_head: AuthorityHead {
527                event_seq: authority_head,
528            },
529            events,
530        })
531    }
532}
533
534/// Compare only committed local evidence with an authority head. An incoherent
535/// local ledger/coverage or local-ahead state is a fail-closed authority reload,
536/// never a client-side attempt to fill a missing sequence.
537pub const fn compare_committed_recovery(
538    local: CommittedRecoveryHead,
539    authority: AuthorityHead,
540) -> RecoveryComparison {
541    if !local.is_coherent() || local.cursor.0 > authority.event_seq.0 {
542        return RecoveryComparison::AuthorityReloadRequired;
543    }
544    if local.cursor.0 == authority.event_seq.0 {
545        RecoveryComparison::Equal
546    } else {
547        RecoveryComparison::Pull {
548            from_exclusive: local.cursor,
549            to_inclusive: authority.event_seq,
550        }
551    }
552}