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}