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