Skip to main content

agent_protocols/
discourse.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::error::{Result, SdkError};
7use crate::identity::{verify_envelope, AgentId, Envelope, Event};
8
9pub const PROTOCOL: &str = "agent-discourse/1.0";
10pub const LEGACY_PROTOCOL: &str = "adp/1.0";
11
12pub mod event_type {
13    pub const ROOM_CREATE: &str = "room.create";
14    pub const ROOM_JOIN: &str = "room.join";
15    pub const ROOM_LEAVE: &str = "room.leave";
16    pub const ROOM_MEMBER_ROLE_UPDATE: &str = "room.member.role.update";
17    pub const ROOM_INVITE: &str = "room.invite";
18    pub const ROOM_INVITE_REVOKE: &str = "room.invite.revoke";
19    pub const ROOM_CLOSE: &str = "room.close";
20    pub const ROOM_CANCEL: &str = "room.cancel";
21    pub const MESSAGE_TEXT: &str = "message.text";
22    pub const MESSAGE_MARKDOWN: &str = "message.markdown";
23    pub const MESSAGE_DATA: &str = "message.data";
24    pub const REACTION_CREATE: &str = "reaction.create";
25    pub const PROPOSAL_CREATE: &str = "proposal.create";
26    pub const POLL_CREATE: &str = "poll.create";
27    pub const POLL_VOTE: &str = "poll.vote";
28    pub const RESOLUTION_CREATE: &str = "resolution.create";
29    pub const SOURCE_ADD: &str = "source.add";
30    pub const TURN_UPDATE: &str = "turn.update";
31    pub const QUESTION_GENERATE: &str = "question.generate";
32    pub const DISCOURSE_STEER: &str = "discourse.steer";
33    pub const MINDMAP_UPDATE: &str = "mindmap.update";
34    pub const REPORT_GENERATE: &str = "report.generate";
35    pub const SESSION_AUTH: &str = "session.auth";
36}
37
38#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum RoomState {
41    Scheduled,
42    Active,
43    Ended,
44    Cancelled,
45}
46
47#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(rename_all = "snake_case")]
49pub enum Visibility {
50    Public,
51    Private,
52    Unlisted,
53}
54
55#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "snake_case")]
57pub enum DiscourseMode {
58    Plain,
59    Collaborative,
60    Moderated,
61}
62
63#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "snake_case")]
65pub enum TurnPolicy {
66    Free,
67    RoundRobin,
68    ModeratorLed,
69}
70
71#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(rename_all = "snake_case")]
73pub enum Role {
74    Moderator,
75    Expert,
76    Participant,
77    Observer,
78}
79
80#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
81#[serde(rename_all = "snake_case")]
82pub enum MessageIntent {
83    Question,
84    Answer,
85    Clarification,
86    Critique,
87    Synthesis,
88    FollowUp,
89    Other,
90}
91
92#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
93#[serde(rename_all = "snake_case")]
94pub enum MindmapOperation {
95    UpsertNode,
96    MoveNode,
97    DeleteNode,
98    MergeNodes,
99    ReplaceSnapshot,
100    MarkResolved,
101}
102
103#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
104pub struct RoomCreatePayload {
105    pub topic: String,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub agenda: Option<String>,
108    pub visibility: Visibility,
109    pub start_time: i64,
110    pub end_time: i64,
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub tags: Vec<String>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub language: Option<String>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub discourse_mode: Option<DiscourseMode>,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub turn_policy: Option<TurnPolicy>,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub mindmap_enabled: Option<bool>,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub source_curation_enabled: Option<bool>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub reporting_enabled: Option<bool>,
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub moderator_agent_ids: Vec<AgentId>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub max_participants: Option<u32>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub observer_allowed: Option<bool>,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub observer_steering_allowed: Option<bool>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub participant_approval_required: Option<bool>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub observer_approval_required: Option<bool>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub profile_service: Option<String>,
139    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
140    pub metadata: BTreeMap<String, Value>,
141}
142
143impl RoomCreatePayload {
144    pub fn new(
145        topic: impl Into<String>,
146        visibility: Visibility,
147        start_time: i64,
148        end_time: i64,
149    ) -> Self {
150        Self {
151            topic: topic.into(),
152            agenda: None,
153            visibility,
154            start_time,
155            end_time,
156            tags: Vec::new(),
157            language: None,
158            discourse_mode: None,
159            turn_policy: None,
160            mindmap_enabled: None,
161            source_curation_enabled: None,
162            reporting_enabled: None,
163            moderator_agent_ids: Vec::new(),
164            max_participants: None,
165            observer_allowed: None,
166            observer_steering_allowed: None,
167            participant_approval_required: None,
168            observer_approval_required: None,
169            profile_service: None,
170            metadata: BTreeMap::new(),
171        }
172    }
173}
174
175#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
176pub struct RoomCreateResponse {
177    pub room_id: String,
178    pub status: RoomState,
179    pub created_event_id: String,
180    pub room_uri: String,
181}
182
183#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
184pub struct RoomJoinPayload {
185    pub role: Role,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub perspective: Option<String>,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub invite: Option<Value>,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub webhook_url: Option<String>,
192}
193
194#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
195pub struct RoomLeavePayload {
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub reason: Option<String>,
198}
199
200#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
201pub struct RoleUpdatePayload {
202    pub member: AgentId,
203    pub role: Role,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub reason: Option<String>,
206}
207
208#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
209pub struct RoomInvitePayload {
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub invitee: Option<AgentId>,
212    pub role: Role,
213    pub expires_at: i64,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub max_uses: Option<u32>,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub approval_required: Option<bool>,
218}
219
220#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
221pub struct InviteRevokePayload {
222    pub invite_event_id: String,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub reason: Option<String>,
225}
226
227#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
228pub struct MessageTextPayload {
229    pub text: String,
230    #[serde(default, skip_serializing_if = "Vec::is_empty")]
231    pub references: Vec<String>,
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub intent: Option<MessageIntent>,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub turn_id: Option<String>,
236    #[serde(default, skip_serializing_if = "Vec::is_empty")]
237    pub source_event_ids: Vec<String>,
238}
239
240impl MessageTextPayload {
241    pub fn new(text: impl Into<String>) -> Self {
242        Self {
243            text: text.into(),
244            references: Vec::new(),
245            intent: None,
246            turn_id: None,
247            source_event_ids: Vec::new(),
248        }
249    }
250}
251
252#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
253pub struct MessageMarkdownPayload {
254    pub markdown: String,
255    #[serde(default, skip_serializing_if = "Vec::is_empty")]
256    pub references: Vec<String>,
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub intent: Option<MessageIntent>,
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub turn_id: Option<String>,
261    #[serde(default, skip_serializing_if = "Vec::is_empty")]
262    pub source_event_ids: Vec<String>,
263}
264
265#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
266pub struct MessageDataPayload {
267    pub content_type: String,
268    pub body: Value,
269    #[serde(default, skip_serializing_if = "Vec::is_empty")]
270    pub references: Vec<String>,
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub intent: Option<MessageIntent>,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub turn_id: Option<String>,
275    #[serde(default, skip_serializing_if = "Vec::is_empty")]
276    pub source_event_ids: Vec<String>,
277}
278
279#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
280pub struct ReactionCreatePayload {
281    pub target_event_id: String,
282    pub reaction: String,
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub score: Option<f64>,
285}
286
287impl ReactionCreatePayload {
288    pub fn new(target_event_id: impl Into<String>, reaction: impl Into<String>) -> Self {
289        Self {
290            target_event_id: target_event_id.into(),
291            reaction: reaction.into(),
292            score: None,
293        }
294    }
295}
296
297#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
298pub struct SourceAddPayload {
299    pub source_type: String,
300    pub uri: String,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub title: Option<String>,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub retrieved_at: Option<i64>,
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub content_digest: Option<String>,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub excerpt: Option<String>,
309    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
310    pub metadata: BTreeMap<String, Value>,
311}
312
313#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
314pub struct TurnUpdatePayload {
315    pub turn_id: String,
316    pub speaker: AgentId,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub intent: Option<MessageIntent>,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub topic: Option<String>,
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub expires_at: Option<i64>,
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub reason: Option<String>,
325}
326
327#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
328pub struct QuestionGeneratePayload {
329    pub question: String,
330    #[serde(default, skip_serializing_if = "Vec::is_empty")]
331    pub target_perspectives: Vec<String>,
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub basis: Option<String>,
334    #[serde(default, skip_serializing_if = "Vec::is_empty")]
335    pub source_event_ids: Vec<String>,
336    #[serde(default, skip_serializing_if = "Vec::is_empty")]
337    pub references: Vec<String>,
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub priority: Option<f64>,
340}
341
342#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
343pub struct DiscourseSteerPayload {
344    pub instruction: String,
345    pub target: String,
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub priority: Option<f64>,
348    #[serde(default, skip_serializing_if = "Vec::is_empty")]
349    pub references: Vec<String>,
350}
351
352#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
353#[serde(rename_all = "snake_case")]
354pub enum MindmapNodeStatus {
355    Open,
356    Resolved,
357    Closed,
358}
359
360#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
361pub struct MindmapNode {
362    pub id: String,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub parent_id: Option<String>,
365    pub title: String,
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub summary: Option<String>,
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub status: Option<MindmapNodeStatus>,
370    #[serde(default, skip_serializing_if = "Vec::is_empty")]
371    pub source_event_ids: Vec<String>,
372    #[serde(default, skip_serializing_if = "Vec::is_empty")]
373    pub discussion_event_ids: Vec<String>,
374    #[serde(default, skip_serializing_if = "Vec::is_empty")]
375    pub children: Vec<MindmapNode>,
376    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
377    pub metadata: BTreeMap<String, Value>,
378}
379
380#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
381pub struct MindmapUpdatePayload {
382    pub operation: MindmapOperation,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub node: Option<MindmapNode>,
385    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
386    pub metadata: BTreeMap<String, Value>,
387    #[serde(default, flatten)]
388    pub extra: BTreeMap<String, Value>,
389}
390
391#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
392pub struct ReportGeneratePayload {
393    pub artifact_id: String,
394    pub format: String,
395    pub title: String,
396    pub uri: String,
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub content_digest: Option<String>,
399    #[serde(default, skip_serializing_if = "Vec::is_empty")]
400    pub source_event_ids: Vec<String>,
401    #[serde(default, skip_serializing_if = "Vec::is_empty")]
402    pub discussion_event_ids: Vec<String>,
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub mindmap_event_id: Option<String>,
405}
406
407#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
408pub struct RoomClosePayload {
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub reason: Option<String>,
411}
412
413#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
414pub struct RoomCancelPayload {
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub reason: Option<String>,
417}
418
419#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
420pub struct ServerRecord<P = Value> {
421    pub room_id: String,
422    pub seq: u64,
423    pub received_at: i64,
424    pub envelope: Envelope<P>,
425}
426
427#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
428pub struct ProfileResolverMetadata {
429    pub mode: String,
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub service: Option<String>,
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub protocol: Option<String>,
434}
435
436#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
437pub struct DiscourseProtocolDiscovery {
438    pub protocol: String,
439    pub host: String,
440    #[serde(default, skip_serializing_if = "Vec::is_empty")]
441    pub features: Vec<String>,
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub profile: Option<ProfileResolverMetadata>,
444    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
445    pub endpoints: BTreeMap<String, String>,
446}
447
448#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
449pub struct MindmapSnapshotRef {
450    pub event_id: String,
451    pub digest: String,
452}
453
454#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
455pub struct ArtifactManifest {
456    pub artifact_id: String,
457    #[serde(rename = "type")]
458    pub kind: String,
459    pub format: String,
460    pub uri: String,
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub content_digest: Option<String>,
463}
464
465#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
466pub struct ArchiveManifest {
467    pub protocol: String,
468    #[serde(rename = "type")]
469    pub kind: String,
470    pub host: String,
471    pub room_id: String,
472    pub room_uri: String,
473    pub generated_at: i64,
474    pub event_count: u64,
475    pub first_seq: u64,
476    pub last_seq: u64,
477    pub events_sha256: String,
478    pub archive_root: String,
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub mindmap_snapshot: Option<MindmapSnapshotRef>,
481    #[serde(default, skip_serializing_if = "Vec::is_empty")]
482    pub artifacts: Vec<ArtifactManifest>,
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub discourse_trace_quality_score: Option<f64>,
485    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
486    pub formats: BTreeMap<String, String>,
487}
488
489pub fn room_create_event(
490    actor: AgentId,
491    created_at: i64,
492    nonce: impl Into<String>,
493    payload: RoomCreatePayload,
494) -> Event<RoomCreatePayload> {
495    Event::new(
496        PROTOCOL,
497        event_type::ROOM_CREATE,
498        actor,
499        created_at,
500        nonce,
501        payload,
502    )
503}
504
505pub fn discourse_event<P>(
506    kind: impl Into<String>,
507    actor: AgentId,
508    created_at: i64,
509    nonce: impl Into<String>,
510    room_id: impl Into<String>,
511    payload: P,
512) -> Event<P> {
513    Event::new(PROTOCOL, kind, actor, created_at, nonce, payload).with_room_id(room_id)
514}
515
516pub fn validate_discourse_envelope<P>(
517    envelope: &Envelope<P>,
518    accept_legacy_protocol: bool,
519) -> Result<()>
520where
521    P: Serialize,
522{
523    verify_envelope(envelope)?;
524    let protocol = envelope.event.protocol.as_str();
525    let protocol_ok =
526        protocol == PROTOCOL || (accept_legacy_protocol && protocol == LEGACY_PROTOCOL);
527    if !protocol_ok {
528        return Err(SdkError::InvalidEventProtocol {
529            expected: PROTOCOL.to_owned(),
530            actual: envelope.event.protocol.clone(),
531        });
532    }
533    if event_requires_room_id(&envelope.event.kind) && envelope.event.room_id.is_none() {
534        return Err(SdkError::MissingRoomId);
535    }
536    Ok(())
537}
538
539pub fn validate_room_path<P>(envelope: &Envelope<P>, path_room_id: &str) -> Result<()> {
540    let actual = envelope
541        .event
542        .room_id
543        .as_deref()
544        .ok_or(SdkError::MissingRoomId)?;
545    if actual == path_room_id {
546        Ok(())
547    } else {
548        Err(SdkError::RoomIdMismatch {
549            expected: path_room_id.to_owned(),
550            actual: actual.to_owned(),
551        })
552    }
553}
554
555pub fn event_requires_room_id(event_type: &str) -> bool {
556    event_type != event_type::ROOM_CREATE
557}
558
559#[derive(Clone, Debug, Default, PartialEq, Eq)]
560pub struct PermissionContext {
561    pub role: Option<Role>,
562    pub is_creator: bool,
563    pub moderator_authorized: bool,
564    pub expert_policy_allowed: bool,
565    pub participant_policy_allowed: bool,
566    pub observer_steering_allowed: bool,
567    pub observer_poll_vote_allowed: bool,
568}
569
570impl PermissionContext {
571    pub fn for_role(role: Role) -> Self {
572        Self {
573            role: Some(role),
574            ..Self::default()
575        }
576    }
577
578    pub fn creator(role: Option<Role>) -> Self {
579        Self {
580            role,
581            is_creator: true,
582            ..Self::default()
583        }
584    }
585}
586
587#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
588pub struct StateWriteOptions {
589    pub post_end_reaction_allowed: bool,
590}
591
592pub fn can_submit_event(event_type: &str, context: &PermissionContext) -> bool {
593    if event_type == event_type::ROOM_CREATE || event_type == event_type::ROOM_JOIN {
594        return true;
595    }
596
597    if context.is_creator {
598        return is_known_event_type(event_type);
599    }
600
601    match context.role {
602        Some(Role::Moderator) => moderator_can_submit(event_type, context.moderator_authorized),
603        Some(Role::Expert) => speaker_can_submit(event_type, context.expert_policy_allowed),
604        Some(Role::Participant) => {
605            speaker_can_submit(event_type, context.participant_policy_allowed)
606        }
607        Some(Role::Observer) => observer_can_submit(event_type, context),
608        None => false,
609    }
610}
611
612pub fn can_write_in_state(event_type: &str, state: RoomState, options: StateWriteOptions) -> bool {
613    match state {
614        RoomState::Scheduled => matches!(
615            event_type,
616            event_type::ROOM_JOIN
617                | event_type::ROOM_INVITE
618                | event_type::ROOM_INVITE_REVOKE
619                | event_type::ROOM_CANCEL
620        ),
621        RoomState::Active => {
622            event_type != event_type::ROOM_CREATE && event_type != event_type::ROOM_CANCEL
623        }
624        RoomState::Ended => {
625            options.post_end_reaction_allowed && event_type == event_type::REACTION_CREATE
626        }
627        RoomState::Cancelled => false,
628    }
629}
630
631pub fn can_accept_room_write(
632    event_type: &str,
633    state: RoomState,
634    permission: &PermissionContext,
635    state_options: StateWriteOptions,
636) -> bool {
637    can_submit_event(event_type, permission) && can_write_in_state(event_type, state, state_options)
638}
639
640pub fn validate_room_write(
641    event_type: &str,
642    state: RoomState,
643    permission: &PermissionContext,
644    state_options: StateWriteOptions,
645) -> Result<()> {
646    if can_accept_room_write(event_type, state, permission, state_options) {
647        Ok(())
648    } else {
649        Err(SdkError::PermissionDenied)
650    }
651}
652
653fn moderator_can_submit(event_type: &str, moderator_authorized: bool) -> bool {
654    matches!(
655        event_type,
656        event_type::ROOM_INVITE
657            | event_type::ROOM_INVITE_REVOKE
658            | event_type::ROOM_CLOSE
659            | event_type::MESSAGE_TEXT
660            | event_type::MESSAGE_MARKDOWN
661            | event_type::MESSAGE_DATA
662            | event_type::SOURCE_ADD
663            | event_type::TURN_UPDATE
664            | event_type::QUESTION_GENERATE
665            | event_type::DISCOURSE_STEER
666            | event_type::MINDMAP_UPDATE
667            | event_type::REPORT_GENERATE
668            | event_type::PROPOSAL_CREATE
669            | event_type::POLL_CREATE
670            | event_type::POLL_VOTE
671            | event_type::RESOLUTION_CREATE
672            | event_type::REACTION_CREATE
673            | event_type::ROOM_LEAVE
674    ) || (moderator_authorized
675        && matches!(
676            event_type,
677            event_type::ROOM_MEMBER_ROLE_UPDATE | event_type::ROOM_CANCEL
678        ))
679}
680
681fn speaker_can_submit(event_type: &str, policy_allowed: bool) -> bool {
682    matches!(
683        event_type,
684        event_type::MESSAGE_TEXT
685            | event_type::MESSAGE_MARKDOWN
686            | event_type::MESSAGE_DATA
687            | event_type::SOURCE_ADD
688            | event_type::DISCOURSE_STEER
689            | event_type::PROPOSAL_CREATE
690            | event_type::POLL_CREATE
691            | event_type::POLL_VOTE
692            | event_type::REACTION_CREATE
693            | event_type::ROOM_LEAVE
694    ) || (policy_allowed
695        && matches!(
696            event_type,
697            event_type::QUESTION_GENERATE
698                | event_type::MINDMAP_UPDATE
699                | event_type::REPORT_GENERATE
700                | event_type::RESOLUTION_CREATE
701        ))
702}
703
704fn observer_can_submit(event_type: &str, context: &PermissionContext) -> bool {
705    matches!(
706        event_type,
707        event_type::REACTION_CREATE | event_type::ROOM_LEAVE
708    ) || (context.observer_steering_allowed && event_type == event_type::DISCOURSE_STEER)
709        || (context.observer_poll_vote_allowed && event_type == event_type::POLL_VOTE)
710}
711
712fn is_known_event_type(event_type: &str) -> bool {
713    matches!(
714        event_type,
715        event_type::ROOM_CREATE
716            | event_type::ROOM_JOIN
717            | event_type::ROOM_LEAVE
718            | event_type::ROOM_MEMBER_ROLE_UPDATE
719            | event_type::ROOM_INVITE
720            | event_type::ROOM_INVITE_REVOKE
721            | event_type::ROOM_CLOSE
722            | event_type::ROOM_CANCEL
723            | event_type::MESSAGE_TEXT
724            | event_type::MESSAGE_MARKDOWN
725            | event_type::MESSAGE_DATA
726            | event_type::REACTION_CREATE
727            | event_type::PROPOSAL_CREATE
728            | event_type::POLL_CREATE
729            | event_type::POLL_VOTE
730            | event_type::RESOLUTION_CREATE
731            | event_type::SOURCE_ADD
732            | event_type::TURN_UPDATE
733            | event_type::QUESTION_GENERATE
734            | event_type::DISCOURSE_STEER
735            | event_type::MINDMAP_UPDATE
736            | event_type::REPORT_GENERATE
737            | event_type::SESSION_AUTH
738    )
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744    use crate::identity::AgentSigner;
745
746    #[test]
747    fn validates_room_create_without_room_id() {
748        let signer = AgentSigner::from_seed([14; 32]);
749        let payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
750        let event = Event::new(
751            PROTOCOL,
752            event_type::ROOM_CREATE,
753            signer.agent_id(),
754            100,
755            "n_room",
756            payload,
757        );
758        let envelope = signer.sign_event(event).unwrap();
759
760        validate_discourse_envelope(&envelope, false).unwrap();
761    }
762
763    #[test]
764    fn rejects_room_event_without_room_id() {
765        let signer = AgentSigner::from_seed([15; 32]);
766        let event = Event::new(
767            PROTOCOL,
768            event_type::MESSAGE_TEXT,
769            signer.agent_id(),
770            100,
771            "n_message",
772            MessageTextPayload::new("hello"),
773        );
774        let envelope = signer.sign_event(event).unwrap();
775
776        assert!(matches!(
777            validate_discourse_envelope(&envelope, false),
778            Err(SdkError::MissingRoomId)
779        ));
780    }
781
782    #[test]
783    fn applies_permission_matrix() {
784        let observer = PermissionContext::for_role(Role::Observer);
785        assert!(can_submit_event(event_type::REACTION_CREATE, &observer));
786        assert!(!can_submit_event(event_type::MESSAGE_TEXT, &observer));
787
788        let mut moderator = PermissionContext::for_role(Role::Moderator);
789        assert!(!can_submit_event(event_type::ROOM_CANCEL, &moderator));
790        moderator.moderator_authorized = true;
791        assert!(can_submit_event(event_type::ROOM_CANCEL, &moderator));
792    }
793
794    #[test]
795    fn applies_state_restrictions() {
796        let participant = PermissionContext::for_role(Role::Participant);
797        assert!(can_accept_room_write(
798            event_type::MESSAGE_TEXT,
799            RoomState::Active,
800            &participant,
801            StateWriteOptions::default()
802        ));
803        assert!(!can_accept_room_write(
804            event_type::MESSAGE_TEXT,
805            RoomState::Scheduled,
806            &participant,
807            StateWriteOptions::default()
808        ));
809        assert!(!can_accept_room_write(
810            event_type::REACTION_CREATE,
811            RoomState::Ended,
812            &participant,
813            StateWriteOptions::default()
814        ));
815        assert!(can_accept_room_write(
816            event_type::REACTION_CREATE,
817            RoomState::Ended,
818            &participant,
819            StateWriteOptions {
820                post_end_reaction_allowed: true,
821            }
822        ));
823    }
824}