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