Skip to main content

ai_crew_sync/store/
conversations.rs

1//! Conversations: addressed threads with per-recipient receipts.
2//!
3//! Every function here answers one of two questions before it does anything
4//! else: *may this caller see this thread?* and *may this caller change it?*
5//! Both are answered from the credential and from rows, never from a label a
6//! caller supplied. A project grant is a row. A private membership is a row.
7//! A role a session published for discovery grants nothing at all.
8//!
9//! Three properties the implementation exists to keep (ADR 0001):
10//!
11//! 1. **Recipients are snapshotted at acceptance.** `message_recipients` is
12//!    written with the message, so a later join never enters an older
13//!    message's denominator and a removal never erases a receipt.
14//! 2. **Receipts are observations, never inferences.** Reading a thread does
15//!    not acknowledge it; a cursor is not a person. `presented_at` stays null
16//!    where the host cannot confirm injection, because unknown is not "no".
17//! 3. **The exceptional paths are explicit and audited.** A membership
18//!    transfer needs the target to accept; owner recovery needs every session
19//!    of that agent to be closed, and is read-only.
20
21use sqlx::PgPool;
22use uuid::Uuid;
23
24use crate::{
25    auth::AuthCtx,
26    error::{BusError, BusResult},
27    model::{
28        ConversationActivity, ConversationInfo, ConversationMessage, ConversationRead,
29        MembershipInfo, MessageReceipts, ProjectInfo, ReceiptInfo, SentMessage, TransferResult, ts,
30        ts_opt,
31    },
32    store::backend::MessagingBackend,
33};
34
35/// Longest conversation title and project name. Identifiers people type.
36pub const MAX_TITLE_BYTES: usize = 200;
37/// Longest message body. The same ceiling channel messages have.
38pub const MAX_BODY_BYTES: usize = 1024 * 1024;
39/// Most messages one read returns.
40pub const MAX_PAGE: i64 = 200;
41pub const DEFAULT_PAGE: i64 = 50;
42/// Members one conversation may hold. A thread is addressed, not broadcast.
43pub const MAX_MEMBERS: i64 = 200;
44
45/// Refuse early and clearly when the team has not turned conversations on.
46/// The tools are advertised only to teams that have, but a direct call must
47/// be refused too: a catalogue is not an authorization boundary.
48/// Whether this caller's team has conversations turned on. Used to decide
49/// what to advertise; `require_capability` is what decides what to allow.
50pub async fn capability_enabled(pool: &PgPool, auth: &AuthCtx) -> BusResult<bool> {
51    let enabled: Option<(bool,)> =
52        sqlx::query_as("SELECT conversations_enabled FROM teams WHERE id = $1")
53            .bind(auth.team_id)
54            .fetch_optional(pool)
55            .await?;
56    Ok(matches!(enabled, Some((true,))))
57}
58
59pub async fn require_capability(pool: &PgPool, auth: &AuthCtx) -> BusResult<()> {
60    let enabled: Option<(bool,)> =
61        sqlx::query_as("SELECT conversations_enabled FROM teams WHERE id = $1")
62            .bind(auth.team_id)
63            .fetch_optional(pool)
64            .await?;
65    match enabled {
66        Some((true,)) => Ok(()),
67        _ => Err(BusError::Forbidden(
68            "conversations are not enabled for this team. An operator turns them on with \
69             `ai-crew-sync team capability --team <slug> --conversations on`; until then use \
70             channels and direct messages."
71                .to_owned(),
72        )),
73    }
74}
75
76fn address_of(agent: &str, session: &str) -> String {
77    if session.is_empty() {
78        agent.to_owned()
79    } else {
80        format!("{agent}/{session}")
81    }
82}
83
84fn check_title(field: &str, raw: &str) -> BusResult<String> {
85    let value = raw.trim();
86    if value.is_empty() {
87        return Err(BusError::invalid(format!("{field} cannot be empty")));
88    }
89    if value.len() > MAX_TITLE_BYTES {
90        return Err(BusError::invalid(format!(
91            "{field} is {} bytes; the limit is {MAX_TITLE_BYTES}",
92            value.len()
93        )));
94    }
95    if value.chars().any(char::is_control) {
96        return Err(BusError::invalid(format!(
97            "{field} must not contain control characters"
98        )));
99    }
100    Ok(value.to_owned())
101}
102
103// ----------------------------------------------------------------- projects --
104
105pub async fn create_project(pool: &PgPool, auth: &AuthCtx, name: &str) -> BusResult<ProjectInfo> {
106    require_capability(pool, auth).await?;
107    let name = crate::store::presence::normalize_label("project name", name)?;
108    if name.is_empty() {
109        return Err(BusError::invalid("a project name is required"));
110    }
111    let mut tx = pool.begin().await?;
112    crate::store::sessions::guard(&mut tx, auth).await?;
113    let row: Option<(Uuid,)> = sqlx::query_as(
114        "INSERT INTO projects (team_id, name, created_by) VALUES ($1, $2, $3)
115         ON CONFLICT (team_id, name) DO NOTHING RETURNING id",
116    )
117    .bind(auth.team_id)
118    .bind(&name)
119    .bind(auth.agent_id)
120    .fetch_optional(&mut *tx)
121    .await?;
122    let id = match row {
123        Some((id,)) => {
124            // The creator has access to what they created; everyone else
125            // needs a grant.
126            sqlx::query(
127                "INSERT INTO project_agent_access (project_id, agent_id, team_id, granted_by)
128                 VALUES ($1, $2, $3, $2) ON CONFLICT DO NOTHING",
129            )
130            .bind(id)
131            .bind(auth.agent_id)
132            .bind(auth.team_id)
133            .execute(&mut *tx)
134            .await?;
135            audit(
136                &mut tx,
137                auth,
138                None,
139                "project.create",
140                Some(id),
141                serde_json::json!({ "name": name }),
142            )
143            .await?;
144            id
145        }
146        None => {
147            // Already exists. Say so rather than silently adopting it: a
148            // caller that expected to create it should know it did not.
149            let (id,): (Uuid,) =
150                sqlx::query_as("SELECT id FROM projects WHERE team_id = $1 AND name = $2")
151                    .bind(auth.team_id)
152                    .bind(&name)
153                    .fetch_one(&mut *tx)
154                    .await?;
155            id
156        }
157    };
158    tx.commit().await?;
159    project_info(pool, auth, id).await
160}
161
162/// A project as someone with access sees it. Refused to anyone without.
163pub async fn project_info(pool: &PgPool, auth: &AuthCtx, id: Uuid) -> BusResult<ProjectInfo> {
164    let row: Option<(
165        String,
166        Option<chrono::DateTime<chrono::Utc>>,
167        chrono::DateTime<chrono::Utc>,
168    )> = sqlx::query_as(
169        "SELECT p.name, p.archived_at, p.created_at
170               FROM projects p
171              WHERE p.id = $1 AND p.team_id = $2
172                AND EXISTS (SELECT 1 FROM project_agent_access a
173                             WHERE a.project_id = p.id AND a.agent_id = $3)",
174    )
175    .bind(id)
176    .bind(auth.team_id)
177    .bind(auth.agent_id)
178    .fetch_optional(pool)
179    .await?;
180    let Some((name, archived_at, created_at)) = row else {
181        return Err(BusError::not_found(
182            "no such project, or you have no access to it",
183        ));
184    };
185    let members: Vec<(String,)> = sqlx::query_as(
186        "SELECT ag.name FROM project_agent_access a
187           JOIN agents ag ON ag.id = a.agent_id
188          WHERE a.project_id = $1 ORDER BY ag.name",
189    )
190    .bind(id)
191    .fetch_all(pool)
192    .await?;
193    Ok(ProjectInfo {
194        id: id.to_string(),
195        name,
196        members: members.into_iter().map(|m| m.0).collect(),
197        archived: archived_at.is_some(),
198        created_at: ts(created_at),
199    })
200}
201
202/// Grant or revoke a teammate's access to a project. Only someone who
203/// already has access may extend it, which keeps the grant chain inside the
204/// project rather than making it an administrative power.
205pub async fn set_project_access(
206    pool: &PgPool,
207    auth: &AuthCtx,
208    project: &str,
209    agent: &str,
210    grant: bool,
211) -> BusResult<ProjectInfo> {
212    require_capability(pool, auth).await?;
213    let project_id = project_id_for(pool, auth, project).await?;
214    let target = crate::store::agent_id_by_name(pool, auth.team_id, agent).await?;
215    let mut tx = pool.begin().await?;
216    crate::store::sessions::guard(&mut tx, auth).await?;
217    // The caller's own grant is re-read and locked *inside* this
218    // transaction. Checked only before it, a revoke can commit in between
219    // and the revoked caller still hands access to somebody else — while
220    // the tool promises a revocation takes effect immediately.
221    let still_mine: Option<(Uuid,)> = sqlx::query_as(
222        "SELECT project_id FROM project_agent_access
223          WHERE project_id = $1 AND agent_id = $2 FOR UPDATE",
224    )
225    .bind(project_id)
226    .bind(auth.agent_id)
227    .fetch_optional(&mut *tx)
228    .await?;
229    if still_mine.is_none() {
230        return Err(BusError::Forbidden(
231            "your access to this project has been revoked, so you cannot change anyone else's. Nothing was written."
232                .to_owned(),
233        ));
234    }
235    if grant {
236        sqlx::query(
237            "INSERT INTO project_agent_access (project_id, agent_id, team_id, granted_by)
238             VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
239        )
240        .bind(project_id)
241        .bind(target)
242        .bind(auth.team_id)
243        .bind(auth.agent_id)
244        .execute(&mut *tx)
245        .await?;
246    } else {
247        if target == auth.agent_id {
248            return Err(BusError::invalid(
249                "you cannot revoke your own access; ask another member to do it",
250            ));
251        }
252        sqlx::query("DELETE FROM project_agent_access WHERE project_id = $1 AND agent_id = $2")
253            .bind(project_id)
254            .bind(target)
255            .execute(&mut *tx)
256            .await?;
257    }
258    audit(
259        &mut tx,
260        auth,
261        None,
262        if grant {
263            "project.grant"
264        } else {
265            "project.revoke"
266        },
267        Some(target),
268        serde_json::json!({ "agent": agent, "project": project }),
269    )
270    .await?;
271    tx.commit().await?;
272    project_info(pool, auth, project_id).await
273}
274
275/// Resolve a project the caller has access to, by name or id.
276async fn project_id_for(pool: &PgPool, auth: &AuthCtx, project: &str) -> BusResult<Uuid> {
277    let by_id = project.parse::<Uuid>().ok();
278    let row: Option<(Uuid,)> = sqlx::query_as(
279        "SELECT p.id FROM projects p
280          WHERE p.team_id = $1
281            AND ($2::uuid IS NOT NULL AND p.id = $2 OR p.name = $3)
282            AND EXISTS (SELECT 1 FROM project_agent_access a
283                         WHERE a.project_id = p.id AND a.agent_id = $4)",
284    )
285    .bind(auth.team_id)
286    .bind(by_id)
287    .bind(project.trim().to_lowercase())
288    .bind(auth.agent_id)
289    .fetch_optional(pool)
290    .await?;
291    row.map(|r| r.0).ok_or_else(|| {
292        BusError::not_found(format!(
293            "no project '{project}' you have access to. Create it with create_project, or ask \
294             a member to grant you access."
295        ))
296    })
297}
298
299pub async fn list_projects(pool: &PgPool, auth: &AuthCtx) -> BusResult<Vec<ProjectInfo>> {
300    require_capability(pool, auth).await?;
301    let ids: Vec<(Uuid,)> = sqlx::query_as(
302        "SELECT p.id FROM projects p
303           JOIN project_agent_access a ON a.project_id = p.id AND a.agent_id = $2
304          WHERE p.team_id = $1 ORDER BY p.name",
305    )
306    .bind(auth.team_id)
307    .bind(auth.agent_id)
308    .fetch_all(pool)
309    .await?;
310    let mut out = Vec::with_capacity(ids.len());
311    for (id,) in ids {
312        out.push(project_info(pool, auth, id).await?);
313    }
314    Ok(out)
315}
316
317// ------------------------------------------------------------------ access --
318
319/// What a caller may do in a conversation, resolved from rows alone.
320#[derive(Clone, Debug)]
321pub struct Access {
322    pub conversation_id: Uuid,
323    pub visibility: String,
324    pub archived: bool,
325    pub last_seq: i64,
326    pub title: String,
327    /// Present when the caller is (or was) a member of this thread.
328    pub membership: Option<Membership>,
329    /// True when the caller can read by project access rather than
330    /// membership.
331    pub by_project: bool,
332}
333
334#[derive(Clone, Debug)]
335pub struct Membership {
336    pub id: Uuid,
337    pub role: String,
338    pub state: String,
339    pub history_from_seq: Option<i64>,
340}
341
342impl Access {
343    /// A project thread is open to a caller only while the caller has the
344    /// project. A seat in it is not a second door: the grant governs the
345    /// project, revoking it is promised to take effect at once, including
346    /// for threads being read, and an active membership that outlived the
347    /// grant used to read on regardless.
348    fn project_open(&self) -> bool {
349        self.visibility != "project" || self.by_project
350    }
351
352    pub fn can_read(&self) -> bool {
353        self.project_open()
354            && (self.by_project
355                || matches!(
356                    self.membership.as_ref().map(|m| m.state.as_str()),
357                    Some("active") | Some("invited")
358                ))
359    }
360    /// Reading the thread's *contents*, which an invitation does not grant.
361    ///
362    /// An invitee can see that a thread exists and who invited them — that
363    /// is what they are deciding about — and nothing that was said in it
364    /// until they accept. Otherwise an invitation would be a way to read a
365    /// private thread without ever joining it, which is the opposite of
366    /// what "a thread cannot conscript a window" means.
367    pub fn can_read_messages(&self) -> bool {
368        self.project_open()
369            && (self.by_project
370                || matches!(
371                    self.membership.as_ref().map(|m| m.state.as_str()),
372                    Some("active")
373                ))
374    }
375    /// Observers read and acknowledge; they do not write.
376    pub fn can_send(&self) -> bool {
377        self.project_open()
378            && matches!(
379                self.membership
380                    .as_ref()
381                    .map(|m| (m.state.as_str(), m.role.as_str())),
382                Some(("active", "owner"))
383                    | Some(("active", "moderator"))
384                    | Some(("active", "participant"))
385            )
386    }
387    pub fn can_moderate(&self) -> bool {
388        self.project_open()
389            && matches!(
390                self.membership
391                    .as_ref()
392                    .map(|m| (m.state.as_str(), m.role.as_str())),
393                Some(("active", "owner")) | Some(("active", "moderator"))
394            )
395    }
396    pub fn is_owner(&self) -> bool {
397        self.project_open()
398            && matches!(
399                self.membership
400                    .as_ref()
401                    .map(|m| (m.state.as_str(), m.role.as_str())),
402                Some(("active", "owner"))
403            )
404    }
405}
406
407/// Resolve what this caller may do with `conversation`, by id.
408pub async fn access(pool: &PgPool, auth: &AuthCtx, conversation: Uuid) -> BusResult<Access> {
409    let row: Option<(
410        String,
411        String,
412        Option<chrono::DateTime<chrono::Utc>>,
413        i64,
414        Option<Uuid>,
415        bool,
416    )> = sqlx::query_as(
417        "SELECT c.visibility, c.title, c.archived_at, c.last_seq, c.project_id,
418                COALESCE(
419                  -- Only a project-visible thread is readable by project
420                  -- grant. A private thread that also names a project is
421                  -- still private: the grant governs the project, not
422                  -- everything that mentions it.
423                  c.visibility = 'project' AND c.project_id IS NOT NULL AND EXISTS (
424                    SELECT 1 FROM project_agent_access a
425                     WHERE a.project_id = c.project_id AND a.agent_id = $3), false)
426           FROM conversations c
427          WHERE c.id = $1 AND c.team_id = $2",
428    )
429    .bind(conversation)
430    .bind(auth.team_id)
431    .bind(auth.agent_id)
432    .fetch_optional(pool)
433    .await?;
434    let Some((visibility, title, archived_at, last_seq, _project, by_project)) = row else {
435        // Same answer for "not in this team" and "does not exist": a caller
436        // must not learn that another team has a conversation with this id.
437        return Err(BusError::not_found("no such conversation"));
438    };
439    // A seat taken by a registered window belongs to that window, and the
440    // label in a header is a name rather than a proof. The parent agent
441    // token cannot sit in its own window's chair — that is what the audited
442    // `recover_conversation_history` exists for. A legacy seat (session_id
443    // NULL) keeps matching by label, as it always did.
444    let membership: Option<(Uuid, String, String, Option<i64>)> = sqlx::query_as(
445        "SELECT id, role, state, history_from_seq FROM conversation_memberships
446          WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
447            AND (session_id IS NULL OR $4::uuid IS NOT NULL)",
448    )
449    .bind(conversation)
450    .bind(auth.agent_id)
451    .bind(&auth.session)
452    .bind(auth.session_id)
453    .fetch_optional(pool)
454    .await?;
455    Ok(Access {
456        conversation_id: conversation,
457        visibility,
458        title,
459        archived: archived_at.is_some(),
460        last_seq,
461        by_project,
462        membership: membership.map(|(id, role, state, history_from_seq)| Membership {
463            id,
464            role,
465            state,
466            history_from_seq,
467        }),
468    })
469}
470
471/// Re-read the caller's project grant for `conversation` **inside** `tx`,
472/// share-locked. Every mutation of a project thread admits its caller with
473/// `access()` before its transaction opens; a revocation that commits in
474/// between would otherwise let the revoked caller write once more. Locked
475/// here, a revocation that committed first is seen, and one in flight waits
476/// for this transaction to land or be refused (`set_project_access` deletes
477/// the row, which waits on the share lock). Not a project thread: open.
478async fn require_project_open(
479    tx: &mut sqlx::PgConnection,
480    auth: &AuthCtx,
481    conversation: Uuid,
482) -> BusResult<()> {
483    let open: Option<(bool,)> = sqlx::query_as(
484        "SELECT c.visibility <> 'project'
485                OR EXISTS (SELECT 1 FROM project_agent_access a
486                            WHERE a.project_id = c.project_id AND a.agent_id = $2
487                            FOR SHARE)
488           FROM conversations c WHERE c.id = $1 AND c.team_id = $3",
489    )
490    .bind(conversation)
491    .bind(auth.agent_id)
492    .bind(auth.team_id)
493    .fetch_optional(&mut *tx)
494    .await?;
495    match open {
496        Some((true,)) => Ok(()),
497        Some((false,)) => Err(BusError::Forbidden(
498            "your access to this project has been revoked, so this conversation is closed to \
499             you. Nothing was written; ask someone with access to grant it again."
500                .to_owned(),
501        )),
502        None => Err(BusError::not_found("no such conversation")),
503    }
504}
505
506/// Resolve and require read access in one step.
507/// What a retry is compared against. The body is staged in this row only
508/// until its backend confirms it; the digest stays.
509pub fn body_digest(body: &str) -> String {
510    use sha2::{Digest, Sha256};
511    hex::encode(Sha256::digest(body.as_bytes()))
512}
513
514/// Refuse a content read to a seat that has not been accepted.
515fn require_accepted(a: &Access) -> BusResult<()> {
516    if a.can_read_messages() {
517        return Ok(());
518    }
519    Err(BusError::Forbidden(
520        "you have been invited to this conversation and have not accepted. Call \
521         join_conversation first; an invitation is not membership, and it does not read \
522         what was said before you answered it."
523            .to_owned(),
524    ))
525}
526
527async fn readable(pool: &PgPool, auth: &AuthCtx, conversation: Uuid) -> BusResult<Access> {
528    let a = access(pool, auth, conversation).await?;
529    if !a.can_read() {
530        // A private conversation must not confirm its own existence to a
531        // non-member.
532        return Err(BusError::not_found("no such conversation"));
533    }
534    Ok(a)
535}
536
537async fn audit(
538    tx: &mut sqlx::PgConnection,
539    auth: &AuthCtx,
540    conversation: Option<Uuid>,
541    action: &str,
542    subject: Option<Uuid>,
543    detail: serde_json::Value,
544) -> BusResult<()> {
545    sqlx::query(
546        "INSERT INTO conversation_audit
547            (team_id, conversation_id, actor_agent, actor_session, action, subject, detail)
548         VALUES ($1, $2, $3, $4, $5, $6, $7)",
549    )
550    .bind(auth.team_id)
551    .bind(conversation)
552    .bind(auth.agent_id)
553    .bind(&auth.session)
554    .bind(action)
555    .bind(subject)
556    .bind(detail)
557    .execute(tx)
558    .await?;
559    Ok(())
560}
561
562// ------------------------------------------------------------ conversations --
563
564pub struct CreateInput {
565    pub title: String,
566    pub project: Option<String>,
567    pub private: bool,
568    pub invite: Vec<String>,
569}
570
571pub async fn create_conversation(
572    pool: &PgPool,
573    auth: &AuthCtx,
574    input: CreateInput,
575) -> BusResult<ConversationInfo> {
576    require_capability(pool, auth).await?;
577    let title = check_title("title", &input.title)?;
578    let project_id = match (&input.project, input.private) {
579        (Some(_), true) => {
580            // Both would mean "members only" and "everyone with the project
581            // grant" at once, and one of the two would be a lie to whoever
582            // spoke in it.
583            return Err(BusError::invalid(
584                "a conversation is either private or visible to a project, not both. Drop `project` for a members-only thread, or `private` for a project one.",
585            ));
586        }
587        (Some(p), false) => Some(project_id_for(pool, auth, p).await?),
588        (None, true) => None,
589        (None, false) => {
590            return Err(BusError::invalid(
591                "a project conversation needs `project`; pass `private: true` for a thread \
592                 only its members can see",
593            ));
594        }
595    };
596    let visibility = if input.private { "private" } else { "project" };
597
598    // Resolve the invitees before opening the transaction: a name that does
599    // not exist should not leave a half-made thread.
600    let mut invites = Vec::new();
601    for raw in &input.invite {
602        let (agent, session) = crate::store::messaging::parse_address(raw)?;
603        let agent_id = crate::store::agent_id_by_name(pool, auth.team_id, &agent).await?;
604        invites.push((agent_id, session.unwrap_or_default(), raw.clone()));
605    }
606    if invites.len() as i64 > MAX_MEMBERS {
607        return Err(BusError::invalid(format!(
608            "a conversation holds at most {MAX_MEMBERS} members"
609        )));
610    }
611
612    let mut tx = pool.begin().await?;
613    crate::store::sessions::guard(&mut tx, auth).await?;
614    // The creator's grant, re-read and share-locked here: a project thread
615    // is not opened by someone whose access was revoked while this request
616    // was on its way.
617    if let Some(project_id) = project_id {
618        let granted: Option<(Uuid,)> = sqlx::query_as(
619            "SELECT project_id FROM project_agent_access
620              WHERE project_id = $1 AND agent_id = $2 FOR SHARE",
621        )
622        .bind(project_id)
623        .bind(auth.agent_id)
624        .fetch_optional(&mut *tx)
625        .await?;
626        if granted.is_none() {
627            return Err(BusError::Forbidden(
628                "your access to this project has been revoked, so you cannot open a thread in \
629                 it. Nothing was written."
630                    .to_owned(),
631            ));
632        }
633    }
634    // The backend is the team's current routing, captured at creation: a
635    // thread never changes backend once it holds messages, because half a
636    // history in each place is the one shape nobody can read.
637    let (id,): (Uuid,) = sqlx::query_as(
638        "INSERT INTO conversations
639            (team_id, project_id, visibility, title, created_by, created_session,
640             backend, publication)
641         SELECT $1, $2, $3, $4, $5, $6, t.default_backend,
642                CASE WHEN t.default_backend = 'postgres' THEN 'sync' ELSE 'outbox' END
643           FROM teams t WHERE t.id = $1
644         RETURNING id",
645    )
646    .bind(auth.team_id)
647    .bind(project_id)
648    .bind(visibility)
649    .bind(&title)
650    .bind(auth.agent_id)
651    .bind(&auth.session)
652    .fetch_one(&mut *tx)
653    .await?;
654
655    // The creator owns it, from the beginning.
656    sqlx::query(
657        "INSERT INTO conversation_memberships
658            (conversation_id, agent_id, session, session_id, role, state, history_from_seq,
659             invited_by, accepted_at)
660         VALUES ($1, $2, $3, $4, 'owner', 'active', NULL, $2, now())",
661    )
662    .bind(id)
663    .bind(auth.agent_id)
664    .bind(&auth.session)
665    .bind(auth.session_id)
666    .execute(&mut *tx)
667    .await?;
668
669    for (agent_id, session, raw) in &invites {
670        sqlx::query(
671            "INSERT INTO conversation_memberships
672                (conversation_id, agent_id, session, role, state, history_from_seq, invited_by)
673             VALUES ($1, $2, $3, 'participant', 'invited', 0, $4)
674             ON CONFLICT (conversation_id, agent_id, session) DO NOTHING",
675        )
676        .bind(id)
677        .bind(agent_id)
678        .bind(session)
679        .bind(auth.agent_id)
680        .execute(&mut *tx)
681        .await?;
682        audit(
683            &mut tx,
684            auth,
685            Some(id),
686            "member.invite",
687            Some(*agent_id),
688            serde_json::json!({ "address": raw }),
689        )
690        .await?;
691    }
692    audit(
693        &mut tx,
694        auth,
695        Some(id),
696        "conversation.create",
697        Some(id),
698        serde_json::json!({ "title": title, "visibility": visibility }),
699    )
700    .await?;
701    tx.commit().await?;
702    conversation_info(pool, auth, id).await
703}
704
705pub async fn conversation_info(
706    pool: &PgPool,
707    auth: &AuthCtx,
708    id: Uuid,
709) -> BusResult<ConversationInfo> {
710    let a = readable(pool, auth, id).await?;
711    let row: (
712        String,
713        String,
714        Option<String>,
715        String,
716        chrono::DateTime<chrono::Utc>,
717        Option<chrono::DateTime<chrono::Utc>>,
718        i64,
719    ) = sqlx::query_as(
720        "SELECT c.title, c.visibility, p.name, ag.name, c.created_at, c.archived_at, c.last_seq
721           FROM conversations c
722           LEFT JOIN projects p ON p.id = c.project_id
723           JOIN agents ag ON ag.id = c.created_by
724          WHERE c.id = $1",
725    )
726    .bind(id)
727    .fetch_one(pool)
728    .await?;
729    // Who is in the thread is the members' business. A project grant lets
730    // you read a project thread; it does not tell you which windows of which
731    // people are in it, with their roles and history boundaries.
732    let members = if a.membership.is_some() {
733        members_of(pool, id).await?
734    } else {
735        Vec::new()
736    };
737    let mine = a.membership.as_ref().and_then(|m| {
738        members
739            .iter()
740            .find(|info| info.membership_id == m.id.to_string())
741            .cloned()
742    });
743    Ok(ConversationInfo {
744        id: id.to_string(),
745        title: row.0,
746        visibility: row.1,
747        project: row.2,
748        created_by: row.3,
749        created_at: ts(row.4),
750        archived: row.5.is_some(),
751        last_seq: row.6,
752        membership: mine,
753        members,
754    })
755}
756
757async fn members_of(pool: &PgPool, id: Uuid) -> BusResult<Vec<MembershipInfo>> {
758    let rows: Vec<(
759        Uuid,
760        String,
761        String,
762        String,
763        String,
764        Option<i64>,
765        chrono::DateTime<chrono::Utc>,
766        Option<chrono::DateTime<chrono::Utc>>,
767    )> = sqlx::query_as(
768        "SELECT m.id, ag.name, m.session, m.role, m.state, m.history_from_seq,
769                m.invited_at, m.accepted_at
770           FROM conversation_memberships m
771           JOIN agents ag ON ag.id = m.agent_id
772          WHERE m.conversation_id = $1
773          ORDER BY ag.name, m.session",
774    )
775    .bind(id)
776    .fetch_all(pool)
777    .await?;
778    Ok(rows
779        .into_iter()
780        .map(
781            |(mid, agent, session, role, state, history_from_seq, invited_at, accepted_at)| {
782                MembershipInfo {
783                    membership_id: mid.to_string(),
784                    address: address_of(&agent, &session),
785                    session: (!session.is_empty()).then_some(session),
786                    agent,
787                    role,
788                    state,
789                    history_from_seq,
790                    invited_at: ts(invited_at),
791                    accepted_at: ts_opt(accepted_at),
792                }
793            },
794        )
795        .collect())
796}
797
798/// Every conversation this caller may read: their own memberships, plus the
799/// project threads their grants cover.
800pub async fn list_conversations(
801    pool: &PgPool,
802    auth: &AuthCtx,
803    include_archived: bool,
804) -> BusResult<Vec<ConversationInfo>> {
805    require_capability(pool, auth).await?;
806    let ids = list_candidates(pool, auth, include_archived).await?;
807    list_conversations_among(pool, auth, ids).await
808}
809
810/// The conversations the caller may list, by id: the first half of
811/// [`list_conversations`]. Not part of the documented API: it is exposed so
812/// the integration suite (the only database-backed harness this crate has)
813/// can commit a revocation between the two halves and prove the second
814/// copes. `list_conversations` keeps the capability check in front of both.
815///
816/// The candidates obey the same rules as `access`: a seat taken by a
817/// registered window belongs to that window (the parent token wearing the
818/// label does not sit in it), and a project grant opens project threads
819/// only, never a private thread that happens to name a project. Listing a
820/// seat the caller could not then open failed the whole listing with "no
821/// such conversation".
822#[doc(hidden)]
823pub async fn list_candidates(
824    pool: &PgPool,
825    auth: &AuthCtx,
826    include_archived: bool,
827) -> BusResult<Vec<Uuid>> {
828    let ids: Vec<(Uuid,)> = sqlx::query_as(
829        "SELECT DISTINCT c.id
830           FROM conversations c
831           LEFT JOIN conversation_memberships m
832                  ON m.conversation_id = c.id AND m.agent_id = $2 AND m.session = $3
833                 AND (m.session_id IS NULL OR $5::uuid IS NOT NULL)
834          WHERE c.team_id = $1
835            AND ($4::bool OR c.archived_at IS NULL)
836            AND (
837                 (m.state IN ('invited', 'active')
838                  AND (c.visibility <> 'project' OR EXISTS (
839                        SELECT 1 FROM project_agent_access a
840                         WHERE a.project_id = c.project_id AND a.agent_id = $2)))
841                 OR (c.visibility = 'project' AND c.project_id IS NOT NULL AND EXISTS (
842                        SELECT 1 FROM project_agent_access a
843                         WHERE a.project_id = c.project_id AND a.agent_id = $2))
844            )
845          ORDER BY c.id",
846    )
847    .bind(auth.team_id)
848    .bind(auth.agent_id)
849    .bind(&auth.session)
850    .bind(include_archived)
851    .bind(auth.session_id)
852    .fetch_all(pool)
853    .await?;
854    Ok(ids.into_iter().map(|(id,)| id).collect())
855}
856
857/// Resolve listed candidates into what the caller may see now: the second
858/// half of [`list_conversations`], exposed for the same reason as
859/// [`list_candidates`]. Permissions can change between the candidate query
860/// and this read: a seat removed or a grant revoked meanwhile is simply not
861/// listed, and does not take the rest of the listing with it.
862#[doc(hidden)]
863pub async fn list_conversations_among(
864    pool: &PgPool,
865    auth: &AuthCtx,
866    ids: Vec<Uuid>,
867) -> BusResult<Vec<ConversationInfo>> {
868    let mut out = Vec::with_capacity(ids.len());
869    for id in ids {
870        match conversation_info(pool, auth, id).await {
871            Ok(info) => out.push(info),
872            Err(BusError::NotFound(_)) | Err(BusError::Forbidden(_)) => continue,
873            Err(e) => return Err(e),
874        }
875    }
876    Ok(out)
877}
878
879pub async fn archive_conversation(
880    pool: &PgPool,
881    auth: &AuthCtx,
882    id: Uuid,
883) -> BusResult<ConversationInfo> {
884    require_capability(pool, auth).await?;
885    let a = readable(pool, auth, id).await?;
886    if !a.can_moderate() {
887        return Err(BusError::Forbidden(
888            "only an owner or moderator of this conversation can archive it".to_owned(),
889        ));
890    }
891    let mut tx = pool.begin().await?;
892    crate::store::sessions::guard(&mut tx, auth).await?;
893    require_project_open(&mut tx, auth, id).await?;
894    sqlx::query(
895        "UPDATE conversations SET archived_at = now() WHERE id = $1 AND archived_at IS NULL",
896    )
897    .bind(id)
898    .execute(&mut *tx)
899    .await?;
900    audit(
901        &mut tx,
902        auth,
903        Some(id),
904        "conversation.archive",
905        Some(id),
906        serde_json::json!({}),
907    )
908    .await?;
909    tx.commit().await?;
910    conversation_info(pool, auth, id).await
911}
912
913// ------------------------------------------------------------- membership --
914
915/// Invite an address into a conversation. The invitee is not a member until
916/// it accepts: a thread cannot conscript a window into its receipts.
917pub async fn invite(
918    pool: &PgPool,
919    auth: &AuthCtx,
920    id: Uuid,
921    address: &str,
922    role: Option<&str>,
923    history_from_start: bool,
924) -> BusResult<ConversationInfo> {
925    require_capability(pool, auth).await?;
926    let a = readable(pool, auth, id).await?;
927    if !a.can_moderate() {
928        return Err(BusError::Forbidden(
929            "only an owner or moderator of this conversation can invite".to_owned(),
930        ));
931    }
932    if a.archived {
933        return Err(BusError::conflict("this conversation is archived"));
934    }
935    let role = match role.map(str::trim).filter(|r| !r.is_empty()) {
936        None => "participant".to_owned(),
937        Some(r) => {
938            let r = r.to_lowercase();
939            if !["moderator", "participant", "observer"].contains(&r.as_str()) {
940                return Err(BusError::invalid(
941                    "role must be moderator, participant or observer. An owner is the \
942                     creator, or someone a transfer made one.",
943                ));
944            }
945            r
946        }
947    };
948    let (agent, session) = crate::store::messaging::parse_address(address)?;
949    let agent_id = crate::store::agent_id_by_name(pool, auth.team_id, &agent).await?;
950    let session = session.unwrap_or_default();
951
952    let mut tx = pool.begin().await?;
953    crate::store::sessions::guard(&mut tx, auth).await?;
954    // Lock the thread, then count. Read outside the transaction, two
955    // concurrent invitations both see room and both commit, and the cap is
956    // a suggestion.
957    let (last_seq,): (i64,) =
958        sqlx::query_as("SELECT last_seq FROM conversations WHERE id = $1 FOR UPDATE")
959            .bind(id)
960            .fetch_one(&mut *tx)
961            .await?;
962    require_project_open(&mut tx, auth, id).await?;
963    // The inviter's own seat, re-read under lock. `readable` answered before
964    // this transaction: a removal that commits in between would leave a
965    // moderator who can no longer read anything still inviting, with a
966    // floor it no longer has. What may be granted is what this row says
967    // now, and only an active owner or moderator grants anything.
968    let inviter: Option<(String, String, Option<i64>)> = sqlx::query_as(
969        "SELECT role, state, history_from_seq FROM conversation_memberships
970          WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
971          FOR UPDATE",
972    )
973    .bind(id)
974    .bind(auth.agent_id)
975    .bind(&auth.session)
976    .fetch_optional(&mut *tx)
977    .await?;
978    let (inviter_role, inviter_floor) = match inviter {
979        Some((role, state, floor))
980            if state == "active" && (role == "owner" || role == "moderator") =>
981        {
982            (role, floor)
983        }
984        _ => {
985            return Err(BusError::Forbidden(
986                "your seat in this conversation changed while you were inviting: you no \
987                 longer moderate it. Nothing was written."
988                    .to_owned(),
989            ));
990        }
991    };
992    let (count,): (i64,) =
993        sqlx::query_as("SELECT count(*) FROM conversation_memberships WHERE conversation_id = $1")
994            .bind(id)
995            .fetch_one(&mut *tx)
996            .await?;
997    if count >= MAX_MEMBERS {
998        return Err(BusError::conflict(format!(
999            "this conversation already holds {MAX_MEMBERS} members"
1000        )));
1001    }
1002
1003    // Was this address removed from the thread? Re-admitting is a moderator
1004    // decision and stays allowed, but it is recorded as one, and it never
1005    // hands back the history the removal took away — whatever the inviter
1006    // asks for.
1007    let seated: Option<(String, String)> = sqlx::query_as(
1008        "SELECT state, role FROM conversation_memberships
1009          WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
1010          FOR UPDATE",
1011    )
1012    .bind(id)
1013    .bind(agent_id)
1014    .bind(&session)
1015    .fetch_optional(&mut *tx)
1016    .await?;
1017    let readmitting = seated.as_ref().map(|r| r.0.as_str()) == Some("removed");
1018
1019    // An owner's seat is only an owner's to change. Re-inviting someone
1020    // already seated changes their role on the spot (a seat that stays
1021    // active has nothing to accept), and removal refuses a moderator acting
1022    // on an owner; an invitation that demoted the owner first walked
1023    // around that refusal. Decided from the locked rows, the target's and
1024    // the inviter's, so nothing committed in between can change the answer.
1025    if let Some((state, role)) = &seated
1026        && (state == "active" || state == "invited")
1027        && role == "owner"
1028        && inviter_role != "owner"
1029    {
1030        return Err(BusError::Forbidden(format!(
1031            "'{address}' is an owner of this conversation, and only an owner can change or \
1032             remove another owner. Nothing was written."
1033        )));
1034    }
1035
1036    // History boundary: from here on unless the inviter asks for more, and
1037    // never more than the inviter can read itself. A moderator admitted
1038    // without the past cannot hand that past to somebody else — nor to
1039    // another window of its own agent — so a full-history grant is clamped
1040    // to the inviter's floor, and the audit row keeps both what was asked
1041    // and what was given. A re-admission is always from here on.
1042    let from_seq: Option<i64> = if readmitting || !history_from_start {
1043        Some(last_seq)
1044    } else {
1045        inviter_floor
1046    };
1047    sqlx::query(
1048        "INSERT INTO conversation_memberships
1049            (conversation_id, agent_id, session, role, state, history_from_seq, invited_by)
1050         VALUES ($1, $2, $3, $4, 'invited', $5, $6)
1051         ON CONFLICT (conversation_id, agent_id, session) DO UPDATE SET
1052            role = EXCLUDED.role,
1053            state = CASE WHEN conversation_memberships.state IN ('left', 'removed')
1054                         THEN 'invited' ELSE conversation_memberships.state END,
1055            -- A seat that is offered again gets the floor decided now: the
1056            -- one it had when it left is not this inviter's to give back.
1057            -- A seat that stays active or invited keeps its own.
1058            history_from_seq = CASE WHEN conversation_memberships.state IN ('left', 'removed')
1059                                    THEN EXCLUDED.history_from_seq
1060                                    ELSE conversation_memberships.history_from_seq END,
1061            -- A seat that is actually being re-offered is not the seat the
1062            -- old window held, so whoever accepts proves it is them again.
1063            -- A seat that stays active keeps its binding: clearing it on a
1064            -- repeated invitation would quietly downgrade a protected
1065            -- window to a legacy one, and the parent agent token would be
1066            -- back in the room.
1067            session_id = CASE WHEN conversation_memberships.state IN ('left', 'removed')
1068                              THEN NULL ELSE conversation_memberships.session_id END,
1069            invited_by = EXCLUDED.invited_by,
1070            invited_at = now(),
1071            ended_at = NULL",
1072    )
1073    .bind(id)
1074    .bind(agent_id)
1075    .bind(&session)
1076    .bind(&role)
1077    .bind(from_seq)
1078    .bind(auth.agent_id)
1079    .execute(&mut *tx)
1080    .await?;
1081    audit(
1082        &mut tx,
1083        auth,
1084        Some(id),
1085        if readmitting {
1086            "member.readmit"
1087        } else {
1088            "member.invite"
1089        },
1090        Some(agent_id),
1091        serde_json::json!({
1092            "address": address,
1093            "role": role,
1094            "history_from_seq": from_seq,
1095            "history_from_start_requested": history_from_start,
1096        }),
1097    )
1098    .await?;
1099    tx.commit().await?;
1100    conversation_info(pool, auth, id).await
1101}
1102
1103/// Accept an invitation. Only the invited window can: a sibling session of
1104/// the same agent is a different address and a different membership.
1105pub async fn join(pool: &PgPool, auth: &AuthCtx, id: Uuid) -> BusResult<ConversationInfo> {
1106    require_capability(pool, auth).await?;
1107    // If this label is a registered window, only that window may take the
1108    // seat. Otherwise the parent agent token could accept an invitation
1109    // addressed to one of its own windows and then read, send and
1110    // acknowledge as it — without the audit trail that the documented
1111    // recovery path carries.
1112    crate::store::sessions::require_window(pool, auth).await?;
1113    let mut tx = pool.begin().await?;
1114    crate::store::sessions::guard(&mut tx, auth).await?;
1115    // A project thread's invitation is only worth accepting while the
1116    // project is still granted, decided inside this transaction with the
1117    // grant row share-locked, so no seat is taken behind a revocation.
1118    require_project_open(&mut tx, auth, id).await?;
1119    let updated: Option<(Uuid,)> = sqlx::query_as(
1120        "UPDATE conversation_memberships
1121            SET state = 'active', accepted_at = now(), session_id = $4
1122          WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
1123            AND state = 'invited'
1124          RETURNING id",
1125    )
1126    .bind(id)
1127    .bind(auth.agent_id)
1128    .bind(&auth.session)
1129    .bind(auth.session_id)
1130    .fetch_optional(&mut *tx)
1131    .await?;
1132    if updated.is_none() {
1133        let a = access(pool, auth, id).await?;
1134        return Err(match a.membership.as_ref().map(|m| m.state.as_str()) {
1135            Some("active") => BusError::conflict("you are already in this conversation"),
1136            Some("removed") => {
1137                BusError::Forbidden("you were removed from this conversation".to_owned())
1138            }
1139            _ => BusError::not_found(
1140                "no invitation for this window. An invitation is addressed to one \
1141                 agent/session; a sibling window cannot accept it for you.",
1142            ),
1143        });
1144    }
1145    if let Some((membership_id,)) = updated {
1146        // A pending transfer from another window of this agent completes
1147        // here: its seat is superseded rather than duplicated.
1148        supersede_predecessor(&mut tx, auth, id, membership_id).await?;
1149    }
1150    audit(
1151        &mut tx,
1152        auth,
1153        Some(id),
1154        "member.join",
1155        None,
1156        serde_json::json!({}),
1157    )
1158    .await?;
1159    tx.commit().await?;
1160    conversation_info(pool, auth, id).await
1161}
1162
1163/// Leave a conversation. History and receipts stay exactly as they were.
1164pub async fn leave(pool: &PgPool, auth: &AuthCtx, id: Uuid) -> BusResult<()> {
1165    require_capability(pool, auth).await?;
1166    let mut tx = pool.begin().await?;
1167    crate::store::sessions::guard(&mut tx, auth).await?;
1168    let row: Option<(Uuid,)> = sqlx::query_as(
1169        "UPDATE conversation_memberships
1170            SET state = 'left', ended_at = now()
1171          WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
1172            AND state IN ('invited', 'active')
1173          RETURNING id",
1174    )
1175    .bind(id)
1176    .bind(auth.agent_id)
1177    .bind(&auth.session)
1178    .fetch_optional(&mut *tx)
1179    .await?;
1180    if row.is_none() {
1181        return Err(BusError::not_found("you are not in this conversation"));
1182    }
1183    audit(
1184        &mut tx,
1185        auth,
1186        Some(id),
1187        "member.leave",
1188        None,
1189        serde_json::json!({}),
1190    )
1191    .await?;
1192    tx.commit().await?;
1193    Ok(())
1194}
1195
1196/// Remove someone else. A removal is effective immediately and is not
1197/// undone by a later transfer or recovery.
1198pub async fn remove_member(
1199    pool: &PgPool,
1200    auth: &AuthCtx,
1201    id: Uuid,
1202    address: &str,
1203) -> BusResult<ConversationInfo> {
1204    require_capability(pool, auth).await?;
1205    let a = readable(pool, auth, id).await?;
1206    if !a.can_moderate() {
1207        return Err(BusError::Forbidden(
1208            "only an owner or moderator of this conversation can remove a member".to_owned(),
1209        ));
1210    }
1211    let (agent, session) = crate::store::messaging::parse_address(address)?;
1212    let agent_id = crate::store::agent_id_by_name(pool, auth.team_id, &agent).await?;
1213    let session = session.unwrap_or_default();
1214    if agent_id == auth.agent_id && session == auth.session {
1215        return Err(BusError::invalid(
1216            "use leave_conversation to remove yourself",
1217        ));
1218    }
1219    let mut tx = pool.begin().await?;
1220    crate::store::sessions::guard(&mut tx, auth).await?;
1221    // The thread first, then the seats, in the same order as invite and
1222    // transfer. The caller's role is what its row says now, under lock,
1223    // not what it said before the transaction: a demotion that commits in
1224    // between must not leave a former owner removing owners.
1225    sqlx::query("SELECT id FROM conversations WHERE id = $1 FOR UPDATE")
1226        .bind(id)
1227        .execute(&mut *tx)
1228        .await?;
1229    require_project_open(&mut tx, auth, id).await?;
1230    let caller: Option<(String, String)> = sqlx::query_as(
1231        "SELECT role, state FROM conversation_memberships
1232          WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
1233          FOR UPDATE",
1234    )
1235    .bind(id)
1236    .bind(auth.agent_id)
1237    .bind(&auth.session)
1238    .fetch_optional(&mut *tx)
1239    .await?;
1240    let caller = caller
1241        .as_ref()
1242        .map(|(role, state)| (role.as_str(), state.as_str()));
1243    if !matches!(caller, Some(("owner" | "moderator", "active"))) {
1244        return Err(BusError::Forbidden(
1245            "your seat in this conversation changed while you were removing a member: you \
1246             no longer moderate it. Nothing was written."
1247                .to_owned(),
1248        ));
1249    }
1250    let caller_is_owner = matches!(caller, Some(("owner", "active")));
1251    let row: Option<(String,)> = sqlx::query_as(
1252        "UPDATE conversation_memberships
1253            SET state = 'removed', ended_at = now()
1254          WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
1255            AND state IN ('invited', 'active')
1256          RETURNING role",
1257    )
1258    .bind(id)
1259    .bind(agent_id)
1260    .bind(&session)
1261    .fetch_optional(&mut *tx)
1262    .await?;
1263    let Some((role,)) = row else {
1264        return Err(BusError::not_found(format!(
1265            "'{address}' is not in this conversation"
1266        )));
1267    };
1268    if role == "owner" && !caller_is_owner {
1269        return Err(BusError::Forbidden(
1270            "only an owner can remove another owner".to_owned(),
1271        ));
1272    }
1273    audit(
1274        &mut tx,
1275        auth,
1276        Some(id),
1277        "member.remove",
1278        Some(agent_id),
1279        serde_json::json!({ "address": address }),
1280    )
1281    .await?;
1282    tx.commit().await?;
1283    conversation_info(pool, auth, id).await
1284}
1285
1286// ---------------------------------------------------------------- messages --
1287
1288pub struct SendInput {
1289    pub body: String,
1290    pub request_id: Uuid,
1291    pub reply_to: Option<Uuid>,
1292    pub metadata: Option<serde_json::Value>,
1293}
1294
1295/// Send into a conversation.
1296///
1297/// Body, sequence, recipient snapshot, receipts and audit commit together.
1298/// `stored` reports the backend's confirmation of the body, never that anyone
1299/// has seen anything, and `publication` says where it stands: on a Postgres
1300/// thread this commit *is* the persistence, so both say stored; on an outbox
1301/// thread the commit records the message and its slot, and the reply says
1302/// `pending_publication` until the worker settles it as `stored` or `failed`.
1303/// A repeat of the same `request_id` returns the original
1304/// message rather than making a second one, and the same key with a
1305/// different body is refused instead of silently keeping the first.
1306pub async fn send(
1307    pool: &PgPool,
1308    auth: &AuthCtx,
1309    id: Uuid,
1310    input: SendInput,
1311) -> BusResult<SentMessage> {
1312    require_capability(pool, auth).await?;
1313    let a = readable(pool, auth, id).await?;
1314    if !a.can_send() {
1315        return Err(BusError::Forbidden(
1316            "you cannot post in this conversation: accept your invitation first, and note \
1317             that an observer reads and acknowledges but does not write"
1318                .to_owned(),
1319        ));
1320    }
1321    if a.archived {
1322        return Err(BusError::conflict(
1323            "this conversation is archived; its history stays readable",
1324        ));
1325    }
1326    let body = crate::store::check_text("message body", &input.body, MAX_BODY_BYTES)?;
1327    if body.is_empty() {
1328        return Err(BusError::invalid("a message body is required"));
1329    }
1330    let metadata = crate::store::normalize_metadata(input.metadata);
1331    crate::store::check_metadata("message", metadata.as_ref())?;
1332    let metadata = metadata.unwrap_or_else(|| serde_json::Value::Object(Default::default()));
1333
1334    let mut tx = pool.begin().await?;
1335    crate::store::sessions::guard(&mut tx, auth).await?;
1336
1337    // Lock the thread before deciding anything. Two things depend on it:
1338    // two retries of one request_id serialize here instead of racing to the
1339    // unique index, and the authorization below is re-read while it cannot
1340    // change underneath.
1341    let (archived_now, paused): (
1342        Option<chrono::DateTime<chrono::Utc>>,
1343        Option<chrono::DateTime<chrono::Utc>>,
1344    ) = sqlx::query_as(
1345        "SELECT archived_at, write_paused_at FROM conversations WHERE id = $1 FOR UPDATE",
1346    )
1347    .bind(id)
1348    .fetch_one(&mut *tx)
1349    .await?;
1350    if archived_now.is_some() {
1351        return Err(BusError::conflict(
1352            "this conversation is archived; its history stays readable",
1353        ));
1354    }
1355    // A supervised backend move holds this one thread still while it copies
1356    // the tail. Read under the same lock the move takes: checked outside the
1357    // transaction, a request that passed a moment earlier lands after the
1358    // tail was copied and is left behind. Seconds, not minutes, and only
1359    // this thread.
1360    if let Some(since) = paused {
1361        let secs = (chrono::Utc::now() - since).num_seconds().max(0);
1362        return Err(BusError::conflict(format!(
1363            "this conversation's storage is being moved by an operator and writes are \
1364             paused (for {secs}s so far). Reading still works. Try again in a moment; \
1365             nothing you have sent was lost."
1366        )));
1367    }
1368    // The sender's project grant as it is *now*, locked, for the same
1369    // reason as the membership below.
1370    require_project_open(&mut tx, auth, id).await?;
1371    // Membership as it is *now*. It was checked before this transaction
1372    // opened, and a removal that committed in between must take effect on
1373    // this call rather than the next one.
1374    let current: Option<(String, String)> = sqlx::query_as(
1375        "SELECT state, role FROM conversation_memberships
1376          WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
1377            AND (session_id IS NULL OR $4::uuid IS NOT NULL)
1378          FOR SHARE",
1379    )
1380    .bind(id)
1381    .bind(auth.agent_id)
1382    .bind(&auth.session)
1383    .bind(auth.session_id)
1384    .fetch_optional(&mut *tx)
1385    .await?;
1386    let can_send_now = matches!(
1387        current
1388            .as_ref()
1389            .map(|(state, role)| (state.as_str(), role.as_str())),
1390        Some(("active", "owner")) | Some(("active", "moderator")) | Some(("active", "participant"))
1391    );
1392    if !can_send_now {
1393        return Err(BusError::Forbidden(
1394            "your membership of this conversation is no longer one that can post. Nothing was written."
1395                .to_owned(),
1396        ));
1397    }
1398
1399    // Idempotency, under that lock: a retry that raced the original sees it
1400    // rather than allocating a second sequence.
1401    #[allow(clippy::type_complexity)]
1402    let existing: Option<(
1403        Uuid,
1404        i64,
1405        chrono::DateTime<chrono::Utc>,
1406        String,
1407        Option<String>,
1408    )> = sqlx::query_as(
1409        "SELECT id, seq, created_at, publication_state, body_sha256
1410           FROM conversation_messages
1411          WHERE conversation_id = $1 AND request_id = $2",
1412    )
1413    .bind(id)
1414    .bind(input.request_id)
1415    .fetch_optional(&mut *tx)
1416    .await?;
1417    if let Some((mid, seq, created_at, publication_state, digest)) = existing {
1418        // The digest, not the body. Once a publication releases the staging
1419        // copy there is no body here to compare with, and a legitimate
1420        // retry would be refused as a different message.
1421        if digest.as_deref() != Some(body_digest(&body).as_str()) {
1422            return Err(BusError::conflict(
1423                "this request_id already sent a different message. Use a fresh UUID for a \
1424                 new message; reusing one is how a retry is recognised.",
1425            ));
1426        }
1427        let recipients = recipients_of(&mut tx, mid).await?;
1428        tx.commit().await?;
1429        return Ok(SentMessage {
1430            message_id: mid.to_string(),
1431            conversation_id: id.to_string(),
1432            seq,
1433            // What the original actually is, not what the first call was
1434            // told. A retry of an accepted-but-unpublished message must not
1435            // be handed a storage confirmation the first call did not get,
1436            // and one of a message published since is told it is stored.
1437            stored: publication_state == "stored",
1438            publication: publication_state,
1439            recipients,
1440            created_at: ts(created_at),
1441        });
1442    }
1443
1444    // The conversation row is the sequence allocator, and locking it is what
1445    // makes `seq` gap-free rather than merely unique.
1446    let (seq,): (i64,) = sqlx::query_as(
1447        "UPDATE conversations SET last_seq = last_seq + 1 WHERE id = $1 RETURNING last_seq",
1448    )
1449    .bind(id)
1450    .fetch_one(&mut *tx)
1451    .await?;
1452
1453    if let Some(reply_to) = input.reply_to {
1454        let ok: Option<(Uuid,)> = sqlx::query_as(
1455            "SELECT id FROM conversation_messages WHERE id = $1 AND conversation_id = $2",
1456        )
1457        .bind(reply_to)
1458        .bind(id)
1459        .fetch_optional(&mut *tx)
1460        .await?;
1461        if ok.is_none() {
1462            return Err(BusError::not_found(
1463                "reply_to is not a message of this conversation",
1464            ));
1465        }
1466    }
1467
1468    let (message_id,): (Uuid,) = sqlx::query_as(
1469        "INSERT INTO conversation_messages
1470            (conversation_id, seq, sender_agent, sender_session, body, reply_to, metadata,
1471             request_id, body_sha256, backend)
1472         SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, c.backend
1473           FROM conversations c WHERE c.id = $1
1474         RETURNING id",
1475    )
1476    .bind(id)
1477    .bind(seq)
1478    .bind(auth.agent_id)
1479    .bind(&auth.session)
1480    .bind(&body)
1481    .bind(input.reply_to)
1482    .bind(&metadata)
1483    .bind(input.request_id)
1484    .bind(body_digest(&body))
1485    .fetch_one(&mut *tx)
1486    .await?;
1487
1488    // The snapshot: who this message was addressed to, as of now. Everyone
1489    // active except the sender — a sender does not acknowledge itself — and,
1490    // in a project thread, only those who still have the project: a seat
1491    // whose grant was revoked gets no new obligation, in the same
1492    // transaction that stores the message, so a revocation that committed
1493    // first is honoured and one that commits later finds nothing to undo.
1494    sqlx::query(
1495        "INSERT INTO message_recipients (message_id, membership_id, agent_id, session)
1496         SELECT $1, m.id, m.agent_id, m.session
1497           FROM conversation_memberships m
1498           JOIN conversations c ON c.id = m.conversation_id
1499          WHERE m.conversation_id = $2
1500            AND m.state = 'active'
1501            AND NOT (m.agent_id = $3 AND m.session = $4)
1502            -- Share-locked: a revocation in flight waits for this message
1503            -- to commit (and the recipient is owed it), or committed first
1504            -- and the recipient is not listed. Never a snapshot that
1505            -- becomes an obligation after the grant is gone.
1506            AND (c.visibility <> 'project' OR EXISTS (
1507                    SELECT 1 FROM project_agent_access a
1508                     WHERE a.project_id = c.project_id AND a.agent_id = m.agent_id
1509                     FOR SHARE))",
1510    )
1511    .bind(message_id)
1512    .bind(id)
1513    .bind(auth.agent_id)
1514    .bind(&auth.session)
1515    .execute(&mut *tx)
1516    .await?;
1517
1518    // Which path this conversation is on. `sync` is the default and the
1519    // only one with a body that is durable the moment this commits.
1520    let (publication,): (String,) =
1521        sqlx::query_as("SELECT publication FROM conversations WHERE id = $1")
1522            .bind(id)
1523            .fetch_one(&mut *tx)
1524            .await?;
1525    let asynchronous = publication == "outbox";
1526
1527    // One receipt row per recipient. `stored_at` is set here only when the
1528    // body is durable here: on the outbox path it stays null until the
1529    // backend confirms, because saying stored before that would be a claim
1530    // nobody could check.
1531    sqlx::query(
1532        "INSERT INTO message_receipts (message_id, membership_id, stored_at)
1533         SELECT message_id, membership_id,
1534                CASE WHEN $2 THEN NULL ELSE now() END
1535           FROM message_recipients WHERE message_id = $1",
1536    )
1537    .bind(message_id)
1538    .bind(asynchronous)
1539    .execute(&mut *tx)
1540    .await?;
1541
1542    if asynchronous {
1543        let (backend,): (String,) =
1544            sqlx::query_as("SELECT backend FROM conversations WHERE id = $1")
1545                .bind(id)
1546                .fetch_one(&mut *tx)
1547                .await?;
1548        crate::store::outbox::enqueue(&mut tx, message_id, id, auth.team_id, &backend, &body)
1549            .await?;
1550    }
1551
1552    audit(
1553        &mut tx,
1554        auth,
1555        Some(id),
1556        "message.send",
1557        Some(message_id),
1558        serde_json::json!({ "seq": seq }),
1559    )
1560    .await?;
1561
1562    let recipients = recipients_of(&mut tx, message_id).await?;
1563    let created_at: (chrono::DateTime<chrono::Utc>,) =
1564        sqlx::query_as("SELECT created_at FROM conversation_messages WHERE id = $1")
1565            .bind(message_id)
1566            .fetch_one(&mut *tx)
1567            .await?;
1568    tx.commit().await?;
1569
1570    Ok(SentMessage {
1571        message_id: message_id.to_string(),
1572        conversation_id: id.to_string(),
1573        seq,
1574        // Accepted is not stored. On the synchronous path they coincide
1575        // because this commit *is* the persistence; on the outbox path the
1576        // caller is told the truth, in the same words a read uses, and can
1577        // watch it settle.
1578        stored: !asynchronous,
1579        publication: if asynchronous {
1580            "pending_publication"
1581        } else {
1582            "stored"
1583        }
1584        .to_owned(),
1585        recipients,
1586        created_at: ts(created_at.0),
1587    })
1588}
1589
1590/// What is known about one message's body.
1591///
1592/// A body that cannot be served is **not** an error when reading a thread:
1593/// the message keeps its sequence, its sender and its receipts, and the
1594/// reason is stated on the message itself. Only a caller who asked for one
1595/// specific body gets a refusal.
1596pub enum BodyState {
1597    Present(String),
1598    /// `publication` is the honest status — `pending_publication`, `failed`
1599    /// or `tombstoned` — and `why` says what it means for this caller.
1600    Missing {
1601        publication: &'static str,
1602        why: BusError,
1603    },
1604    /// The backend holding this body cannot be reached. Different from
1605    /// missing in the way that matters: the message *is* stored, nothing is
1606    /// lost, and the body comes back when the backend does. The row keeps
1607    /// its own publication state and the reader is told why it cannot see
1608    /// the body right now.
1609    Unreachable(BusError),
1610}
1611
1612/// The columns resolving a body needs. Read once per message, alongside the
1613/// rest of the row, so a page of history is still one query plus whatever
1614/// the backend charges for the bodies it actually holds.
1615struct BodyRow {
1616    body: String,
1617    state: String,
1618    locator: Option<String>,
1619    /// Where THIS body is authoritative, which during a supervised move is
1620    /// not necessarily where the conversation is.
1621    backend: String,
1622    tombstoned: bool,
1623    tombstone_reason: Option<String>,
1624}
1625
1626/// Turn one row into a body or an explained absence.
1627///
1628/// `backend` must be the conversation's own backend: a locator is only
1629/// meaningful to the adapter that issued it, and `Backends::for_conversation`
1630/// is how you get the right one.
1631async fn resolve_body(
1632    pool: &PgPool,
1633    backends: &crate::store::routing::Backends,
1634    team_id: Uuid,
1635    message_id: Uuid,
1636    row: BodyRow,
1637) -> BusResult<BodyState> {
1638    if row.tombstoned {
1639        return Ok(BodyState::Missing {
1640            publication: "tombstoned",
1641            why: BusError::not_found(format!(
1642                "this message's body is no longer held by its backend ({}). Its place in \
1643                 the thread, its recipients and its receipts remain.",
1644                row.tombstone_reason.as_deref().unwrap_or("retention")
1645            )),
1646        });
1647    }
1648    // Still in the local row: either Postgres is the backend, or the
1649    // publication has not been confirmed and the temporary copy released.
1650    if row.backend == crate::store::backend::PostgresBackend::NAME || !row.body.is_empty() {
1651        return Ok(BodyState::Present(row.body));
1652    }
1653    match row.state.as_str() {
1654        "pending_publication" => Ok(BodyState::Missing {
1655            publication: "pending_publication",
1656            why: BusError::conflict(
1657                "this message has been accepted but its backend has not confirmed it yet. \
1658                 It is not lost; read it again in a moment.",
1659            ),
1660        }),
1661        "failed" => Ok(BodyState::Missing {
1662            publication: "failed",
1663            why: BusError::not_found(
1664                "this message was never stored by its backend. Its slot is kept so the gap \
1665                 is visible rather than silent.",
1666            ),
1667        }),
1668        _ => {
1669            let Some(locator) = row.locator else {
1670                return Ok(BodyState::Missing {
1671                    publication: "failed",
1672                    why: BusError::not_found(
1673                        "this message has no body and no locator; there is nothing to read",
1674                    ),
1675                });
1676            };
1677            // A backend this process cannot reach is not a message that
1678            // does not exist. Losing the broker costs the bodies it holds,
1679            // for as long as it is down, and nothing else: not the thread,
1680            // not the other messages, not the page.
1681            // The reason a model reads carries one classification and no
1682            // broker internals; the internals go to the log, where an
1683            // operator looks for them.
1684            let backend = match backends.for_message(&row.backend, team_id).await {
1685                Ok(backend) => backend,
1686                Err(why) => {
1687                    tracing::warn!(error = %why, %message_id, "the body's backend cannot be opened");
1688                    return Ok(BodyState::Unreachable(BusError::conflict(
1689                        "the backend holding this body cannot be reached right now. The \
1690                         message is not lost: its place in the thread, its recipients and \
1691                         its receipts are here, and the body comes back when the backend \
1692                         does.",
1693                    )));
1694                }
1695            };
1696            let fetched = match backend
1697                .fetch(&crate::store::backend::Locator(locator), message_id)
1698                .await
1699            {
1700                Ok(fetched) => fetched,
1701                Err(why) => {
1702                    tracing::warn!(error = %why, %message_id, "the body could not be read from its backend");
1703                    return Ok(BodyState::Unreachable(BusError::conflict(
1704                        "this body could not be read from its backend right now. It is not \
1705                         lost: its place in the thread, its recipients and its receipts are \
1706                         here, and the body comes back when the backend does.",
1707                    )));
1708                }
1709            };
1710            match fetched {
1711                Some(body) => Ok(BodyState::Present(body)),
1712                None => {
1713                    // Gone from the backend without a tombstone: record one,
1714                    // so the next reader gets an explanation rather than the
1715                    // same surprise.
1716                    crate::store::outbox::tombstone(pool, message_id, "missing from backend")
1717                        .await?;
1718                    Ok(BodyState::Missing {
1719                        publication: "tombstoned",
1720                        why: BusError::not_found(
1721                            "this message's body is no longer held by its backend. Its place \
1722                             in the thread, its recipients and its receipts remain.",
1723                        ),
1724                    })
1725                }
1726            }
1727        }
1728    }
1729}
1730
1731/// Fetch one body that may live on another backend.
1732///
1733/// Access is the caller's business and was already checked; this is storage,
1734/// not policy.
1735pub async fn body_of(
1736    pool: &PgPool,
1737    backends: &crate::store::routing::Backends,
1738    message_id: Uuid,
1739) -> BusResult<String> {
1740    let row: Option<(
1741        String,
1742        String,
1743        Option<String>,
1744        String,
1745        Uuid,
1746        Option<chrono::DateTime<chrono::Utc>>,
1747        Option<String>,
1748    )> = sqlx::query_as(
1749        "SELECT m.body, m.publication_state, m.canonical_locator, m.backend, c.team_id,
1750                m.tombstoned_at, m.tombstone_reason
1751           FROM conversation_messages m
1752           JOIN conversations c ON c.id = m.conversation_id
1753          WHERE m.id = $1",
1754    )
1755    .bind(message_id)
1756    .fetch_optional(pool)
1757    .await?;
1758    let Some((body, state, locator, backend, team_id, tombstoned, tombstone_reason)) = row else {
1759        return Err(BusError::not_found("no such message"));
1760    };
1761    let resolved = resolve_body(
1762        pool,
1763        backends,
1764        team_id,
1765        message_id,
1766        BodyRow {
1767            body,
1768            state,
1769            locator,
1770            backend,
1771            tombstoned: tombstoned.is_some(),
1772            tombstone_reason,
1773        },
1774    )
1775    .await?;
1776    match resolved {
1777        BodyState::Present(body) => Ok(body),
1778        BodyState::Missing { why, .. } | BodyState::Unreachable(why) => Err(why),
1779    }
1780}
1781
1782async fn recipients_of(tx: &mut sqlx::PgConnection, message_id: Uuid) -> BusResult<Vec<String>> {
1783    let rows: Vec<(String, String)> = sqlx::query_as(
1784        "SELECT ag.name, r.session FROM message_recipients r
1785           JOIN agents ag ON ag.id = r.agent_id
1786          WHERE r.message_id = $1 ORDER BY ag.name, r.session",
1787    )
1788    .bind(message_id)
1789    .fetch_all(tx)
1790    .await?;
1791    Ok(rows
1792        .into_iter()
1793        .map(|(agent, session)| address_of(&agent, &session))
1794        .collect())
1795}
1796
1797/// Read a thread. Reading is **not** acknowledging: no receipt is touched
1798/// here, and no cursor is advanced on anyone's behalf.
1799pub async fn read(
1800    pool: &PgPool,
1801    backends: &crate::store::routing::Backends,
1802    auth: &AuthCtx,
1803    id: Uuid,
1804    after_seq: Option<i64>,
1805    limit: Option<i64>,
1806) -> BusResult<ConversationRead> {
1807    require_capability(pool, auth).await?;
1808    let a = readable(pool, auth, id).await?;
1809    require_accepted(&a)?;
1810    let limit = limit.unwrap_or(DEFAULT_PAGE).clamp(1, MAX_PAGE);
1811    // A member reads from its own boundary; a project reader sees the thread
1812    // from the start, which is what project visibility means.
1813    let floor = a
1814        .membership
1815        .as_ref()
1816        .and_then(|m| m.history_from_seq)
1817        .unwrap_or(0);
1818    let after = after_seq.unwrap_or(0).max(floor);
1819
1820    #[allow(clippy::type_complexity)]
1821    let rows: Vec<(
1822        Uuid,
1823        i64,
1824        String,
1825        String,
1826        String,
1827        Option<Uuid>,
1828        serde_json::Value,
1829        chrono::DateTime<chrono::Utc>,
1830        String,
1831        Option<String>,
1832        Option<chrono::DateTime<chrono::Utc>>,
1833        Option<String>,
1834        String,
1835    )> = sqlx::query_as(
1836        // Messages awaiting publication are returned, not hidden. Hiding
1837        // them let a reader believe the thread ended there; showing each
1838        // one with its publication state tells the truth and still stops a
1839        // cursor from walking over a gap it never knew about.
1840        "SELECT m.id, m.seq, ag.name, m.sender_session, m.body, m.reply_to, m.metadata,
1841                m.created_at, m.publication_state, m.canonical_locator, m.tombstoned_at,
1842                m.tombstone_reason, m.backend
1843           FROM conversation_messages m
1844           JOIN agents ag ON ag.id = m.sender_agent
1845          WHERE m.conversation_id = $1 AND m.seq > $2 AND m.deleted_at IS NULL
1846          ORDER BY m.seq
1847          LIMIT $3",
1848    )
1849    .bind(id)
1850    .bind(after)
1851    .bind(limit)
1852    .fetch_all(pool)
1853    .await?;
1854
1855    // The membership that may see these bodies is rechecked *here*, after
1856    // the rows are in hand and immediately before any body is served: an
1857    // ACL that changed while a publication was in flight takes effect on
1858    // this read, not the next one.
1859    let a = readable(pool, auth, id).await?;
1860    require_accepted(&a)?;
1861
1862    // One query for every receipt on the page. A page of 200 messages used
1863    // to be 200 extra round trips, which is a read that gets slower exactly
1864    // as a thread gets busier.
1865    let mut mine: std::collections::HashMap<Uuid, ReceiptInfo> = std::collections::HashMap::new();
1866    if let Some(m) = &a.membership {
1867        let ids: Vec<Uuid> = rows.iter().map(|r| r.0).collect();
1868        for (message_id, receipt) in receipts_for(pool, &ids, m.id).await? {
1869            mine.insert(message_id, receipt);
1870        }
1871    }
1872
1873    let mut messages = Vec::with_capacity(rows.len());
1874    for (
1875        mid,
1876        seq,
1877        from,
1878        from_session,
1879        body,
1880        reply_to,
1881        metadata,
1882        created_at,
1883        state,
1884        locator,
1885        tombstoned,
1886        tombstone_reason,
1887        message_backend,
1888    ) in rows
1889    {
1890        let my_receipt = mine.remove(&mid);
1891        let publication = state.clone();
1892        let resolved = resolve_body(
1893            pool,
1894            backends,
1895            auth.team_id,
1896            mid,
1897            BodyRow {
1898                body,
1899                state,
1900                locator,
1901                backend: message_backend,
1902                tombstoned: tombstoned.is_some(),
1903                tombstone_reason,
1904            },
1905        )
1906        .await?;
1907        let (body, publication, unavailable) = match resolved {
1908            BodyState::Present(body) => (body, publication, None),
1909            // One body the backend cannot serve does not fail the page. The
1910            // message keeps its sequence and says what happened to it.
1911            BodyState::Missing { publication, why } => {
1912                (String::new(), publication.to_owned(), Some(why.to_string()))
1913            }
1914            // Stored, and unreadable for as long as the backend is away.
1915            BodyState::Unreachable(why) => (String::new(), publication, Some(why.to_string())),
1916        };
1917        messages.push(ConversationMessage {
1918            message_id: mid.to_string(),
1919            seq,
1920            from_address: address_of(&from, &from_session),
1921            from,
1922            body,
1923            reply_to: reply_to.map(|r| r.to_string()),
1924            metadata,
1925            created_at: ts(created_at),
1926            my_receipt,
1927            publication,
1928            unavailable,
1929        });
1930    }
1931    // The cursor stops at the first message still awaiting publication. A
1932    // caller following it must not step over a sequence that is about to
1933    // fill and never come back for it.
1934    let first_pending = messages
1935        .iter()
1936        .find(|m| m.publication == "pending_publication")
1937        .map(|m| m.seq);
1938    let next = messages
1939        .last()
1940        .map(|m| m.seq)
1941        .map(|last| match first_pending {
1942            Some(pending) => last.min(pending - 1),
1943            None => last,
1944        })
1945        .filter(|s| *s < a.last_seq && *s > 0);
1946    Ok(ConversationRead {
1947        conversation_id: id.to_string(),
1948        next_after_seq: next,
1949        history_from_seq: a.membership.as_ref().and_then(|m| m.history_from_seq),
1950        messages,
1951    })
1952}
1953
1954/// Every receipt this membership holds on a page of messages, in one query.
1955#[allow(clippy::type_complexity)]
1956async fn receipts_for(
1957    pool: &PgPool,
1958    message_ids: &[Uuid],
1959    membership_id: Uuid,
1960) -> BusResult<Vec<(Uuid, ReceiptInfo)>> {
1961    if message_ids.is_empty() {
1962        return Ok(Vec::new());
1963    }
1964    let rows: Vec<(
1965        Uuid,
1966        String,
1967        String,
1968        Option<chrono::DateTime<chrono::Utc>>,
1969        Option<chrono::DateTime<chrono::Utc>>,
1970        Option<chrono::DateTime<chrono::Utc>>,
1971        Option<chrono::DateTime<chrono::Utc>>,
1972        Option<chrono::DateTime<chrono::Utc>>,
1973        Option<String>,
1974    )> = sqlx::query_as(
1975        "SELECT r.message_id, ag.name, m.session, r.stored_at, r.delivered_at, r.presented_at,
1976                r.acknowledged_at, r.resolved_at, r.note
1977           FROM message_receipts r
1978           JOIN conversation_memberships m ON m.id = r.membership_id
1979           JOIN agents ag ON ag.id = m.agent_id
1980          WHERE r.message_id = ANY($1) AND r.membership_id = $2",
1981    )
1982    .bind(message_ids)
1983    .bind(membership_id)
1984    .fetch_all(pool)
1985    .await?;
1986    Ok(rows
1987        .into_iter()
1988        .map(
1989            |(message_id, agent, session, stored, delivered, presented, acked, resolved, note)| {
1990                (
1991                    message_id,
1992                    ReceiptInfo {
1993                        address: address_of(&agent, &session),
1994                        session: (!session.is_empty()).then_some(session),
1995                        agent,
1996                        stored_at: ts_opt(stored),
1997                        delivered_at: ts_opt(delivered),
1998                        presented_at: ts_opt(presented),
1999                        acknowledged_at: ts_opt(acked),
2000                        resolved_at: ts_opt(resolved),
2001                        note,
2002                    },
2003                )
2004            },
2005        )
2006        .collect())
2007}
2008
2009async fn receipt_of(
2010    pool: &PgPool,
2011    message_id: Uuid,
2012    membership_id: Uuid,
2013) -> BusResult<Option<ReceiptInfo>> {
2014    let row: Option<(
2015        String,
2016        String,
2017        Option<chrono::DateTime<chrono::Utc>>,
2018        Option<chrono::DateTime<chrono::Utc>>,
2019        Option<chrono::DateTime<chrono::Utc>>,
2020        Option<chrono::DateTime<chrono::Utc>>,
2021        Option<chrono::DateTime<chrono::Utc>>,
2022        Option<String>,
2023    )> = sqlx::query_as(
2024        "SELECT ag.name, m.session, r.stored_at, r.delivered_at, r.presented_at,
2025                r.acknowledged_at, r.resolved_at, r.note
2026           FROM message_receipts r
2027           JOIN conversation_memberships m ON m.id = r.membership_id
2028           JOIN agents ag ON ag.id = m.agent_id
2029          WHERE r.message_id = $1 AND r.membership_id = $2",
2030    )
2031    .bind(message_id)
2032    .bind(membership_id)
2033    .fetch_optional(pool)
2034    .await?;
2035    Ok(row.map(
2036        |(agent, session, stored, delivered, presented, acked, resolved, note)| ReceiptInfo {
2037            address: address_of(&agent, &session),
2038            session: (!session.is_empty()).then_some(session),
2039            agent,
2040            stored_at: ts_opt(stored),
2041            delivered_at: ts_opt(delivered),
2042            presented_at: ts_opt(presented),
2043            acknowledged_at: ts_opt(acked),
2044            resolved_at: ts_opt(resolved),
2045            note,
2046        },
2047    ))
2048}
2049
2050/// One message, by id.
2051pub async fn get_message(
2052    pool: &PgPool,
2053    backends: &crate::store::routing::Backends,
2054    auth: &AuthCtx,
2055    message_id: Uuid,
2056) -> BusResult<ConversationMessage> {
2057    require_capability(pool, auth).await?;
2058    #[allow(clippy::type_complexity)]
2059    let row: Option<(
2060        Uuid,
2061        i64,
2062        String,
2063        String,
2064        String,
2065        Option<Uuid>,
2066        serde_json::Value,
2067        chrono::DateTime<chrono::Utc>,
2068        String,
2069        Option<String>,
2070        Option<chrono::DateTime<chrono::Utc>>,
2071        Option<String>,
2072        String,
2073    )> = sqlx::query_as(
2074        "SELECT m.conversation_id, m.seq, ag.name, m.sender_session, m.body, m.reply_to,
2075                    m.metadata, m.created_at, m.publication_state, m.canonical_locator,
2076                    m.tombstoned_at, m.tombstone_reason, m.backend
2077               FROM conversation_messages m
2078               JOIN agents ag ON ag.id = m.sender_agent
2079              WHERE m.id = $1 AND m.deleted_at IS NULL",
2080    )
2081    .bind(message_id)
2082    .fetch_optional(pool)
2083    .await?;
2084    let Some((
2085        conversation_id,
2086        seq,
2087        from,
2088        from_session,
2089        body,
2090        reply_to,
2091        metadata,
2092        created_at,
2093        state,
2094        locator,
2095        tombstoned,
2096        tombstone_reason,
2097        message_backend,
2098    )) = row
2099    else {
2100        return Err(BusError::not_found("no such message"));
2101    };
2102    // Access is rechecked here, now: a membership that ended since the
2103    // message was sent does not keep reading it.
2104    let a = readable(pool, auth, conversation_id).await?;
2105    require_accepted(&a)?;
2106    if let Some(m) = &a.membership
2107        && let Some(floor) = m.history_from_seq
2108        && seq <= floor
2109    {
2110        return Err(BusError::Forbidden(
2111            "this message is before the point your membership starts".to_owned(),
2112        ));
2113    }
2114    let my_receipt = match &a.membership {
2115        Some(m) => receipt_of(pool, message_id, m.id).await?,
2116        None => None,
2117    };
2118    // The body is fetched only after that recheck passed, and from the
2119    // backend this message's body is actually on.
2120    let publication = state.clone();
2121    let (body, publication, unavailable) = match resolve_body(
2122        pool,
2123        backends,
2124        auth.team_id,
2125        message_id,
2126        BodyRow {
2127            body,
2128            state,
2129            locator,
2130            backend: message_backend,
2131            tombstoned: tombstoned.is_some(),
2132            tombstone_reason,
2133        },
2134    )
2135    .await?
2136    {
2137        BodyState::Present(body) => (body, publication, None),
2138        BodyState::Missing { publication, why } => {
2139            (String::new(), publication.to_owned(), Some(why.to_string()))
2140        }
2141        BodyState::Unreachable(why) => (String::new(), publication, Some(why.to_string())),
2142    };
2143    Ok(ConversationMessage {
2144        message_id: message_id.to_string(),
2145        seq,
2146        from_address: address_of(&from, &from_session),
2147        from,
2148        body,
2149        reply_to: reply_to.map(|r| r.to_string()),
2150        metadata,
2151        created_at: ts(created_at),
2152        my_receipt,
2153        publication,
2154        unavailable,
2155    })
2156}
2157
2158/// Record this window's own observation of a message. A caller can only
2159/// speak for itself: there is no parameter for whose receipt this is.
2160pub async fn ack(
2161    pool: &PgPool,
2162    auth: &AuthCtx,
2163    message_id: Uuid,
2164    resolved: bool,
2165    note: Option<String>,
2166) -> BusResult<ReceiptInfo> {
2167    require_capability(pool, auth).await?;
2168    let (conversation_id,): (Uuid,) =
2169        sqlx::query_as("SELECT conversation_id FROM conversation_messages WHERE id = $1")
2170            .bind(message_id)
2171            .fetch_optional(pool)
2172            .await?
2173            .ok_or_else(|| BusError::not_found("no such message"))?;
2174    let a = readable(pool, auth, conversation_id).await?;
2175    let Some(membership) = a.membership.as_ref().filter(|m| m.state == "active") else {
2176        return Err(BusError::Forbidden(
2177            "only an active member of this conversation can acknowledge its messages".to_owned(),
2178        ));
2179    };
2180    let note = match note {
2181        Some(n) => Some(crate::store::check_text("note", &n, 2000)?),
2182        None => None,
2183    };
2184
2185    let mut tx = pool.begin().await?;
2186    crate::store::sessions::guard(&mut tx, auth).await?;
2187    require_project_open(&mut tx, auth, conversation_id).await?;
2188    // Only a recipient has a receipt row. Someone who joined after the
2189    // message was sent was not asked, and saying they acknowledged it would
2190    // put them in a denominator they were never in.
2191    // The membership state is re-read inside this update, not trusted from
2192    // the check above: a removal that committed while this request waited
2193    // takes effect here.
2194    let updated: Option<(Uuid,)> = sqlx::query_as(
2195        "UPDATE message_receipts r
2196            SET acknowledged_at = COALESCE(acknowledged_at, now()),
2197                resolved_at = CASE WHEN $3 THEN COALESCE(resolved_at, now()) ELSE resolved_at END,
2198                note = COALESCE($4, note)
2199           FROM conversation_memberships m
2200          WHERE r.message_id = $1 AND r.membership_id = $2
2201            AND m.id = r.membership_id AND m.state = 'active'
2202          RETURNING r.membership_id",
2203    )
2204    .bind(message_id)
2205    .bind(membership.id)
2206    .bind(resolved)
2207    .bind(note.as_deref())
2208    .fetch_optional(&mut *tx)
2209    .await?;
2210    if updated.is_none() {
2211        return Err(BusError::Forbidden(
2212            "this message was not addressed to your window, so there is nothing for you to \
2213             acknowledge. You can read it, which is not the same observation."
2214                .to_owned(),
2215        ));
2216    }
2217    audit(
2218        &mut tx,
2219        auth,
2220        Some(conversation_id),
2221        "message.ack",
2222        Some(message_id),
2223        serde_json::json!({ "resolved": resolved }),
2224    )
2225    .await?;
2226    // Tell the sender to look again, on the same transaction as the receipt
2227    // itself: a notification cannot exist without the receipt it reports,
2228    // and a receipt cannot be written without queueing the notification.
2229    // A no-op for a thread on Postgres, where the event hub already does it.
2230    crate::store::inbox::enqueue_receipt_reference(
2231        &mut tx,
2232        message_id,
2233        membership.id,
2234        if resolved { "resolved" } else { "acknowledged" },
2235    )
2236    .await?;
2237    tx.commit().await?;
2238    receipt_of(pool, message_id, membership.id)
2239        .await?
2240        .ok_or_else(|| BusError::not_found("receipt vanished"))
2241}
2242
2243/// Who was asked, and what each of them has observed.
2244pub async fn receipts(
2245    pool: &PgPool,
2246    auth: &AuthCtx,
2247    message_id: Uuid,
2248) -> BusResult<MessageReceipts> {
2249    require_capability(pool, auth).await?;
2250    let row: Option<(Uuid, i64)> =
2251        sqlx::query_as("SELECT conversation_id, seq FROM conversation_messages WHERE id = $1")
2252            .bind(message_id)
2253            .fetch_optional(pool)
2254            .await?;
2255    let Some((conversation_id, seq)) = row else {
2256        return Err(BusError::not_found("no such message"));
2257    };
2258    let a = readable(pool, auth, conversation_id).await?;
2259    require_accepted(&a)?;
2260    // The same boundary `get_message` applies. A receipt carries recipient
2261    // addresses and a free-text note, which is the discussion itself often
2262    // enough; a member who may not read the message may not read who
2263    // answered it either.
2264    if let Some(m) = &a.membership
2265        && let Some(floor) = m.history_from_seq
2266        && seq <= floor
2267    {
2268        return Err(BusError::Forbidden(
2269            "this message is before the point your membership starts, so its receipts are not yours to read either"
2270                .to_owned(),
2271        ));
2272    }
2273    let rows: Vec<(
2274        String,
2275        String,
2276        Option<chrono::DateTime<chrono::Utc>>,
2277        Option<chrono::DateTime<chrono::Utc>>,
2278        Option<chrono::DateTime<chrono::Utc>>,
2279        Option<chrono::DateTime<chrono::Utc>>,
2280        Option<chrono::DateTime<chrono::Utc>>,
2281        Option<String>,
2282    )> = sqlx::query_as(
2283        "SELECT ag.name, m.session, r.stored_at, r.delivered_at, r.presented_at,
2284                r.acknowledged_at, r.resolved_at, r.note
2285           FROM message_receipts r
2286           JOIN conversation_memberships m ON m.id = r.membership_id
2287           JOIN agents ag ON ag.id = m.agent_id
2288          WHERE r.message_id = $1
2289          ORDER BY ag.name, m.session",
2290    )
2291    .bind(message_id)
2292    .fetch_all(pool)
2293    .await?;
2294    let receipts: Vec<ReceiptInfo> = rows
2295        .into_iter()
2296        .map(
2297            |(agent, session, stored, delivered, presented, acked, resolved, note)| ReceiptInfo {
2298                address: address_of(&agent, &session),
2299                session: (!session.is_empty()).then_some(session),
2300                agent,
2301                stored_at: ts_opt(stored),
2302                delivered_at: ts_opt(delivered),
2303                presented_at: ts_opt(presented),
2304                acknowledged_at: ts_opt(acked),
2305                resolved_at: ts_opt(resolved),
2306                note,
2307            },
2308        )
2309        .collect();
2310    Ok(MessageReceipts {
2311        message_id: message_id.to_string(),
2312        seq,
2313        acknowledged: receipts
2314            .iter()
2315            .filter(|r| r.acknowledged_at.is_some())
2316            .count(),
2317        resolved: receipts.iter().filter(|r| r.resolved_at.is_some()).count(),
2318        total: receipts.len(),
2319        receipts,
2320    })
2321}
2322
2323/// Conversations with something waiting for this window.
2324pub async fn activity(pool: &PgPool, auth: &AuthCtx) -> BusResult<Vec<ConversationActivity>> {
2325    let rows: Vec<(Uuid, String, i64, i64)> = sqlx::query_as(
2326        "SELECT c.id, c.title, c.last_seq,
2327                (SELECT count(*) FROM message_receipts r
2328                  WHERE r.membership_id = m.id AND r.acknowledged_at IS NULL)
2329           FROM conversations c
2330           JOIN conversation_memberships m
2331                ON m.conversation_id = c.id AND m.agent_id = $2 AND m.session = $3
2332          WHERE c.team_id = $1 AND c.archived_at IS NULL AND m.state = 'active'
2333            -- A project thread wakes nobody whose grant is gone.
2334            AND (c.visibility <> 'project' OR EXISTS (
2335                    SELECT 1 FROM project_agent_access a
2336                     WHERE a.project_id = c.project_id AND a.agent_id = m.agent_id))
2337          ORDER BY c.last_seq DESC",
2338    )
2339    .bind(auth.team_id)
2340    .bind(auth.agent_id)
2341    .bind(&auth.session)
2342    .fetch_all(pool)
2343    .await?;
2344    Ok(rows
2345        .into_iter()
2346        .filter(|(_, _, _, unack)| *unack > 0)
2347        .map(
2348            |(id, title, last_seq, unacknowledged)| ConversationActivity {
2349                conversation_id: id.to_string(),
2350                title,
2351                last_seq,
2352                unacknowledged,
2353            },
2354        )
2355        .collect())
2356}
2357
2358// -------------------------------------------- transfer and owner recovery --
2359
2360/// Hand this window's membership to another session **of the same agent**.
2361///
2362/// Two halves, and the second is the target's: a proposal marks the target
2363/// membership `invited` with the same role and history boundary, and nothing
2364/// moves until that window accepts with `join_conversation`. Acceptance
2365/// supersedes the old membership rather than rewriting it: authorship stays,
2366/// old receipts stay, and the successor is recorded so unfinished work has a
2367/// link rather than a forged acknowledgement.
2368pub async fn transfer_membership(
2369    pool: &PgPool,
2370    auth: &AuthCtx,
2371    id: Uuid,
2372    to: &str,
2373) -> BusResult<TransferResult> {
2374    require_capability(pool, auth).await?;
2375    let a = readable(pool, auth, id).await?;
2376    let Some(mine) = a.membership.as_ref().filter(|m| m.state == "active") else {
2377        return Err(BusError::Forbidden(
2378            "you have no active membership in this conversation to transfer".to_owned(),
2379        ));
2380    };
2381    let (agent, session) = crate::store::messaging::parse_address(to)?;
2382    let agent_id = crate::store::agent_id_by_name(pool, auth.team_id, &agent).await?;
2383    // Same agent only. A transfer moves a window's seat between that
2384    // person's own windows; handing it to someone else is an invite, which
2385    // the other member has to accept on its own terms and which does not
2386    // carry this one's history boundary.
2387    if agent_id != auth.agent_id {
2388        return Err(BusError::Forbidden(format!(
2389            "a membership can only be transferred to another window of the same agent. \
2390             '{to}' is someone else — invite them instead, which gives them their own \
2391             history boundary and their own receipts."
2392        )));
2393    }
2394    let session = session.unwrap_or_default();
2395    if session == auth.session {
2396        return Err(BusError::invalid("that is the window you are calling from"));
2397    }
2398
2399    let mut tx = pool.begin().await?;
2400    crate::store::sessions::guard(&mut tx, auth).await?;
2401    // The thread first, in the same order as `invite`: every change of who
2402    // sits where serialises on the conversation row, so two transfers
2403    // between the same windows cannot take each other's seats in opposite
2404    // orders, and an invitation cannot create the target seat between the
2405    // check below and the write.
2406    sqlx::query("SELECT id FROM conversations WHERE id = $1 FOR UPDATE")
2407        .bind(id)
2408        .execute(&mut *tx)
2409        .await?;
2410    require_project_open(&mut tx, auth, id).await?;
2411    // The source seat is re-read and locked here: it was active when the
2412    // request arrived, and leaving or being removed in between must stop
2413    // the transfer rather than hand over a seat that no longer exists.
2414    let source: Option<(String,)> =
2415        sqlx::query_as("SELECT state FROM conversation_memberships WHERE id = $1 FOR UPDATE")
2416            .bind(mine.id)
2417            .fetch_optional(&mut *tx)
2418            .await?;
2419    if source.as_ref().map(|s| s.0.as_str()) != Some("active") {
2420        return Err(BusError::conflict(
2421            "your membership of this conversation is no longer active, so there is nothing to transfer. Nothing was written.",
2422        ));
2423    }
2424    // The target's own seat, if it has one. A transfer moves this window's
2425    // seat to a window that has none. One that is active or invited already
2426    // has a seat, with its own role and its own history boundary, and a
2427    // proposal it cannot accept (`join` takes invitations only) must not
2428    // rewrite them meanwhile. One that was removed was put out on purpose:
2429    // a transfer does not undo a removal.
2430    let target: Option<(String,)> = sqlx::query_as(
2431        "SELECT state FROM conversation_memberships
2432          WHERE conversation_id = $1 AND agent_id = $2 AND session = $3
2433          FOR UPDATE",
2434    )
2435    .bind(id)
2436    .bind(auth.agent_id)
2437    .bind(&session)
2438    .fetch_optional(&mut *tx)
2439    .await?;
2440    match target.as_ref().map(|t| t.0.as_str()) {
2441        Some("active") => {
2442            return Err(BusError::conflict(format!(
2443                "'{to}' is already in this conversation with a seat of its own, so there is \
2444                 nothing to hand over; nothing was written. Keep using that window, or have \
2445                 it leave first if it should take this seat and its history instead."
2446            )));
2447        }
2448        Some("invited") => {
2449            return Err(BusError::conflict(format!(
2450                "'{to}' already holds an invitation to this conversation; nothing was \
2451                 written. Accept it from that window, or have it leave first and transfer \
2452                 again."
2453            )));
2454        }
2455        Some("removed") => {
2456            return Err(BusError::Forbidden(format!(
2457                "'{to}' was removed from this conversation, and a transfer does not undo a \
2458                 removal; nothing was written."
2459            )));
2460        }
2461        _ => {}
2462    }
2463    let moved: Option<(Uuid,)> = sqlx::query_as(
2464        "INSERT INTO conversation_memberships
2465            (conversation_id, agent_id, session, role, state, history_from_seq, invited_by,
2466             transfer_from)
2467         SELECT $1, $2, $3, m.role, 'invited', m.history_from_seq, $2, m.id
2468           FROM conversation_memberships m WHERE m.id = $4
2469         -- Only a seat that was left reaches this: the others were refused
2470         -- above. It is offered again, unbound, so whoever accepts proves
2471         -- it is that window.
2472         ON CONFLICT (conversation_id, agent_id, session) DO UPDATE SET
2473            role = EXCLUDED.role,
2474            state = 'invited',
2475            history_from_seq = EXCLUDED.history_from_seq,
2476            transfer_from = EXCLUDED.transfer_from,
2477            session_id = NULL,
2478            invited_at = now(),
2479            ended_at = NULL
2480         RETURNING id",
2481    )
2482    .bind(id)
2483    .bind(auth.agent_id)
2484    .bind(&session)
2485    .bind(mine.id)
2486    .fetch_optional(&mut *tx)
2487    .await?;
2488    if moved.is_none() {
2489        return Err(BusError::conflict(
2490            "that window's seat could not be offered the transfer; nothing was written",
2491        ));
2492    }
2493    // The link is recorded now; the supersede happens when the target joins.
2494    sqlx::query("UPDATE conversation_memberships SET superseded_by = NULL WHERE id = $1")
2495        .bind(mine.id)
2496        .execute(&mut *tx)
2497        .await?;
2498    audit(
2499        &mut tx,
2500        auth,
2501        Some(id),
2502        "member.transfer",
2503        Some(auth.agent_id),
2504        serde_json::json!({ "from": address_of(&auth.agent_name, &auth.session), "to": to }),
2505    )
2506    .await?;
2507    tx.commit().await?;
2508    Ok(TransferResult {
2509        conversation_id: id.to_string(),
2510        from_address: address_of(&auth.agent_name, &auth.session),
2511        to_address: to.to_owned(),
2512        state: "proposed".into(),
2513    })
2514}
2515
2516/// Complete a transfer: the accepting window takes the seat and the old one
2517/// is superseded. Called from `join` when a proposal is pending.
2518async fn supersede_predecessor(
2519    tx: &mut sqlx::PgConnection,
2520    auth: &AuthCtx,
2521    conversation: Uuid,
2522    new_membership: Uuid,
2523) -> BusResult<()> {
2524    // Exactly the seat this one was offered, and only if that proposal is
2525    // still the open one. "Some transfer by this agent exists" would let an
2526    // unrelated invitation close a live seat, and one transfer close
2527    // several.
2528    sqlx::query(
2529        "UPDATE conversation_memberships p
2530            SET state = 'left', ended_at = now(), superseded_by = $3
2531           FROM conversation_memberships n
2532          WHERE n.id = $3 AND n.transfer_from = p.id
2533            AND p.conversation_id = $1 AND p.agent_id = $2 AND p.id <> $3
2534            AND p.state = 'active'
2535            AND p.superseded_by IS NULL",
2536    )
2537    .bind(conversation)
2538    .bind(auth.agent_id)
2539    .bind(new_membership)
2540    .execute(&mut *tx)
2541    .await?;
2542    // The proposal is spent either way: accepted once, not standing.
2543    sqlx::query("UPDATE conversation_memberships SET transfer_from = NULL WHERE id = $1")
2544        .bind(new_membership)
2545        .execute(tx)
2546        .await?;
2547    Ok(())
2548}
2549
2550/// Read-only recovery for an agent whose windows are all gone.
2551///
2552/// The exception the ADR documents: privacy inside a team is not isolation
2553/// from the owning agent. It requires an **agent token** (not a session
2554/// credential), every session of that agent to be server-confirmed closed,
2555/// revoked or expired — offline presence is not enough — and it returns
2556/// history only for memberships that were not revoked. It grants nothing:
2557/// no posting, no receipts, no new membership. Every use is audited.
2558pub async fn recover_history(
2559    pool: &PgPool,
2560    backends: &crate::store::routing::Backends,
2561    auth: &AuthCtx,
2562    id: Uuid,
2563    after_seq: Option<i64>,
2564    limit: Option<i64>,
2565) -> BusResult<ConversationRead> {
2566    require_capability(pool, auth).await?;
2567    if auth.session_is_authenticated() {
2568        return Err(BusError::Forbidden(
2569            "recovery is an owner-agent operation: call it with your agent token, not with \
2570             a window's session credential."
2571                .to_owned(),
2572        ));
2573    }
2574    // Serialised with registration: a window opening right now must either
2575    // block this recovery or happen after it, never race it.
2576    let mut tx = pool.begin().await?;
2577    // Lock the AGENT row, not the session rows. Locking the sessions that
2578    // exist locks nothing against the one about to be inserted: registration
2579    // writes a new row, conflicts with no held lock, and can slip in between
2580    // this check and the read below. Registration takes the same lock, so
2581    // the two serialise on something that is always there.
2582    sqlx::query("SELECT id FROM agents WHERE id = $1 FOR NO KEY UPDATE")
2583        .bind(auth.agent_id)
2584        .fetch_one(&mut *tx)
2585        .await?;
2586    // Live means able to answer: a session whose parent token was revoked
2587    // is refused at authentication, so it cannot read the thread on the
2588    // agent's behalf and does not block the agent from recovering it.
2589    let live: Vec<(Uuid,)> = sqlx::query_as(
2590        "SELECT s.id FROM agent_sessions s
2591          WHERE s.agent_id = $1 AND s.revoked_at IS NULL AND s.expires_at > now()
2592            AND NOT EXISTS (SELECT 1 FROM api_tokens t
2593                             WHERE t.id = s.parent_token AND t.revoked_at IS NOT NULL)",
2594    )
2595    .bind(auth.agent_id)
2596    .fetch_all(&mut *tx)
2597    .await?;
2598    if !live.is_empty() {
2599        return Err(BusError::conflict(format!(
2600            "{} of your sessions are still live. Recovery is for windows that are gone: \
2601             ask that window to read the thread, or revoke_session it first.",
2602            live.len()
2603        )));
2604    }
2605
2606    // Only memberships that were not removed, and only their own history.
2607    let rows: Vec<(
2608        Uuid,
2609        i64,
2610        String,
2611        String,
2612        String,
2613        Option<Uuid>,
2614        serde_json::Value,
2615        chrono::DateTime<chrono::Utc>,
2616    )> = sqlx::query_as(
2617        "SELECT m.id, m.seq, ag.name, m.sender_session, m.body, m.reply_to, m.metadata,
2618                    m.created_at
2619               FROM conversation_messages m
2620               JOIN agents ag ON ag.id = m.sender_agent
2621               JOIN conversations c ON c.id = m.conversation_id
2622              WHERE m.conversation_id = $1
2623                AND c.team_id = $2
2624                AND m.seq > $5
2625                AND m.deleted_at IS NULL
2626                AND EXISTS (
2627                    SELECT 1 FROM conversation_memberships me
2628                     WHERE me.conversation_id = m.conversation_id
2629                       AND me.agent_id = $3
2630                       AND me.state <> 'removed'
2631                       -- An invitation is not membership: a window that was
2632                       -- asked and never answered read nothing, and its
2633                       -- agent recovers nothing on its behalf. A seat that
2634                       -- was active once still counts, however it ended.
2635                       AND me.accepted_at IS NOT NULL
2636                       AND m.seq > COALESCE(me.history_from_seq, 0))
2637                -- A project thread additionally needs current project access.
2638                AND (c.visibility = 'private' OR EXISTS (
2639                    SELECT 1 FROM project_agent_access a
2640                     WHERE a.project_id = c.project_id AND a.agent_id = $3))
2641              ORDER BY m.seq
2642              LIMIT $4",
2643    )
2644    .bind(id)
2645    .bind(auth.team_id)
2646    .bind(auth.agent_id)
2647    .bind(limit.unwrap_or(DEFAULT_PAGE).clamp(1, MAX_PAGE))
2648    .bind(after_seq.unwrap_or(0))
2649    .fetch_all(&mut *tx)
2650    .await?;
2651    if rows.is_empty() {
2652        return Err(BusError::not_found(
2653            "nothing to recover here: no non-revoked membership of yours covers this \
2654             conversation",
2655        ));
2656    }
2657    audit(
2658        &mut tx,
2659        auth,
2660        Some(id),
2661        "member.recover",
2662        Some(auth.agent_id),
2663        serde_json::json!({ "messages": rows.len(), "read_only": true }),
2664    )
2665    .await?;
2666    tx.commit().await?;
2667
2668    // A real cursor: recovery can be more than one page, and a caller that
2669    // is told `None` stops at the page limit believing it has everything.
2670    let next_after_seq = (rows.len() as i64 == limit.unwrap_or(DEFAULT_PAGE).clamp(1, MAX_PAGE))
2671        .then(|| rows.last().map(|r| r.1))
2672        .flatten();
2673    // Bodies are resolved after the commit, never inside it: the backend
2674    // may be a broker, and a network round trip does not belong in a
2675    // transaction that holds session rows.
2676    let mut messages = Vec::with_capacity(rows.len());
2677    for (mid, seq, from, from_session, body, reply_to, metadata, created_at) in rows {
2678        #[allow(clippy::type_complexity)]
2679        let state: (
2680            String,
2681            Option<String>,
2682            Option<chrono::DateTime<chrono::Utc>>,
2683            Option<String>,
2684            String,
2685        ) = sqlx::query_as(
2686            "SELECT publication_state, canonical_locator, tombstoned_at, tombstone_reason,
2687                    backend
2688               FROM conversation_messages WHERE id = $1",
2689        )
2690        .bind(mid)
2691        .fetch_one(pool)
2692        .await?;
2693        let publication = state.0.clone();
2694        let (body, publication, unavailable) = match resolve_body(
2695            pool,
2696            backends,
2697            auth.team_id,
2698            mid,
2699            BodyRow {
2700                body,
2701                state: state.0,
2702                locator: state.1,
2703                backend: state.4,
2704                tombstoned: state.2.is_some(),
2705                tombstone_reason: state.3,
2706            },
2707        )
2708        .await?
2709        {
2710            BodyState::Present(body) => (body, publication, None),
2711            BodyState::Missing { publication, why } => {
2712                (String::new(), publication.to_owned(), Some(why.to_string()))
2713            }
2714            BodyState::Unreachable(why) => (String::new(), publication, Some(why.to_string())),
2715        };
2716        messages.push(ConversationMessage {
2717            message_id: mid.to_string(),
2718            seq,
2719            from_address: address_of(&from, &from_session),
2720            from,
2721            body,
2722            reply_to: reply_to.map(|r| r.to_string()),
2723            metadata,
2724            created_at: ts(created_at),
2725            // Recovery observes nothing: it is a read, and inventing a
2726            // receipt is the one thing it must not do.
2727            my_receipt: None,
2728            publication,
2729            unavailable,
2730        });
2731    }
2732    Ok(ConversationRead {
2733        conversation_id: id.to_string(),
2734        next_after_seq,
2735        history_from_seq: None,
2736        messages,
2737    })
2738}