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