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