Skip to main content

agent_protocols/
discourse.rs

1//! Agent Discourse Protocol 1.0: kernel types, the room type system, and
2//! verification helpers.
3//!
4//! The protocol defines nine built-in event types. Every other event type is
5//! declared per room as a schema-validated type definition, either inline or
6//! imported from a type pack. Hosts validate structure and permissions; they
7//! never need to understand application semantics.
8
9use 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};
20
21pub const PROTOCOL: &str = "agent-discourse/1.0";
22
23/// The nine built-in event types. All other types are room-defined.
24pub 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
48/// Custom event types must not use these prefixes.
49pub const RESERVED_TYPE_PREFIXES: [&str; 2] = ["room.", "type."];
50
51/// Registered type packs defined by the specification in `1.0.packs.json`.
52pub 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/// Permission class of an event type.
88#[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/// A room-scoped declaration of a custom event type.
184#[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    /// Self-contained JSON Schema (draft 2020-12) for the event payload.
193    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/// Per-type adjustments applied when importing a pack.
217#[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/// Imports a registered pack (`use`) or an external pack (`pack` + `digest`).
232#[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/// One entry of `room.create.payload.types` or a `type.define` payload.
247#[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/// The shape of `1.0.packs.json` and externally published pack documents.
266#[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
274/// Indexes the packs of a document by pack id for registry materialization.
275pub 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 guidance: Option<String>,
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub visibility: Option<Visibility>,
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub start_time: Option<i64>,
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub end_time: Option<i64>,
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub policy: Option<RoomPolicy>,
320    /// Materialized type registry served by the host.
321    #[serde(default, skip_serializing_if = "Vec::is_empty")]
322    pub types: Vec<TypeDef>,
323    pub seq: u64,
324    #[serde(default)]
325    pub pre_hash: Option<String>,
326    pub hash: String,
327    pub received_at: i64,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub envelope: Option<Envelope<RoomCreatePayload>>,
330}
331
332#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
333pub struct RoomJoinPayload {
334    pub request_id: String,
335    pub role: Role,
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub perspective: Option<String>,
338    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
339    pub extra: BTreeMap<String, Value>,
340}
341
342#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
343pub struct RoomJoinRequestPayload {
344    pub requested_role: Role,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub perspective: Option<String>,
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub reason: Option<String>,
349    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
350    pub extra: BTreeMap<String, Value>,
351}
352
353impl RoomJoinRequestPayload {
354    pub fn new(requested_role: Role) -> Self {
355        Self {
356            requested_role,
357            perspective: None,
358            reason: None,
359            extra: BTreeMap::new(),
360        }
361    }
362}
363
364#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
365pub struct RoomJoinRequest {
366    pub id: String,
367    pub room_id: String,
368    pub applicant: AgentId,
369    pub requested_role: Role,
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub approved_role: Option<Role>,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub perspective: Option<String>,
374    pub status: JoinRequestStatus,
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub request_reason: Option<String>,
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub review_reason: Option<String>,
379    pub created_at: i64,
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub reviewed_by: Option<AgentId>,
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub reviewed_at: Option<i64>,
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub expires_at: Option<i64>,
386    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
387    pub extra: BTreeMap<String, Value>,
388}
389
390#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
391pub struct RoomJoinReviewPayload {
392    pub request_id: String,
393    pub member: AgentId,
394    pub decision: JoinDecision,
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub role: Option<Role>,
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub reason: Option<String>,
399    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
400    pub extra: BTreeMap<String, Value>,
401}
402
403#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
404pub struct RoleUpdatePayload {
405    pub member: AgentId,
406    pub role: Role,
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub reason: Option<String>,
409    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
410    pub extra: BTreeMap<String, Value>,
411}
412
413/// Shared payload of `room.leave`, `room.close`, and `room.cancel`.
414#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
415pub struct ReasonPayload {
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub reason: Option<String>,
418    #[serde(default, skip_serializing_if = "Vec::is_empty")]
419    pub references: Vec<String>,
420    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
421    pub extra: BTreeMap<String, Value>,
422}
423
424pub type RoomLeavePayload = ReasonPayload;
425pub type RoomClosePayload = ReasonPayload;
426pub type RoomCancelPayload = ReasonPayload;
427
428#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
429pub struct MessageCreatePayload {
430    pub content_type: String,
431    pub content: Value,
432    #[serde(default, skip_serializing_if = "Vec::is_empty")]
433    pub references: Vec<String>,
434    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
435    pub extra: BTreeMap<String, Value>,
436}
437
438impl MessageCreatePayload {
439    pub fn new(content_type: impl Into<String>, content: Value) -> Self {
440        Self {
441            content_type: content_type.into(),
442            content,
443            references: Vec::new(),
444            extra: BTreeMap::new(),
445        }
446    }
447
448    pub fn text(text: impl Into<String>) -> Self {
449        Self::new("text/plain", Value::String(text.into()))
450    }
451
452    pub fn markdown(markdown: impl Into<String>) -> Self {
453        Self::new("text/markdown", Value::String(markdown.into()))
454    }
455}
456
457#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
458pub struct ProfileResolverMetadata {
459    pub mode: String,
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub service: Option<String>,
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub protocol: Option<String>,
464}
465
466#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
467pub struct DiscourseProtocolDiscovery {
468    pub protocol: String,
469    pub host: String,
470    #[serde(default, skip_serializing_if = "Vec::is_empty")]
471    pub features: Vec<String>,
472    #[serde(default, skip_serializing_if = "Vec::is_empty")]
473    pub registered_packs: Vec<String>,
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub profile: Option<ProfileResolverMetadata>,
476    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
477    pub endpoints: BTreeMap<String, String>,
478}
479
480#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
481pub struct ArchiveManifest {
482    pub protocol: String,
483    #[serde(rename = "type")]
484    pub kind: String,
485    pub host: String,
486    pub room_id: String,
487    pub url: String,
488    pub generated_at: i64,
489    pub event_count: u64,
490    pub first_seq: u64,
491    pub last_seq: u64,
492    pub last_hash: String,
493    pub events_sha3_256: String,
494    pub archive_root: String,
495    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
496    pub formats: BTreeMap<String, String>,
497    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
498    pub extra: BTreeMap<String, Value>,
499}
500
501pub fn room_create_event(
502    actor: AgentId,
503    created_at: i64,
504    nonce: u64,
505    payload: RoomCreatePayload,
506) -> Event<RoomCreatePayload> {
507    Event::new(
508        PROTOCOL,
509        event_type::ROOM_CREATE,
510        actor,
511        created_at,
512        nonce,
513        payload,
514    )
515}
516
517pub fn type_define_event(
518    actor: AgentId,
519    created_at: i64,
520    nonce: u64,
521    room_id: impl Into<String>,
522    declaration: TypeDeclaration,
523) -> Event<TypeDeclaration> {
524    Event::new(
525        PROTOCOL,
526        event_type::TYPE_DEFINE,
527        actor,
528        created_at,
529        nonce,
530        declaration,
531    )
532    .with_room_id(room_id)
533}
534
535pub fn discourse_event<P>(
536    kind: impl Into<String>,
537    actor: AgentId,
538    created_at: i64,
539    nonce: u64,
540    room_id: impl Into<String>,
541    payload: P,
542) -> Event<P> {
543    Event::new(PROTOCOL, kind, actor, created_at, nonce, payload).with_room_id(room_id)
544}
545
546pub fn is_builtin_event_type(event_type: &str) -> bool {
547    BUILTIN_EVENT_TYPES.contains(&event_type)
548}
549
550pub fn event_requires_room_id(event_type: &str) -> bool {
551    event_type != event_type::ROOM_CREATE
552}
553
554pub fn validate_discourse_envelope<P>(envelope: &Envelope<P>) -> Result<()>
555where
556    P: Serialize,
557{
558    verify_envelope(envelope)?;
559    let protocol = envelope.event.protocol.as_str();
560    if protocol != PROTOCOL {
561        return Err(SdkError::InvalidEventProtocol {
562            expected: PROTOCOL.to_owned(),
563            actual: envelope.event.protocol.clone(),
564        });
565    }
566    if envelope.event.kind == event_type::ROOM_CREATE {
567        if envelope.event.room_id.is_some() {
568            return Err(SdkError::InvalidPayload(
569                "room.create must not include room_id".to_owned(),
570            ));
571        }
572    } else if envelope.event.room_id.is_none() {
573        return Err(SdkError::MissingRoomId);
574    }
575    Ok(())
576}
577
578pub fn validate_room_path<P>(envelope: &Envelope<P>, path_room_id: &str) -> Result<()> {
579    if envelope.event.kind == event_type::ROOM_CREATE {
580        return match envelope.event.room_id.as_deref() {
581            None => Ok(()),
582            Some(_) => Err(SdkError::InvalidPayload(
583                "room.create must not include room_id".to_owned(),
584            )),
585        };
586    }
587    match envelope.event.room_id.as_deref() {
588        Some(actual) if actual == path_room_id => Ok(()),
589        Some(actual) => Err(SdkError::RoomIdMismatch {
590            expected: path_room_id.to_owned(),
591            actual: actual.to_owned(),
592        }),
593        None => Err(SdkError::MissingRoomId),
594    }
595}
596
597/// Checks the shape of a custom event type name: lowercase dot-separated,
598/// at least two segments, not built-in, not under a reserved prefix.
599pub fn validate_custom_event_type_name(name: &str) -> Result<()> {
600    let segments: Vec<&str> = name.split('.').collect();
601    let valid_shape = segments.len() >= 2
602        && segments.iter().all(|segment| {
603            let mut chars = segment.chars();
604            matches!(chars.next(), Some(first) if first.is_ascii_lowercase() || first.is_ascii_digit())
605                && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
606        });
607    if !valid_shape {
608        return Err(SdkError::InvalidPayload(format!(
609            "invalid event type name: {name}"
610        )));
611    }
612    if is_builtin_event_type(name) {
613        return Err(SdkError::InvalidPayload(format!(
614            "{name} is a built-in event type"
615        )));
616    }
617    if RESERVED_TYPE_PREFIXES
618        .iter()
619        .any(|prefix| name.starts_with(prefix))
620    {
621        return Err(SdkError::InvalidPayload(format!(
622            "{name} uses a reserved type prefix"
623        )));
624    }
625    Ok(())
626}
627
628pub fn validate_type_def(def: &TypeDef) -> Result<()> {
629    validate_custom_event_type_name(&def.name)?;
630    if def.title.trim().is_empty() {
631        return Err(SdkError::InvalidPayload(
632            "type definition title must not be empty".to_owned(),
633        ));
634    }
635    if !def.schema.is_object() {
636        return Err(SdkError::InvalidPayload(
637            "type definition schema must be a JSON Schema object".to_owned(),
638        ));
639    }
640    compile_schema(&def.schema)?;
641    if matches!(&def.roles, Some(roles) if roles.is_empty()) {
642        return Err(SdkError::InvalidPayload(
643            "type definition roles must not be empty".to_owned(),
644        ));
645    }
646    if matches!(def.rate_hint, Some(0)) || matches!(def.max_payload_hint, Some(0)) {
647        return Err(SdkError::InvalidPayload(
648            "type definition hints must be positive".to_owned(),
649        ));
650    }
651    Ok(())
652}
653
654pub fn validate_pack_import(import: &PackImport) -> Result<()> {
655    match (&import.use_pack, &import.pack, &import.digest) {
656        (Some(id), None, None) => {
657            if !is_registered_pack_id(id) {
658                return Err(SdkError::InvalidPayload(format!(
659                    "invalid registered pack id: {id}"
660                )));
661            }
662        }
663        (None, Some(_), Some(digest)) => {
664            if digest.trim().is_empty() {
665                return Err(SdkError::InvalidPayload(
666                    "external pack digest must not be empty".to_owned(),
667                ));
668            }
669        }
670        _ => {
671            return Err(SdkError::InvalidPayload(
672                "pack import requires either use, or pack with digest".to_owned(),
673            ));
674        }
675    }
676    if matches!(&import.types, Some(types) if types.is_empty()) {
677        return Err(SdkError::InvalidPayload(
678            "pack import types subset must not be empty".to_owned(),
679        ));
680    }
681    Ok(())
682}
683
684pub fn validate_type_declaration(declaration: &TypeDeclaration) -> Result<()> {
685    match declaration {
686        TypeDeclaration::Def(def) => validate_type_def(def),
687        TypeDeclaration::Import(import) => validate_pack_import(import),
688    }
689}
690
691fn is_registered_pack_id(id: &str) -> bool {
692    let Some(rest) = id.strip_prefix("adp:") else {
693        return false;
694    };
695    let Some((name, version)) = rest.split_once('/') else {
696        return false;
697    };
698    !name.is_empty()
699        && name
700            .chars()
701            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
702        && version.split('.').count() == 2
703        && version
704            .split('.')
705            .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
706}
707
708pub fn validate_room_create_payload(payload: &RoomCreatePayload) -> Result<()> {
709    if payload.topic.trim().is_empty() {
710        return Err(SdkError::InvalidPayload(
711            "room topic must not be empty".to_owned(),
712        ));
713    }
714    if payload.start_time >= payload.end_time {
715        return Err(SdkError::InvalidPayload(
716            "start_time must be before end_time".to_owned(),
717        ));
718    }
719    if let Some(policy) = &payload.policy {
720        if matches!(policy.max_speakers, Some(0)) {
721            return Err(SdkError::InvalidPayload(
722                "max_speakers must be a positive integer".to_owned(),
723            ));
724        }
725    }
726    for declaration in &payload.types {
727        validate_type_declaration(declaration)?;
728    }
729    Ok(())
730}
731
732pub fn validate_message_create_payload(payload: &MessageCreatePayload) -> Result<()> {
733    if payload.content_type.trim().is_empty() {
734        return Err(SdkError::InvalidPayload(
735            "content_type must not be empty".to_owned(),
736        ));
737    }
738    Ok(())
739}
740
741/// The effective set of type definitions active in a room.
742#[derive(Clone, Debug, Default)]
743pub struct TypeRegistry {
744    types: BTreeMap<String, TypeDef>,
745}
746
747impl TypeRegistry {
748    pub fn new() -> Self {
749        Self::default()
750    }
751
752    /// Materializes a registry from declarations, resolving pack imports from
753    /// `packs`, keyed by registered pack id or external pack URI.
754    pub fn from_declarations(
755        declarations: &[TypeDeclaration],
756        packs: &BTreeMap<String, Pack>,
757    ) -> Result<Self> {
758        let mut registry = Self::new();
759        for declaration in declarations {
760            registry.apply(declaration, packs)?;
761        }
762        Ok(registry)
763    }
764
765    /// Applies one declaration: an inline definition or a pack import.
766    /// Redefining an existing type replaces it; the latest definition wins.
767    pub fn apply(
768        &mut self,
769        declaration: &TypeDeclaration,
770        packs: &BTreeMap<String, Pack>,
771    ) -> Result<()> {
772        match declaration {
773            TypeDeclaration::Def(def) => self.define(def.clone()),
774            TypeDeclaration::Import(import) => self.import(import, packs),
775        }
776    }
777
778    pub fn define(&mut self, def: TypeDef) -> Result<()> {
779        validate_type_def(&def)?;
780        self.types.insert(def.name.clone(), def);
781        Ok(())
782    }
783
784    fn import(&mut self, import: &PackImport, packs: &BTreeMap<String, Pack>) -> Result<()> {
785        validate_pack_import(import)?;
786        let reference = import
787            .use_pack
788            .as_deref()
789            .or(import.pack.as_deref())
790            .expect("validated pack import has a reference");
791        let pack = packs
792            .get(reference)
793            .ok_or_else(|| SdkError::PackUnavailable(reference.to_owned()))?;
794        let available: BTreeSet<&str> = pack.types.iter().map(|def| def.name.as_str()).collect();
795        if let Some(subset) = &import.types {
796            for name in subset {
797                if !available.contains(name.as_str()) {
798                    return Err(SdkError::PackUnavailable(format!(
799                        "type {name} is not in pack {reference}"
800                    )));
801                }
802            }
803        }
804        for name in import.overrides.keys() {
805            let imported = import
806                .types
807                .as_ref()
808                .map(|subset| subset.contains(name))
809                .unwrap_or_else(|| available.contains(name.as_str()));
810            if !imported {
811                return Err(SdkError::InvalidPayload(format!(
812                    "override target {name} is not imported from pack {reference}"
813                )));
814            }
815        }
816        for def in &pack.types {
817            if let Some(subset) = &import.types {
818                if !subset.contains(&def.name) {
819                    continue;
820                }
821            }
822            let mut def = def.clone();
823            if let Some(over) = import.overrides.get(&def.name) {
824                if let Some(roles) = &over.roles {
825                    def.roles = Some(roles.clone());
826                }
827                if let Some(instructions) = &over.instructions {
828                    def.instructions = Some(instructions.clone());
829                }
830                if let Some(status) = over.status {
831                    def.status = Some(status);
832                }
833                if let Some(rate_hint) = over.rate_hint {
834                    def.rate_hint = Some(rate_hint);
835                }
836                if let Some(max_payload_hint) = over.max_payload_hint {
837                    def.max_payload_hint = Some(max_payload_hint);
838                }
839            }
840            self.define(def)?;
841        }
842        Ok(())
843    }
844
845    pub fn get(&self, event_type: &str) -> Option<&TypeDef> {
846        self.types.get(event_type)
847    }
848
849    pub fn contains(&self, event_type: &str) -> bool {
850        self.types.contains_key(event_type)
851    }
852
853    pub fn len(&self) -> usize {
854        self.types.len()
855    }
856
857    pub fn is_empty(&self) -> bool {
858        self.types.is_empty()
859    }
860
861    pub fn definitions(&self) -> impl Iterator<Item = &TypeDef> {
862        self.types.values()
863    }
864
865    /// Validates a custom event payload against the type's schema and status.
866    pub fn validate_payload(&self, event_type: &str, payload: &Value) -> Result<()> {
867        let def = self
868            .get(event_type)
869            .ok_or_else(|| SdkError::TypeNotDefined(event_type.to_owned()))?;
870        if def.status() == TypeStatus::Disabled {
871            return Err(SdkError::TypeDisabled(event_type.to_owned()));
872        }
873        let validator = compile_schema(&def.schema)?;
874        if let Err(error) = validator.validate(payload) {
875            return Err(SdkError::PayloadSchemaViolation(format!(
876                "{event_type}: {error}"
877            )));
878        }
879        Ok(())
880    }
881}
882
883/// Validates an event payload: built-in payloads are accepted as-is (use the
884/// typed validators for them); custom payloads must satisfy the registry.
885pub fn validate_event_against_registry(
886    event_type: &str,
887    payload: &Value,
888    registry: &TypeRegistry,
889) -> Result<()> {
890    if is_builtin_event_type(event_type) {
891        return Ok(());
892    }
893    registry.validate_payload(event_type, payload)
894}
895
896fn compile_schema(schema: &Value) -> Result<jsonschema::Validator> {
897    jsonschema::options()
898        .with_draft(jsonschema::Draft::Draft202012)
899        .build(schema)
900        .map_err(|err| SdkError::InvalidPayload(format!("invalid type schema: {err}")))
901}
902
903/// Verifies a `<algorithm>:<base64url-digest>` content digest over raw bytes.
904/// Supports `sha256` and `sha3-256`.
905pub fn verify_pack_digest(bytes: &[u8], digest: &str) -> Result<()> {
906    let (algorithm, expected) = digest.split_once(':').ok_or_else(|| {
907        SdkError::PackUnavailable(format!("invalid digest format: {digest}"))
908    })?;
909    let actual = match algorithm {
910        "sha256" => URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)),
911        "sha3-256" => URL_SAFE_NO_PAD.encode(Sha3_256::digest(bytes)),
912        _ => {
913            return Err(SdkError::PackUnavailable(format!(
914                "unsupported digest algorithm: {algorithm}"
915            )));
916        }
917    };
918    if actual == expected {
919        Ok(())
920    } else {
921        Err(SdkError::PackUnavailable(
922            "pack digest mismatch".to_owned(),
923        ))
924    }
925}
926
927pub fn server_record_hash_payload(
928    room_id: &str,
929    seq: u64,
930    pre_hash: Option<&str>,
931    envelope_hash: &str,
932    received_at: i64,
933) -> ServerRecordHashPayload {
934    ServerRecordHashPayload {
935        room_id: room_id.to_owned(),
936        seq,
937        pre_hash: pre_hash.map(str::to_owned),
938        envelope_hash: envelope_hash.to_owned(),
939        received_at,
940    }
941}
942
943pub fn server_record_hash(
944    room_id: &str,
945    seq: u64,
946    pre_hash: Option<&str>,
947    envelope_hash: &str,
948    received_at: i64,
949) -> Result<String> {
950    hash_canonical_json(&server_record_hash_payload(
951        room_id,
952        seq,
953        pre_hash,
954        envelope_hash,
955        received_at,
956    ))
957}
958
959pub fn build_server_record<P>(
960    room_id: impl Into<String>,
961    seq: u64,
962    pre_hash: Option<String>,
963    received_at: i64,
964    envelope: Envelope<P>,
965) -> Result<ServerRecord<P>> {
966    let room_id = room_id.into();
967    let hash = server_record_hash(
968        &room_id,
969        seq,
970        pre_hash.as_deref(),
971        &envelope.hash,
972        received_at,
973    )?;
974    Ok(ServerRecord {
975        room_id,
976        seq,
977        pre_hash,
978        hash,
979        received_at,
980        envelope,
981    })
982}
983
984pub fn verify_server_record<P>(record: &ServerRecord<P>) -> Result<()>
985where
986    P: Serialize,
987{
988    let expected = server_record_hash(
989        &record.room_id,
990        record.seq,
991        record.pre_hash.as_deref(),
992        &record.envelope.hash,
993        record.received_at,
994    )?;
995    if record.hash == expected {
996        Ok(())
997    } else {
998        Err(SdkError::InvalidEventHash {
999            expected,
1000            actual: record.hash.clone(),
1001        })
1002    }
1003}
1004
1005pub fn verify_server_record_chain<P>(records: &[ServerRecord<P>]) -> Result<()>
1006where
1007    P: Serialize,
1008{
1009    let mut previous: Option<&ServerRecord<P>> = None;
1010    for record in records {
1011        verify_server_record(record)?;
1012        if let Some(previous) = previous {
1013            if record.seq != previous.seq + 1 {
1014                return Err(SdkError::InvalidPayload(
1015                    "seq must increase by 1".to_owned(),
1016                ));
1017            }
1018            if record.pre_hash.as_deref() != Some(previous.hash.as_str()) {
1019                return Err(SdkError::InvalidPayload("pre_hash mismatch".to_owned()));
1020            }
1021        } else if record.seq != 1 {
1022            return Err(SdkError::InvalidPayload("first seq must be 1".to_owned()));
1023        } else if record.pre_hash.is_some() {
1024            return Err(SdkError::InvalidPayload(
1025                "first pre_hash must be null".to_owned(),
1026            ));
1027        }
1028        previous = Some(record);
1029    }
1030    Ok(())
1031}
1032
1033pub fn archive_events_digest<P>(records: &[ServerRecord<P>]) -> Result<String>
1034where
1035    P: Serialize,
1036{
1037    hash_canonical_json(records)
1038}
1039
1040/// Permission inputs for one actor in one room.
1041#[derive(Clone, Debug, Default, PartialEq, Eq)]
1042pub struct PermissionContext {
1043    pub role: Option<Role>,
1044    pub is_creator: bool,
1045    pub join_request_approved: bool,
1046}
1047
1048impl PermissionContext {
1049    pub fn for_role(role: Role) -> Self {
1050        Self {
1051            role: Some(role),
1052            ..Self::default()
1053        }
1054    }
1055
1056    pub fn creator(role: Option<Role>) -> Self {
1057        Self {
1058            role,
1059            is_creator: true,
1060            ..Self::default()
1061        }
1062    }
1063}
1064
1065/// Default sender roles for each kind. The creator passes every role check.
1066pub fn default_kind_roles(kind: TypeKind) -> &'static [Role] {
1067    match kind {
1068        TypeKind::Message => &[Role::Moderator, Role::Speaker],
1069        TypeKind::Signal => &[Role::Moderator, Role::Speaker, Role::Observer],
1070        TypeKind::Control => &[Role::Moderator],
1071    }
1072}
1073
1074/// Role check for one event type, using kind defaults and per-type overrides
1075/// from the room's type registry. State checks are separate.
1076pub fn can_submit_event(
1077    event_type: &str,
1078    context: &PermissionContext,
1079    registry: &TypeRegistry,
1080) -> bool {
1081    match event_type {
1082        event_type::ROOM_CREATE => true,
1083        event_type::ROOM_JOIN => context.join_request_approved,
1084        event_type::ROOM_LEAVE => context.is_creator || context.role.is_some(),
1085        event_type::ROOM_JOIN_REVIEW
1086        | event_type::ROOM_MEMBER_ROLE_UPDATE
1087        | event_type::ROOM_CLOSE
1088        | event_type::ROOM_CANCEL
1089        | event_type::TYPE_DEFINE => {
1090            context.is_creator || context.role == Some(Role::Moderator)
1091        }
1092        event_type::MESSAGE_CREATE => {
1093            context.is_creator
1094                || matches!(context.role, Some(Role::Moderator) | Some(Role::Speaker))
1095        }
1096        custom => {
1097            let Some(def) = registry.get(custom) else {
1098                return false;
1099            };
1100            if def.status() == TypeStatus::Disabled {
1101                return false;
1102            }
1103            if context.is_creator {
1104                return true;
1105            }
1106            let Some(role) = context.role else {
1107                return false;
1108            };
1109            match &def.roles {
1110                Some(roles) => roles.contains(&role),
1111                None => default_kind_roles(def.kind).contains(&role),
1112            }
1113        }
1114    }
1115}
1116
1117pub fn can_write_in_state(event_type: &str, state: RoomState) -> bool {
1118    match state {
1119        RoomState::Scheduled => matches!(
1120            event_type,
1121            event_type::ROOM_JOIN
1122                | event_type::ROOM_JOIN_REVIEW
1123                | event_type::ROOM_MEMBER_ROLE_UPDATE
1124                | event_type::ROOM_LEAVE
1125                | event_type::TYPE_DEFINE
1126                | event_type::ROOM_CANCEL
1127        ),
1128        RoomState::Active => {
1129            event_type != event_type::ROOM_CREATE && event_type != event_type::ROOM_CANCEL
1130        }
1131        RoomState::Ended | RoomState::Cancelled => false,
1132    }
1133}
1134
1135pub fn can_accept_room_write(
1136    event_type: &str,
1137    state: RoomState,
1138    permission: &PermissionContext,
1139    registry: &TypeRegistry,
1140) -> bool {
1141    can_submit_event(event_type, permission, registry) && can_write_in_state(event_type, state)
1142}
1143
1144pub fn validate_room_write(
1145    event_type: &str,
1146    state: RoomState,
1147    permission: &PermissionContext,
1148    registry: &TypeRegistry,
1149) -> Result<()> {
1150    if can_accept_room_write(event_type, state, permission, registry) {
1151        Ok(())
1152    } else {
1153        Err(SdkError::PermissionDenied)
1154    }
1155}
1156
1157fn hash_canonical_json<T>(value: &T) -> Result<String>
1158where
1159    T: Serialize + ?Sized,
1160{
1161    let bytes = serde_jcs::to_vec(value).map_err(|err| SdkError::CanonicalJson(err.to_string()))?;
1162    let digest = Sha3_256::digest(bytes);
1163    Ok(URL_SAFE_NO_PAD.encode(digest))
1164}
1165
1166#[cfg(test)]
1167mod tests {
1168    use super::*;
1169    use crate::identity::AgentSigner;
1170    use serde_json::json;
1171
1172    fn registered_packs() -> BTreeMap<String, Pack> {
1173        let raw = std::fs::read_to_string(concat!(
1174            env!("CARGO_MANIFEST_DIR"),
1175            "/../../docs/protocols/agent-discourse/1.0.packs.json"
1176        ))
1177        .expect("read registered packs");
1178        let document: PackDocument = serde_json::from_str(&raw).expect("parse registered packs");
1179        assert_eq!(document.protocol, PROTOCOL);
1180        pack_map(&document)
1181    }
1182
1183    fn finding_def() -> TypeDef {
1184        TypeDef {
1185            name: "review.finding".to_owned(),
1186            kind: TypeKind::Message,
1187            title: "Review finding".to_owned(),
1188            description: None,
1189            schema: json!({
1190                "type": "object",
1191                "required": ["severity", "summary"],
1192                "properties": {
1193                    "severity": {"type": "string", "enum": ["low", "medium", "high"]},
1194                    "summary": {"type": "string", "minLength": 1}
1195                },
1196                "additionalProperties": false
1197            }),
1198            roles: None,
1199            instructions: None,
1200            version: None,
1201            status: None,
1202            rate_hint: None,
1203            max_payload_hint: None,
1204            extra: BTreeMap::new(),
1205        }
1206    }
1207
1208    #[test]
1209    fn validates_room_create_without_room_id() {
1210        let signer = AgentSigner::from_seed([14; 32]);
1211        let payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1212        let envelope = signer
1213            .sign_event(room_create_event(signer.agent_id(), 100, 1, payload))
1214            .unwrap();
1215
1216        validate_discourse_envelope(&envelope).unwrap();
1217        validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").unwrap();
1218    }
1219
1220    #[test]
1221    fn rejects_room_create_with_room_id() {
1222        let signer = AgentSigner::from_seed([14; 32]);
1223        let payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1224        let event = room_create_event(signer.agent_id(), 100, 1, payload)
1225            .with_room_id("d8ftedhpqhsusbg001tg");
1226        let envelope = signer.sign_event(event).unwrap();
1227
1228        assert!(matches!(
1229            validate_discourse_envelope(&envelope),
1230            Err(SdkError::InvalidPayload(_))
1231        ));
1232        assert!(validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").is_err());
1233    }
1234
1235    #[test]
1236    fn rejects_room_event_without_room_id() {
1237        let signer = AgentSigner::from_seed([15; 32]);
1238        let event = Event::new(
1239            PROTOCOL,
1240            event_type::MESSAGE_CREATE,
1241            signer.agent_id(),
1242            100,
1243            1,
1244            MessageCreatePayload::text("hello"),
1245        );
1246        let envelope = signer.sign_event(event).unwrap();
1247
1248        assert!(matches!(
1249            validate_discourse_envelope(&envelope),
1250            Err(SdkError::MissingRoomId)
1251        ));
1252    }
1253
1254    #[test]
1255    fn role_serde_matches_spec() {
1256        assert_eq!(serde_json::to_string(&Role::Speaker).unwrap(), "\"speaker\"");
1257        assert!(serde_json::from_str::<Role>("\"expert\"").is_err());
1258        assert!(serde_json::from_str::<Role>("\"participant\"").is_err());
1259    }
1260
1261    #[test]
1262    fn validates_custom_event_type_names() {
1263        validate_custom_event_type_name("review.finding").unwrap();
1264        validate_custom_event_type_name("poll.vote").unwrap();
1265        assert!(validate_custom_event_type_name("freeform").is_err());
1266        assert!(validate_custom_event_type_name("room.custom").is_err());
1267        assert!(validate_custom_event_type_name("type.new").is_err());
1268        assert!(validate_custom_event_type_name("message.create").is_err());
1269        assert!(validate_custom_event_type_name("Bad.Name").is_err());
1270    }
1271
1272    #[test]
1273    fn materializes_registry_from_packs_and_inline_defs() {
1274        let packs = registered_packs();
1275        let declarations = vec![
1276            TypeDeclaration::Import(PackImport {
1277                use_pack: Some(pack_id::REACTIONS.to_owned()),
1278                ..PackImport::default()
1279            }),
1280            TypeDeclaration::Import(PackImport {
1281                use_pack: Some(pack_id::DELIBERATION.to_owned()),
1282                overrides: BTreeMap::from([(
1283                    "poll.vote".to_owned(),
1284                    TypeOverride {
1285                        roles: Some(vec![Role::Moderator, Role::Speaker, Role::Observer]),
1286                        ..TypeOverride::default()
1287                    },
1288                )]),
1289                ..PackImport::default()
1290            }),
1291            TypeDeclaration::Def(finding_def()),
1292        ];
1293        let registry = TypeRegistry::from_declarations(&declarations, &packs).unwrap();
1294
1295        assert_eq!(registry.len(), 6);
1296        assert!(registry.contains("reaction.create"));
1297        assert!(registry.contains("poll.create"));
1298        assert!(registry.contains("review.finding"));
1299        let vote = registry.get("poll.vote").unwrap();
1300        assert_eq!(
1301            vote.roles.as_deref(),
1302            Some([Role::Moderator, Role::Speaker, Role::Observer].as_slice())
1303        );
1304
1305        let subset = TypeDeclaration::Import(PackImport {
1306            use_pack: Some(pack_id::DELIBERATION.to_owned()),
1307            types: Some(vec!["poll.create".to_owned(), "poll.vote".to_owned()]),
1308            ..PackImport::default()
1309        });
1310        let registry = TypeRegistry::from_declarations(&[subset], &packs).unwrap();
1311        assert_eq!(registry.len(), 2);
1312        assert!(!registry.contains("question.create"));
1313    }
1314
1315    #[test]
1316    fn rejects_bad_pack_imports() {
1317        let packs = registered_packs();
1318        let unknown = TypeDeclaration::Import(PackImport {
1319            use_pack: Some("adp:unknown/1.0".to_owned()),
1320            ..PackImport::default()
1321        });
1322        assert!(matches!(
1323            TypeRegistry::from_declarations(&[unknown], &packs),
1324            Err(SdkError::PackUnavailable(_))
1325        ));
1326
1327        let bad_override = TypeDeclaration::Import(PackImport {
1328            use_pack: Some(pack_id::REACTIONS.to_owned()),
1329            overrides: BTreeMap::from([("poll.vote".to_owned(), TypeOverride::default())]),
1330            ..PackImport::default()
1331        });
1332        assert!(TypeRegistry::from_declarations(&[bad_override], &packs).is_err());
1333
1334        let both = PackImport {
1335            use_pack: Some(pack_id::REACTIONS.to_owned()),
1336            pack: Some("https://example.com/p.json".to_owned()),
1337            digest: Some("sha256:abc".to_owned()),
1338            ..PackImport::default()
1339        };
1340        assert!(validate_pack_import(&both).is_err());
1341    }
1342
1343    #[test]
1344    fn latest_type_definition_wins() {
1345        let mut registry = TypeRegistry::new();
1346        registry.define(finding_def()).unwrap();
1347        let mut redefined = finding_def();
1348        redefined.status = Some(TypeStatus::Disabled);
1349        registry.define(redefined).unwrap();
1350        assert_eq!(
1351            registry.get("review.finding").unwrap().status(),
1352            TypeStatus::Disabled
1353        );
1354    }
1355
1356    #[test]
1357    fn validates_custom_payloads_against_pack_schemas() {
1358        let packs = registered_packs();
1359        let registry = TypeRegistry::from_declarations(
1360            &[TypeDeclaration::Import(PackImport {
1361                use_pack: Some(pack_id::DELIBERATION.to_owned()),
1362                ..PackImport::default()
1363            })],
1364            &packs,
1365        )
1366        .unwrap();
1367
1368        let hash = "GDt8oHZQfQ3jl5ZUfyNxKZu07yAJdDYuaw_jf_JjLYs";
1369        registry
1370            .validate_payload(
1371                "poll.vote",
1372                &json!({"poll_event_id": hash, "option_ids": ["a"]}),
1373            )
1374            .unwrap();
1375        assert!(matches!(
1376            registry.validate_payload("poll.vote", &json!({"poll_event_id": hash})),
1377            Err(SdkError::PayloadSchemaViolation(_))
1378        ));
1379        assert!(matches!(
1380            registry.validate_payload("turn.update", &json!({})),
1381            Err(SdkError::TypeNotDefined(_))
1382        ));
1383
1384        let mut disabled = TypeRegistry::new();
1385        let mut def = finding_def();
1386        def.status = Some(TypeStatus::Disabled);
1387        disabled.define(def).unwrap();
1388        assert!(matches!(
1389            disabled.validate_payload("review.finding", &json!({"severity": "high", "summary": "s"})),
1390            Err(SdkError::TypeDisabled(_))
1391        ));
1392    }
1393
1394    #[test]
1395    fn applies_kind_based_permissions() {
1396        let packs = registered_packs();
1397        let registry = TypeRegistry::from_declarations(
1398            &[
1399                TypeDeclaration::Import(PackImport {
1400                    use_pack: Some(pack_id::REACTIONS.to_owned()),
1401                    ..PackImport::default()
1402                }),
1403                TypeDeclaration::Import(PackImport {
1404                    use_pack: Some(pack_id::DELIBERATION.to_owned()),
1405                    overrides: BTreeMap::from([(
1406                        "poll.vote".to_owned(),
1407                        TypeOverride {
1408                            roles: Some(vec![Role::Moderator, Role::Speaker, Role::Observer]),
1409                            ..TypeOverride::default()
1410                        },
1411                    )]),
1412                    ..PackImport::default()
1413                }),
1414                TypeDeclaration::Import(PackImport {
1415                    use_pack: Some(pack_id::CURATION.to_owned()),
1416                    ..PackImport::default()
1417                }),
1418            ],
1419            &packs,
1420        )
1421        .unwrap();
1422
1423        let observer = PermissionContext::for_role(Role::Observer);
1424        let speaker = PermissionContext::for_role(Role::Speaker);
1425        let moderator = PermissionContext::for_role(Role::Moderator);
1426        let creator = PermissionContext::creator(Some(Role::Observer));
1427
1428        // signal kind: all members, including observers
1429        assert!(can_submit_event("reaction.create", &observer, &registry));
1430        // poll.vote default excludes observers, but this room overrode roles
1431        assert!(can_submit_event("poll.vote", &observer, &registry));
1432        // message kind: speakers and moderators only
1433        assert!(can_submit_event("resource.add", &speaker, &registry));
1434        assert!(!can_submit_event("resource.add", &observer, &registry));
1435        // control kind: moderators only
1436        assert!(can_submit_event("graph.update", &moderator, &registry));
1437        assert!(!can_submit_event("graph.update", &speaker, &registry));
1438        // creator passes every role check regardless of current role
1439        assert!(can_submit_event("graph.update", &creator, &registry));
1440        assert!(can_submit_event(event_type::MESSAGE_CREATE, &creator, &registry));
1441        // undefined types are rejected
1442        assert!(!can_submit_event("session.offer", &speaker, &registry));
1443
1444        // built-in lifecycle rules
1445        assert!(can_submit_event(event_type::ROOM_JOIN_REVIEW, &moderator, &registry));
1446        assert!(!can_submit_event(event_type::ROOM_JOIN_REVIEW, &speaker, &registry));
1447        assert!(can_submit_event(event_type::ROOM_MEMBER_ROLE_UPDATE, &moderator, &registry));
1448        assert!(can_submit_event(event_type::ROOM_CANCEL, &moderator, &registry));
1449        assert!(can_submit_event(event_type::TYPE_DEFINE, &moderator, &registry));
1450        assert!(!can_submit_event(event_type::TYPE_DEFINE, &speaker, &registry));
1451        assert!(can_submit_event(event_type::MESSAGE_CREATE, &speaker, &registry));
1452        assert!(!can_submit_event(event_type::MESSAGE_CREATE, &observer, &registry));
1453        assert!(can_submit_event(event_type::ROOM_LEAVE, &observer, &registry));
1454        assert!(!can_submit_event(event_type::ROOM_JOIN, &observer, &registry));
1455        let approved = PermissionContext {
1456            join_request_approved: true,
1457            ..PermissionContext::default()
1458        };
1459        assert!(can_submit_event(event_type::ROOM_JOIN, &approved, &registry));
1460    }
1461
1462    #[test]
1463    fn applies_state_restrictions() {
1464        let registry = TypeRegistry::new();
1465        let speaker = PermissionContext::for_role(Role::Speaker);
1466        let moderator = PermissionContext::for_role(Role::Moderator);
1467
1468        assert!(can_accept_room_write(
1469            event_type::MESSAGE_CREATE,
1470            RoomState::Active,
1471            &speaker,
1472            &registry
1473        ));
1474        assert!(!can_accept_room_write(
1475            event_type::MESSAGE_CREATE,
1476            RoomState::Scheduled,
1477            &speaker,
1478            &registry
1479        ));
1480        // scheduled allows pre-start setup: reviews, role updates, leave, type.define
1481        assert!(can_write_in_state(event_type::ROOM_JOIN_REVIEW, RoomState::Scheduled));
1482        assert!(can_write_in_state(event_type::ROOM_MEMBER_ROLE_UPDATE, RoomState::Scheduled));
1483        assert!(can_write_in_state(event_type::ROOM_LEAVE, RoomState::Scheduled));
1484        assert!(can_write_in_state(event_type::TYPE_DEFINE, RoomState::Scheduled));
1485        assert!(can_write_in_state(event_type::ROOM_CANCEL, RoomState::Scheduled));
1486        assert!(!can_write_in_state(event_type::ROOM_CLOSE, RoomState::Scheduled));
1487        assert!(can_accept_room_write(
1488            event_type::TYPE_DEFINE,
1489            RoomState::Scheduled,
1490            &moderator,
1491            &registry
1492        ));
1493        // ended rooms are strictly read-only
1494        assert!(!can_write_in_state("reaction.create", RoomState::Ended));
1495        assert!(!can_write_in_state(event_type::ROOM_LEAVE, RoomState::Ended));
1496        assert!(!can_write_in_state(event_type::ROOM_JOIN, RoomState::Cancelled));
1497        // cancel only while scheduled, close only while active
1498        assert!(can_write_in_state(event_type::ROOM_CLOSE, RoomState::Active));
1499        assert!(!can_write_in_state(event_type::ROOM_CANCEL, RoomState::Active));
1500    }
1501
1502    #[test]
1503    fn validates_room_creation_payloads() {
1504        let mut payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1505        payload.policy = Some(RoomPolicy {
1506            max_speakers: Some(2),
1507            ..RoomPolicy::default()
1508        });
1509        payload.guidance = Some("Cite sources.".to_owned());
1510        payload.types = vec![
1511            TypeDeclaration::Import(PackImport {
1512                use_pack: Some(pack_id::REACTIONS.to_owned()),
1513                ..PackImport::default()
1514            }),
1515            TypeDeclaration::Def(finding_def()),
1516        ];
1517        validate_room_create_payload(&payload).unwrap();
1518
1519        let empty_topic = RoomCreatePayload::new(" ", Visibility::Public, 1000, 2000);
1520        assert!(validate_room_create_payload(&empty_topic).is_err());
1521
1522        let invalid_time = RoomCreatePayload::new("Research room", Visibility::Public, 2000, 1000);
1523        assert!(validate_room_create_payload(&invalid_time).is_err());
1524
1525        let mut zero_speakers =
1526            RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1527        zero_speakers.policy = Some(RoomPolicy {
1528            max_speakers: Some(0),
1529            ..RoomPolicy::default()
1530        });
1531        assert!(validate_room_create_payload(&zero_speakers).is_err());
1532
1533        let mut reserved = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1534        let mut bad_def = finding_def();
1535        bad_def.name = "room.custom".to_owned();
1536        reserved.types = vec![TypeDeclaration::Def(bad_def)];
1537        assert!(validate_room_create_payload(&reserved).is_err());
1538    }
1539
1540    #[test]
1541    fn verifies_pack_digests() {
1542        let bytes = b"pack document bytes";
1543        let digest = format!("sha256:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)));
1544        verify_pack_digest(bytes, &digest).unwrap();
1545        assert!(matches!(
1546            verify_pack_digest(b"tampered", &digest),
1547            Err(SdkError::PackUnavailable(_))
1548        ));
1549        assert!(verify_pack_digest(bytes, "md5:abc").is_err());
1550        assert!(verify_pack_digest(bytes, "not-a-digest").is_err());
1551    }
1552
1553    #[test]
1554    fn builds_and_verifies_server_record_chains() {
1555        let signer = AgentSigner::from_seed([18; 32]);
1556        let envelope1 = signer
1557            .sign_event(Event::new(
1558                PROTOCOL,
1559                event_type::ROOM_CREATE,
1560                signer.agent_id(),
1561                100,
1562                1,
1563                json!({
1564                    "topic": "Research room",
1565                    "visibility": "public",
1566                    "start_time": 1000,
1567                    "end_time": 2000
1568                }),
1569            ))
1570            .unwrap();
1571        let record1 = build_server_record("room123", 1, None, 110, envelope1).unwrap();
1572
1573        let envelope2 = signer
1574            .sign_event(discourse_event(
1575                event_type::MESSAGE_CREATE,
1576                signer.agent_id(),
1577                120,
1578                2,
1579                "room123",
1580                json!({"content_type": "text/plain", "content": "hello"}),
1581            ))
1582            .unwrap();
1583        let record2 =
1584            build_server_record("room123", 2, Some(record1.hash.clone()), 130, envelope2).unwrap();
1585
1586        assert_eq!(
1587            record1.hash,
1588            server_record_hash("room123", 1, None, &record1.envelope.hash, 110).unwrap()
1589        );
1590        verify_server_record(&record1).unwrap();
1591        verify_server_record_chain(&[record1.clone(), record2.clone()]).unwrap();
1592        assert_eq!(
1593            archive_events_digest(&[record1.clone(), record2.clone()])
1594                .unwrap()
1595                .len(),
1596            43
1597        );
1598        assert!(verify_server_record_chain(&[record2]).is_err());
1599
1600        let broken = ServerRecord {
1601            pre_hash: Some("bad".to_owned()),
1602            ..record1
1603        };
1604        assert!(verify_server_record_chain(&[broken]).is_err());
1605    }
1606
1607    #[test]
1608    fn type_declaration_serde_round_trips() {
1609        let inline: TypeDeclaration = serde_json::from_value(json!({
1610            "type": "review.finding",
1611            "kind": "message",
1612            "title": "Review finding",
1613            "schema": {"type": "object"}
1614        }))
1615        .unwrap();
1616        assert!(matches!(inline, TypeDeclaration::Def(_)));
1617
1618        let import: TypeDeclaration = serde_json::from_value(json!({
1619            "use": "adp:reactions/1.0",
1620            "overrides": {"reaction.create": {"status": "deprecated"}}
1621        }))
1622        .unwrap();
1623        match &import {
1624            TypeDeclaration::Import(import) => {
1625                assert_eq!(import.use_pack.as_deref(), Some("adp:reactions/1.0"));
1626            }
1627            TypeDeclaration::Def(_) => panic!("expected pack import"),
1628        }
1629        validate_type_declaration(&import).unwrap();
1630    }
1631}