Skip to main content

cli/client/
discussion_sync.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Hosted discussion sync bridge.
3//!
4//! Local discussions live in the append-only [`CollaborationStore`] op-log
5//! (`.heddle/collaboration/ops`). The hosted weft `CollaborationService` speaks
6//! a different, per-state `DiscussionsBlob` model (id-keyed discussions with a
7//! linear turn list). This module bridges the two:
8//!
9//! * **Push (write path):** after a successful `heddle push`, replay local
10//!   symbol-anchored discussion turns *we authored* to the server via the
11//!   caller-authenticated `OpenDiscussion` / `AppendTurn` RPCs (enforce-mode
12//!   signed). #549 rejects attachments in the pack, so they cannot ride it.
13//! * **Pull/clone (read path):** after a successful clone/pull, `ListByState`
14//!   the head state's discussions and materialize any turns we do not already
15//!   hold into the local op-log so `discuss list` / `discuss show` see them.
16//!
17//! ## Turn identity
18//!
19//! Local turn order is op-log materialization order; server turn order is
20//! push/append order. They diverge the moment both sides append, so a single
21//! "N turns synced" prefix count is a lie. Instead the per-repo mirror map
22//! (`.heddle/collaboration/hosted-mirror.json`) records, per discussion, an
23//! explicit set of **turn links**: a local turn id ↔ a server turn ordinal.
24//!
25//! A local turn id is `(CollabOpId, index-within-op)` — NOT the `CollabOpId`
26//! alone, because a `LegacyImported` op (a migrated blob→op-log discussion)
27//! materializes *all* its turns under one shared `CollabOpId`. Keying on the op
28//! alone would give turns 2..N the same idempotency key with different bodies
29//! (a weft `with_idempotency` conflict) and collapse them into one link, so the
30//! rest would be silently dropped on exactly the migrated repos.
31//!
32//! Push sends only turns that are self-authored AND unlinked; pull materializes
33//! only server ordinals not yet linked. Client operation ids are derived from
34//! the stable turn id, so a retry replays instead of conflicting.
35//!
36//! ## Reconciliation is author-aware, never body-alone
37//!
38//! When the mirror map is lost/rebuilt, an unlinked server turn is reconciled
39//! against an unlinked local turn only under an explicit **author** rule — never
40//! body equality alone, which would cross-link two different authors' identical
41//! bodies (`"lgtm"`, `"+1"`) and silently drop one:
42//! * (i) a turn WE pushed — the local turn is self-authored AND the server
43//!   turn's author is our own hosted username (weft stamps
44//!   `Principal::new(username, "")`); or
45//! * (ii) a turn we previously PULLED — the local op's author (written as
46//!   `Principal::new(author_name, author_email)`) and `occurred_at_ms` exactly
47//!   equal the server turn's author and `posted_at`.
48//!
49//! Anything matching neither rule materializes as a new, distinct turn.
50//! Distinguishing "a turn I pushed" from "a turn another clone of the SAME user
51//! pushed" is impossible client-side without server-minted turn ids (weft#640);
52//! rule (i) is precise across distinct hosted principals, which is the real
53//! multi-party case.
54//!
55//! The mirror is saved after **each** discussion and on the error path, with
56//! collect-and-continue per discussion — one wedged discussion (e.g. weft#638's
57//! no-HEAD `AppendTurn`) cannot abort the rest, and a mid-run failure never
58//! leaves durable writes without their mapping.
59//!
60//! Scope: discussions only; `context`/`review` share the same seam (not built).
61//! `resolve`/`reopen` are not yet mirrored (turns only).
62
63#![cfg(feature = "client")]
64
65use std::{
66    collections::{BTreeMap, HashMap, HashSet},
67    fs,
68    path::{Path, PathBuf},
69    time::{SystemTime, UNIX_EPOCH},
70};
71
72use anyhow::{Context, Result, anyhow};
73use objects::{
74    fs_atomic::write_file_atomic,
75    object::{
76        Attribution, CollabOpId, CollaborationAnchor, CollaborationIdempotencyKey,
77        CollaborationOperationBodyV1, CollaborationOperationEnvelope, DiscussionRecordId,
78        DiscussionTurnV1, MaterializedDiscussion, Principal, StateId, VisibilityTier,
79    },
80    store::ObjectStore,
81};
82use repo::{CollaborationStore, Repository, mark_legacy_discussions_migrated};
83use serde::{Deserialize, Serialize};
84
85use crate::{
86    client::HostedClient,
87    hosted_runtime::hosted::{HostedDiscussion, HostedDiscussionTurn},
88};
89
90/// Deterministic namespace for the derived client-operation-ids so a retried
91/// push replays (server-side idempotent) rather than duplicating a turn.
92const OP_NAMESPACE: uuid::Uuid = uuid::Uuid::from_u128(0x6865_6464_6c65_6469_7363_7573_7379_6e63);
93
94#[derive(Debug, Default, Serialize, Deserialize)]
95struct HostedMirror {
96    /// Server repo path → mirror state for that hosted repo.
97    #[serde(default)]
98    repos: BTreeMap<String, RepoMirror>,
99}
100
101#[derive(Debug, Default, Serialize, Deserialize)]
102struct RepoMirror {
103    #[serde(default)]
104    discussions: Vec<MirrorEntry>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108struct MirrorEntry {
109    /// Local `DiscussionRecordId` (string form).
110    local_id: String,
111    /// Server-assigned discussion id.
112    server_id: String,
113    /// Turns known to exist on BOTH sides, each carrying its identity on both.
114    #[serde(default)]
115    links: Vec<TurnLink>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119struct TurnLink {
120    /// Local turn id: `{CollabOpId}#{index-within-op}` — the stable turn
121    /// identity (unique even when a `LegacyImported` op carries many turns).
122    local_turn_id: String,
123    /// Position of the turn in the server's linear turn list.
124    server_ordinal: usize,
125}
126
127/// One local turn with the identity + attribution the sync bridge reasons over.
128struct LocalTurn {
129    turn_id: String,
130    body: String,
131    author_name: String,
132    author_email: String,
133    occurred_at_ms: i64,
134    is_self: bool,
135}
136
137fn turn_identity(op_id: &CollabOpId, index_within_op: usize) -> String {
138    format!("{}#{index_within_op}", op_id.to_string_full())
139}
140
141fn mirror_path(heddle_dir: &Path) -> PathBuf {
142    heddle_dir.join("collaboration").join("hosted-mirror.json")
143}
144
145fn load_mirror(heddle_dir: &Path) -> Result<HostedMirror> {
146    match fs::read(mirror_path(heddle_dir)) {
147        Ok(bytes) => serde_json::from_slice(&bytes).context("decode hosted discussion mirror map"),
148        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(HostedMirror::default()),
149        Err(error) => Err(error).context("read hosted discussion mirror map"),
150    }
151}
152
153fn save_mirror(heddle_dir: &Path, mirror: &HostedMirror) -> Result<()> {
154    let path = mirror_path(heddle_dir);
155    if let Some(parent) = path.parent() {
156        fs::create_dir_all(parent).context("create collaboration dir")?;
157    }
158    let bytes = serde_json::to_vec_pretty(mirror).context("encode hosted discussion mirror map")?;
159    write_file_atomic(&path, &bytes).context("write hosted discussion mirror map")?;
160    Ok(())
161}
162
163fn open_op_id(repo_path: &str, local_id: &str) -> String {
164    uuid::Uuid::new_v5(
165        &OP_NAMESPACE,
166        format!("open:{repo_path}:{local_id}").as_bytes(),
167    )
168    .to_string()
169}
170
171fn append_op_id(repo_path: &str, server_id: &str, turn_id: &str) -> String {
172    uuid::Uuid::new_v5(
173        &OP_NAMESPACE,
174        format!("append:{repo_path}:{server_id}:{turn_id}").as_bytes(),
175    )
176    .to_string()
177}
178
179fn now_ms() -> i64 {
180    SystemTime::now()
181        .duration_since(UNIX_EPOCH)
182        .map(|d| d.as_millis() as i64)
183        .unwrap_or(0)
184}
185
186/// Enumerate a materialized discussion's turns with their per-op index (turn
187/// identity), author, and whether the local principal authored them. Reads each
188/// distinct op once for its author/timestamp.
189fn collect_local_turns(
190    store: &CollaborationStore,
191    discussion: &MaterializedDiscussion,
192    self_attr: Option<&Attribution>,
193) -> Result<Vec<LocalTurn>> {
194    let mut per_op: HashMap<CollabOpId, usize> = HashMap::new();
195    let mut op_author: HashMap<CollabOpId, (Principal, i64)> = HashMap::new();
196    let mut turns = Vec::with_capacity(discussion.turns.len());
197    for (op_id, turn) in &discussion.turns {
198        let index_within_op = {
199            let slot = per_op.entry(*op_id).or_insert(0);
200            let value = *slot;
201            *slot += 1;
202            value
203        };
204        let (principal, occurred_at_ms) = match op_author.get(op_id) {
205            Some(cached) => cached.clone(),
206            None => {
207                let decoded = store
208                    .read_operation(op_id)
209                    .context("read collaboration operation")?
210                    .ok_or_else(|| anyhow!("collaboration operation {op_id} missing"))?;
211                let entry = (
212                    decoded.operation.author.principal.clone(),
213                    decoded.operation.occurred_at_ms,
214                );
215                op_author.insert(*op_id, entry.clone());
216                entry
217            }
218        };
219        // F3: fail closed — an op we cannot attribute to the local principal is
220        // NOT treated as ours (no `self_attr` ⇒ never self).
221        let is_self = self_attr.is_some_and(|attr| principals_match(&principal, &attr.principal));
222        turns.push(LocalTurn {
223            turn_id: turn_identity(op_id, index_within_op),
224            body: turn.body.clone(),
225            author_name: principal.name.clone(),
226            author_email: principal.email.clone(),
227            occurred_at_ms,
228            is_self,
229        });
230    }
231    Ok(turns)
232}
233
234/// Publish local symbol-anchored discussion turns we authored to the hosted
235/// `CollaborationService`. Saves the mirror after each discussion and continues
236/// past a per-discussion failure (warn-and-skip).
237pub async fn push_discussions(
238    repo: &Repository,
239    client: &mut HostedClient,
240    repo_path: &str,
241) -> Result<usize> {
242    let store = CollaborationStore::open(repo.heddle_dir()).context("open collaboration store")?;
243    let materialized = store
244        .materialize()
245        .context("materialize local discussions")?;
246    if materialized.discussions.is_empty() {
247        return Ok(0);
248    }
249    let self_attr = repo.get_attribution().ok();
250
251    let mut mirror = load_mirror(repo.heddle_dir())?;
252    let mut synced = 0usize;
253
254    for (discussion_id, discussion) in &materialized.discussions {
255        let result = push_one(
256            client,
257            &store,
258            repo,
259            repo_path,
260            &mut mirror,
261            self_attr.as_ref(),
262            &discussion_id.to_string(),
263            discussion,
264        )
265        .await;
266        // Persist links after every discussion — including the error path, where
267        // some turns may already be on the server — so a retry resumes cleanly.
268        save_mirror(repo.heddle_dir(), &mirror)?;
269        match result {
270            Ok(true) => synced += 1,
271            Ok(false) => {}
272            Err(error) => {
273                eprintln!(
274                    "{} hosted discussion {}: {error:#}",
275                    crate::cli::style::warn_marker(),
276                    discussion_id
277                );
278            }
279        }
280    }
281
282    Ok(synced)
283}
284
285#[allow(clippy::too_many_arguments)]
286async fn push_one(
287    client: &mut HostedClient,
288    store: &CollaborationStore,
289    repo: &Repository,
290    repo_path: &str,
291    mirror: &mut HostedMirror,
292    self_attr: Option<&Attribution>,
293    local_id: &str,
294    discussion: &MaterializedDiscussion,
295) -> Result<bool> {
296    let CollaborationAnchor::Symbol {
297        state_id,
298        path,
299        symbol,
300    } = &discussion.anchor
301    else {
302        // Only symbol-anchored discussions map to the hosted PathSymbolRef.
303        return Ok(false);
304    };
305    let Some(state) = repo
306        .store()
307        .get_state(state_id)
308        .context("load discussion anchor state")?
309    else {
310        return Ok(false);
311    };
312    let change_id = state.change_id;
313    let visibility = discussion.visibility.as_str().to_string();
314
315    let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
316    let entry_index = repo_mirror
317        .discussions
318        .iter()
319        .position(|entry| entry.local_id == local_id);
320    let linked: HashSet<String> = match entry_index {
321        Some(i) => repo_mirror.discussions[i]
322            .links
323            .iter()
324            .map(|link| link.local_turn_id.clone())
325            .collect(),
326        None => HashSet::new(),
327    };
328
329    // Candidates: turns we authored that the server does not already hold.
330    let local_turns = collect_local_turns(store, discussion, self_attr)?;
331    let mut candidates: Vec<(String, String)> = Vec::new(); // (turn_id, body)
332    let mut skipped_foreign = 0usize;
333    for turn in &local_turns {
334        if linked.contains(&turn.turn_id) {
335            continue;
336        }
337        if !turn.is_self {
338            // Never re-publish another author's turn under our identity.
339            skipped_foreign += 1;
340            continue;
341        }
342        candidates.push((turn.turn_id.clone(), turn.body.clone()));
343    }
344    if skipped_foreign > 0 {
345        // F3: surface principal drift / foreign-authored unpushed turns instead
346        // of silently producing an empty candidate set.
347        eprintln!(
348            "{} hosted discussion {local_id}: {skipped_foreign} unlinked turn(s) not attributed to the local principal were left unpublished",
349            crate::cli::style::warn_marker(),
350        );
351    }
352    if candidates.is_empty() {
353        return Ok(false);
354    }
355
356    match entry_index {
357        None => {
358            let (open_turn_id, open_body) = candidates[0].clone();
359            let hosted = client
360                .open_discussion(
361                    repo_path,
362                    change_id,
363                    path,
364                    symbol,
365                    &open_body,
366                    &visibility,
367                    open_op_id(repo_path, local_id),
368                )
369                .await
370                .with_context(|| format!("open hosted discussion for {local_id}"))?;
371            let server_id = hosted.id.clone();
372            let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
373            repo_mirror.discussions.push(MirrorEntry {
374                local_id: local_id.to_string(),
375                server_id: server_id.clone(),
376                links: vec![TurnLink {
377                    local_turn_id: open_turn_id,
378                    server_ordinal: 0,
379                }],
380            });
381            let index = repo_mirror.discussions.len() - 1;
382            for (turn_id, body) in &candidates[1..] {
383                let hosted = client
384                    .append_turn(
385                        repo_path,
386                        &server_id,
387                        body,
388                        append_op_id(repo_path, &server_id, turn_id),
389                    )
390                    .await
391                    .with_context(|| format!("append hosted turn for {local_id}"))?;
392                push_link(
393                    mirror,
394                    repo_path,
395                    index,
396                    turn_id.clone(),
397                    hosted.turns.len().saturating_sub(1),
398                );
399            }
400            Ok(true)
401        }
402        Some(index) => {
403            let server_id = mirror.repos[repo_path].discussions[index].server_id.clone();
404            for (turn_id, body) in &candidates {
405                let hosted = client
406                    .append_turn(
407                        repo_path,
408                        &server_id,
409                        body,
410                        append_op_id(repo_path, &server_id, turn_id),
411                    )
412                    .await
413                    .with_context(|| format!("append hosted turn for {local_id}"))?;
414                push_link(
415                    mirror,
416                    repo_path,
417                    index,
418                    turn_id.clone(),
419                    hosted.turns.len().saturating_sub(1),
420                );
421            }
422            Ok(true)
423        }
424    }
425}
426
427/// Fetch hosted discussions for the repository head and materialize any turns we
428/// do not already hold. Saves the mirror after each discussion and continues
429/// past a per-discussion failure.
430pub async fn pull_discussions(
431    repo: &Repository,
432    client: &mut HostedClient,
433    repo_path: &str,
434) -> Result<usize> {
435    // Hosted discussions arrive as server-minted `Discussions` state-attachments
436    // on the pulled objects. Those are the transport form of what we
437    // authoritatively re-materialize below via the CollaborationService RPCs —
438    // so claim the one-shot legacy blob->op-log migration marker to keep it from
439    // also converting them (which would duplicate every discussion and diverge
440    // on multi-turn supersede history). Fresh clones have no genuine local
441    // legacy discussions, and existing repos already hold the marker.
442    mark_legacy_discussions_migrated(repo).context("claim legacy discussion migration marker")?;
443
444    let Some(head_state) = repo.head().context("resolve repository head")? else {
445        // weft#638: a repo with no HEAD cannot resolve a state to list against.
446        return Ok(0);
447    };
448    let Some(state) = repo
449        .store()
450        .get_state(&head_state)
451        .context("load head state")?
452    else {
453        return Ok(0);
454    };
455    let change_id = state.change_id;
456
457    let hosted = client
458        .list_discussions_by_state(repo_path, change_id, "all")
459        .await
460        .context("list hosted discussions")?;
461    if hosted.is_empty() {
462        return Ok(0);
463    }
464    // Our own hosted principal name, so reconciliation can recognize the turns
465    // we pushed (weft stamps `Principal::new(username, "")`).
466    let hosted_username = client.authenticated_username();
467
468    let store = CollaborationStore::open(repo.heddle_dir()).context("open collaboration store")?;
469    let self_attr = repo.get_attribution().ok();
470    let mut mirror = load_mirror(repo.heddle_dir())?;
471    let mut changed = 0usize;
472
473    for discussion in hosted {
474        let result = pull_one(
475            &store,
476            repo_path,
477            &mut mirror,
478            head_state,
479            hosted_username.as_deref(),
480            self_attr.as_ref(),
481            &discussion,
482        );
483        save_mirror(repo.heddle_dir(), &mirror)?;
484        match result {
485            Ok(true) => changed += 1,
486            Ok(false) => {}
487            Err(error) => {
488                eprintln!(
489                    "{} hosted discussion {}: {error:#}",
490                    crate::cli::style::warn_marker(),
491                    discussion.id
492                );
493            }
494        }
495    }
496
497    Ok(changed)
498}
499
500#[allow(clippy::too_many_arguments)]
501fn pull_one(
502    store: &CollaborationStore,
503    repo_path: &str,
504    mirror: &mut HostedMirror,
505    head_state: StateId,
506    hosted_username: Option<&str>,
507    self_attr: Option<&Attribution>,
508    discussion: &HostedDiscussion,
509) -> Result<bool> {
510    if discussion.turns.is_empty() {
511        return Ok(false);
512    }
513    let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
514    let entry_index = repo_mirror
515        .discussions
516        .iter()
517        .position(|entry| entry.server_id == discussion.id);
518
519    match entry_index {
520        None => {
521            let local_id = DiscussionRecordId::generate();
522            let anchor = CollaborationAnchor::Symbol {
523                state_id: discussion.opened_against_state.unwrap_or(head_state),
524                path: discussion.file.clone(),
525                symbol: discussion.symbol.clone(),
526            };
527            let title = derive_title(&discussion.turns[0].body, &discussion.symbol);
528            let visibility = parse_visibility_token(&discussion.visibility);
529
530            let first = &discussion.turns[0];
531            let open_op = write_local_operation(
532                store,
533                local_id,
534                Vec::new(),
535                turn_attribution(first),
536                turn_ms(first),
537                CollaborationOperationBodyV1::Open {
538                    title,
539                    anchor,
540                    visibility,
541                    turn: turn_body(first)?,
542                },
543            )?;
544            // Record the mapping immediately so a mid-materialization failure
545            // resumes into the `Some` arm instead of orphaning the written ops.
546            let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
547            repo_mirror.discussions.push(MirrorEntry {
548                local_id: local_id.to_string(),
549                server_id: discussion.id.clone(),
550                links: vec![TurnLink {
551                    local_turn_id: turn_identity(&open_op, 0),
552                    server_ordinal: 0,
553                }],
554            });
555            let index = repo_mirror.discussions.len() - 1;
556
557            let mut heads = vec![open_op];
558            for (ordinal, turn) in discussion.turns.iter().enumerate().skip(1) {
559                let op_id = write_local_operation(
560                    store,
561                    local_id,
562                    heads.clone(),
563                    turn_attribution(turn),
564                    turn_ms(turn),
565                    CollaborationOperationBodyV1::AppendTurn {
566                        turn: turn_body(turn)?,
567                    },
568                )?;
569                heads = vec![op_id];
570                push_link(mirror, repo_path, index, turn_identity(&op_id, 0), ordinal);
571            }
572            Ok(true)
573        }
574        Some(index) => {
575            let local_id: DiscussionRecordId = repo_mirror.discussions[index]
576                .local_id
577                .parse()
578                .map_err(|e| anyhow!("mirror map has an invalid local discussion id: {e}"))?;
579            let linked_ordinals: HashSet<usize> = repo_mirror.discussions[index]
580                .links
581                .iter()
582                .map(|link| link.server_ordinal)
583                .collect();
584            let linked_turn_ids: HashSet<String> = repo_mirror.discussions[index]
585                .links
586                .iter()
587                .map(|link| link.local_turn_id.clone())
588                .collect();
589
590            let existing = store
591                .materialize_discussion(&local_id)
592                .context("materialize mirrored discussion")?
593                .ok_or_else(|| anyhow!("mirrored discussion {local_id} missing locally"))?;
594            let mut heads: Vec<CollabOpId> = existing.heads.iter().copied().collect();
595            // Unlinked local turns available to reconcile against server turns —
596            // author-aware only (see the module note on why body alone is wrong).
597            let mut available: Vec<LocalTurn> = collect_local_turns(store, &existing, self_attr)?
598                .into_iter()
599                .filter(|turn| !linked_turn_ids.contains(&turn.turn_id))
600                .collect();
601
602            let mut changed = false;
603            for (ordinal, server_turn) in discussion.turns.iter().enumerate() {
604                if linked_ordinals.contains(&ordinal) {
605                    continue;
606                }
607                if let Some(pos) = reconcile(&available, server_turn, hosted_username) {
608                    let local = available.swap_remove(pos);
609                    push_link(mirror, repo_path, index, local.turn_id, ordinal);
610                    changed = true;
611                    continue;
612                }
613                let op_id = write_local_operation(
614                    store,
615                    local_id,
616                    heads.clone(),
617                    turn_attribution(server_turn),
618                    turn_ms(server_turn),
619                    CollaborationOperationBodyV1::AppendTurn {
620                        turn: turn_body(server_turn)?,
621                    },
622                )?;
623                heads = vec![op_id];
624                push_link(mirror, repo_path, index, turn_identity(&op_id, 0), ordinal);
625                changed = true;
626            }
627            Ok(changed)
628        }
629    }
630}
631
632/// Match an unlinked server turn against an unlinked local turn by AUTHOR, never
633/// body alone. Returns the index into `available` when one of the two identity
634/// rules holds.
635fn reconcile(
636    available: &[LocalTurn],
637    server_turn: &HostedDiscussionTurn,
638    hosted_username: Option<&str>,
639) -> Option<usize> {
640    let server_ms = server_turn.posted_at_secs.saturating_mul(1000);
641    available.iter().position(|local| {
642        if local.body != server_turn.body {
643            return false;
644        }
645        // (i) A turn we pushed: locally self-authored AND the server stamped it
646        // with our own hosted username.
647        let pushed_by_us = local.is_self
648            && hosted_username.is_some_and(|username| username == server_turn.author_name);
649        // (ii) A turn we previously pulled: the local op copied the server
650        // author + timestamp verbatim.
651        let pulled_before = local.author_name == server_turn.author_name
652            && local.author_email == server_turn.author_email
653            && local.occurred_at_ms == server_ms;
654        pushed_by_us || pulled_before
655    })
656}
657
658fn push_link(
659    mirror: &mut HostedMirror,
660    repo_path: &str,
661    index: usize,
662    local_turn_id: String,
663    server_ordinal: usize,
664) {
665    if let Some(entry) = mirror
666        .repos
667        .get_mut(repo_path)
668        .and_then(|repo_mirror| repo_mirror.discussions.get_mut(index))
669    {
670        entry.links.push(TurnLink {
671            local_turn_id,
672            server_ordinal,
673        });
674    }
675}
676
677fn principals_match(a: &Principal, b: &Principal) -> bool {
678    a.name == b.name && a.email == b.email
679}
680
681fn write_local_operation(
682    store: &CollaborationStore,
683    discussion_id: DiscussionRecordId,
684    parents: Vec<CollabOpId>,
685    author: Attribution,
686    occurred_at_ms: i64,
687    body: CollaborationOperationBodyV1,
688) -> Result<CollabOpId> {
689    let key = CollaborationIdempotencyKey::new(uuid::Uuid::new_v4().to_string())
690        .map_err(|e| anyhow!("invalid idempotency key: {e}"))?;
691    let operation = CollaborationOperationEnvelope::new(
692        discussion_id,
693        parents,
694        key,
695        author,
696        occurred_at_ms,
697        body,
698    )
699    .map_err(|e| anyhow!("build collaboration operation: {e}"))?;
700    Ok(store
701        .write_operation(&operation)
702        .context("write collaboration operation")?
703        .operation_id)
704}
705
706fn turn_body(turn: &HostedDiscussionTurn) -> Result<DiscussionTurnV1> {
707    DiscussionTurnV1::new(turn.body.clone()).map_err(|e| anyhow!("invalid discussion turn: {e}"))
708}
709
710fn turn_attribution(turn: &HostedDiscussionTurn) -> Attribution {
711    Attribution::human(Principal::new(
712        turn.author_name.clone(),
713        turn.author_email.clone(),
714    ))
715}
716
717fn turn_ms(turn: &HostedDiscussionTurn) -> i64 {
718    if turn.posted_at_secs > 0 {
719        turn.posted_at_secs.saturating_mul(1000)
720    } else {
721        now_ms()
722    }
723}
724
725fn derive_title(body: &str, symbol: &str) -> String {
726    body.lines()
727        .map(str::trim)
728        .find(|line| !line.is_empty())
729        .unwrap_or(symbol)
730        .to_string()
731}
732
733fn parse_visibility_token(token: &str) -> VisibilityTier {
734    match token {
735        "public" => VisibilityTier::Public,
736        "internal" => VisibilityTier::Internal,
737        "team_scoped" => VisibilityTier::TeamScoped {
738            team_id: String::new(),
739        },
740        "restricted" => VisibilityTier::Restricted {
741            scope_label: String::new(),
742        },
743        "private" => VisibilityTier::Private {
744            scope_label: String::new(),
745        },
746        _ => VisibilityTier::Internal,
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use objects::object::{
753        Attribution, CollaborationAnchor, CollaborationIdempotencyKey,
754        CollaborationOperationBodyV1, CollaborationOperationEnvelope, ContentHash,
755        DiscussionRecordId, DiscussionTurnV1, LegacyDiscussionId, LegacyDiscussionResolutionV1,
756        LegacySourceLocator, Principal, StateAttachmentId, StateId, VisibilityTier,
757    };
758
759    use super::*;
760
761    fn local(
762        body: &str,
763        author_name: &str,
764        author_email: &str,
765        is_self: bool,
766        ms: i64,
767    ) -> LocalTurn {
768        LocalTurn {
769            turn_id: format!("co-{author_name}#0"),
770            body: body.to_string(),
771            author_name: author_name.to_string(),
772            author_email: author_email.to_string(),
773            occurred_at_ms: ms,
774            is_self,
775        }
776    }
777
778    fn server(
779        body: &str,
780        author_name: &str,
781        author_email: &str,
782        posted_at_secs: i64,
783    ) -> HostedDiscussionTurn {
784        HostedDiscussionTurn {
785            author_name: author_name.to_string(),
786            author_email: author_email.to_string(),
787            body: body.to_string(),
788            posted_at_secs,
789        }
790    }
791
792    // F1: identical bodies from DIFFERENT authors must NOT reconcile — the
793    // server turn materializes as its own distinct turn; the local turn is left
794    // unlinked (so push will still publish it). Body equality alone never links.
795    #[test]
796    fn reconcile_rejects_identical_body_across_authors() {
797        // A's own unpushed "lgtm" (local principal "alice", not yet on server).
798        let available = vec![local("lgtm", "alice", "alice@x", true, 111)];
799        // B pushed "lgtm" (server stamped it "bob"); our hosted username is "alice".
800        let st = server("lgtm", "bob", "", 5);
801        assert_eq!(
802            reconcile(&available, &st, Some("alice")),
803            None,
804            "a self turn must not link to a DIFFERENT author's identical body (rule i needs our username to be the server author)"
805        );
806    }
807
808    // F1 rule (i): a turn WE pushed (self-authored locally, stamped with our
809    // hosted username on the server) reconciles.
810    #[test]
811    fn reconcile_links_turn_we_pushed() {
812        let available = vec![local("ship it", "alice-local", "alice@x", true, 111)];
813        let st = server("ship it", "alice", "", 9); // server stamped our hosted username
814        assert_eq!(reconcile(&available, &st, Some("alice")), Some(0));
815    }
816
817    // F1 rule (ii): a turn we previously PULLED (local op copied the server
818    // author + posted_at verbatim) reconciles.
819    #[test]
820    fn reconcile_links_turn_we_pulled() {
821        let available = vec![local("+1", "bob", "bob@x", false, 7000)]; // occurred = 7 * 1000
822        let st = server("+1", "bob", "bob@x", 7);
823        assert_eq!(reconcile(&available, &st, Some("alice")), Some(0));
824        // Same body, wrong author → no match.
825        let st_other = server("+1", "carol", "carol@x", 7);
826        assert_eq!(reconcile(&available, &st_other, Some("alice")), None);
827    }
828
829    // F2: a LegacyImported op carries N turns under ONE CollabOpId. They must
830    // yield N DISTINCT turn ids and thus N DISTINCT append idempotency keys —
831    // otherwise weft dedup conflicts on turn 3 and turns 3..N are dropped.
832    #[test]
833    fn legacy_imported_multi_turn_op_has_distinct_identities_and_keys() {
834        let temp = tempfile::TempDir::new().unwrap();
835        let store = CollaborationStore::open(temp.path()).unwrap();
836        let discussion_id: DiscussionRecordId =
837            "disc-018f47ea-4a54-7c89-b012-3456789abcde".parse().unwrap();
838        let author = Attribution::human(Principal::new("Importer", "importer@x"));
839        let anchor = CollaborationAnchor::Symbol {
840            state_id: StateId::from_bytes([1; 32]),
841            path: "src/lib.rs".to_string(),
842            symbol: "run".to_string(),
843        };
844        let op = CollaborationOperationEnvelope::new(
845            discussion_id,
846            Vec::new(),
847            CollaborationIdempotencyKey::new("legacy-1").unwrap(),
848            author.clone(),
849            1_000,
850            CollaborationOperationBodyV1::LegacyImported {
851                source: LegacySourceLocator::new(
852                    StateId::from_bytes([1; 32]),
853                    StateAttachmentId::from_hash(ContentHash::from_bytes([4; 32])),
854                    ContentHash::from_bytes([5; 32]),
855                ),
856                legacy_discussion_id: LegacyDiscussionId::new("legacy-1".to_string()).unwrap(),
857                aliases: Vec::new(),
858                title: "run".to_string(),
859                anchor,
860                visibility: VisibilityTier::Internal,
861                turns: vec![
862                    DiscussionTurnV1::new("turn one").unwrap(),
863                    DiscussionTurnV1::new("turn two").unwrap(),
864                    DiscussionTurnV1::new("turn three").unwrap(),
865                ],
866                resolution: LegacyDiscussionResolutionV1::Open,
867            },
868        )
869        .unwrap();
870        store.write_operation(&op).unwrap();
871
872        let materialized = store
873            .materialize_discussion(&discussion_id)
874            .unwrap()
875            .unwrap();
876        assert_eq!(materialized.turns.len(), 3);
877        let self_attr = Attribution::human(Principal::new("Importer", "importer@x"));
878        let turns = collect_local_turns(&store, &materialized, Some(&self_attr)).unwrap();
879
880        // All three turns share ONE CollabOpId but MUST have distinct ids…
881        let ids: HashSet<&String> = turns.iter().map(|t| &t.turn_id).collect();
882        assert_eq!(ids.len(), 3, "multi-turn op must yield distinct turn ids");
883        // …and distinct append idempotency keys.
884        let keys: HashSet<String> = turns
885            .iter()
886            .map(|t| append_op_id("ns/repo", "server-1", &t.turn_id))
887            .collect();
888        assert_eq!(
889            keys.len(),
890            3,
891            "each turn must get a distinct idempotency key"
892        );
893        // All authored by the importer (self) → all are push candidates.
894        assert!(turns.iter().all(|t| t.is_self));
895    }
896
897    // F3: no local principal ⇒ turns are NOT treated as ours (fail closed).
898    #[test]
899    fn collect_local_turns_fails_closed_without_self_principal() {
900        let temp = tempfile::TempDir::new().unwrap();
901        let store = CollaborationStore::open(temp.path()).unwrap();
902        let discussion_id = DiscussionRecordId::generate();
903        let op = CollaborationOperationEnvelope::new(
904            discussion_id,
905            Vec::new(),
906            CollaborationIdempotencyKey::new("k").unwrap(),
907            Attribution::human(Principal::new("Ada", "ada@x")),
908            1,
909            CollaborationOperationBodyV1::Open {
910                title: "t".to_string(),
911                anchor: CollaborationAnchor::Symbol {
912                    state_id: StateId::from_bytes([2; 32]),
913                    path: "a.rs".to_string(),
914                    symbol: "a".to_string(),
915                },
916                visibility: VisibilityTier::Internal,
917                turn: DiscussionTurnV1::new("hi").unwrap(),
918            },
919        )
920        .unwrap();
921        store.write_operation(&op).unwrap();
922        let materialized = store
923            .materialize_discussion(&discussion_id)
924            .unwrap()
925            .unwrap();
926        let turns = collect_local_turns(&store, &materialized, None).unwrap();
927        assert!(
928            turns.iter().all(|t| !t.is_self),
929            "with no local principal, no turn may be classified as ours"
930        );
931    }
932}