Skip to main content

agent_protocols/
discourse.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use base64::engine::general_purpose::URL_SAFE_NO_PAD;
4use base64::Engine as _;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use sha3::{Digest, Sha3_256};
8
9use crate::error::{Result, SdkError};
10use crate::identity::{verify_envelope, AgentId, Envelope, Event};
11
12pub const PROTOCOL: &str = "agent-discourse/1.0";
13
14pub mod event_type {
15    pub const ROOM_CREATE: &str = "room.create";
16    pub const ROOM_JOIN: &str = "room.join";
17    pub const ROOM_JOIN_REVIEW: &str = "room.join.review";
18    pub const ROOM_LEAVE: &str = "room.leave";
19    pub const ROOM_MEMBER_ROLE_UPDATE: &str = "room.member.role.update";
20    pub const ROOM_CLOSE: &str = "room.close";
21    pub const ROOM_CANCEL: &str = "room.cancel";
22    pub const MESSAGE_CREATE: &str = "message.create";
23    pub const REACTION_CREATE: &str = "reaction.create";
24    pub const MESSAGE_PROPOSAL_CREATE: &str = "message.proposal.create";
25    pub const MESSAGE_POLL_CREATE: &str = "message.poll.create";
26    pub const MESSAGE_POLL_VOTE: &str = "message.poll.vote";
27    pub const MESSAGE_RESOLUTION_CREATE: &str = "message.resolution.create";
28    pub const SOURCE_ADD: &str = "source.add";
29    pub const TURN_UPDATE: &str = "turn.update";
30    pub const QUESTION_CREATE: &str = "question.create";
31    pub const ROOM_STEER: &str = "room.steer";
32    pub const MAP_UPDATE: &str = "map.update";
33    pub const ARTIFACT_CREATE: &str = "artifact.create";
34}
35
36#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum RoomState {
39    Scheduled,
40    Active,
41    Ended,
42    Cancelled,
43}
44
45#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(rename_all = "snake_case")]
47pub enum Visibility {
48    Public,
49    Restricted,
50    Private,
51}
52
53#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "snake_case")]
55pub enum TurnPolicy {
56    Free,
57    RoundRobin,
58    ModeratorLed,
59}
60
61#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(rename_all = "snake_case")]
63pub enum Role {
64    Moderator,
65    Expert,
66    Participant,
67    Observer,
68}
69
70#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
71#[serde(rename_all = "snake_case")]
72pub enum JoinRequestStatus {
73    Pending,
74    Approved,
75    Rejected,
76    Expired,
77}
78
79#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
80#[serde(rename_all = "snake_case")]
81pub enum MessageIntent {
82    Question,
83    Answer,
84    Clarification,
85    Critique,
86    Synthesis,
87    FollowUp,
88    Other,
89}
90
91#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
92#[serde(rename_all = "snake_case")]
93pub enum MapOperation {
94    UpsertNode,
95    MoveNode,
96    DeleteNode,
97    MergeNodes,
98    ReplaceSnapshot,
99    MarkResolved,
100}
101
102#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
103#[serde(rename_all = "snake_case")]
104pub enum ResolutionOutcome {
105    Accepted,
106    Rejected,
107    Deferred,
108    Superseded,
109}
110
111#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
112pub struct RoomCreatePayload {
113    pub topic: String,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub agenda: Option<String>,
116    pub visibility: Visibility,
117    pub start_time: i64,
118    pub end_time: i64,
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub tags: Vec<String>,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub language: Option<String>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub policy: Option<RoomPolicy>,
125    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
126    pub extensions: BTreeMap<String, Value>,
127    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
128    pub extra: BTreeMap<String, Value>,
129}
130
131#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
132pub struct RoomPolicy {
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub turn_policy: Option<TurnPolicy>,
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub moderator_agent_ids: Vec<AgentId>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub max_participants: Option<u32>,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub observer_allowed: Option<bool>,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub observer_steering_allowed: Option<bool>,
143    #[serde(default, flatten)]
144    pub extra: BTreeMap<String, Value>,
145}
146
147impl RoomPolicy {
148    pub fn new() -> Self {
149        Self::default()
150    }
151}
152
153impl RoomCreatePayload {
154    pub fn new(
155        topic: impl Into<String>,
156        visibility: Visibility,
157        start_time: i64,
158        end_time: i64,
159    ) -> Self {
160        Self {
161            topic: topic.into(),
162            agenda: None,
163            visibility,
164            start_time,
165            end_time,
166            tags: Vec::new(),
167            language: None,
168            policy: None,
169            extensions: BTreeMap::new(),
170            extra: BTreeMap::new(),
171        }
172    }
173}
174
175#[derive(Clone, Debug, Serialize, Deserialize)]
176pub struct ServerRecord<P = Value> {
177    pub room_id: String,
178    pub seq: u64,
179    #[serde(default)]
180    pub pre_hash: Option<String>,
181    pub hash: String,
182    pub received_at: i64,
183    pub envelope: Envelope<P>,
184}
185
186#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
187pub struct ServerRecordHashPayload {
188    pub room_id: String,
189    pub seq: u64,
190    pub pre_hash: Option<String>,
191    pub envelope_hash: String,
192    pub received_at: i64,
193}
194
195#[derive(Clone, Debug, Serialize, Deserialize)]
196pub struct RoomResponse {
197    pub id: String,
198    pub status: RoomState,
199    pub url: String,
200    pub seq: u64,
201    #[serde(default)]
202    pub pre_hash: Option<String>,
203    pub hash: String,
204    pub received_at: i64,
205    pub envelope: Option<Envelope<RoomCreatePayload>>,
206}
207
208#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
209pub struct RoomJoinPayload {
210    pub request_id: String,
211    pub role: Role,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub perspective: Option<String>,
214}
215
216#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
217pub struct RoomJoinRequestPayload {
218    pub requested_role: Role,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub perspective: Option<String>,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub reason: Option<String>,
223    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
224    pub extra: BTreeMap<String, Value>,
225}
226
227impl RoomJoinRequestPayload {
228    pub fn new(requested_role: Role) -> Self {
229        Self {
230            requested_role,
231            perspective: None,
232            reason: None,
233            extra: BTreeMap::new(),
234        }
235    }
236}
237
238#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
239pub struct RoomJoinRequest {
240    pub id: String,
241    pub room_id: String,
242    pub applicant: AgentId,
243    pub requested_role: Role,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub approved_role: Option<Role>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub perspective: Option<String>,
248    pub status: JoinRequestStatus,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub request_reason: Option<String>,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub review_reason: Option<String>,
253    pub created_at: i64,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub reviewed_by: Option<AgentId>,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub reviewed_at: Option<i64>,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub expires_at: Option<i64>,
260    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
261    pub extra: BTreeMap<String, Value>,
262}
263
264#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
265#[serde(rename_all = "snake_case")]
266pub enum JoinDecision {
267    Approve,
268    Reject,
269}
270
271#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
272pub struct RoomJoinReviewPayload {
273    pub request_id: String,
274    pub member: AgentId,
275    pub decision: JoinDecision,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub role: Option<Role>,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub reason: Option<String>,
280}
281
282#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
283pub struct RoomLeavePayload {
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub reason: Option<String>,
286}
287
288#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
289pub struct RoleUpdatePayload {
290    pub member: AgentId,
291    pub role: Role,
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub reason: Option<String>,
294}
295
296#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
297pub struct MessageCreatePayload {
298    pub content_type: String,
299    pub content: Value,
300    #[serde(default, skip_serializing_if = "Vec::is_empty")]
301    pub references: Vec<String>,
302}
303
304impl MessageCreatePayload {
305    pub fn new(content_type: impl Into<String>, content: Value) -> Self {
306        Self {
307            content_type: content_type.into(),
308            content,
309            references: Vec::new(),
310        }
311    }
312
313    pub fn text(text: impl Into<String>) -> Self {
314        Self::new("text/plain", Value::String(text.into()))
315    }
316
317    pub fn markdown(markdown: impl Into<String>) -> Self {
318        Self::new("text/markdown", Value::String(markdown.into()))
319    }
320}
321
322#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
323pub struct ProposalCreatePayload {
324    pub proposal_id: String,
325    pub title: String,
326    pub body: String,
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub content_type: Option<String>,
329    #[serde(default, skip_serializing_if = "Vec::is_empty")]
330    pub references: Vec<String>,
331    #[serde(default, skip_serializing_if = "Vec::is_empty")]
332    pub source_event_ids: Vec<String>,
333    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
334    pub extra: BTreeMap<String, Value>,
335}
336
337#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
338pub struct PollOption {
339    pub id: String,
340    pub label: String,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub description: Option<String>,
343}
344
345#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
346pub struct PollCreatePayload {
347    pub poll_id: String,
348    pub question: String,
349    pub options: Vec<PollOption>,
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub min_choices: Option<u32>,
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub max_choices: Option<u32>,
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub closes_at: Option<i64>,
356    #[serde(default, skip_serializing_if = "Vec::is_empty")]
357    pub references: Vec<String>,
358    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
359    pub extra: BTreeMap<String, Value>,
360}
361
362#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
363pub struct PollVotePayload {
364    pub event_id: String,
365    pub option_ids: Vec<String>,
366}
367
368#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
369pub struct ResolutionCreatePayload {
370    pub resolution_id: String,
371    pub outcome: ResolutionOutcome,
372    pub summary: String,
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub proposal_event_id: Option<String>,
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub poll_event_id: Option<String>,
377    #[serde(default, skip_serializing_if = "Vec::is_empty")]
378    pub references: Vec<String>,
379    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
380    pub extra: BTreeMap<String, Value>,
381}
382
383#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
384pub struct ReactionCreatePayload {
385    pub event_id: String,
386    pub reaction: String,
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub score: Option<f64>,
389}
390
391impl ReactionCreatePayload {
392    pub fn new(event_id: impl Into<String>, reaction: impl Into<String>) -> Self {
393        Self {
394            event_id: event_id.into(),
395            reaction: reaction.into(),
396            score: None,
397        }
398    }
399}
400
401#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
402pub struct SourceAddPayload {
403    pub source_type: String,
404    pub uri: String,
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub title: Option<String>,
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub retrieved_at: Option<i64>,
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub content_digest: Option<String>,
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub excerpt: Option<String>,
413    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
414    pub extra: BTreeMap<String, Value>,
415}
416
417#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
418pub struct TurnUpdatePayload {
419    pub turn_id: u64,
420    pub speaker: AgentId,
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub intent: Option<MessageIntent>,
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub topic: Option<String>,
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub expires_at: Option<i64>,
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub reason: Option<String>,
429}
430
431#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
432pub struct QuestionGeneratePayload {
433    pub question: String,
434    #[serde(default, skip_serializing_if = "Vec::is_empty")]
435    pub target_perspectives: Vec<String>,
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub basis: Option<String>,
438    #[serde(default, skip_serializing_if = "Vec::is_empty")]
439    pub source_event_ids: Vec<String>,
440    #[serde(default, skip_serializing_if = "Vec::is_empty")]
441    pub references: Vec<String>,
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub priority: Option<f64>,
444}
445
446#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
447pub struct DiscourseSteerPayload {
448    pub instruction: String,
449    pub target: String,
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub priority: Option<f64>,
452    #[serde(default, skip_serializing_if = "Vec::is_empty")]
453    pub references: Vec<String>,
454}
455
456#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
457#[serde(rename_all = "snake_case")]
458pub enum MapNodeStatus {
459    Open,
460    Resolved,
461    Closed,
462}
463
464#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
465pub struct MapNode {
466    pub id: String,
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub parent_id: Option<String>,
469    pub title: String,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub summary: Option<String>,
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub status: Option<MapNodeStatus>,
474    #[serde(default, skip_serializing_if = "Vec::is_empty")]
475    pub source_event_ids: Vec<String>,
476    #[serde(default, skip_serializing_if = "Vec::is_empty")]
477    pub discussion_event_ids: Vec<String>,
478    #[serde(default, skip_serializing_if = "Vec::is_empty")]
479    pub children: Vec<MapNode>,
480    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
481    pub extra: BTreeMap<String, Value>,
482}
483
484#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
485pub struct MapUpdatePayload {
486    pub operation: MapOperation,
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub node: Option<MapNode>,
489    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
490    pub extra: BTreeMap<String, Value>,
491    #[serde(default, flatten)]
492    pub extensions: BTreeMap<String, Value>,
493}
494
495#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
496pub struct ArtifactCreatePayload {
497    pub artifact_id: String,
498    pub format: String,
499    pub title: String,
500    pub uri: String,
501    #[serde(default, skip_serializing_if = "Option::is_none")]
502    pub content_digest: Option<String>,
503    #[serde(default, skip_serializing_if = "Vec::is_empty")]
504    pub source_event_ids: Vec<String>,
505    #[serde(default, skip_serializing_if = "Vec::is_empty")]
506    pub discussion_event_ids: Vec<String>,
507    #[serde(default, skip_serializing_if = "Option::is_none")]
508    pub map_event_id: Option<String>,
509}
510
511#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
512pub struct RoomClosePayload {
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub reason: Option<String>,
515}
516
517#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
518pub struct RoomCancelPayload {
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub reason: Option<String>,
521}
522
523#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
524pub struct ProfileResolverMetadata {
525    pub mode: String,
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub service: Option<String>,
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    pub protocol: Option<String>,
530}
531
532#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
533pub struct DiscourseProtocolDiscovery {
534    pub protocol: String,
535    pub host: String,
536    #[serde(default, skip_serializing_if = "Vec::is_empty")]
537    pub features: Vec<String>,
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub profile: Option<ProfileResolverMetadata>,
540    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
541    pub endpoints: BTreeMap<String, String>,
542}
543
544#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
545pub struct MapSnapshotRef {
546    pub event_id: String,
547    pub digest: String,
548}
549
550#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
551pub struct ArtifactManifest {
552    pub artifact_id: String,
553    #[serde(rename = "type")]
554    pub kind: String,
555    pub format: String,
556    pub uri: String,
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub content_digest: Option<String>,
559}
560
561#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
562pub struct ArchiveManifest {
563    pub protocol: String,
564    #[serde(rename = "type")]
565    pub kind: String,
566    pub host: String,
567    pub room_id: String,
568    pub url: String,
569    pub generated_at: i64,
570    pub event_count: u64,
571    pub first_seq: u64,
572    pub last_seq: u64,
573    pub last_hash: String,
574    pub events_sha3_256: String,
575    pub archive_root: String,
576    #[serde(default, skip_serializing_if = "Option::is_none")]
577    pub map_snapshot: Option<MapSnapshotRef>,
578    #[serde(default, skip_serializing_if = "Vec::is_empty")]
579    pub artifacts: Vec<ArtifactManifest>,
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub discourse_trace_quality_score: Option<f64>,
582    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
583    pub formats: BTreeMap<String, String>,
584}
585
586pub fn room_create_event(
587    actor: AgentId,
588    created_at: i64,
589    nonce: u64,
590    payload: RoomCreatePayload,
591) -> Event<RoomCreatePayload> {
592    Event::new(
593        PROTOCOL,
594        event_type::ROOM_CREATE,
595        actor,
596        created_at,
597        nonce,
598        payload,
599    )
600}
601
602pub fn discourse_event<P>(
603    kind: impl Into<String>,
604    actor: AgentId,
605    created_at: i64,
606    nonce: u64,
607    room_id: impl Into<String>,
608    payload: P,
609) -> Event<P> {
610    Event::new(PROTOCOL, kind, actor, created_at, nonce, payload).with_room_id(room_id)
611}
612
613pub fn validate_discourse_envelope<P>(envelope: &Envelope<P>) -> Result<()>
614where
615    P: Serialize,
616{
617    verify_envelope(envelope)?;
618    let protocol = envelope.event.protocol.as_str();
619    if protocol != PROTOCOL {
620        return Err(SdkError::InvalidEventProtocol {
621            expected: PROTOCOL.to_owned(),
622            actual: envelope.event.protocol.clone(),
623        });
624    }
625    if event_requires_room_id(&envelope.event.kind) && envelope.event.room_id.is_none() {
626        return Err(SdkError::MissingRoomId);
627    }
628    Ok(())
629}
630
631pub fn validate_room_path<P>(envelope: &Envelope<P>, path_room_id: &str) -> Result<()> {
632    match envelope.event.room_id.as_deref() {
633        Some(actual) if actual == path_room_id => Ok(()),
634        Some(actual) => Err(SdkError::RoomIdMismatch {
635            expected: path_room_id.to_owned(),
636            actual: actual.to_owned(),
637        }),
638        None if envelope.event.kind == event_type::ROOM_CREATE => Ok(()),
639        None => Err(SdkError::MissingRoomId),
640    }
641}
642
643pub fn event_requires_room_id(event_type: &str) -> bool {
644    event_type != event_type::ROOM_CREATE
645}
646
647pub fn validate_room_create_payload(payload: &RoomCreatePayload) -> Result<()> {
648    if payload.topic.trim().is_empty() {
649        return Err(SdkError::InvalidPayload(
650            "room topic must not be empty".to_owned(),
651        ));
652    }
653    if payload.start_time >= payload.end_time {
654        return Err(SdkError::InvalidPayload(
655            "start_time must be before end_time".to_owned(),
656        ));
657    }
658    if let Some(policy) = &payload.policy {
659        if matches!(policy.max_participants, Some(0)) {
660            return Err(SdkError::InvalidPayload(
661                "max_participants must be a positive integer".to_owned(),
662            ));
663        }
664    }
665    Ok(())
666}
667
668pub fn validate_poll_create_payload(payload: &PollCreatePayload) -> Result<()> {
669    if payload.poll_id.trim().is_empty() || payload.question.trim().is_empty() {
670        return Err(SdkError::InvalidPayload(
671            "poll_id and question are required".to_owned(),
672        ));
673    }
674    if payload.options.len() < 2 {
675        return Err(SdkError::InvalidPayload(
676            "poll requires at least two options".to_owned(),
677        ));
678    }
679    let mut option_ids = BTreeSet::new();
680    for option in &payload.options {
681        if option.id.trim().is_empty() || option.label.trim().is_empty() {
682            return Err(SdkError::InvalidPayload(
683                "option id and label are required".to_owned(),
684            ));
685        }
686        if !option_ids.insert(option.id.as_str()) {
687            return Err(SdkError::InvalidPayload(
688                "poll option ids must be unique".to_owned(),
689            ));
690        }
691    }
692    let min_choices = payload.min_choices.unwrap_or(1);
693    let max_choices = payload.max_choices.unwrap_or(1);
694    if min_choices < 1 || max_choices < min_choices {
695        return Err(SdkError::InvalidPayload(
696            "invalid poll choice limits".to_owned(),
697        ));
698    }
699    Ok(())
700}
701
702pub fn validate_poll_vote_payload(
703    payload: &PollVotePayload,
704    poll: &PollCreatePayload,
705    now_ms: Option<i64>,
706) -> Result<()> {
707    if let (Some(closes_at), Some(now_ms)) = (poll.closes_at, now_ms) {
708        if now_ms > closes_at {
709            return Err(SdkError::InvalidPayload("poll is closed".to_owned()));
710        }
711    }
712    let min_choices = poll.min_choices.unwrap_or(1) as usize;
713    let max_choices = poll.max_choices.unwrap_or(1) as usize;
714    let option_ids: BTreeSet<&str> = poll
715        .options
716        .iter()
717        .map(|option| option.id.as_str())
718        .collect();
719    let selected: BTreeSet<&str> = payload.option_ids.iter().map(String::as_str).collect();
720    if selected.len() != payload.option_ids.len() {
721        return Err(SdkError::InvalidPayload(
722            "duplicate poll options".to_owned(),
723        ));
724    }
725    if selected.len() < min_choices || selected.len() > max_choices {
726        return Err(SdkError::InvalidPayload(
727            "invalid number of options".to_owned(),
728        ));
729    }
730    if selected
731        .iter()
732        .any(|option_id| !option_ids.contains(option_id))
733    {
734        return Err(SdkError::InvalidPayload("unknown poll option".to_owned()));
735    }
736    Ok(())
737}
738
739pub fn server_record_hash_payload(
740    room_id: &str,
741    seq: u64,
742    pre_hash: Option<&str>,
743    envelope_hash: &str,
744    received_at: i64,
745) -> ServerRecordHashPayload {
746    ServerRecordHashPayload {
747        room_id: room_id.to_owned(),
748        seq,
749        pre_hash: pre_hash.map(str::to_owned),
750        envelope_hash: envelope_hash.to_owned(),
751        received_at,
752    }
753}
754
755pub fn server_record_hash(
756    room_id: &str,
757    seq: u64,
758    pre_hash: Option<&str>,
759    envelope_hash: &str,
760    received_at: i64,
761) -> Result<String> {
762    hash_canonical_json(&server_record_hash_payload(
763        room_id,
764        seq,
765        pre_hash,
766        envelope_hash,
767        received_at,
768    ))
769}
770
771pub fn build_server_record<P>(
772    room_id: impl Into<String>,
773    seq: u64,
774    pre_hash: Option<String>,
775    received_at: i64,
776    envelope: Envelope<P>,
777) -> Result<ServerRecord<P>> {
778    let room_id = room_id.into();
779    let hash = server_record_hash(
780        &room_id,
781        seq,
782        pre_hash.as_deref(),
783        &envelope.hash,
784        received_at,
785    )?;
786    Ok(ServerRecord {
787        room_id,
788        seq,
789        pre_hash,
790        hash,
791        received_at,
792        envelope,
793    })
794}
795
796pub fn verify_server_record<P>(record: &ServerRecord<P>) -> Result<()>
797where
798    P: Serialize,
799{
800    let expected = server_record_hash(
801        &record.room_id,
802        record.seq,
803        record.pre_hash.as_deref(),
804        &record.envelope.hash,
805        record.received_at,
806    )?;
807    if record.hash == expected {
808        Ok(())
809    } else {
810        Err(SdkError::InvalidEventHash {
811            expected,
812            actual: record.hash.clone(),
813        })
814    }
815}
816
817pub fn verify_server_record_chain<P>(records: &[ServerRecord<P>]) -> Result<()>
818where
819    P: Serialize,
820{
821    let mut previous: Option<&ServerRecord<P>> = None;
822    for record in records {
823        verify_server_record(record)?;
824        if let Some(previous) = previous {
825            if record.seq != previous.seq + 1 {
826                return Err(SdkError::InvalidPayload(
827                    "seq must increase by 1".to_owned(),
828                ));
829            }
830            if record.pre_hash.as_deref() != Some(previous.hash.as_str()) {
831                return Err(SdkError::InvalidPayload("pre_hash mismatch".to_owned()));
832            }
833        } else if record.seq != 1 {
834            return Err(SdkError::InvalidPayload("first seq must be 1".to_owned()));
835        } else if record.pre_hash.is_some() {
836            return Err(SdkError::InvalidPayload(
837                "first pre_hash must be null".to_owned(),
838            ));
839        }
840        previous = Some(record);
841    }
842    Ok(())
843}
844
845pub fn archive_events_digest<P>(records: &[ServerRecord<P>]) -> Result<String>
846where
847    P: Serialize,
848{
849    hash_canonical_json(records)
850}
851
852#[derive(Clone, Debug, Default, PartialEq, Eq)]
853pub struct PermissionContext {
854    pub role: Option<Role>,
855    pub is_creator: bool,
856    pub join_request_approved: bool,
857    pub moderator_authorized: bool,
858    pub expert_policy_allowed: bool,
859    pub participant_policy_allowed: bool,
860    pub observer_steering_allowed: bool,
861    pub observer_poll_vote_allowed: bool,
862}
863
864impl PermissionContext {
865    pub fn for_role(role: Role) -> Self {
866        Self {
867            role: Some(role),
868            ..Self::default()
869        }
870    }
871
872    pub fn creator(role: Option<Role>) -> Self {
873        Self {
874            role,
875            is_creator: true,
876            ..Self::default()
877        }
878    }
879}
880
881#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
882pub struct StateWriteOptions {
883    pub post_end_reaction_allowed: bool,
884}
885
886pub fn can_submit_event(event_type: &str, context: &PermissionContext) -> bool {
887    if event_type == event_type::ROOM_CREATE {
888        return true;
889    }
890    if event_type == event_type::ROOM_JOIN {
891        return context.join_request_approved;
892    }
893
894    if context.is_creator {
895        return is_known_event_type(event_type);
896    }
897
898    match context.role {
899        Some(Role::Moderator) => moderator_can_submit(event_type, context.moderator_authorized),
900        Some(Role::Expert) => speaker_can_submit(event_type, context.expert_policy_allowed),
901        Some(Role::Participant) => {
902            speaker_can_submit(event_type, context.participant_policy_allowed)
903        }
904        Some(Role::Observer) => observer_can_submit(event_type, context),
905        None => false,
906    }
907}
908
909pub fn can_write_in_state(event_type: &str, state: RoomState, options: StateWriteOptions) -> bool {
910    match state {
911        RoomState::Scheduled => matches!(
912            event_type,
913            event_type::ROOM_JOIN | event_type::ROOM_JOIN_REVIEW | event_type::ROOM_CANCEL
914        ),
915        RoomState::Active => {
916            event_type != event_type::ROOM_CREATE && event_type != event_type::ROOM_CANCEL
917        }
918        RoomState::Ended => {
919            options.post_end_reaction_allowed && event_type == event_type::REACTION_CREATE
920        }
921        RoomState::Cancelled => false,
922    }
923}
924
925pub fn can_accept_room_write(
926    event_type: &str,
927    state: RoomState,
928    permission: &PermissionContext,
929    state_options: StateWriteOptions,
930) -> bool {
931    can_submit_event(event_type, permission) && can_write_in_state(event_type, state, state_options)
932}
933
934pub fn validate_room_write(
935    event_type: &str,
936    state: RoomState,
937    permission: &PermissionContext,
938    state_options: StateWriteOptions,
939) -> Result<()> {
940    if can_accept_room_write(event_type, state, permission, state_options) {
941        Ok(())
942    } else {
943        Err(SdkError::PermissionDenied)
944    }
945}
946
947fn moderator_can_submit(event_type: &str, moderator_authorized: bool) -> bool {
948    matches!(
949        event_type,
950        event_type::ROOM_JOIN_REVIEW
951            | event_type::ROOM_CLOSE
952            | event_type::MESSAGE_CREATE
953            | event_type::SOURCE_ADD
954            | event_type::TURN_UPDATE
955            | event_type::QUESTION_CREATE
956            | event_type::ROOM_STEER
957            | event_type::MAP_UPDATE
958            | event_type::ARTIFACT_CREATE
959            | event_type::MESSAGE_PROPOSAL_CREATE
960            | event_type::MESSAGE_POLL_CREATE
961            | event_type::MESSAGE_POLL_VOTE
962            | event_type::MESSAGE_RESOLUTION_CREATE
963            | event_type::REACTION_CREATE
964            | event_type::ROOM_LEAVE
965    ) || (moderator_authorized
966        && matches!(
967            event_type,
968            event_type::ROOM_MEMBER_ROLE_UPDATE | event_type::ROOM_CANCEL
969        ))
970}
971
972fn speaker_can_submit(event_type: &str, policy_allowed: bool) -> bool {
973    matches!(
974        event_type,
975        event_type::MESSAGE_CREATE
976            | event_type::SOURCE_ADD
977            | event_type::ROOM_STEER
978            | event_type::MESSAGE_PROPOSAL_CREATE
979            | event_type::MESSAGE_POLL_CREATE
980            | event_type::MESSAGE_POLL_VOTE
981            | event_type::REACTION_CREATE
982            | event_type::ROOM_LEAVE
983    ) || (policy_allowed
984        && matches!(
985            event_type,
986            event_type::QUESTION_CREATE
987                | event_type::MAP_UPDATE
988                | event_type::ARTIFACT_CREATE
989                | event_type::MESSAGE_RESOLUTION_CREATE
990        ))
991}
992
993fn observer_can_submit(event_type: &str, context: &PermissionContext) -> bool {
994    matches!(
995        event_type,
996        event_type::REACTION_CREATE | event_type::ROOM_LEAVE
997    ) || (context.observer_steering_allowed && event_type == event_type::ROOM_STEER)
998        || (context.observer_poll_vote_allowed && event_type == event_type::MESSAGE_POLL_VOTE)
999}
1000
1001fn is_known_event_type(event_type: &str) -> bool {
1002    matches!(
1003        event_type,
1004        event_type::ROOM_CREATE
1005            | event_type::ROOM_JOIN
1006            | event_type::ROOM_JOIN_REVIEW
1007            | event_type::ROOM_LEAVE
1008            | event_type::ROOM_MEMBER_ROLE_UPDATE
1009            | event_type::ROOM_CLOSE
1010            | event_type::ROOM_CANCEL
1011            | event_type::MESSAGE_CREATE
1012            | event_type::REACTION_CREATE
1013            | event_type::MESSAGE_PROPOSAL_CREATE
1014            | event_type::MESSAGE_POLL_CREATE
1015            | event_type::MESSAGE_POLL_VOTE
1016            | event_type::MESSAGE_RESOLUTION_CREATE
1017            | event_type::SOURCE_ADD
1018            | event_type::TURN_UPDATE
1019            | event_type::QUESTION_CREATE
1020            | event_type::ROOM_STEER
1021            | event_type::MAP_UPDATE
1022            | event_type::ARTIFACT_CREATE
1023    )
1024}
1025
1026fn hash_canonical_json<T>(value: &T) -> Result<String>
1027where
1028    T: Serialize + ?Sized,
1029{
1030    let bytes = serde_jcs::to_vec(value).map_err(|err| SdkError::CanonicalJson(err.to_string()))?;
1031    let digest = Sha3_256::digest(bytes);
1032    Ok(URL_SAFE_NO_PAD.encode(digest))
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037    use super::*;
1038    use crate::identity::AgentSigner;
1039    use serde_json::json;
1040
1041    #[test]
1042    fn validates_room_create_without_room_id() {
1043        let signer = AgentSigner::from_seed([14; 32]);
1044        let payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1045        let event = Event::new(
1046            PROTOCOL,
1047            event_type::ROOM_CREATE,
1048            signer.agent_id(),
1049            100,
1050            1,
1051            payload,
1052        );
1053        let envelope = signer.sign_event(event).unwrap();
1054
1055        validate_discourse_envelope(&envelope).unwrap();
1056        validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").unwrap();
1057    }
1058
1059    #[test]
1060    fn rejects_room_event_without_room_id() {
1061        let signer = AgentSigner::from_seed([15; 32]);
1062        let event = Event::new(
1063            PROTOCOL,
1064            event_type::MESSAGE_CREATE,
1065            signer.agent_id(),
1066            100,
1067            1,
1068            MessageCreatePayload::text("hello"),
1069        );
1070        let envelope = signer.sign_event(event).unwrap();
1071
1072        assert!(matches!(
1073            validate_discourse_envelope(&envelope),
1074            Err(SdkError::MissingRoomId)
1075        ));
1076    }
1077
1078    #[test]
1079    fn applies_permission_matrix() {
1080        let observer = PermissionContext::for_role(Role::Observer);
1081        assert!(can_submit_event(event_type::REACTION_CREATE, &observer));
1082        assert!(!can_submit_event(event_type::MESSAGE_CREATE, &observer));
1083        assert!(!can_submit_event(event_type::ROOM_JOIN, &observer));
1084
1085        let approved_join = PermissionContext {
1086            join_request_approved: true,
1087            ..PermissionContext::default()
1088        };
1089        assert!(can_submit_event(event_type::ROOM_JOIN, &approved_join));
1090
1091        let mut moderator = PermissionContext::for_role(Role::Moderator);
1092        assert!(can_submit_event(event_type::ROOM_JOIN_REVIEW, &moderator));
1093        assert!(!can_submit_event(event_type::ROOM_CANCEL, &moderator));
1094        moderator.moderator_authorized = true;
1095        assert!(can_submit_event(event_type::ROOM_CANCEL, &moderator));
1096
1097        let participant = PermissionContext::for_role(Role::Participant);
1098        assert!(!can_submit_event(
1099            event_type::ROOM_JOIN_REVIEW,
1100            &participant
1101        ));
1102    }
1103
1104    #[test]
1105    fn applies_state_restrictions() {
1106        let participant = PermissionContext::for_role(Role::Participant);
1107        assert!(can_accept_room_write(
1108            event_type::MESSAGE_CREATE,
1109            RoomState::Active,
1110            &participant,
1111            StateWriteOptions::default()
1112        ));
1113        assert!(!can_accept_room_write(
1114            event_type::MESSAGE_CREATE,
1115            RoomState::Scheduled,
1116            &participant,
1117            StateWriteOptions::default()
1118        ));
1119        assert!(!can_accept_room_write(
1120            event_type::REACTION_CREATE,
1121            RoomState::Ended,
1122            &participant,
1123            StateWriteOptions::default()
1124        ));
1125        assert!(can_accept_room_write(
1126            event_type::ROOM_JOIN_REVIEW,
1127            RoomState::Scheduled,
1128            &PermissionContext::for_role(Role::Moderator),
1129            StateWriteOptions::default()
1130        ));
1131        assert!(can_accept_room_write(
1132            event_type::ROOM_JOIN,
1133            RoomState::Scheduled,
1134            &PermissionContext {
1135                join_request_approved: true,
1136                ..PermissionContext::default()
1137            },
1138            StateWriteOptions::default()
1139        ));
1140        assert!(can_accept_room_write(
1141            event_type::REACTION_CREATE,
1142            RoomState::Ended,
1143            &participant,
1144            StateWriteOptions {
1145                post_end_reaction_allowed: true,
1146            }
1147        ));
1148    }
1149
1150    #[test]
1151    fn validates_room_creation_payloads() {
1152        let mut payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1153        payload.policy = Some(RoomPolicy {
1154            max_participants: Some(2),
1155            ..RoomPolicy::default()
1156        });
1157        validate_room_create_payload(&payload).unwrap();
1158
1159        let empty_topic = RoomCreatePayload::new(" ", Visibility::Public, 1000, 2000);
1160        assert!(matches!(
1161            validate_room_create_payload(&empty_topic),
1162            Err(SdkError::InvalidPayload(_))
1163        ));
1164
1165        let invalid_time = RoomCreatePayload::new("Research room", Visibility::Public, 2000, 1000);
1166        assert!(matches!(
1167            validate_room_create_payload(&invalid_time),
1168            Err(SdkError::InvalidPayload(_))
1169        ));
1170    }
1171
1172    #[test]
1173    fn validates_poll_payloads_and_votes() {
1174        let poll = PollCreatePayload {
1175            poll_id: "poll_review_order".to_owned(),
1176            question: "Which review order?".to_owned(),
1177            options: vec![
1178                PollOption {
1179                    id: "a".to_owned(),
1180                    label: "Correctness first".to_owned(),
1181                    description: None,
1182                },
1183                PollOption {
1184                    id: "b".to_owned(),
1185                    label: "Security first".to_owned(),
1186                    description: None,
1187                },
1188            ],
1189            min_choices: Some(1),
1190            max_choices: Some(1),
1191            closes_at: None,
1192            references: Vec::new(),
1193            extra: BTreeMap::new(),
1194        };
1195
1196        validate_poll_create_payload(&poll).unwrap();
1197        validate_poll_vote_payload(
1198            &PollVotePayload {
1199                event_id: "evt".to_owned(),
1200                option_ids: vec!["a".to_owned()],
1201            },
1202            &poll,
1203            None,
1204        )
1205        .unwrap();
1206        assert!(matches!(
1207            validate_poll_vote_payload(
1208                &PollVotePayload {
1209                    event_id: "evt".to_owned(),
1210                    option_ids: vec!["a".to_owned(), "b".to_owned()],
1211                },
1212                &poll,
1213                None,
1214            ),
1215            Err(SdkError::InvalidPayload(_))
1216        ));
1217
1218        let mut duplicate = poll.clone();
1219        duplicate.options[1].id = "a".to_owned();
1220        assert!(matches!(
1221            validate_poll_create_payload(&duplicate),
1222            Err(SdkError::InvalidPayload(_))
1223        ));
1224    }
1225
1226    #[test]
1227    fn builds_and_verifies_server_record_chains() {
1228        let signer = AgentSigner::from_seed([18; 32]);
1229        let envelope1 = signer
1230            .sign_event(Event::new(
1231                PROTOCOL,
1232                event_type::ROOM_CREATE,
1233                signer.agent_id(),
1234                100,
1235                1,
1236                json!({
1237                    "topic": "Research room",
1238                    "visibility": "public",
1239                    "start_time": 1000,
1240                    "end_time": 2000
1241                }),
1242            ))
1243            .unwrap();
1244        let record1 = build_server_record("room123", 1, None, 110, envelope1).unwrap();
1245
1246        let envelope2 = signer
1247            .sign_event(discourse_event(
1248                event_type::MESSAGE_CREATE,
1249                signer.agent_id(),
1250                120,
1251                2,
1252                "room123",
1253                json!({"content_type": "text/plain", "content": "hello"}),
1254            ))
1255            .unwrap();
1256        let record2 =
1257            build_server_record("room123", 2, Some(record1.hash.clone()), 130, envelope2).unwrap();
1258
1259        assert_eq!(
1260            record1.hash,
1261            server_record_hash("room123", 1, None, &record1.envelope.hash, 110).unwrap()
1262        );
1263        verify_server_record(&record1).unwrap();
1264        verify_server_record_chain(&[record1.clone(), record2.clone()]).unwrap();
1265        assert_eq!(
1266            archive_events_digest(&[record1.clone(), record2.clone()])
1267                .unwrap()
1268                .len(),
1269            43
1270        );
1271        assert!(verify_server_record_chain(&[record2]).is_err());
1272
1273        let broken = ServerRecord {
1274            pre_hash: Some("bad".to_owned()),
1275            ..record1
1276        };
1277        assert!(verify_server_record_chain(&[broken]).is_err());
1278    }
1279}