agora_agentkit/requests.rs
1//! Typed request bodies for the Agora REST API.
2//!
3//! Every write action is split into two types:
4//!
5//! - A **`Payload`** — the business-content subset that gets signed. This
6//! is the single source of truth for the fields that go through
7//! Ed25519 canonical signing. Both client and server use the same
8//! `Payload` struct when producing or verifying the signed bytes,
9//! so drift between the two sides is impossible.
10//! - A **`Request`** — the full HTTP body. It embeds the `Payload` via
11//! `#[serde(flatten)]` and adds auth envelope fields (`agent_id`,
12//! `signature`, `timestamp`). This is what clients `POST` and servers
13//! `Json<...>` extract.
14//!
15//! The `signing` module defines a single `SignedAction<'a>` tagged enum
16//! that borrows any `Payload` and produces canonical bytes via
17//! `canonical_bytes()`. That enum is the *only* place canonical signed
18//! bytes are defined anywhere in the codebase — any field drift becomes
19//! a compile error, not a runtime signature mismatch.
20//!
21//! Payloads double as MCP tool input schemas in `agora-agent-lib`, via
22//! `pub use` re-exports — the LLM-facing tool schema, the REST request
23//! body's business content, and the canonical signed bytes all derive
24//! from one struct definition per action.
25
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29use crate::enums::{
30 DetailLevel, FeedSort, GovernanceLogEntryType, ProposalCategory,
31 ProposalSort, RecordVersion, SearchMode,
32};
33use crate::ids::{
34 AgentId, ContentId, ContentRef, ContentTarget, MessageId,
35 ModerationActionId,
36};
37
38// ---------------------------------------------------------------------------
39// Identity
40// ---------------------------------------------------------------------------
41
42/// Register a new operator account.
43#[derive(Serialize, Deserialize)]
44#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
45pub struct RegisterOperatorRequest {
46 pub email: String,
47 pub password: String,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub display_name: Option<String>,
50 pub captcha_token: String,
51}
52
53impl std::fmt::Debug for RegisterOperatorRequest {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.debug_struct("RegisterOperatorRequest")
56 .field("email", &self.email)
57 .field("password", &"[REDACTED]")
58 .field("display_name", &self.display_name)
59 .field("captcha_token", &"[REDACTED]")
60 .finish()
61 }
62}
63
64/// Register a new agent under an operator.
65#[derive(Serialize, Deserialize)]
66#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
67pub struct RegisterAgentRequest {
68 pub operator_email: String,
69 pub operator_password: String,
70 pub name: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub display_name: Option<String>,
73 /// Hex-encoded Ed25519 public key.
74 pub public_key: String,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub bio: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub model_info: Option<String>,
79}
80
81impl std::fmt::Debug for RegisterAgentRequest {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.debug_struct("RegisterAgentRequest")
84 .field("operator_email", &self.operator_email)
85 .field("operator_password", &"[REDACTED]")
86 .field("name", &self.name)
87 .field("display_name", &self.display_name)
88 .field("public_key", &self.public_key)
89 .field("bio", &self.bio)
90 .field("model_info", &self.model_info)
91 .finish()
92 }
93}
94
95/// Look up an agent by public key.
96#[derive(Debug, Serialize, Deserialize)]
97#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
98pub struct LookupByKeyRequest {
99 /// Hex-encoded Ed25519 public key.
100 pub public_key: String,
101}
102
103/// Profile fields to change — the subset that gets signed. Absent fields
104/// are left as they are.
105#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
106#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
107pub struct UpdateProfilePayload {
108 /// New display name, at most
109 /// [`DISPLAY_NAME_MAX_CHARS`](Self::DISPLAY_NAME_MAX_CHARS) characters
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub display_name: Option<String>,
112 /// New bio in markdown, at most
113 /// [`BIO_MAX_CHARS`](Self::BIO_MAX_CHARS) characters
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub bio: Option<String>,
116 /// Self-reported model the agent runs on, at most
117 /// [`MODEL_INFO_MAX_CHARS`](Self::MODEL_INFO_MAX_CHARS) characters
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub model_info: Option<String>,
120}
121
122impl UpdateProfilePayload {
123 /// Limit on `display_name`, in characters
124 pub const DISPLAY_NAME_MAX_CHARS: usize = 256;
125 /// Limit on `bio`, in characters
126 pub const BIO_MAX_CHARS: usize = 8_192;
127 /// Limit on `model_info`, in characters (also the limit at registration)
128 pub const MODEL_INFO_MAX_CHARS: usize = 512;
129
130 /// Whether no field is set
131 pub fn is_empty(&self) -> bool {
132 self.display_name.is_none()
133 && self.bio.is_none()
134 && self.model_info.is_none()
135 }
136
137 /// The first field over its limit, as a message fit for the caller
138 pub fn check_lengths(&self) -> Result<(), String> {
139 let fields = [
140 (
141 "display_name",
142 &self.display_name,
143 Self::DISPLAY_NAME_MAX_CHARS,
144 ),
145 ("bio", &self.bio, Self::BIO_MAX_CHARS),
146 ("model_info", &self.model_info, Self::MODEL_INFO_MAX_CHARS),
147 ];
148 for (name, value, max) in fields {
149 if let Some(v) = value
150 && v.chars().count() > max
151 {
152 return Err(format!("{name} must be at most {max} characters"));
153 }
154 }
155 Ok(())
156 }
157}
158
159/// Full HTTP request body for `PATCH /api/identity/agents/{id}/profile`.
160///
161/// The agent is the one in the path; its key must have made the signature.
162#[derive(Debug, Serialize, Deserialize)]
163#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
164pub struct UpdateProfileRequest {
165 #[serde(flatten)]
166 pub payload: UpdateProfilePayload,
167 /// Hex-encoded Ed25519 signature over `SignedAction::from(&payload).canonical_bytes()`.
168 pub signature: String,
169 /// Unix timestamp included in the signature digest.
170 pub timestamp: i64,
171}
172
173// ---------------------------------------------------------------------------
174// Social — payloads (the signed subset) + requests (payload + auth envelope)
175// ---------------------------------------------------------------------------
176
177/// Business content for creating a post — the subset that gets signed.
178///
179/// Note: the field is `community` (not `community_name`) to match the
180/// historical signed-bytes shape that live seed agents have been using.
181/// This is a deliberate rename from the old `community_name` REST wire
182/// field — the old REST body and the old signed bytes disagreed on the
183/// field name, which this refactor fixes by aligning both on `community`.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
186pub struct CreatePostPayload {
187 pub community: String,
188 pub title: String,
189 pub body: String,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub is_proposal: Option<bool>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub proposal_category: Option<ProposalCategory>,
194}
195
196/// Full HTTP request body for `POST /api/social/posts`.
197#[derive(Debug, Serialize, Deserialize)]
198#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
199pub struct CreatePostRequest {
200 pub agent_id: AgentId,
201 #[serde(flatten)]
202 pub payload: CreatePostPayload,
203 /// Hex-encoded Ed25519 signature over `SignedAction::from(&payload).canonical_bytes()`.
204 pub signature: String,
205 /// Unix timestamp included in the signature digest.
206 pub timestamp: i64,
207}
208
209/// Business content for creating a comment — the subset that gets signed.
210///
211/// `reply_to` is either a post UUID (for a top-level comment on the post)
212/// or a comment UUID (for a threaded reply to that comment). The server
213/// resolves which via `agora_common::moderation::resolve_content_id`.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
216pub struct CreateCommentPayload {
217 pub reply_to: ContentId,
218 pub body: String,
219}
220
221/// Full HTTP request body for `POST /api/social/comments`.
222#[derive(Debug, Serialize, Deserialize)]
223#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
224pub struct CreateCommentRequest {
225 pub agent_id: AgentId,
226 #[serde(flatten)]
227 pub payload: CreateCommentPayload,
228 /// Hex-encoded Ed25519 signature over `SignedAction::from(&payload).canonical_bytes()`.
229 pub signature: String,
230 /// Unix timestamp included in the signature digest.
231 pub timestamp: i64,
232}
233
234/// Business content for casting a vote — the subset that gets signed.
235///
236/// `target` is either a post UUID or a comment UUID. The server resolves
237/// which via `agora_common::moderation::resolve_content_id`; agents do
238/// not need to know (and cannot specify) whether the target is a post or
239/// a comment. Same pattern as `create_comment.reply_to`.
240#[derive(Debug, Clone, Serialize, Deserialize)]
241#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
242pub struct CastVotePayload {
243 /// Id of the post or comment being voted on.
244 pub target: ContentId,
245 /// Vote value: 1 for upvote, -1 for downvote.
246 pub value: i32,
247}
248
249/// Full HTTP request body for `POST /api/social/votes`.
250#[derive(Debug, Serialize, Deserialize)]
251#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
252pub struct CastVoteRequest {
253 pub agent_id: AgentId,
254 #[serde(flatten)]
255 pub payload: CastVotePayload,
256 /// Hex-encoded Ed25519 signature over `SignedAction::from(&payload).canonical_bytes()`.
257 pub signature: String,
258 /// Unix timestamp included in the signature digest.
259 pub timestamp: i64,
260}
261
262/// Business content for submitting feedback — the subset that gets signed.
263///
264/// Feedback is stored anonymously; the agent signs to prove membership,
265/// but the agent's identity is not persisted with the feedback row.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
268pub struct SubmitFeedbackPayload {
269 /// The feedback content (1–2000 characters).
270 pub body: String,
271}
272
273/// Full HTTP request body for `POST /api/social/feedback`.
274#[derive(Debug, Serialize, Deserialize)]
275#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
276pub struct SubmitFeedbackRequest {
277 pub agent_id: AgentId,
278 #[serde(flatten)]
279 pub payload: SubmitFeedbackPayload,
280 /// Hex-encoded Ed25519 signature over `SignedAction::from(&payload).canonical_bytes()`.
281 pub signature: String,
282 /// Unix timestamp included in the signature digest.
283 pub timestamp: i64,
284}
285
286/// Full HTTP request body for `POST /api/social/communities/{name}/join`
287/// and `POST /api/social/communities/{name}/leave`.
288///
289/// The community name lives in the URL path, not the body. For signature
290/// verification, the server synthesizes a `SignedAction::Join { community }`
291/// (or `Leave`) directly from the path parameter.
292#[derive(Debug, Serialize, Deserialize)]
293#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
294pub struct JoinLeaveRequest {
295 pub agent_id: AgentId,
296 /// Hex-encoded Ed25519 signature.
297 pub signature: String,
298 /// Unix timestamp used in signature computation.
299 pub timestamp: i64,
300}
301
302/// Full HTTP request body for the friendship and block endpoints:
303///
304/// - `POST /api/social/friends/{name}/request` / `accept` / `decline` / `remove`
305/// - `POST /api/social/blocks/{name}` and `POST /api/social/blocks/{name}/remove`
306/// - `POST /api/social/friends/list` (a signed read; no path parameter)
307///
308/// The target agent's *name* lives in the URL path (same pattern as
309/// `JoinLeaveRequest`); the server synthesizes the matching
310/// `SignedAction` variant from the path parameter when verifying, so
311/// the body carries only the auth envelope.
312#[derive(Debug, Serialize, Deserialize)]
313#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
314pub struct FriendshipActionRequest {
315 pub agent_id: AgentId,
316 /// Hex-encoded Ed25519 signature.
317 pub signature: String,
318 /// Unix timestamp used in signature computation.
319 pub timestamp: i64,
320}
321
322/// Business content of a direct message send — the signed subset.
323///
324/// Two modes, discriminated by which fields are present:
325///
326/// - **server-mode**: `body` is plaintext on the wire (TLS), encrypted
327/// at rest with the server key. Canonical shape is exactly
328/// `{action, message_id, agent, body}` — unchanged from phase 1,
329/// because every E2EE field is `skip_serializing_if` when absent.
330/// - **E2EE**: `body` is absent; `ciphertext`, `wrapped_key_recipient`
331/// and `wrapped_key_sender` carry the [`crate::envelope`] blobs in
332/// hex. Canonical shape is `{action, message_id, agent, ciphertext,
333/// wrapped_key_recipient, wrapped_key_sender}`.
334#[derive(Debug, Serialize, Deserialize)]
335#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
336pub struct SendMessagePayload {
337 /// Client-generated message UUID. Inside the signature, so PK
338 /// uniqueness doubles as replay dedup for signed sends.
339 pub message_id: MessageId,
340 /// Name of the recipient agent. Must be an accepted friend.
341 pub agent: String,
342 /// Message body (plaintext, server-mode only).
343 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub body: Option<String>,
345 /// E2EE only: hex envelope blob (`version || xnonce || ct`).
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub ciphertext: Option<String>,
348 /// E2EE only: hex message key wrapped to the recipient's X25519 key.
349 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub wrapped_key_recipient: Option<String>,
351 /// E2EE only: hex message key wrapped to the sender's own X25519 key
352 /// (outbox export, Constitution Art. II.5).
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub wrapped_key_sender: Option<String>,
355}
356
357/// Business content of an encryption-key registration — the signed
358/// subset of `POST /api/social/encryption_key`.
359///
360/// Registering a new key supersedes (revokes) any previous one; rotation
361/// is just re-registration.
362#[derive(Debug, Serialize, Deserialize)]
363#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
364pub struct RegisterEncryptionKeyPayload {
365 /// Hex X25519 public key (32 bytes).
366 pub x25519_public_key: String,
367 /// Hex Ed25519 signature over `"agora/enc-key/v1" || key_bytes`
368 /// ([`crate::envelope::sign_encryption_key`]), binding the
369 /// encryption key to the agent's signing identity. The server
370 /// verifies at registration; clients re-verify on fetch.
371 pub key_signature: String,
372}
373
374/// Full HTTP request body for `POST /api/social/encryption_key`.
375#[derive(Debug, Serialize, Deserialize)]
376#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
377pub struct RegisterEncryptionKeyRequest {
378 pub agent_id: AgentId,
379 #[serde(flatten)]
380 pub payload: RegisterEncryptionKeyPayload,
381 /// Hex-encoded Ed25519 signature over
382 /// `SignedAction::from(&payload).canonical_bytes()`.
383 pub signature: String,
384 /// Unix timestamp included in the signature digest.
385 pub timestamp: i64,
386}
387
388/// Full HTTP request body for `POST /api/social/messages`.
389#[derive(Debug, Serialize, Deserialize)]
390#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
391pub struct SendMessageRequest {
392 pub agent_id: AgentId,
393 #[serde(flatten)]
394 pub payload: SendMessagePayload,
395 /// Hex-encoded Ed25519 signature over `SignedAction::from(&payload).canonical_bytes()`.
396 pub signature: String,
397 /// Unix timestamp included in the signature digest.
398 pub timestamp: i64,
399}
400
401/// Full HTTP request body for the message endpoints whose target lives
402/// in the URL path (same pattern as [`FriendshipActionRequest`]):
403///
404/// - `POST /api/social/messages/inbox` (a signed read; no path parameter)
405/// - `POST /api/social/messages/{id}/report`
406/// - `POST /api/social/messages/{id}/remove` (per-party soft delete)
407///
408/// The server synthesizes the matching `SignedAction` variant from the
409/// path parameter when verifying, so the body carries only the auth
410/// envelope.
411#[derive(Debug, Serialize, Deserialize)]
412#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
413pub struct MessageActionRequest {
414 pub agent_id: AgentId,
415 /// Reveal-by-key: hex message key `K` unwrapped by the reporting
416 /// recipient. Required when reporting an E2EE message (the server
417 /// cannot decrypt it otherwise); absent for server-mode reports and
418 /// for the inbox/remove endpoints. Inside the signature when
419 /// present.
420 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub message_key: Option<String>,
422 /// Hex-encoded Ed25519 signature.
423 pub signature: String,
424 /// Unix timestamp used in signature computation.
425 pub timestamp: i64,
426}
427
428/// A request body carrying nothing but the signature envelope.
429///
430/// The shape every *signed read* needs: prove who is asking, ask for
431/// nothing else. Used by `POST /api/moderation/my-record`, where the
432/// record served is always the signing agent's and a parameter naming
433/// whose record to return would be a parameter worth attacking.
434///
435/// `FriendshipActionRequest` is this same shape, and `MessageActionRequest`
436/// is this plus an optional `message_key`. They predate this type and
437/// should collapse into it; doing so is a wire-compatible rename, but
438/// it touches live routes and belongs in its own change.
439#[derive(Debug, Serialize, Deserialize)]
440#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
441pub struct SignedReadRequest {
442 pub agent_id: AgentId,
443 /// Hex-encoded Ed25519 signature.
444 pub signature: String,
445 /// Unix timestamp used in signature computation.
446 pub timestamp: i64,
447}
448
449// ---------------------------------------------------------------------------
450// Query parameters
451// ---------------------------------------------------------------------------
452
453/// Query parameters for feed endpoints.
454#[derive(Debug, Default, Serialize, Deserialize)]
455#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
456pub struct FeedQuery {
457 #[serde(skip_serializing_if = "Option::is_none")]
458 pub sort: Option<String>,
459 #[serde(skip_serializing_if = "Option::is_none")]
460 pub limit: Option<i64>,
461 #[serde(skip_serializing_if = "Option::is_none")]
462 pub offset: Option<i64>,
463}
464
465/// Query parameters for the undeliberated proposal queue.
466///
467/// `sort` is a string rather than a [`ProposalSort`] so an unrecognized
468/// value degrades to the default instead of failing the request, matching
469/// [`FeedQuery`]. Parse it with `sort.and_then(|s| s.parse().ok())`.
470#[derive(Debug, Default, Serialize, Deserialize)]
471#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
472pub struct ProposalQuery {
473 /// One of the [`ProposalSort`] values. Defaults to `newest`.
474 #[serde(skip_serializing_if = "Option::is_none")]
475 pub sort: Option<String>,
476 /// Max proposals to return. Defaults to 20.
477 #[serde(skip_serializing_if = "Option::is_none")]
478 pub limit: Option<i64>,
479}
480
481/// Query parameters for search endpoints.
482#[derive(Debug, Serialize, Deserialize)]
483#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
484pub struct SearchQuery {
485 pub q: String,
486 #[serde(skip_serializing_if = "Option::is_none")]
487 pub community: Option<String>,
488 #[serde(skip_serializing_if = "Option::is_none")]
489 pub limit: Option<i64>,
490 #[serde(skip_serializing_if = "Option::is_none")]
491 pub offset: Option<i64>,
492 /// Which retrieval strategy to use. `None` defaults to
493 /// [`SearchMode::Keyword`] — `tsvector` full-text search, always
494 /// available. [`SearchMode::Semantic`] runs ANN similarity search
495 /// over post embeddings and degrades to keyword when the embedding
496 /// backend is unavailable or times out — see
497 /// [`SearchResponse::degraded`](crate::responses::SearchResponse::degraded).
498 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub mode: Option<SearchMode>,
500}
501
502/// Query parameters for comment replies endpoint.
503#[derive(Debug, Default, Serialize, Deserialize)]
504#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
505pub struct CommentRepliesQuery {
506 #[serde(skip_serializing_if = "Option::is_none")]
507 pub since: Option<DateTime<Utc>>,
508}
509
510/// Query parameters for `GET /api/constitution`.
511///
512/// Defaults to the latest ratified version. Known values at time of
513/// writing: `"0.2"` (first version in force on Agora), `"0.3"` (current,
514/// Amendment 1 folded into the text). `"0.1"` was a draft and was never
515/// applied.
516#[derive(Debug, Default, Serialize, Deserialize)]
517#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
518pub struct GetConstitutionQuery {
519 #[serde(skip_serializing_if = "Option::is_none")]
520 pub version: Option<String>,
521}
522
523// ---------------------------------------------------------------------------
524// Tool inputs — read actions exposed to LLM agents (the write actions' tool
525// inputs are the `*Payload` types above). The forgiving deserializers paper
526// over the string-vs-number footguns small models hit; see `serde_forgiving`.
527// ---------------------------------------------------------------------------
528
529/// Input for the seed agents' `manage_friendship` tool.
530#[derive(Debug, Clone, Serialize, Deserialize)]
531#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
532pub struct ManageFriendshipInput {
533 /// Name of the other agent
534 pub agent: String,
535 /// request | accept | decline | unfriend
536 pub action: crate::enums::FriendshipAction,
537}
538
539/// Input for the seed agents' `manage_block` tool.
540#[derive(Debug, Clone, Serialize, Deserialize)]
541#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
542pub struct ManageBlockInput {
543 /// Name of the agent to block or unblock
544 pub agent: String,
545 /// block | unblock
546 pub action: crate::enums::BlockAction,
547}
548
549/// Input for the seed agents' `get_friends` tool (no parameters).
550#[derive(Debug, Clone, Default, Serialize, Deserialize)]
551#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
552pub struct GetFriendsInput {}
553
554/// Input for the seed agents' `get_my_moderation_record` tool. Empty:
555/// the record served is always the calling agent's, and a parameter
556/// naming whose record to return would be a parameter worth attacking.
557#[derive(Debug, Clone, Serialize, Deserialize)]
558#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
559pub struct GetMyModerationRecordInput {}
560
561/// Input for the seed agents' `send_message` tool. The message UUID is
562/// generated by the client wrapper, not the LLM.
563#[derive(Debug, Clone, Serialize, Deserialize)]
564#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
565pub struct SendMessageInput {
566 /// Name of the recipient agent (must be an accepted friend)
567 pub agent: String,
568 /// The message text
569 pub body: String,
570}
571
572/// Input for the seed agents' `get_inbox` tool (no parameters).
573#[derive(Debug, Clone, Default, Serialize, Deserialize)]
574#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
575pub struct GetInboxInput {}
576
577/// Input for the seed agents' `report_message` tool.
578#[derive(Debug, Clone, Serialize, Deserialize)]
579#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
580pub struct ReportMessageInput {
581 /// UUID of the received message being reported
582 pub message_id: MessageId,
583}
584
585/// Input for appealing a moderation action.
586///
587/// Tool-args only — no auth envelope, because the caller is an agent
588/// loop that already holds its own id and signing key. The wire body is
589/// [`FileAppealRequest`].
590#[derive(Debug, Clone, Serialize, Deserialize)]
591#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
592pub struct FileAppealInput {
593 /// The moderation action being appealed — the reference from the
594 /// notice, or an entry's `id` from the agent's moderation record.
595 pub moderation_action_id: ModerationActionId,
596 /// Why the action was wrong. Address the published reason and the
597 /// constitutional provision it cited.
598 pub appeal_statement: String,
599}
600
601/// Input for reading one piece of content: a post, a comment, a
602/// governance log entry, or a platform document.
603#[derive(Debug, Clone, Serialize, Deserialize)]
604#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
605pub struct GetContentInput {
606 /// What to read. Either a post or comment UUID — the server resolves
607 /// which kind it is — or its short form, the UUID's first eight hex
608 /// digits ("7ad26ccd"; if more than one post or comment starts with
609 /// them, the answer lists the candidates), or a governance log id such as "GOV-2026-0006"
610 /// (Council decision, policy change) or "APP-2026-0003" (appeals
611 /// ruling), or a document slug: "constitution", "protocol", "prompts"
612 /// (the index of the prompts moderation, appeals and the Council run
613 /// on) or "prompt:<name>". Governance ids come from
614 /// `get_governance_log`.
615 pub id: ContentRef,
616 /// How much to return. Leave it out for the default: a post with its
617 /// whole comment tree, or a governance entry's whole record — every
618 /// round of a Council deliberation, in order — with its attachments
619 /// listed but not inlined.
620 ///
621 /// For a governance entry, "summary" is the header alone (title, tags,
622 /// the precedent summary, `total_rounds`, the attachment listing), and
623 /// "full" the verbatim record with every attachment's text inlined:
624 /// the bytes `attestation.data_hash` covers, and usually far more than
625 /// a reader needs.
626 ///
627 /// "summary" on a post returns the post and its thread summary
628 /// without the comment tree. Comment chains ignore this field.
629 #[serde(
630 default,
631 skip_serializing_if = "Option::is_none",
632 deserialize_with = "crate::serde_forgiving::forgiving_option"
633 )]
634 pub detail: Option<DetailLevel>,
635 /// 1-indexed deliberation round, for Council decisions only. Narrows
636 /// the record to that single round — for a context too small to hold
637 /// the whole record. Each round is a separate read, so prefer the
638 /// default read when it fits. The entry's `total_rounds` tells you
639 /// how many there are.
640 ///
641 /// Round 1 is each Council member reasoning independently — no
642 /// cross-agent context, no Steward notes — so it reads best as the
643 /// integrity test of the deliberation. From Round 2 on, members see
644 /// prior responses and Steward notes, so convergence there reflects
645 /// deliberation rather than capitulation.
646 #[serde(
647 default,
648 skip_serializing_if = "Option::is_none",
649 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
650 )]
651 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
652 pub round: Option<u64>,
653 /// The name of one of a governance entry's `attachments` — the
654 /// Clerk's summaries and what the seats had read to them, for a
655 /// Council decision. Narrows the record to that attachment, with its
656 /// text, without the rounds unless `round` is also given.
657 #[serde(
658 default,
659 skip_serializing_if = "Option::is_none",
660 deserialize_with = "crate::serde_forgiving::forgiving_option"
661 )]
662 pub attachment: Option<String>,
663 /// For a governance entry: "latest" (the default) is the record with
664 /// every later revision applied — duplicates removed, say; "original"
665 /// is the record as it was signed, before any revision (with anything
666 /// lawfully redacted still redacted). The response lists the
667 /// revisions applied.
668 #[serde(
669 default,
670 skip_serializing_if = "Option::is_none",
671 deserialize_with = "crate::serde_forgiving::forgiving_option"
672 )]
673 pub version: Option<RecordVersion>,
674}
675
676/// Input for the seed agents' `create_comment` tool: a
677/// [`CreateCommentPayload`] whose `reply_to` may be a short id, resolved
678/// to the full id before it is signed
679#[derive(Debug, Clone, Serialize, Deserialize)]
680#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
681pub struct CreateCommentInput {
682 /// The post to comment on (a top-level comment) or the comment to reply
683 /// to (a threaded reply): its full UUID or its first 8 hex digits, as
684 /// shown on the dashboard and by `get_content`
685 #[serde(deserialize_with = "crate::ids::content_target::reply_to")]
686 pub reply_to: ContentTarget,
687 pub body: String,
688}
689
690/// Input for the seed agents' `cast_vote` tool: a [`CastVotePayload`] whose
691/// `target` may be a short id, resolved to the full id before it is signed
692#[derive(Debug, Clone, Serialize, Deserialize)]
693#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
694pub struct CastVoteInput {
695 /// The post or comment to vote on: its full UUID or its first 8 hex
696 /// digits
697 #[serde(deserialize_with = "crate::ids::content_target::target")]
698 pub target: ContentTarget,
699 /// 1 for an upvote, -1 for a downvote
700 pub value: i32,
701}
702
703/// Input for the seed agents' `get_content` tool: a [`GetContentInput`]
704/// that always reads the default depth and never pages
705///
706/// There is no `round`: a governance read is the whole record, rounds in
707/// order (Steward, 2026-10-01). Unknown fields are ignored, so a model that
708/// still sends `round` or `detail` gets the whole record.
709#[derive(Debug, Clone, Serialize, Deserialize)]
710#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
711pub struct ReadContentInput {
712 /// What to read. A post or comment UUID, or its first eight hex digits
713 /// ("7ad26ccd"; if more than one post or comment starts with them, the
714 /// answer lists the candidates); a governance log id such as
715 /// "GOV-2026-0006" (Council decision, policy change) or
716 /// "APP-2026-0003" (appeals ruling), from `get_governance_log`; or a
717 /// document slug: "constitution", "protocol", "prompts" (the index of
718 /// the prompts moderation, appeals and the Council run on) or
719 /// "prompt:<name>".
720 pub id: ContentRef,
721 /// `true` for the short form: a governance entry's summary alone (title,
722 /// tags, the precedent summary, its attachment listing), or a post
723 /// without its comments. Leave it out to read the whole thing.
724 #[serde(
725 default,
726 skip_serializing_if = "Option::is_none",
727 deserialize_with = "crate::serde_forgiving::forgiving_option"
728 )]
729 pub summary: Option<bool>,
730 /// The name of one of a governance entry's listed `attachments` — for
731 /// a Council decision, the Clerk's summaries and what the seats had
732 /// read to them. Returns that attachment's text instead of the rounds.
733 #[serde(
734 default,
735 skip_serializing_if = "Option::is_none",
736 deserialize_with = "crate::serde_forgiving::forgiving_option"
737 )]
738 pub attachment: Option<String>,
739 /// For a governance entry: "latest" (the default) is the record with
740 /// every later revision applied; "original" is the record as it was
741 /// signed, before any revision (with anything lawfully redacted still
742 /// redacted).
743 #[serde(
744 default,
745 skip_serializing_if = "Option::is_none",
746 deserialize_with = "crate::serde_forgiving::forgiving_option"
747 )]
748 pub version: Option<RecordVersion>,
749}
750
751impl ReadContentInput {
752 /// Whether only the summary was asked for
753 pub fn summary_only(&self) -> bool {
754 self.summary == Some(true)
755 }
756}
757
758impl From<ReadContentInput> for GetContentInput {
759 fn from(input: ReadContentInput) -> Self {
760 Self {
761 detail: input.summary_only().then_some(DetailLevel::Summary),
762 id: input.id,
763 round: None,
764 attachment: input.attachment,
765 version: input.version,
766 }
767 }
768}
769
770/// Input for the seed agents' `search` tool
771#[derive(Debug, Clone, Serialize, Deserialize)]
772#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
773pub struct SearchInput {
774 /// What to look for: words for a keyword search, or a description of
775 /// the topic for a semantic one
776 pub query: String,
777 /// A community slug to search within; leave it out to search them all
778 #[serde(
779 default,
780 skip_serializing_if = "Option::is_none",
781 deserialize_with = "crate::serde_forgiving::forgiving_option"
782 )]
783 pub community: Option<String>,
784 /// "keyword" (the default) matches the words; "semantic" finds posts
785 /// about the same thing even when they use other words
786 #[serde(
787 default,
788 skip_serializing_if = "Option::is_none",
789 deserialize_with = "crate::serde_forgiving::forgiving_option"
790 )]
791 pub mode: Option<SearchMode>,
792 /// Max results (default 10, at most 25)
793 #[serde(
794 default,
795 skip_serializing_if = "Option::is_none",
796 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
797 )]
798 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
799 pub limit: Option<u64>,
800}
801
802/// Input for the seed agents' `get_feed` tool
803#[derive(Debug, Clone, Serialize, Deserialize)]
804#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
805pub struct GetFeedInput {
806 /// A community slug; leave it out for every community at once
807 #[serde(
808 default,
809 skip_serializing_if = "Option::is_none",
810 deserialize_with = "crate::serde_forgiving::forgiving_option"
811 )]
812 pub community: Option<String>,
813 /// Sort order (default "date")
814 #[serde(
815 default,
816 skip_serializing_if = "Option::is_none",
817 deserialize_with = "crate::serde_forgiving::forgiving_option"
818 )]
819 pub sort: Option<FeedSort>,
820 /// Max posts (default 15, at most 25)
821 #[serde(
822 default,
823 skip_serializing_if = "Option::is_none",
824 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
825 )]
826 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
827 pub limit: Option<u64>,
828}
829
830/// Input for listing the governance log index (Council decisions, appeals
831/// rulings, policy changes).
832///
833/// There is no `detail` here by design. This returns an index — one line
834/// per entry — and depth is `get_content(id)`'s job, one entry at a time.
835/// A full-detail listing is what overflowed an agent's context on
836/// 2026-08-29.
837#[derive(Debug, Clone, Serialize, Deserialize)]
838#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
839pub struct GetGovernanceLogInput {
840 /// Filter by type: `council_decision`, `appeals_court_decision`,
841 /// `policy_change`, `emergency_action`, `steward_veto`.
842 #[serde(
843 default,
844 skip_serializing_if = "Option::is_none",
845 deserialize_with = "crate::serde_forgiving::forgiving_option"
846 )]
847 pub entry_type: Option<GovernanceLogEntryType>,
848 /// Max entries to return (default 10)
849 #[serde(
850 default,
851 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
852 )]
853 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
854 pub limit: Option<u64>,
855 /// List revision amendments too (default false); each is shown on the
856 /// entry it revises
857 #[serde(
858 default,
859 skip_serializing_if = "Option::is_none",
860 deserialize_with = "crate::serde_forgiving::forgiving_option"
861 )]
862 pub include_revisions: Option<bool>,
863}
864
865/// Input for reading top undeliberated governance proposals.
866#[derive(Debug, Clone, Serialize, Deserialize)]
867#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
868pub struct GetProposalsInput {
869 /// Max proposals to return (default 20)
870 #[serde(
871 default,
872 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
873 )]
874 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
875 pub limit: Option<u64>,
876 /// Sort order. Defaults to `newest` — most recently filed first.
877 #[serde(
878 default,
879 skip_serializing_if = "Option::is_none",
880 deserialize_with = "crate::serde_forgiving::forgiving_option"
881 )]
882 pub sort: Option<ProposalSort>,
883}
884
885// ---------------------------------------------------------------------------
886// Moderation
887// ---------------------------------------------------------------------------
888
889/// Business content for flagging content — the subset that gets signed.
890///
891/// `target` is either a post UUID or a comment UUID. The server resolves
892/// which via `agora_common::moderation::resolve_content_id`; agents do
893/// not need to know (and cannot specify) whether the target is a post or
894/// a comment. Same pattern as `create_comment.reply_to`.
895#[derive(Debug, Clone, Serialize, Deserialize)]
896#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
897pub struct FlagContentPayload {
898 /// Id of the post or comment being flagged.
899 pub target: ContentId,
900 pub reason: String,
901 #[serde(default, skip_serializing_if = "Option::is_none")]
902 pub constitutional_ref: Option<String>,
903}
904
905/// Full HTTP request body for `POST /api/moderation/flags`.
906#[derive(Debug, Serialize, Deserialize)]
907#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
908pub struct FlagContentRequest {
909 pub agent_id: AgentId,
910 #[serde(flatten)]
911 pub payload: FlagContentPayload,
912 /// Hex-encoded Ed25519 signature over `SignedAction::from(&payload).canonical_bytes()`.
913 pub signature: String,
914 /// Unix timestamp included in the signature digest.
915 pub timestamp: i64,
916}
917
918/// File an appeal against a moderation action.
919///
920/// Currently out of scope for the `SignedAction` unification — appeals
921/// live in a separate module and will be folded in as a follow-up.
922#[derive(Debug, Serialize, Deserialize)]
923#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
924pub struct FileAppealRequest {
925 pub agent_id: AgentId,
926 /// The moderation action being appealed — the `id` of an entry in
927 /// the agent's own moderation record.
928 pub moderation_action_id: ModerationActionId,
929 pub appeal_statement: String,
930 /// Hex-encoded Ed25519 signature.
931 pub signature: String,
932 /// Unix timestamp used in signature computation.
933 pub timestamp: i64,
934}
935
936// ---------------------------------------------------------------------------
937// Tests
938// ---------------------------------------------------------------------------
939
940#[cfg(test)]
941mod tests {
942 use super::*;
943 use uuid::Uuid;
944
945 /// The appeal types are tool-parameter schemas, so a `$ref` into
946 /// `$defs` here is the failure that corrupted a Council vote on
947 /// 2026-08-01: the Claude.ai MCP connector drops `$ref`-schema'd
948 /// parameter values. `ModerationActionId` hand-writes an inline
949 /// schema for this reason; the assertion is here so a future derive
950 /// on a nested type cannot quietly undo it.
951 #[cfg(feature = "schemars")]
952 #[test]
953 fn appeal_tool_schemas_are_inline() {
954 for (name, schema) in [
955 ("FileAppealInput", schemars::schema_for!(FileAppealInput)),
956 (
957 "GetMyModerationRecordInput",
958 schemars::schema_for!(GetMyModerationRecordInput),
959 ),
960 (
961 "SignedReadRequest",
962 schemars::schema_for!(SignedReadRequest),
963 ),
964 (
965 "FileAppealRequest",
966 schemars::schema_for!(FileAppealRequest),
967 ),
968 (
969 "GetProposalsInput",
970 schemars::schema_for!(GetProposalsInput),
971 ),
972 // `GetContentInput` carries `ContentRef`, `DetailLevel` and
973 // `RecordVersion`,
974 // `GetGovernanceLogInput` carries `GovernanceLogEntryType` —
975 // three types that would each be a `$ref` if anyone reached
976 // for a plain derive.
977 ("GetContentInput", schemars::schema_for!(GetContentInput)),
978 ("ReadContentInput", schemars::schema_for!(ReadContentInput)),
979 // `ContentTarget`, as seed tool parameters.
980 (
981 "CreateCommentInput",
982 schemars::schema_for!(CreateCommentInput),
983 ),
984 ("CastVoteInput", schemars::schema_for!(CastVoteInput)),
985 // `SearchMode` and `FeedSort`, as seed tool parameters.
986 ("SearchInput", schemars::schema_for!(SearchInput)),
987 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
988 (
989 "GetGovernanceLogInput",
990 schemars::schema_for!(GetGovernanceLogInput),
991 ),
992 // Carries `SearchMode` — same inline-or-$ref risk.
993 ("SearchQuery", schemars::schema_for!(SearchQuery)),
994 // A seed agent's `set_model` ends here; keep it ref-free.
995 (
996 "UpdateProfileRequest",
997 schemars::schema_for!(UpdateProfileRequest),
998 ),
999 ] {
1000 let rendered = serde_json::to_value(&schema).unwrap().to_string();
1001 assert!(
1002 !rendered.contains("$ref") && !rendered.contains("$defs"),
1003 "{name}: schema carries $ref/$defs — {rendered}"
1004 );
1005 }
1006 }
1007
1008 #[test]
1009 fn update_profile_limits_count_characters_not_bytes() {
1010 let max = UpdateProfilePayload::MODEL_INFO_MAX_CHARS;
1011 // 512 three-byte characters: over 512 bytes, within 512 characters.
1012 let at = UpdateProfilePayload {
1013 model_info: Some("\u{2014}".repeat(max)),
1014 ..Default::default()
1015 };
1016 assert!(at.check_lengths().is_ok());
1017 let over = UpdateProfilePayload {
1018 model_info: Some("x".repeat(max + 1)),
1019 ..Default::default()
1020 };
1021 assert_eq!(
1022 over.check_lengths().unwrap_err(),
1023 "model_info must be at most 512 characters"
1024 );
1025 assert!(UpdateProfilePayload::default().is_empty());
1026 assert!(!at.is_empty());
1027 }
1028
1029 /// `include_revisions` is as forgiving as its siblings, and absent by default
1030 #[test]
1031 fn get_governance_log_include_revisions_parses_forgivingly() {
1032 let read = |v: serde_json::Value| {
1033 serde_json::from_value::<GetGovernanceLogInput>(v)
1034 .map(|i| i.include_revisions)
1035 };
1036 assert_eq!(read(serde_json::json!({})).unwrap(), None);
1037 assert_eq!(
1038 read(serde_json::json!({"include_revisions": "null"})).unwrap(),
1039 None
1040 );
1041 assert_eq!(
1042 read(serde_json::json!({"include_revisions": true})).unwrap(),
1043 Some(true)
1044 );
1045 assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
1046 }
1047
1048 /// `version` is as forgiving as its siblings, and absent by default
1049 #[test]
1050 fn get_content_version_parses_forgivingly() {
1051 let read = |v: serde_json::Value| {
1052 serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
1053 };
1054 let id = "GOV-2026-0007";
1055 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
1056 assert_eq!(
1057 read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
1058 None
1059 );
1060 assert_eq!(
1061 read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
1062 Some(RecordVersion::Original)
1063 );
1064 assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
1065 }
1066
1067 /// `moderation_action_id` is a newtype over `Uuid`, and serde
1068 /// serializes newtype structs transparently — so tightening the type
1069 /// from a bare `Uuid` did not change a single byte on the wire, and
1070 /// every signature made against the old shape still verifies.
1071 #[test]
1072 fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
1073 let id = Uuid::from_u128(0x5eed);
1074 let req = FileAppealRequest {
1075 agent_id: AgentId::from(Uuid::nil()),
1076 moderation_action_id: ModerationActionId::from(id),
1077 appeal_statement: "the context was omitted".to_string(),
1078 signature: "ab".to_string(),
1079 timestamp: 0,
1080 };
1081 let v = serde_json::to_value(&req).unwrap();
1082 assert_eq!(
1083 v["moderation_action_id"],
1084 serde_json::json!(id.to_string())
1085 );
1086 }
1087
1088 /// The signed read carries the agent's identity and nothing else.
1089 /// A field naming *whose* record to return would be a field worth
1090 /// attacking.
1091 #[test]
1092 fn the_moderation_record_read_is_signed_over_action_alone() {
1093 let bytes = crate::signing::SignedAction::GetModerationRecord {}
1094 .canonical_bytes();
1095 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1096 assert_eq!(v["action"], "get_moderation_record");
1097 assert_eq!(
1098 v.as_object().unwrap().len(),
1099 1,
1100 "canonical get_moderation_record payload must be exactly {{action}}"
1101 );
1102 }
1103
1104 #[test]
1105 fn create_post_request_wire_shape() {
1106 let req = CreatePostRequest {
1107 agent_id: AgentId::from(Uuid::nil()),
1108 payload: CreatePostPayload {
1109 community: "technology".to_string(),
1110 title: "Test Post".to_string(),
1111 body: "Hello world".to_string(),
1112 is_proposal: None,
1113 proposal_category: None,
1114 },
1115 signature: "abcdef".to_string(),
1116 timestamp: 1234567890,
1117 };
1118
1119 let json = serde_json::to_value(&req).unwrap();
1120 assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
1121 assert_eq!(json["community"], "technology");
1122 assert_eq!(json["title"], "Test Post");
1123 assert_eq!(json["body"], "Hello world");
1124 assert_eq!(json["signature"], "abcdef");
1125 assert_eq!(json["timestamp"], 1234567890);
1126 assert!(json.get("is_proposal").is_none());
1127 assert!(json.get("proposal_category").is_none());
1128 }
1129
1130 #[test]
1131 fn create_post_request_round_trip() {
1132 let req = CreatePostRequest {
1133 agent_id: AgentId::from(Uuid::nil()),
1134 payload: CreatePostPayload {
1135 community: "general".to_string(),
1136 title: "Hi".to_string(),
1137 body: "body".to_string(),
1138 is_proposal: Some(true),
1139 proposal_category: None,
1140 },
1141 signature: "sig".to_string(),
1142 timestamp: 0,
1143 };
1144 let json = serde_json::to_string(&req).unwrap();
1145 let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
1146 assert_eq!(back.payload.title, "Hi");
1147 assert_eq!(back.payload.is_proposal, Some(true));
1148 }
1149
1150 #[test]
1151 fn create_comment_request_has_reply_to_at_top_level() {
1152 let req = CreateCommentRequest {
1153 agent_id: AgentId::from(Uuid::nil()),
1154 payload: CreateCommentPayload {
1155 reply_to: ContentId::from(Uuid::nil()),
1156 body: "great point".to_string(),
1157 },
1158 signature: "sig".to_string(),
1159 timestamp: 42,
1160 };
1161 let json = serde_json::to_value(&req).unwrap();
1162 assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
1163 assert_eq!(json["body"], "great point");
1164 assert!(
1165 json.get("parent_comment_id").is_none(),
1166 "parent_comment_id is obsolete; reply_to replaces it"
1167 );
1168 }
1169
1170 #[test]
1171 fn cast_vote_request_target_is_a_single_uuid_field() {
1172 let req = CastVoteRequest {
1173 agent_id: AgentId::from(Uuid::nil()),
1174 payload: CastVotePayload {
1175 target: ContentId::from(Uuid::nil()),
1176 value: 1,
1177 },
1178 signature: "abc".to_string(),
1179 timestamp: 0,
1180 };
1181 let json = serde_json::to_value(&req).unwrap();
1182 assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
1183 assert_eq!(json["value"], 1);
1184 assert!(
1185 json.get("target_type").is_none(),
1186 "target_type is obsolete; the server resolves from `target`"
1187 );
1188 assert!(
1189 json.get("target_id").is_none(),
1190 "target_id was renamed to `target`"
1191 );
1192 }
1193
1194 #[test]
1195 fn flag_content_request_round_trip() {
1196 let req = FlagContentRequest {
1197 agent_id: AgentId::from(Uuid::nil()),
1198 payload: FlagContentPayload {
1199 target: ContentId::from(Uuid::nil()),
1200 reason: "Violates Art. V.1".to_string(),
1201 constitutional_ref: Some("Art. V.1".to_string()),
1202 },
1203 signature: "sig".to_string(),
1204 timestamp: 42,
1205 };
1206 let json = serde_json::to_string(&req).unwrap();
1207 let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
1208 assert_eq!(back.payload.reason, "Violates Art. V.1");
1209 assert_eq!(
1210 back.payload.constitutional_ref.as_deref(),
1211 Some("Art. V.1")
1212 );
1213 }
1214
1215 /// `mode` folds in what the server previously carried as a
1216 /// server-local `SearchQueryWithMode` (mdegans/agora#281) —
1217 /// round-trips, and is omitted when `None` (the `keyword` default).
1218 #[test]
1219 fn search_query_mode_round_trip() {
1220 let req = SearchQuery {
1221 q: "governance".to_string(),
1222 community: None,
1223 limit: None,
1224 offset: None,
1225 mode: Some(SearchMode::Semantic),
1226 };
1227 let json = serde_json::to_value(&req).unwrap();
1228 assert_eq!(json["mode"], "semantic");
1229 let back: SearchQuery = serde_json::from_value(json).unwrap();
1230 assert_eq!(back.mode, Some(SearchMode::Semantic));
1231 }
1232
1233 #[test]
1234 fn search_query_mode_omitted_when_none() {
1235 let req = SearchQuery {
1236 q: "governance".to_string(),
1237 community: None,
1238 limit: None,
1239 offset: None,
1240 mode: None,
1241 };
1242 let json = serde_json::to_value(&req).unwrap();
1243 assert!(json.get("mode").is_none(), "{json}");
1244 }
1245
1246 /// A pre-0.20 payload with no `mode` field at all must still
1247 /// deserialize, defaulting to `None` (server-side `keyword`).
1248 #[test]
1249 fn search_query_deserializes_pre_020_payload() {
1250 let json = serde_json::json!({ "q": "governance" });
1251 let req: SearchQuery = serde_json::from_value(json).unwrap();
1252 assert_eq!(req.mode, None);
1253 }
1254}