1use std::collections::{BTreeMap, BTreeSet};
10
11use base64::engine::general_purpose::URL_SAFE_NO_PAD;
12use base64::Engine as _;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use sha2::Sha256;
16use sha3::{Digest, Sha3_256};
17
18use crate::error::{Result, SdkError};
19use crate::identity::{verify_envelope, AgentId, Envelope, Event, MAX_SAFE_NONCE};
20
21pub const PROTOCOL: &str = "agent-discourse/1.0";
22
23pub mod event_type {
25 pub const ROOM_CREATE: &str = "room.create";
26 pub const ROOM_JOIN: &str = "room.join";
27 pub const ROOM_JOIN_REVIEW: &str = "room.join.review";
28 pub const ROOM_LEAVE: &str = "room.leave";
29 pub const ROOM_MEMBER_ROLE_UPDATE: &str = "room.member.role.update";
30 pub const ROOM_CLOSE: &str = "room.close";
31 pub const ROOM_CANCEL: &str = "room.cancel";
32 pub const TYPE_DEFINE: &str = "type.define";
33 pub const MESSAGE_CREATE: &str = "message.create";
34}
35
36pub const BUILTIN_EVENT_TYPES: [&str; 9] = [
37 event_type::ROOM_CREATE,
38 event_type::ROOM_JOIN,
39 event_type::ROOM_JOIN_REVIEW,
40 event_type::ROOM_LEAVE,
41 event_type::ROOM_MEMBER_ROLE_UPDATE,
42 event_type::ROOM_CLOSE,
43 event_type::ROOM_CANCEL,
44 event_type::TYPE_DEFINE,
45 event_type::MESSAGE_CREATE,
46];
47
48pub const RESERVED_TYPE_PREFIXES: [&str; 2] = ["room.", "type."];
50
51pub mod pack_id {
53 pub const REACTIONS: &str = "adp:reactions/1.0";
54 pub const DELIBERATION: &str = "adp:deliberation/1.0";
55 pub const CURATION: &str = "adp:curation/1.0";
56 pub const MODERATION: &str = "adp:moderation/1.0";
57 pub const REALTIME: &str = "adp:realtime/1.0";
58
59 pub const REGISTERED: [&str; 5] = [REACTIONS, DELIBERATION, CURATION, MODERATION, REALTIME];
60}
61
62#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
63#[serde(rename_all = "snake_case")]
64pub enum RoomState {
65 Scheduled,
66 Active,
67 Ended,
68 Cancelled,
69}
70
71#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(rename_all = "snake_case")]
73pub enum Visibility {
74 Public,
75 Restricted,
76 Private,
77}
78
79#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
80#[serde(rename_all = "snake_case")]
81pub enum Role {
82 Moderator,
83 Speaker,
84 Observer,
85}
86
87#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "snake_case")]
90pub enum TypeKind {
91 Message,
92 Signal,
93 Control,
94}
95
96#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
97#[serde(rename_all = "snake_case")]
98pub enum TypeStatus {
99 Active,
100 Deprecated,
101 Disabled,
102}
103
104#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "snake_case")]
106pub enum JoinRequestStatus {
107 Pending,
108 Approved,
109 Rejected,
110 Expired,
111}
112
113#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
114#[serde(rename_all = "snake_case")]
115pub enum JoinDecision {
116 Approve,
117 Reject,
118}
119
120#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
121pub struct RoomCreatePayload {
122 pub topic: String,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub agenda: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub guidance: Option<String>,
127 pub visibility: Visibility,
128 pub start_time: i64,
129 pub end_time: i64,
130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub tags: Vec<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub language: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub policy: Option<RoomPolicy>,
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 pub types: Vec<TypeDeclaration>,
138 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
139 pub extra: BTreeMap<String, Value>,
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 guidance: None,
153 visibility,
154 start_time,
155 end_time,
156 tags: Vec::new(),
157 language: None,
158 policy: None,
159 types: Vec::new(),
160 extra: BTreeMap::new(),
161 }
162 }
163}
164
165#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
166pub struct RoomPolicy {
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub moderator_agent_ids: Vec<AgentId>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub max_speakers: Option<u32>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub observer_allowed: Option<bool>,
173 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
174 pub extra: BTreeMap<String, Value>,
175}
176
177impl RoomPolicy {
178 pub fn new() -> Self {
179 Self::default()
180 }
181}
182
183#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
185pub struct TypeDef {
186 #[serde(rename = "type")]
187 pub name: String,
188 pub kind: TypeKind,
189 pub title: String,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub description: Option<String>,
192 pub schema: Value,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub roles: Option<Vec<Role>>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub instructions: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub version: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub status: Option<TypeStatus>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub rate_hint: Option<u32>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub max_payload_hint: Option<u64>,
206 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
207 pub extra: BTreeMap<String, Value>,
208}
209
210impl TypeDef {
211 pub fn status(&self) -> TypeStatus {
212 self.status.unwrap_or(TypeStatus::Active)
213 }
214}
215
216#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
218pub struct TypeOverride {
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub roles: Option<Vec<Role>>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub instructions: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub status: Option<TypeStatus>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub rate_hint: Option<u32>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub max_payload_hint: Option<u64>,
229}
230
231#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
233pub struct PackImport {
234 #[serde(rename = "use", default, skip_serializing_if = "Option::is_none")]
235 pub use_pack: Option<String>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub pack: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub digest: Option<String>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub types: Option<Vec<String>>,
242 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
243 pub overrides: BTreeMap<String, TypeOverride>,
244}
245
246#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
248#[serde(untagged)]
249pub enum TypeDeclaration {
250 Def(TypeDef),
251 Import(PackImport),
252}
253
254#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
255pub struct Pack {
256 pub id: String,
257 pub title: String,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub description: Option<String>,
260 pub types: Vec<TypeDef>,
261 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
262 pub extra: BTreeMap<String, Value>,
263}
264
265#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
267pub struct PackDocument {
268 pub protocol: String,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub description: Option<String>,
271 pub packs: Vec<Pack>,
272}
273
274pub fn pack_map(document: &PackDocument) -> BTreeMap<String, Pack> {
276 document
277 .packs
278 .iter()
279 .map(|pack| (pack.id.clone(), pack.clone()))
280 .collect()
281}
282
283#[derive(Clone, Debug, Serialize, Deserialize)]
284pub struct ServerRecord<P = Value> {
285 pub room_id: String,
286 pub seq: u64,
287 #[serde(default)]
288 pub pre_hash: Option<String>,
289 pub hash: String,
290 pub received_at: i64,
291 pub envelope: Envelope<P>,
292}
293
294#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
295pub struct ServerRecordHashPayload {
296 pub room_id: String,
297 pub seq: u64,
298 pub pre_hash: Option<String>,
299 pub envelope_hash: String,
300 pub received_at: i64,
301}
302
303#[derive(Clone, Debug, Serialize, Deserialize)]
304pub struct RoomResponse {
305 pub id: String,
306 pub status: RoomState,
307 pub url: String,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub topic: Option<String>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub agenda: Option<String>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub guidance: Option<String>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub visibility: Option<Visibility>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub start_time: Option<i64>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub end_time: Option<i64>,
320 #[serde(default, skip_serializing_if = "Vec::is_empty")]
321 pub tags: Vec<String>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub language: Option<String>,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub policy: Option<RoomPolicy>,
326 #[serde(default, skip_serializing_if = "Vec::is_empty")]
328 pub types: Vec<TypeDef>,
329 pub seq: u64,
330 #[serde(default)]
331 pub pre_hash: Option<String>,
332 pub hash: String,
333 pub received_at: i64,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub envelope: Option<Envelope<RoomCreatePayload>>,
336}
337
338#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
339pub struct RoomJoinPayload {
340 pub request_id: String,
341 pub role: Role,
342}
343
344#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
345pub struct RoomJoinRequestInput {
346 pub role: Role,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub perspective: Option<String>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub reason: Option<String>,
351 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
352 pub extra: BTreeMap<String, Value>,
353}
354
355impl RoomJoinRequestInput {
356 pub fn new(role: Role) -> Self {
357 Self {
358 role,
359 perspective: None,
360 reason: None,
361 extra: BTreeMap::new(),
362 }
363 }
364}
365
366#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
367pub struct RoomJoinRequest {
368 pub id: String,
369 pub room_id: String,
370 pub applicant: AgentId,
371 pub role: Role,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub perspective: Option<String>,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub reason: Option<String>,
376 pub created_at: i64,
377 pub expires_at: i64,
378 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
379 pub extra: BTreeMap<String, Value>,
380}
381
382#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
383pub struct RoomJoinRequestStatus {
384 pub request: RoomJoinRequest,
385 pub status: JoinRequestStatus,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub approved_role: Option<Role>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub review_reason: Option<String>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub reviewed_by: Option<AgentId>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub reviewed_at: Option<i64>,
394}
395
396#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
397pub struct AgentStatusInput {
398 pub state: String,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub summary: Option<String>,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub seen_seq: Option<u64>,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub seen_hash: Option<String>,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub claim_id: Option<String>,
407 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub activity: Option<String>,
409 pub expires_at: i64,
410 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
411 pub extra: BTreeMap<String, Value>,
412}
413
414impl AgentStatusInput {
415 pub fn new(state: impl Into<String>, expires_at: i64) -> Self {
416 Self {
417 state: state.into(),
418 summary: None,
419 seen_seq: None,
420 seen_hash: None,
421 claim_id: None,
422 activity: None,
423 expires_at,
424 extra: BTreeMap::new(),
425 }
426 }
427}
428
429#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
430pub struct AgentStatus {
431 pub room_id: String,
432 pub agent_id: AgentId,
433 pub state: String,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub summary: Option<String>,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub seen_seq: Option<u64>,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub seen_hash: Option<String>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub claim_id: Option<String>,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub activity: Option<String>,
444 pub expires_at: i64,
445 pub updated_at: i64,
446 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
447 pub extra: BTreeMap<String, Value>,
448}
449
450#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
451pub struct AgentStatusListResponse {
452 pub statuses: Vec<AgentStatus>,
453}
454
455#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
456pub struct AgentStatusGetResponse {
457 pub status: AgentStatus,
458}
459
460#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
461pub struct RoomJoinReviewPayload {
462 pub request: RoomJoinRequest,
463 pub decision: JoinDecision,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub role: Option<Role>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub reason: Option<String>,
468 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
469 pub extra: BTreeMap<String, Value>,
470}
471
472#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
473pub struct RoleUpdatePayload {
474 pub member: AgentId,
475 pub role: Role,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub reason: Option<String>,
478 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
479 pub extra: BTreeMap<String, Value>,
480}
481
482#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
484pub struct ReasonPayload {
485 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub reason: Option<String>,
487 #[serde(default, skip_serializing_if = "Vec::is_empty")]
488 pub references: Vec<String>,
489 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
490 pub extra: BTreeMap<String, Value>,
491}
492
493pub type RoomLeavePayload = ReasonPayload;
494pub type RoomClosePayload = ReasonPayload;
495pub type RoomCancelPayload = ReasonPayload;
496
497#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
498pub struct MessageCreatePayload {
499 pub content_type: String,
500 pub content: Value,
501 #[serde(default, skip_serializing_if = "Vec::is_empty")]
502 pub references: Vec<String>,
503 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
504 pub extra: BTreeMap<String, Value>,
505}
506
507impl MessageCreatePayload {
508 pub fn new(content_type: impl Into<String>, content: Value) -> Self {
509 Self {
510 content_type: content_type.into(),
511 content,
512 references: Vec::new(),
513 extra: BTreeMap::new(),
514 }
515 }
516
517 pub fn text(text: impl Into<String>) -> Self {
518 Self::new("text/plain", Value::String(text.into()))
519 }
520
521 pub fn markdown(markdown: impl Into<String>) -> Self {
522 Self::new("text/markdown", Value::String(markdown.into()))
523 }
524}
525
526#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
527pub struct ProfileResolverMetadata {
528 pub mode: String,
529 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub service: Option<String>,
531 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub protocol: Option<String>,
533}
534
535#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
536pub struct DiscourseProtocolDiscovery {
537 pub protocol: String,
538 pub host: String,
539 #[serde(default, skip_serializing_if = "Vec::is_empty")]
540 pub features: Vec<String>,
541 #[serde(default, skip_serializing_if = "Vec::is_empty")]
542 pub registered_packs: Vec<String>,
543 #[serde(default, skip_serializing_if = "Option::is_none")]
544 pub profile: Option<ProfileResolverMetadata>,
545 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
546 pub endpoints: BTreeMap<String, String>,
547}
548
549#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
550pub struct ArchiveManifest {
551 pub protocol: String,
552 #[serde(rename = "type")]
553 pub kind: String,
554 pub host: String,
555 pub room_id: String,
556 pub url: String,
557 pub generated_at: i64,
558 pub event_count: u64,
559 pub first_seq: u64,
560 pub last_seq: u64,
561 pub last_hash: String,
562 pub events_sha3_256: String,
563 pub archive_root: String,
564 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
565 pub formats: BTreeMap<String, String>,
566 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
567 pub extra: BTreeMap<String, Value>,
568}
569
570pub fn room_create_event(
571 actor: AgentId,
572 created_at: i64,
573 nonce: u64,
574 payload: RoomCreatePayload,
575) -> Event<RoomCreatePayload> {
576 Event::new(
577 PROTOCOL,
578 event_type::ROOM_CREATE,
579 actor,
580 created_at,
581 nonce,
582 payload,
583 )
584}
585
586pub fn type_define_event(
587 actor: AgentId,
588 created_at: i64,
589 nonce: u64,
590 room_id: impl Into<String>,
591 base_seq: u64,
592 base_hash: impl Into<String>,
593 declaration: TypeDeclaration,
594) -> Event<TypeDeclaration> {
595 Event::new(
596 PROTOCOL,
597 event_type::TYPE_DEFINE,
598 actor,
599 created_at,
600 nonce,
601 declaration,
602 )
603 .with_room_id(room_id)
604 .with_room_head(base_seq, base_hash)
605}
606
607pub fn discourse_event<P>(
608 kind: impl Into<String>,
609 actor: AgentId,
610 created_at: i64,
611 nonce: u64,
612 room_id: impl Into<String>,
613 base_seq: u64,
614 base_hash: impl Into<String>,
615 payload: P,
616) -> Event<P> {
617 Event::new(PROTOCOL, kind, actor, created_at, nonce, payload)
618 .with_room_id(room_id)
619 .with_room_head(base_seq, base_hash)
620}
621
622pub fn is_builtin_event_type(event_type: &str) -> bool {
623 BUILTIN_EVENT_TYPES.contains(&event_type)
624}
625
626pub fn event_requires_room_id(event_type: &str) -> bool {
627 event_type != event_type::ROOM_CREATE
628}
629
630pub fn validate_discourse_envelope<P>(envelope: &Envelope<P>) -> Result<()>
631where
632 P: Serialize,
633{
634 verify_envelope(envelope)?;
635 let protocol = envelope.event.protocol.as_str();
636 if protocol != PROTOCOL {
637 return Err(SdkError::InvalidEventProtocol {
638 expected: PROTOCOL.to_owned(),
639 actual: envelope.event.protocol.clone(),
640 });
641 }
642 if envelope.event.kind == event_type::ROOM_CREATE {
643 if envelope.event.room_id.is_some() {
644 return Err(SdkError::InvalidPayload(
645 "room.create must not include room_id".to_owned(),
646 ));
647 }
648 if envelope.event.base_seq.is_some()
649 || envelope.event.base_hash.is_some()
650 || !envelope.event.mentions.is_empty()
651 {
652 return Err(SdkError::InvalidPayload(
653 "room.create must not include base_seq, base_hash, or mentions".to_owned(),
654 ));
655 }
656 } else {
657 if envelope.event.room_id.is_none() {
658 return Err(SdkError::MissingRoomId);
659 }
660 validate_room_head_precondition(&envelope.event)?;
661 }
662 Ok(())
663}
664
665pub fn validate_room_path<P>(envelope: &Envelope<P>, path_room_id: &str) -> Result<()> {
666 if envelope.event.kind == event_type::ROOM_CREATE {
667 if envelope.event.room_id.is_some() {
668 return Err(SdkError::InvalidPayload(
669 "room.create must not include room_id".to_owned(),
670 ));
671 }
672 if envelope.event.base_seq.is_some()
673 || envelope.event.base_hash.is_some()
674 || !envelope.event.mentions.is_empty()
675 {
676 return Err(SdkError::InvalidPayload(
677 "room.create must not include base_seq, base_hash, or mentions".to_owned(),
678 ));
679 }
680 return Ok(());
681 }
682 validate_room_head_precondition(&envelope.event)?;
683 match envelope.event.room_id.as_deref() {
684 Some(actual) if actual == path_room_id => Ok(()),
685 Some(actual) => Err(SdkError::RoomIdMismatch {
686 expected: path_room_id.to_owned(),
687 actual: actual.to_owned(),
688 }),
689 None => Err(SdkError::MissingRoomId),
690 }
691}
692
693fn validate_room_head_precondition<P>(event: &Event<P>) -> Result<()> {
694 match (event.base_seq, event.base_hash.as_deref()) {
695 (Some(seq), Some(hash)) if seq > 0 && seq <= MAX_SAFE_NONCE && !hash.trim().is_empty() => {
696 Ok(())
697 }
698 (Some(0), _) => Err(SdkError::InvalidPayload(
699 "base_seq must be a positive safe JSON integer".to_owned(),
700 )),
701 (Some(_), Some(hash)) if hash.trim().is_empty() => Err(SdkError::InvalidPayload(
702 "base_hash must not be empty".to_owned(),
703 )),
704 (Some(seq), _) if seq > MAX_SAFE_NONCE => Err(SdkError::InvalidPayload(
705 "base_seq must be a safe JSON integer".to_owned(),
706 )),
707 _ => Err(SdkError::InvalidPayload(
708 "room event requires base_seq and base_hash".to_owned(),
709 )),
710 }
711}
712
713pub fn validate_custom_event_type_name(name: &str) -> Result<()> {
716 let segments: Vec<&str> = name.split('.').collect();
717 let valid_shape = segments.len() >= 2 && segments.iter().all(|segment| {
718 let mut chars = segment.chars();
719 matches!(chars.next(), Some(first) if first.is_ascii_lowercase() || first.is_ascii_digit())
720 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
721 });
722 if !valid_shape {
723 return Err(SdkError::InvalidPayload(format!(
724 "invalid event type name: {name}"
725 )));
726 }
727 if is_builtin_event_type(name) {
728 return Err(SdkError::InvalidPayload(format!(
729 "{name} is a built-in event type"
730 )));
731 }
732 if RESERVED_TYPE_PREFIXES
733 .iter()
734 .any(|prefix| name.starts_with(prefix))
735 {
736 return Err(SdkError::InvalidPayload(format!(
737 "{name} uses a reserved type prefix"
738 )));
739 }
740 Ok(())
741}
742
743pub fn validate_type_def(def: &TypeDef) -> Result<()> {
744 validate_custom_event_type_name(&def.name)?;
745 if def.title.trim().is_empty() {
746 return Err(SdkError::InvalidPayload(
747 "type definition title must not be empty".to_owned(),
748 ));
749 }
750 if !def.schema.is_object() {
751 return Err(SdkError::InvalidPayload(
752 "type definition schema must be a JSON Schema object".to_owned(),
753 ));
754 }
755 compile_schema(&def.schema)?;
756 if matches!(&def.roles, Some(roles) if roles.is_empty()) {
757 return Err(SdkError::InvalidPayload(
758 "type definition roles must not be empty".to_owned(),
759 ));
760 }
761 if matches!(def.rate_hint, Some(0)) || matches!(def.max_payload_hint, Some(0)) {
762 return Err(SdkError::InvalidPayload(
763 "type definition hints must be positive".to_owned(),
764 ));
765 }
766 Ok(())
767}
768
769pub fn validate_pack_import(import: &PackImport) -> Result<()> {
770 match (&import.use_pack, &import.pack, &import.digest) {
771 (Some(id), None, None) => {
772 if !is_registered_pack_id(id) {
773 return Err(SdkError::InvalidPayload(format!(
774 "invalid registered pack id: {id}"
775 )));
776 }
777 }
778 (None, Some(_), Some(digest)) => {
779 if digest.trim().is_empty() {
780 return Err(SdkError::InvalidPayload(
781 "external pack digest must not be empty".to_owned(),
782 ));
783 }
784 }
785 _ => {
786 return Err(SdkError::InvalidPayload(
787 "pack import requires either use, or pack with digest".to_owned(),
788 ));
789 }
790 }
791 if matches!(&import.types, Some(types) if types.is_empty()) {
792 return Err(SdkError::InvalidPayload(
793 "pack import types subset must not be empty".to_owned(),
794 ));
795 }
796 Ok(())
797}
798
799pub fn validate_type_declaration(declaration: &TypeDeclaration) -> Result<()> {
800 match declaration {
801 TypeDeclaration::Def(def) => validate_type_def(def),
802 TypeDeclaration::Import(import) => validate_pack_import(import),
803 }
804}
805
806fn is_registered_pack_id(id: &str) -> bool {
807 let Some(rest) = id.strip_prefix("adp:") else {
808 return false;
809 };
810 let Some((name, version)) = rest.split_once('/') else {
811 return false;
812 };
813 !name.is_empty()
814 && name
815 .chars()
816 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
817 && version.split('.').count() == 2
818 && version
819 .split('.')
820 .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
821}
822
823pub fn validate_room_create_payload(payload: &RoomCreatePayload) -> Result<()> {
824 if payload.topic.trim().is_empty() {
825 return Err(SdkError::InvalidPayload(
826 "room topic must not be empty".to_owned(),
827 ));
828 }
829 if payload.start_time >= payload.end_time {
830 return Err(SdkError::InvalidPayload(
831 "start_time must be before end_time".to_owned(),
832 ));
833 }
834 if let Some(policy) = &payload.policy {
835 if matches!(policy.max_speakers, Some(0)) {
836 return Err(SdkError::InvalidPayload(
837 "max_speakers must be a positive integer".to_owned(),
838 ));
839 }
840 }
841 for declaration in &payload.types {
842 validate_type_declaration(declaration)?;
843 }
844 Ok(())
845}
846
847pub fn validate_message_create_payload(payload: &MessageCreatePayload) -> Result<()> {
848 if payload.content_type.trim().is_empty() {
849 return Err(SdkError::InvalidPayload(
850 "content_type must not be empty".to_owned(),
851 ));
852 }
853 Ok(())
854}
855
856#[derive(Clone, Debug, Default)]
858pub struct TypeRegistry {
859 types: BTreeMap<String, TypeDef>,
860}
861
862impl TypeRegistry {
863 pub fn new() -> Self {
864 Self::default()
865 }
866
867 pub fn from_declarations(
870 declarations: &[TypeDeclaration],
871 packs: &BTreeMap<String, Pack>,
872 ) -> Result<Self> {
873 let mut registry = Self::new();
874 for declaration in declarations {
875 registry.apply(declaration, packs)?;
876 }
877 Ok(registry)
878 }
879
880 pub fn apply(
883 &mut self,
884 declaration: &TypeDeclaration,
885 packs: &BTreeMap<String, Pack>,
886 ) -> Result<()> {
887 match declaration {
888 TypeDeclaration::Def(def) => self.define(def.clone()),
889 TypeDeclaration::Import(import) => self.import(import, packs),
890 }
891 }
892
893 pub fn define(&mut self, def: TypeDef) -> Result<()> {
894 validate_type_def(&def)?;
895 self.types.insert(def.name.clone(), def);
896 Ok(())
897 }
898
899 fn import(&mut self, import: &PackImport, packs: &BTreeMap<String, Pack>) -> Result<()> {
900 validate_pack_import(import)?;
901 let reference = import
902 .use_pack
903 .as_deref()
904 .or(import.pack.as_deref())
905 .expect("validated pack import has a reference");
906 let pack = packs
907 .get(reference)
908 .ok_or_else(|| SdkError::PackUnavailable(reference.to_owned()))?;
909 let available: BTreeSet<&str> = pack.types.iter().map(|def| def.name.as_str()).collect();
910 if let Some(subset) = &import.types {
911 for name in subset {
912 if !available.contains(name.as_str()) {
913 return Err(SdkError::PackUnavailable(format!(
914 "type {name} is not in pack {reference}"
915 )));
916 }
917 }
918 }
919 for name in import.overrides.keys() {
920 let imported = import
921 .types
922 .as_ref()
923 .map(|subset| subset.contains(name))
924 .unwrap_or_else(|| available.contains(name.as_str()));
925 if !imported {
926 return Err(SdkError::InvalidPayload(format!(
927 "override target {name} is not imported from pack {reference}"
928 )));
929 }
930 }
931 for def in &pack.types {
932 if let Some(subset) = &import.types {
933 if !subset.contains(&def.name) {
934 continue;
935 }
936 }
937 let mut def = def.clone();
938 if let Some(over) = import.overrides.get(&def.name) {
939 if let Some(roles) = &over.roles {
940 def.roles = Some(roles.clone());
941 }
942 if let Some(instructions) = &over.instructions {
943 def.instructions = Some(instructions.clone());
944 }
945 if let Some(status) = over.status {
946 def.status = Some(status);
947 }
948 if let Some(rate_hint) = over.rate_hint {
949 def.rate_hint = Some(rate_hint);
950 }
951 if let Some(max_payload_hint) = over.max_payload_hint {
952 def.max_payload_hint = Some(max_payload_hint);
953 }
954 }
955 self.define(def)?;
956 }
957 Ok(())
958 }
959
960 pub fn get(&self, event_type: &str) -> Option<&TypeDef> {
961 self.types.get(event_type)
962 }
963
964 pub fn contains(&self, event_type: &str) -> bool {
965 self.types.contains_key(event_type)
966 }
967
968 pub fn len(&self) -> usize {
969 self.types.len()
970 }
971
972 pub fn is_empty(&self) -> bool {
973 self.types.is_empty()
974 }
975
976 pub fn definitions(&self) -> impl Iterator<Item = &TypeDef> {
977 self.types.values()
978 }
979
980 pub fn validate_payload(&self, event_type: &str, payload: &Value) -> Result<()> {
982 let def = self
983 .get(event_type)
984 .ok_or_else(|| SdkError::TypeNotDefined(event_type.to_owned()))?;
985 if def.status() == TypeStatus::Disabled {
986 return Err(SdkError::TypeDisabled(event_type.to_owned()));
987 }
988 let validator = compile_schema(&def.schema)?;
989 if let Err(error) = validator.validate(payload) {
990 return Err(SdkError::PayloadSchemaViolation(format!(
991 "{event_type}: {error}"
992 )));
993 }
994 Ok(())
995 }
996}
997
998pub fn validate_event_against_registry(
1001 event_type: &str,
1002 payload: &Value,
1003 registry: &TypeRegistry,
1004) -> Result<()> {
1005 if is_builtin_event_type(event_type) {
1006 return Ok(());
1007 }
1008 registry.validate_payload(event_type, payload)
1009}
1010
1011fn compile_schema(schema: &Value) -> Result<jsonschema::Validator> {
1012 jsonschema::options()
1013 .with_draft(jsonschema::Draft::Draft202012)
1014 .build(schema)
1015 .map_err(|err| SdkError::InvalidPayload(format!("invalid type schema: {err}")))
1016}
1017
1018pub fn verify_pack_digest(bytes: &[u8], digest: &str) -> Result<()> {
1021 let (algorithm, expected) = digest
1022 .split_once(':')
1023 .ok_or_else(|| SdkError::PackUnavailable(format!("invalid digest format: {digest}")))?;
1024 let actual = match algorithm {
1025 "sha256" => URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)),
1026 "sha3-256" => URL_SAFE_NO_PAD.encode(Sha3_256::digest(bytes)),
1027 _ => {
1028 return Err(SdkError::PackUnavailable(format!(
1029 "unsupported digest algorithm: {algorithm}"
1030 )));
1031 }
1032 };
1033 if actual == expected {
1034 Ok(())
1035 } else {
1036 Err(SdkError::PackUnavailable("pack digest mismatch".to_owned()))
1037 }
1038}
1039
1040pub fn server_record_hash_payload(
1041 room_id: &str,
1042 seq: u64,
1043 pre_hash: Option<&str>,
1044 envelope_hash: &str,
1045 received_at: i64,
1046) -> ServerRecordHashPayload {
1047 ServerRecordHashPayload {
1048 room_id: room_id.to_owned(),
1049 seq,
1050 pre_hash: pre_hash.map(str::to_owned),
1051 envelope_hash: envelope_hash.to_owned(),
1052 received_at,
1053 }
1054}
1055
1056pub fn server_record_hash(
1057 room_id: &str,
1058 seq: u64,
1059 pre_hash: Option<&str>,
1060 envelope_hash: &str,
1061 received_at: i64,
1062) -> Result<String> {
1063 hash_canonical_json(&server_record_hash_payload(
1064 room_id,
1065 seq,
1066 pre_hash,
1067 envelope_hash,
1068 received_at,
1069 ))
1070}
1071
1072pub fn build_server_record<P>(
1073 room_id: impl Into<String>,
1074 seq: u64,
1075 pre_hash: Option<String>,
1076 received_at: i64,
1077 envelope: Envelope<P>,
1078) -> Result<ServerRecord<P>> {
1079 let room_id = room_id.into();
1080 let hash = server_record_hash(
1081 &room_id,
1082 seq,
1083 pre_hash.as_deref(),
1084 &envelope.hash,
1085 received_at,
1086 )
1087 .expect("server record hash payload is always serializable");
1088 Ok(ServerRecord {
1089 room_id,
1090 seq,
1091 pre_hash,
1092 hash,
1093 received_at,
1094 envelope,
1095 })
1096}
1097
1098pub fn verify_server_record<P>(record: &ServerRecord<P>) -> Result<()>
1099where
1100 P: Serialize,
1101{
1102 let expected = server_record_hash(
1103 &record.room_id,
1104 record.seq,
1105 record.pre_hash.as_deref(),
1106 &record.envelope.hash,
1107 record.received_at,
1108 )
1109 .expect("server record hash payload is always serializable");
1110 if record.hash == expected {
1111 Ok(())
1112 } else {
1113 Err(SdkError::InvalidEventHash {
1114 expected,
1115 actual: record.hash.clone(),
1116 })
1117 }
1118}
1119
1120pub fn verify_server_record_chain<P>(records: &[ServerRecord<P>]) -> Result<()>
1121where
1122 P: Serialize,
1123{
1124 let mut previous: Option<&ServerRecord<P>> = None;
1125 for record in records {
1126 verify_server_record(record)?;
1127 if let Some(previous) = previous {
1128 if record.seq != previous.seq + 1 {
1129 return Err(SdkError::InvalidPayload(
1130 "seq must increase by 1".to_owned(),
1131 ));
1132 }
1133 if record.pre_hash.as_deref() != Some(previous.hash.as_str()) {
1134 return Err(SdkError::InvalidPayload("pre_hash mismatch".to_owned()));
1135 }
1136 } else if record.seq != 1 {
1137 return Err(SdkError::InvalidPayload("first seq must be 1".to_owned()));
1138 } else if record.pre_hash.is_some() {
1139 return Err(SdkError::InvalidPayload(
1140 "first pre_hash must be null".to_owned(),
1141 ));
1142 }
1143 previous = Some(record);
1144 }
1145 Ok(())
1146}
1147
1148pub fn archive_events_digest<P>(records: &[ServerRecord<P>]) -> Result<String>
1149where
1150 P: Serialize,
1151{
1152 hash_canonical_json(records)
1153}
1154
1155#[derive(Clone, Debug, Default, PartialEq, Eq)]
1157pub struct PermissionContext {
1158 pub role: Option<Role>,
1159 pub is_creator: bool,
1160 pub join_request_approved: bool,
1161}
1162
1163impl PermissionContext {
1164 pub fn for_role(role: Role) -> Self {
1165 Self {
1166 role: Some(role),
1167 ..Self::default()
1168 }
1169 }
1170
1171 pub fn creator(role: Option<Role>) -> Self {
1172 Self {
1173 role,
1174 is_creator: true,
1175 ..Self::default()
1176 }
1177 }
1178}
1179
1180pub fn default_kind_roles(kind: TypeKind) -> &'static [Role] {
1182 match kind {
1183 TypeKind::Message => &[Role::Moderator, Role::Speaker],
1184 TypeKind::Signal => &[Role::Moderator, Role::Speaker, Role::Observer],
1185 TypeKind::Control => &[Role::Moderator],
1186 }
1187}
1188
1189pub fn can_submit_event(
1192 event_type: &str,
1193 context: &PermissionContext,
1194 registry: &TypeRegistry,
1195) -> bool {
1196 match event_type {
1197 event_type::ROOM_CREATE => true,
1198 event_type::ROOM_JOIN => context.join_request_approved,
1199 event_type::ROOM_LEAVE => context.is_creator || context.role.is_some(),
1200 event_type::ROOM_JOIN_REVIEW
1201 | event_type::ROOM_MEMBER_ROLE_UPDATE
1202 | event_type::ROOM_CLOSE
1203 | event_type::ROOM_CANCEL
1204 | event_type::TYPE_DEFINE => context.is_creator || context.role == Some(Role::Moderator),
1205 event_type::MESSAGE_CREATE => {
1206 context.is_creator
1207 || matches!(context.role, Some(Role::Moderator) | Some(Role::Speaker))
1208 }
1209 custom => {
1210 let Some(def) = registry.get(custom) else {
1211 return false;
1212 };
1213 if def.status() == TypeStatus::Disabled {
1214 return false;
1215 }
1216 if context.is_creator {
1217 return true;
1218 }
1219 let Some(role) = context.role else {
1220 return false;
1221 };
1222 match &def.roles {
1223 Some(roles) => roles.contains(&role),
1224 None => default_kind_roles(def.kind).contains(&role),
1225 }
1226 }
1227 }
1228}
1229
1230pub fn can_write_in_state(event_type: &str, state: RoomState) -> bool {
1231 match state {
1232 RoomState::Scheduled => matches!(
1233 event_type,
1234 event_type::ROOM_JOIN
1235 | event_type::ROOM_JOIN_REVIEW
1236 | event_type::ROOM_MEMBER_ROLE_UPDATE
1237 | event_type::ROOM_LEAVE
1238 | event_type::TYPE_DEFINE
1239 | event_type::ROOM_CANCEL
1240 ),
1241 RoomState::Active => {
1242 event_type != event_type::ROOM_CREATE && event_type != event_type::ROOM_CANCEL
1243 }
1244 RoomState::Ended | RoomState::Cancelled => false,
1245 }
1246}
1247
1248pub fn can_accept_room_write(
1249 event_type: &str,
1250 state: RoomState,
1251 permission: &PermissionContext,
1252 registry: &TypeRegistry,
1253) -> bool {
1254 can_submit_event(event_type, permission, registry) && can_write_in_state(event_type, state)
1255}
1256
1257pub fn validate_room_write(
1258 event_type: &str,
1259 state: RoomState,
1260 permission: &PermissionContext,
1261 registry: &TypeRegistry,
1262) -> Result<()> {
1263 if can_accept_room_write(event_type, state, permission, registry) {
1264 Ok(())
1265 } else {
1266 Err(SdkError::PermissionDenied)
1267 }
1268}
1269
1270fn hash_canonical_json<T>(value: &T) -> Result<String>
1271where
1272 T: Serialize + ?Sized,
1273{
1274 let bytes = serde_jcs::to_vec(value).map_err(|err| SdkError::CanonicalJson(err.to_string()))?;
1275 let digest = Sha3_256::digest(bytes);
1276 Ok(URL_SAFE_NO_PAD.encode(digest))
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281 use super::*;
1282 use crate::identity::AgentSigner;
1283 use serde::ser;
1284 use serde_json::json;
1285
1286 struct FailingPayload;
1287
1288 impl Serialize for FailingPayload {
1289 fn serialize<S>(&self, _serializer: S) -> std::result::Result<S::Ok, S::Error>
1290 where
1291 S: serde::Serializer,
1292 {
1293 Err(ser::Error::custom("payload cannot be serialized"))
1294 }
1295 }
1296
1297 fn registered_packs() -> BTreeMap<String, Pack> {
1298 let raw = std::fs::read_to_string(concat!(
1299 env!("CARGO_MANIFEST_DIR"),
1300 "/../../docs/protocols/agent-discourse/1.0.packs.json"
1301 ))
1302 .expect("read registered packs");
1303 let document: PackDocument = serde_json::from_str(&raw).expect("parse registered packs");
1304 assert_eq!(document.protocol, PROTOCOL);
1305 pack_map(&document)
1306 }
1307
1308 fn finding_def() -> TypeDef {
1309 TypeDef {
1310 name: "review.finding".to_owned(),
1311 kind: TypeKind::Message,
1312 title: "Review finding".to_owned(),
1313 description: None,
1314 schema: json!({
1315 "type": "object",
1316 "required": ["severity", "summary"],
1317 "properties": {
1318 "severity": {"type": "string", "enum": ["low", "medium", "high"]},
1319 "summary": {"type": "string", "minLength": 1}
1320 },
1321 "additionalProperties": false
1322 }),
1323 roles: None,
1324 instructions: None,
1325 version: None,
1326 status: None,
1327 rate_hint: None,
1328 max_payload_hint: None,
1329 extra: BTreeMap::new(),
1330 }
1331 }
1332
1333 fn join_review_payload(applicant: AgentId) -> RoomJoinReviewPayload {
1334 RoomJoinReviewPayload {
1335 request: RoomJoinRequest {
1336 id: "jr_01J8ZM7A3G2T9B4Q6X8R0N1P2Q".to_owned(),
1337 room_id: "room1".to_owned(),
1338 applicant,
1339 role: Role::Speaker,
1340 perspective: None,
1341 reason: None,
1342 created_at: 100,
1343 expires_at: 200,
1344 extra: BTreeMap::new(),
1345 },
1346 decision: JoinDecision::Approve,
1347 role: Some(Role::Speaker),
1348 reason: None,
1349 extra: BTreeMap::new(),
1350 }
1351 }
1352
1353 #[test]
1354 fn validates_room_create_without_room_id() {
1355 let signer = AgentSigner::from_seed([14; 32]);
1356 let payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1357 let envelope = signer
1358 .sign_event(room_create_event(signer.agent_id(), 100, 1, payload))
1359 .unwrap();
1360
1361 validate_discourse_envelope(&envelope).unwrap();
1362 validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").unwrap();
1363 }
1364
1365 #[test]
1366 fn rejects_room_create_with_room_id() {
1367 let signer = AgentSigner::from_seed([14; 32]);
1368 let payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1369 let event = room_create_event(signer.agent_id(), 100, 1, payload)
1370 .with_room_id("d8ftedhpqhsusbg001tg");
1371 let envelope = signer.sign_event(event).unwrap();
1372
1373 assert!(validate_discourse_envelope(&envelope).is_err());
1374 assert!(validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").is_err());
1375 }
1376
1377 #[test]
1378 fn rejects_room_event_without_room_id() {
1379 let signer = AgentSigner::from_seed([15; 32]);
1380 let event = Event::new(
1381 PROTOCOL,
1382 event_type::MESSAGE_CREATE,
1383 signer.agent_id(),
1384 100,
1385 1,
1386 MessageCreatePayload::text("hello"),
1387 );
1388 let envelope = signer.sign_event(event).unwrap();
1389
1390 assert!(validate_discourse_envelope(&envelope).is_err());
1391 }
1392
1393 #[test]
1394 fn rejects_invalid_discourse_envelope_signature() {
1395 let signer = AgentSigner::from_seed([16; 32]);
1396 let event = discourse_event(
1397 event_type::MESSAGE_CREATE,
1398 signer.agent_id(),
1399 100,
1400 1,
1401 "room1",
1402 1,
1403 "room-head-hash",
1404 MessageCreatePayload::text("hello"),
1405 );
1406 let mut envelope = signer.sign_event(event).unwrap();
1407 envelope.signature = "invalid".to_owned();
1408
1409 assert!(validate_discourse_envelope(&envelope).is_err());
1410 }
1411
1412 #[test]
1413 fn validates_join_review_canonical_request() {
1414 let moderator = AgentSigner::from_seed([21; 32]);
1415 let applicant = AgentSigner::from_seed([22; 32]);
1416 let payload = RoomJoinReviewPayload {
1417 request: RoomJoinRequest {
1418 id: "jr_01J8ZM7A3G2T9B4Q6X8R0N1P2Q".to_owned(),
1419 room_id: "d8ftedhpqhsusbg001tg".to_owned(),
1420 applicant: applicant.agent_id(),
1421 role: Role::Speaker,
1422 perspective: Some("distributed-systems reviewer".to_owned()),
1423 reason: Some("I can cover replication and failure-mode tradeoffs.".to_owned()),
1424 created_at: 1_779_757_210_000,
1425 expires_at: 1_779_760_810_000,
1426 extra: BTreeMap::new(),
1427 },
1428 decision: JoinDecision::Approve,
1429 role: Some(Role::Speaker),
1430 reason: Some("relevant expertise".to_owned()),
1431 extra: BTreeMap::new(),
1432 };
1433 let envelope = moderator
1434 .sign_event(discourse_event(
1435 event_type::ROOM_JOIN_REVIEW,
1436 moderator.agent_id(),
1437 1_779_757_250_000,
1438 1,
1439 "d8ftedhpqhsusbg001tg",
1440 1,
1441 "room-head-hash",
1442 payload.clone(),
1443 ))
1444 .unwrap();
1445
1446 validate_discourse_envelope(&envelope).unwrap();
1447 let value = serde_json::to_value(payload).unwrap();
1448 assert!(value.get("member").is_none());
1449 assert_eq!(
1450 value["request"]["applicant"],
1451 json!(applicant.agent_id().to_string())
1452 );
1453 assert_eq!(value["request"]["role"], json!("speaker"));
1454 }
1455
1456 #[test]
1457 fn role_serde_matches_spec() {
1458 assert_eq!(
1459 serde_json::to_string(&Role::Speaker).unwrap(),
1460 "\"speaker\""
1461 );
1462 assert!(serde_json::from_str::<Role>("\"expert\"").is_err());
1463 assert!(serde_json::from_str::<Role>("\"participant\"").is_err());
1464 }
1465
1466 #[test]
1467 fn room_response_retains_room_metadata_fields() {
1468 let room: RoomResponse = serde_json::from_value(json!({
1469 "id": "room1",
1470 "status": "active",
1471 "url": "https://api.example.test/v1/rooms/room1",
1472 "topic": "Room",
1473 "agenda": "Review the proposal",
1474 "guidance": "Stay concise",
1475 "visibility": "public",
1476 "start_time": 1,
1477 "end_time": 2,
1478 "tags": ["review"],
1479 "language": "en",
1480 "seq": 7,
1481 "pre_hash": "previous",
1482 "hash": "current",
1483 "received_at": 3
1484 }))
1485 .unwrap();
1486
1487 assert_eq!(room.agenda.as_deref(), Some("Review the proposal"));
1488 assert_eq!(room.tags, vec!["review"]);
1489 assert_eq!(room.language.as_deref(), Some("en"));
1490 assert_eq!(
1491 serde_json::to_value(&room).unwrap()["agenda"],
1492 json!("Review the proposal")
1493 );
1494 }
1495
1496 #[test]
1497 fn validates_custom_event_type_names() {
1498 validate_custom_event_type_name("review.finding").unwrap();
1499 validate_custom_event_type_name("poll.vote").unwrap();
1500 assert!(validate_custom_event_type_name("freeform").is_err());
1501 assert!(validate_custom_event_type_name("room.custom").is_err());
1502 assert!(validate_custom_event_type_name("type.new").is_err());
1503 assert!(validate_custom_event_type_name("message.create").is_err());
1504 assert!(validate_custom_event_type_name("Bad.Name").is_err());
1505 assert!(validate_custom_event_type_name(".finding").is_err());
1506 assert!(validate_custom_event_type_name("review.").is_err());
1507 assert!(validate_custom_event_type_name("review.finding!").is_err());
1508 }
1509
1510 #[test]
1511 fn materializes_registry_from_packs_and_inline_defs() {
1512 let packs = registered_packs();
1513 let declarations = vec![
1514 TypeDeclaration::Import(PackImport {
1515 use_pack: Some(pack_id::REACTIONS.to_owned()),
1516 ..PackImport::default()
1517 }),
1518 TypeDeclaration::Import(PackImport {
1519 use_pack: Some(pack_id::DELIBERATION.to_owned()),
1520 overrides: BTreeMap::from([(
1521 "poll.vote".to_owned(),
1522 TypeOverride {
1523 roles: Some(vec![Role::Moderator, Role::Speaker, Role::Observer]),
1524 ..TypeOverride::default()
1525 },
1526 )]),
1527 ..PackImport::default()
1528 }),
1529 TypeDeclaration::Def(finding_def()),
1530 ];
1531 let registry = TypeRegistry::from_declarations(&declarations, &packs).unwrap();
1532
1533 assert_eq!(registry.len(), 6);
1534 assert!(registry.contains("reaction.create"));
1535 assert!(registry.contains("poll.create"));
1536 assert!(registry.contains("review.finding"));
1537 let vote = registry.get("poll.vote").unwrap();
1538 assert_eq!(
1539 vote.roles.as_deref(),
1540 Some([Role::Moderator, Role::Speaker, Role::Observer].as_slice())
1541 );
1542
1543 let subset = TypeDeclaration::Import(PackImport {
1544 use_pack: Some(pack_id::DELIBERATION.to_owned()),
1545 types: Some(vec!["poll.create".to_owned(), "poll.vote".to_owned()]),
1546 ..PackImport::default()
1547 });
1548 let registry = TypeRegistry::from_declarations(&[subset], &packs).unwrap();
1549 assert_eq!(registry.len(), 2);
1550 assert!(!registry.contains("question.create"));
1551 }
1552
1553 #[test]
1554 fn rejects_bad_pack_imports() {
1555 let packs = registered_packs();
1556 let unknown = TypeDeclaration::Import(PackImport {
1557 use_pack: Some("adp:unknown/1.0".to_owned()),
1558 ..PackImport::default()
1559 });
1560 assert!(TypeRegistry::from_declarations(&[unknown], &packs).is_err());
1561
1562 let bad_override = TypeDeclaration::Import(PackImport {
1563 use_pack: Some(pack_id::REACTIONS.to_owned()),
1564 overrides: BTreeMap::from([("poll.vote".to_owned(), TypeOverride::default())]),
1565 ..PackImport::default()
1566 });
1567 assert!(TypeRegistry::from_declarations(&[bad_override], &packs).is_err());
1568
1569 let both = PackImport {
1570 use_pack: Some(pack_id::REACTIONS.to_owned()),
1571 pack: Some("https://example.com/p.json".to_owned()),
1572 digest: Some("sha256:abc".to_owned()),
1573 ..PackImport::default()
1574 };
1575 assert!(validate_pack_import(&both).is_err());
1576 }
1577
1578 #[test]
1579 fn latest_type_definition_wins() {
1580 let mut registry = TypeRegistry::new();
1581 registry.define(finding_def()).unwrap();
1582 let mut redefined = finding_def();
1583 redefined.status = Some(TypeStatus::Disabled);
1584 registry.define(redefined).unwrap();
1585 assert_eq!(
1586 registry.get("review.finding").unwrap().status(),
1587 TypeStatus::Disabled
1588 );
1589 }
1590
1591 #[test]
1592 fn validates_custom_payloads_against_pack_schemas() {
1593 let packs = registered_packs();
1594 let registry = TypeRegistry::from_declarations(
1595 &[TypeDeclaration::Import(PackImport {
1596 use_pack: Some(pack_id::DELIBERATION.to_owned()),
1597 ..PackImport::default()
1598 })],
1599 &packs,
1600 )
1601 .unwrap();
1602
1603 let hash = "GDt8oHZQfQ3jl5ZUfyNxKZu07yAJdDYuaw_jf_JjLYs";
1604 registry
1605 .validate_payload(
1606 "poll.vote",
1607 &json!({"poll_event_id": hash, "option_ids": ["a"]}),
1608 )
1609 .unwrap();
1610 assert!(registry
1611 .validate_payload("poll.vote", &json!({"poll_event_id": hash}))
1612 .is_err());
1613 assert!(registry
1614 .validate_payload("turn.update", &json!({}))
1615 .is_err());
1616
1617 let mut disabled = TypeRegistry::new();
1618 let mut def = finding_def();
1619 def.status = Some(TypeStatus::Disabled);
1620 disabled.define(def).unwrap();
1621 assert!(disabled
1622 .validate_payload(
1623 "review.finding",
1624 &json!({"severity": "high", "summary": "s"})
1625 )
1626 .is_err());
1627 }
1628
1629 #[test]
1630 fn applies_kind_based_permissions() {
1631 let packs = registered_packs();
1632 let registry = TypeRegistry::from_declarations(
1633 &[
1634 TypeDeclaration::Import(PackImport {
1635 use_pack: Some(pack_id::REACTIONS.to_owned()),
1636 ..PackImport::default()
1637 }),
1638 TypeDeclaration::Import(PackImport {
1639 use_pack: Some(pack_id::DELIBERATION.to_owned()),
1640 overrides: BTreeMap::from([(
1641 "poll.vote".to_owned(),
1642 TypeOverride {
1643 roles: Some(vec![Role::Moderator, Role::Speaker, Role::Observer]),
1644 ..TypeOverride::default()
1645 },
1646 )]),
1647 ..PackImport::default()
1648 }),
1649 TypeDeclaration::Import(PackImport {
1650 use_pack: Some(pack_id::CURATION.to_owned()),
1651 ..PackImport::default()
1652 }),
1653 ],
1654 &packs,
1655 )
1656 .unwrap();
1657
1658 let observer = PermissionContext::for_role(Role::Observer);
1659 let speaker = PermissionContext::for_role(Role::Speaker);
1660 let moderator = PermissionContext::for_role(Role::Moderator);
1661 let creator = PermissionContext::creator(Some(Role::Observer));
1662
1663 assert!(can_submit_event("reaction.create", &observer, ®istry));
1665 assert!(can_submit_event("poll.vote", &observer, ®istry));
1667 assert!(can_submit_event("resource.add", &speaker, ®istry));
1669 assert!(!can_submit_event("resource.add", &observer, ®istry));
1670 assert!(can_submit_event("graph.update", &moderator, ®istry));
1672 assert!(!can_submit_event("graph.update", &speaker, ®istry));
1673 assert!(can_submit_event("graph.update", &creator, ®istry));
1675 assert!(can_submit_event(
1676 event_type::ROOM_CREATE,
1677 &PermissionContext::default(),
1678 ®istry
1679 ));
1680 assert!(can_submit_event(
1681 event_type::MESSAGE_CREATE,
1682 &creator,
1683 ®istry
1684 ));
1685 assert!(!can_submit_event("session.offer", &speaker, ®istry));
1687
1688 assert!(can_submit_event(
1690 event_type::ROOM_JOIN_REVIEW,
1691 &moderator,
1692 ®istry
1693 ));
1694 assert!(!can_submit_event(
1695 event_type::ROOM_JOIN_REVIEW,
1696 &speaker,
1697 ®istry
1698 ));
1699 assert!(can_submit_event(
1700 event_type::ROOM_MEMBER_ROLE_UPDATE,
1701 &moderator,
1702 ®istry
1703 ));
1704 assert!(can_submit_event(
1705 event_type::ROOM_CANCEL,
1706 &moderator,
1707 ®istry
1708 ));
1709 assert!(can_submit_event(
1710 event_type::TYPE_DEFINE,
1711 &moderator,
1712 ®istry
1713 ));
1714 assert!(!can_submit_event(
1715 event_type::TYPE_DEFINE,
1716 &speaker,
1717 ®istry
1718 ));
1719 assert!(can_submit_event(
1720 event_type::MESSAGE_CREATE,
1721 &speaker,
1722 ®istry
1723 ));
1724 assert!(!can_submit_event(
1725 event_type::MESSAGE_CREATE,
1726 &observer,
1727 ®istry
1728 ));
1729 assert!(can_submit_event(
1730 event_type::ROOM_LEAVE,
1731 &observer,
1732 ®istry
1733 ));
1734 assert!(!can_submit_event(
1735 event_type::ROOM_JOIN,
1736 &observer,
1737 ®istry
1738 ));
1739 let approved = PermissionContext {
1740 join_request_approved: true,
1741 ..PermissionContext::default()
1742 };
1743 assert!(can_submit_event(
1744 event_type::ROOM_JOIN,
1745 &approved,
1746 ®istry
1747 ));
1748 }
1749
1750 #[test]
1751 fn applies_state_restrictions() {
1752 let registry = TypeRegistry::new();
1753 let speaker = PermissionContext::for_role(Role::Speaker);
1754 let moderator = PermissionContext::for_role(Role::Moderator);
1755
1756 assert!(can_accept_room_write(
1757 event_type::MESSAGE_CREATE,
1758 RoomState::Active,
1759 &speaker,
1760 ®istry
1761 ));
1762 assert!(!can_accept_room_write(
1763 event_type::MESSAGE_CREATE,
1764 RoomState::Scheduled,
1765 &speaker,
1766 ®istry
1767 ));
1768 assert!(can_write_in_state(
1770 event_type::ROOM_JOIN_REVIEW,
1771 RoomState::Scheduled
1772 ));
1773 assert!(can_write_in_state(
1774 event_type::ROOM_MEMBER_ROLE_UPDATE,
1775 RoomState::Scheduled
1776 ));
1777 assert!(can_write_in_state(
1778 event_type::ROOM_LEAVE,
1779 RoomState::Scheduled
1780 ));
1781 assert!(can_write_in_state(
1782 event_type::TYPE_DEFINE,
1783 RoomState::Scheduled
1784 ));
1785 assert!(can_write_in_state(
1786 event_type::ROOM_CANCEL,
1787 RoomState::Scheduled
1788 ));
1789 assert!(!can_write_in_state(
1790 event_type::ROOM_CLOSE,
1791 RoomState::Scheduled
1792 ));
1793 assert!(can_accept_room_write(
1794 event_type::TYPE_DEFINE,
1795 RoomState::Scheduled,
1796 &moderator,
1797 ®istry
1798 ));
1799 assert!(!can_write_in_state("reaction.create", RoomState::Ended));
1801 assert!(!can_write_in_state(
1802 event_type::ROOM_LEAVE,
1803 RoomState::Ended
1804 ));
1805 assert!(!can_write_in_state(
1806 event_type::ROOM_JOIN,
1807 RoomState::Cancelled
1808 ));
1809 assert!(can_write_in_state(
1811 event_type::ROOM_CLOSE,
1812 RoomState::Active
1813 ));
1814 assert!(!can_write_in_state(
1815 event_type::ROOM_CANCEL,
1816 RoomState::Active
1817 ));
1818 }
1819
1820 #[test]
1821 fn validates_room_creation_payloads() {
1822 let mut payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1823 payload.policy = Some(RoomPolicy {
1824 max_speakers: Some(2),
1825 ..RoomPolicy::default()
1826 });
1827 payload.guidance = Some("Cite sources.".to_owned());
1828 payload.types = vec![
1829 TypeDeclaration::Import(PackImport {
1830 use_pack: Some(pack_id::REACTIONS.to_owned()),
1831 ..PackImport::default()
1832 }),
1833 TypeDeclaration::Def(finding_def()),
1834 ];
1835 validate_room_create_payload(&payload).unwrap();
1836
1837 let empty_topic = RoomCreatePayload::new(" ", Visibility::Public, 1000, 2000);
1838 assert!(validate_room_create_payload(&empty_topic).is_err());
1839
1840 let invalid_time = RoomCreatePayload::new("Research room", Visibility::Public, 2000, 1000);
1841 assert!(validate_room_create_payload(&invalid_time).is_err());
1842
1843 let mut zero_speakers =
1844 RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1845 zero_speakers.policy = Some(RoomPolicy {
1846 max_speakers: Some(0),
1847 ..RoomPolicy::default()
1848 });
1849 assert!(validate_room_create_payload(&zero_speakers).is_err());
1850
1851 let mut reserved = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1852 let mut bad_def = finding_def();
1853 bad_def.name = "room.custom".to_owned();
1854 reserved.types = vec![TypeDeclaration::Def(bad_def)];
1855 assert!(validate_room_create_payload(&reserved).is_err());
1856 }
1857
1858 #[test]
1859 fn verifies_pack_digests() {
1860 let bytes = b"pack document bytes";
1861 let digest = format!("sha256:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)));
1862 verify_pack_digest(bytes, &digest).unwrap();
1863 let digest = format!(
1864 "sha3-256:{}",
1865 URL_SAFE_NO_PAD.encode(Sha3_256::digest(bytes))
1866 );
1867 verify_pack_digest(bytes, &digest).unwrap();
1868 assert!(verify_pack_digest(b"tampered", &digest).is_err());
1869 assert!(verify_pack_digest(bytes, "md5:abc").is_err());
1870 assert!(verify_pack_digest(bytes, "not-a-digest").is_err());
1871 }
1872
1873 #[test]
1874 fn hash_canonical_json_rejects_unserializable_values() {
1875 assert!(hash_canonical_json(&FailingPayload).is_err());
1876 }
1877
1878 #[test]
1879 fn builds_and_verifies_server_record_chains() {
1880 let signer = AgentSigner::from_seed([18; 32]);
1881 let envelope1 = signer
1882 .sign_event(Event::new(
1883 PROTOCOL,
1884 event_type::ROOM_CREATE,
1885 signer.agent_id(),
1886 100,
1887 1,
1888 json!({
1889 "topic": "Research room",
1890 "visibility": "public",
1891 "start_time": 1000,
1892 "end_time": 2000
1893 }),
1894 ))
1895 .unwrap();
1896 let record1 = build_server_record("room123", 1, None, 110, envelope1).unwrap();
1897
1898 let envelope2 = signer
1899 .sign_event(discourse_event(
1900 event_type::MESSAGE_CREATE,
1901 signer.agent_id(),
1902 120,
1903 2,
1904 "room123",
1905 1,
1906 record1.hash.clone(),
1907 json!({"content_type": "text/plain", "content": "hello"}),
1908 ))
1909 .unwrap();
1910 let record2 =
1911 build_server_record("room123", 2, Some(record1.hash.clone()), 130, envelope2).unwrap();
1912
1913 assert_eq!(
1914 record1.hash,
1915 server_record_hash("room123", 1, None, &record1.envelope.hash, 110).unwrap()
1916 );
1917 verify_server_record(&record1).unwrap();
1918 verify_server_record_chain(&[record1.clone(), record2.clone()]).unwrap();
1919 assert_eq!(
1920 archive_events_digest(&[record1.clone(), record2.clone()])
1921 .unwrap()
1922 .len(),
1923 43
1924 );
1925 assert!(verify_server_record_chain(&[record2]).is_err());
1926
1927 let broken = ServerRecord {
1928 pre_hash: Some("bad".to_owned()),
1929 ..record1
1930 };
1931 assert!(verify_server_record_chain(&[broken]).is_err());
1932 }
1933
1934 #[test]
1935 fn type_declaration_serde_round_trips() {
1936 let inline: TypeDeclaration = serde_json::from_value(json!({
1937 "type": "review.finding",
1938 "kind": "message",
1939 "title": "Review finding",
1940 "schema": {"type": "object"}
1941 }))
1942 .unwrap();
1943 assert_eq!(
1944 serde_json::to_value(&inline).unwrap()["type"],
1945 json!("review.finding")
1946 );
1947
1948 let import: TypeDeclaration = serde_json::from_value(json!({
1949 "use": "adp:reactions/1.0",
1950 "overrides": {"reaction.create": {"status": "deprecated"}}
1951 }))
1952 .unwrap();
1953 assert_eq!(
1954 serde_json::to_value(&import).unwrap()["use"],
1955 json!("adp:reactions/1.0")
1956 );
1957 validate_type_declaration(&import).unwrap();
1958 }
1959
1960 #[test]
1961 fn constructors_and_helpers() {
1962 assert_eq!(RoomPolicy::new(), RoomPolicy::default());
1963
1964 let input = RoomJoinRequestInput::new(Role::Observer);
1965 assert_eq!(input.role, Role::Observer);
1966 assert!(input.perspective.is_none());
1967
1968 assert_eq!(
1969 MessageCreatePayload::markdown("# title").content_type,
1970 "text/markdown"
1971 );
1972 assert_eq!(
1973 MessageCreatePayload::text("hi").content,
1974 Value::String("hi".to_owned())
1975 );
1976
1977 assert!(event_requires_room_id(event_type::MESSAGE_CREATE));
1978 assert!(!event_requires_room_id(event_type::ROOM_CREATE));
1979
1980 let signer = AgentSigner::from_seed([40; 32]);
1981 let event = type_define_event(
1982 signer.agent_id(),
1983 1,
1984 1,
1985 "room1",
1986 1,
1987 "room-head-hash",
1988 TypeDeclaration::Def(finding_def()),
1989 );
1990 assert_eq!(event.protocol, PROTOCOL);
1991 assert_eq!(event.kind, event_type::TYPE_DEFINE);
1992 assert_eq!(event.room_id.as_deref(), Some("room1"));
1993 }
1994
1995 #[test]
1996 fn validate_discourse_envelope_checks_protocol() {
1997 let signer = AgentSigner::from_seed([41; 32]);
1998 let event = Event::new(
1999 "wrong-protocol/1.0",
2000 event_type::MESSAGE_CREATE,
2001 signer.agent_id(),
2002 100,
2003 1,
2004 MessageCreatePayload::text("hi"),
2005 )
2006 .with_room_id("room1");
2007 let envelope = signer.sign_event(event).unwrap();
2008 assert!(validate_discourse_envelope(&envelope).is_err());
2009 }
2010
2011 #[test]
2012 fn validate_discourse_envelope_covers_payload_instantiations() {
2013 let signer = AgentSigner::from_seed([44; 32]);
2014 let room_payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
2015
2016 let mut invalid_room_create = signer
2017 .sign_event(room_create_event(
2018 signer.agent_id(),
2019 100,
2020 1,
2021 room_payload.clone(),
2022 ))
2023 .unwrap();
2024 invalid_room_create.signature = "invalid".to_owned();
2025 assert!(validate_discourse_envelope(&invalid_room_create).is_err());
2026
2027 let wrong_room_protocol = signer
2028 .sign_event(Event::new(
2029 "wrong-protocol/1.0",
2030 event_type::ROOM_CREATE,
2031 signer.agent_id(),
2032 100,
2033 2,
2034 room_payload.clone(),
2035 ))
2036 .unwrap();
2037 assert!(validate_discourse_envelope(&wrong_room_protocol).is_err());
2038
2039 let missing_room_id = signer
2040 .sign_event(Event::new(
2041 PROTOCOL,
2042 event_type::MESSAGE_CREATE,
2043 signer.agent_id(),
2044 100,
2045 3,
2046 room_payload.clone(),
2047 ))
2048 .unwrap();
2049 assert!(validate_discourse_envelope(&missing_room_id).is_err());
2050
2051 let room_payload_as_message = signer
2052 .sign_event(
2053 Event::new(
2054 PROTOCOL,
2055 event_type::MESSAGE_CREATE,
2056 signer.agent_id(),
2057 100,
2058 4,
2059 room_payload,
2060 )
2061 .with_room_id("room1")
2062 .with_room_head(1, "room-head-hash"),
2063 )
2064 .unwrap();
2065 validate_discourse_envelope(&room_payload_as_message).unwrap();
2066
2067 let message_payload_as_room = signer
2068 .sign_event(Event::new(
2069 PROTOCOL,
2070 event_type::ROOM_CREATE,
2071 signer.agent_id(),
2072 100,
2073 5,
2074 MessageCreatePayload::text("hi"),
2075 ))
2076 .unwrap();
2077 validate_discourse_envelope(&message_payload_as_room).unwrap();
2078 let message_payload_with_room = signer
2079 .sign_event(
2080 Event::new(
2081 PROTOCOL,
2082 event_type::ROOM_CREATE,
2083 signer.agent_id(),
2084 100,
2085 6,
2086 MessageCreatePayload::text("hi"),
2087 )
2088 .with_room_id("room1"),
2089 )
2090 .unwrap();
2091 assert!(validate_discourse_envelope(&message_payload_with_room).is_err());
2092
2093 let review_payload = join_review_payload(signer.agent_id());
2094 let mut invalid_review = signer
2095 .sign_event(discourse_event(
2096 event_type::ROOM_JOIN_REVIEW,
2097 signer.agent_id(),
2098 100,
2099 7,
2100 "room1",
2101 1,
2102 "room-head-hash",
2103 review_payload.clone(),
2104 ))
2105 .unwrap();
2106 invalid_review.signature = "invalid".to_owned();
2107 assert!(validate_discourse_envelope(&invalid_review).is_err());
2108
2109 let wrong_review_protocol = signer
2110 .sign_event(
2111 Event::new(
2112 "wrong-protocol/1.0",
2113 event_type::ROOM_JOIN_REVIEW,
2114 signer.agent_id(),
2115 100,
2116 8,
2117 review_payload.clone(),
2118 )
2119 .with_room_id("room1"),
2120 )
2121 .unwrap();
2122 assert!(validate_discourse_envelope(&wrong_review_protocol).is_err());
2123
2124 let missing_review_room = signer
2125 .sign_event(Event::new(
2126 PROTOCOL,
2127 event_type::ROOM_JOIN_REVIEW,
2128 signer.agent_id(),
2129 100,
2130 9,
2131 review_payload.clone(),
2132 ))
2133 .unwrap();
2134 assert!(validate_discourse_envelope(&missing_review_room).is_err());
2135
2136 let review_payload_as_room = signer
2137 .sign_event(Event::new(
2138 PROTOCOL,
2139 event_type::ROOM_CREATE,
2140 signer.agent_id(),
2141 100,
2142 10,
2143 review_payload,
2144 ))
2145 .unwrap();
2146 validate_discourse_envelope(&review_payload_as_room).unwrap();
2147 }
2148
2149 #[test]
2150 fn validate_room_path_matches_and_rejects() {
2151 let signer = AgentSigner::from_seed([42; 32]);
2152 let in_room = signer
2153 .sign_event(discourse_event(
2154 event_type::MESSAGE_CREATE,
2155 signer.agent_id(),
2156 100,
2157 1,
2158 "room1",
2159 1,
2160 "room-head-hash",
2161 MessageCreatePayload::text("hi"),
2162 ))
2163 .unwrap();
2164 validate_room_path(&in_room, "room1").unwrap();
2165 assert!(validate_room_path(&in_room, "room2").is_err());
2166
2167 let no_room = signer
2168 .sign_event(Event::new(
2169 PROTOCOL,
2170 event_type::MESSAGE_CREATE,
2171 signer.agent_id(),
2172 100,
2173 1,
2174 MessageCreatePayload::text("hi"),
2175 ))
2176 .unwrap();
2177 assert!(validate_room_path(&no_room, "room1").is_err());
2178 }
2179
2180 #[test]
2181 fn validate_room_path_covers_payload_instantiations() {
2182 let signer = AgentSigner::from_seed([45; 32]);
2183 let room_payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
2184 let in_room = signer
2185 .sign_event(
2186 Event::new(
2187 PROTOCOL,
2188 event_type::MESSAGE_CREATE,
2189 signer.agent_id(),
2190 100,
2191 1,
2192 room_payload.clone(),
2193 )
2194 .with_room_id("room1")
2195 .with_room_head(1, "room-head-hash"),
2196 )
2197 .unwrap();
2198 validate_room_path(&in_room, "room1").unwrap();
2199 assert!(validate_room_path(&in_room, "room2").is_err());
2200
2201 let missing_room = signer
2202 .sign_event(Event::new(
2203 PROTOCOL,
2204 event_type::MESSAGE_CREATE,
2205 signer.agent_id(),
2206 100,
2207 2,
2208 room_payload,
2209 ))
2210 .unwrap();
2211 assert!(validate_room_path(&missing_room, "room1").is_err());
2212
2213 let message_room_create = signer
2214 .sign_event(Event::new(
2215 PROTOCOL,
2216 event_type::ROOM_CREATE,
2217 signer.agent_id(),
2218 100,
2219 3,
2220 MessageCreatePayload::text("hi"),
2221 ))
2222 .unwrap();
2223 validate_room_path(&message_room_create, "room1").unwrap();
2224
2225 let message_room_create_with_room = signer
2226 .sign_event(
2227 Event::new(
2228 PROTOCOL,
2229 event_type::ROOM_CREATE,
2230 signer.agent_id(),
2231 100,
2232 4,
2233 MessageCreatePayload::text("hi"),
2234 )
2235 .with_room_id("room1"),
2236 )
2237 .unwrap();
2238 assert!(validate_room_path(&message_room_create_with_room, "room1").is_err());
2239 }
2240
2241 #[test]
2242 fn validate_type_def_rejects_bad_shapes() {
2243 let mut empty_title = finding_def();
2244 empty_title.title = " ".to_owned();
2245 assert!(validate_type_def(&empty_title).is_err());
2246 assert!(TypeRegistry::new().define(empty_title).is_err());
2247
2248 let mut not_object = finding_def();
2249 not_object.schema = json!("nope");
2250 assert!(validate_type_def(¬_object).is_err());
2251
2252 let mut invalid_schema = finding_def();
2253 invalid_schema.schema = json!({"type": 123});
2254 assert!(validate_type_def(&invalid_schema).is_err());
2255
2256 let mut empty_roles = finding_def();
2257 empty_roles.roles = Some(vec![]);
2258 assert!(validate_type_def(&empty_roles).is_err());
2259
2260 let mut zero_rate = finding_def();
2261 zero_rate.rate_hint = Some(0);
2262 assert!(validate_type_def(&zero_rate).is_err());
2263
2264 let mut zero_payload = finding_def();
2265 zero_payload.max_payload_hint = Some(0);
2266 assert!(validate_type_def(&zero_payload).is_err());
2267 }
2268
2269 #[test]
2270 fn validate_pack_import_covers_each_arm() {
2271 validate_pack_import(&PackImport {
2272 use_pack: Some(pack_id::REACTIONS.to_owned()),
2273 ..PackImport::default()
2274 })
2275 .unwrap();
2276
2277 validate_pack_import(&PackImport {
2278 pack: Some("https://example.com/p.json".to_owned()),
2279 digest: Some("sha256:abc".to_owned()),
2280 ..PackImport::default()
2281 })
2282 .unwrap();
2283
2284 assert!(validate_pack_import(&PackImport {
2285 pack: Some("https://example.com/p.json".to_owned()),
2286 digest: Some(" ".to_owned()),
2287 ..PackImport::default()
2288 })
2289 .is_err());
2290
2291 assert!(validate_pack_import(&PackImport::default()).is_err());
2292
2293 assert!(validate_pack_import(&PackImport {
2294 use_pack: Some(pack_id::REACTIONS.to_owned()),
2295 types: Some(vec![]),
2296 ..PackImport::default()
2297 })
2298 .is_err());
2299
2300 let invalid = TypeDeclaration::Import(PackImport {
2301 use_pack: Some(pack_id::REACTIONS.to_owned()),
2302 pack: Some("https://example.com/p.json".to_owned()),
2303 digest: Some("sha256:abc".to_owned()),
2304 ..PackImport::default()
2305 });
2306 assert!(TypeRegistry::from_declarations(&[invalid], ®istered_packs()).is_err());
2307 }
2308
2309 #[test]
2310 fn registered_pack_id_shape() {
2311 assert!(validate_pack_import(&PackImport {
2312 use_pack: Some("not-prefixed".to_owned()),
2313 ..PackImport::default()
2314 })
2315 .is_err());
2316 assert!(validate_pack_import(&PackImport {
2317 use_pack: Some("adp:no-slash".to_owned()),
2318 ..PackImport::default()
2319 })
2320 .is_err());
2321 assert!(validate_pack_import(&PackImport {
2322 use_pack: Some("adp:bad_name/1.0".to_owned()),
2323 ..PackImport::default()
2324 })
2325 .is_err());
2326 assert!(validate_pack_import(&PackImport {
2327 use_pack: Some("adp:bad/1.x".to_owned()),
2328 ..PackImport::default()
2329 })
2330 .is_err());
2331 assert!(validate_pack_import(&PackImport {
2332 use_pack: Some("adp:bad/1.2.3".to_owned()),
2333 ..PackImport::default()
2334 })
2335 .is_err());
2336 }
2337
2338 #[test]
2339 fn validate_message_create_payload_checks_content_type() {
2340 validate_message_create_payload(&MessageCreatePayload::text("hi")).unwrap();
2341 let mut empty = MessageCreatePayload::text("hi");
2342 empty.content_type = " ".to_owned();
2343 assert!(validate_message_create_payload(&empty).is_err());
2344 }
2345
2346 #[test]
2347 fn import_rejects_unknown_subset_and_applies_all_overrides() {
2348 let packs = registered_packs();
2349 let missing_subset = TypeDeclaration::Import(PackImport {
2350 use_pack: Some(pack_id::REACTIONS.to_owned()),
2351 types: Some(vec!["does.not.exist".to_owned()]),
2352 ..PackImport::default()
2353 });
2354 assert!(TypeRegistry::from_declarations(&[missing_subset], &packs).is_err());
2355
2356 let missing_pack = TypeDeclaration::Import(PackImport {
2357 use_pack: Some("adp:missing/1.0".to_owned()),
2358 ..PackImport::default()
2359 });
2360 assert!(TypeRegistry::from_declarations(&[missing_pack], &packs).is_err());
2361
2362 let hidden_override = TypeDeclaration::Import(PackImport {
2363 use_pack: Some(pack_id::DELIBERATION.to_owned()),
2364 types: Some(vec!["poll.create".to_owned()]),
2365 overrides: BTreeMap::from([("poll.vote".to_owned(), TypeOverride::default())]),
2366 ..PackImport::default()
2367 });
2368 assert!(TypeRegistry::from_declarations(&[hidden_override], &packs).is_err());
2369
2370 let empty_pack = BTreeMap::from([(
2371 "adp:empty/1.0".to_owned(),
2372 Pack {
2373 id: "adp:empty/1.0".to_owned(),
2374 title: "Empty pack".to_owned(),
2375 description: None,
2376 types: Vec::new(),
2377 extra: BTreeMap::new(),
2378 },
2379 )]);
2380 let empty_import = TypeDeclaration::Import(PackImport {
2381 use_pack: Some("adp:empty/1.0".to_owned()),
2382 ..PackImport::default()
2383 });
2384 assert!(
2385 TypeRegistry::from_declarations(&[empty_import], &empty_pack)
2386 .unwrap()
2387 .is_empty()
2388 );
2389
2390 let overridden = TypeDeclaration::Import(PackImport {
2391 use_pack: Some(pack_id::REACTIONS.to_owned()),
2392 overrides: BTreeMap::from([(
2393 "reaction.create".to_owned(),
2394 TypeOverride {
2395 roles: Some(vec![Role::Moderator]),
2396 instructions: Some("be concise".to_owned()),
2397 status: Some(TypeStatus::Deprecated),
2398 rate_hint: Some(5),
2399 max_payload_hint: Some(2048),
2400 },
2401 )]),
2402 ..PackImport::default()
2403 });
2404 let registry = TypeRegistry::from_declarations(&[overridden], &packs).unwrap();
2405 let def = registry.get("reaction.create").unwrap();
2406 assert_eq!(def.instructions.as_deref(), Some("be concise"));
2407 assert_eq!(def.status(), TypeStatus::Deprecated);
2408 assert_eq!(def.rate_hint, Some(5));
2409 assert_eq!(def.max_payload_hint, Some(2048));
2410 assert_eq!(def.roles.as_deref(), Some([Role::Moderator].as_slice()));
2411
2412 let no_roles_override = TypeDeclaration::Import(PackImport {
2413 use_pack: Some(pack_id::REACTIONS.to_owned()),
2414 overrides: BTreeMap::from([(
2415 "reaction.create".to_owned(),
2416 TypeOverride {
2417 instructions: Some("no role override".to_owned()),
2418 ..TypeOverride::default()
2419 },
2420 )]),
2421 ..PackImport::default()
2422 });
2423 let registry = TypeRegistry::from_declarations(&[no_roles_override], &packs).unwrap();
2424 let def = registry.get("reaction.create").unwrap();
2425 assert_eq!(def.instructions.as_deref(), Some("no role override"));
2426 assert!(def.roles.is_none());
2427
2428 let mut invalid_def = finding_def();
2429 invalid_def.title = String::new();
2430 let invalid_pack = BTreeMap::from([(
2431 "adp:invalid/1.0".to_owned(),
2432 Pack {
2433 id: "adp:invalid/1.0".to_owned(),
2434 title: "Invalid pack".to_owned(),
2435 description: None,
2436 types: vec![invalid_def],
2437 extra: BTreeMap::new(),
2438 },
2439 )]);
2440 let invalid_import = TypeDeclaration::Import(PackImport {
2441 use_pack: Some("adp:invalid/1.0".to_owned()),
2442 ..PackImport::default()
2443 });
2444 assert!(TypeRegistry::from_declarations(&[invalid_import], &invalid_pack).is_err());
2445 }
2446
2447 #[test]
2448 fn registry_introspection_and_registry_validation() {
2449 let mut registry = TypeRegistry::new();
2450 assert!(registry.is_empty());
2451 registry.define(finding_def()).unwrap();
2452 assert!(!registry.is_empty());
2453 assert_eq!(registry.definitions().count(), 1);
2454
2455 validate_event_against_registry(
2457 event_type::MESSAGE_CREATE,
2458 &json!({"anything": true}),
2459 ®istry,
2460 )
2461 .unwrap();
2462 validate_event_against_registry(
2463 "review.finding",
2464 &json!({"severity": "high", "summary": "s"}),
2465 ®istry,
2466 )
2467 .unwrap();
2468 assert!(validate_event_against_registry("review.finding", &json!({}), ®istry).is_err());
2469
2470 let mut invalid_schema = finding_def();
2471 invalid_schema.schema = json!({"type": 123});
2472 registry
2473 .types
2474 .insert(invalid_schema.name.clone(), invalid_schema);
2475 assert!(registry
2476 .validate_payload(
2477 "review.finding",
2478 &json!({"severity": "high", "summary": "s"})
2479 )
2480 .is_err());
2481 }
2482
2483 #[test]
2484 fn custom_permission_edges_and_room_write() {
2485 let mut registry = TypeRegistry::new();
2486 registry.define(finding_def()).unwrap();
2487 let mut disabled = finding_def();
2488 disabled.name = "review.disabled".to_owned();
2489 disabled.status = Some(TypeStatus::Disabled);
2490 registry.define(disabled).unwrap();
2491
2492 assert!(!can_submit_event(
2494 "review.disabled",
2495 &PermissionContext::creator(None),
2496 ®istry
2497 ));
2498 assert!(!can_submit_event(
2500 "review.finding",
2501 &PermissionContext::default(),
2502 ®istry
2503 ));
2504
2505 validate_room_write(
2506 event_type::MESSAGE_CREATE,
2507 RoomState::Active,
2508 &PermissionContext::for_role(Role::Speaker),
2509 ®istry,
2510 )
2511 .unwrap();
2512 assert!(validate_room_write(
2513 event_type::MESSAGE_CREATE,
2514 RoomState::Ended,
2515 &PermissionContext::for_role(Role::Speaker),
2516 ®istry,
2517 )
2518 .is_err());
2519 }
2520
2521 #[test]
2522 fn server_record_chain_violations() {
2523 let signer = AgentSigner::from_seed([43; 32]);
2524 let make = |seq: u64, nonce: u64, pre_hash: Option<String>| {
2525 let envelope = signer
2526 .sign_event(discourse_event(
2527 event_type::MESSAGE_CREATE,
2528 signer.agent_id(),
2529 100,
2530 nonce,
2531 "room1",
2532 1,
2533 "room-head-hash",
2534 json!({"content_type": "text/plain", "content": "hi"}),
2535 ))
2536 .unwrap();
2537 build_server_record("room1", seq, pre_hash, 100 + seq as i64, envelope).unwrap()
2538 };
2539
2540 let first = make(1, 1, None);
2541 let gap = make(3, 2, Some(first.hash.clone()));
2542 assert!(verify_server_record_chain(&[first.clone(), gap]).is_err());
2543
2544 let wrong_pre = make(2, 3, Some("not-the-previous-hash".to_owned()));
2545 assert!(verify_server_record_chain(&[first.clone(), wrong_pre]).is_err());
2546
2547 let first_with_pre = make(1, 4, Some("unexpected".to_owned()));
2548 assert!(verify_server_record_chain(&[first_with_pre]).is_err());
2549 }
2550}