Skip to main content

aven_core/sync/
wire.rs

1use std::collections::{HashMap, HashSet};
2
3use anyhow::{Context, Result, bail};
4use chrono::{NaiveDate, NaiveTime};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::attachments::validation::{
9    MAX_BLOB_BYTES, validate_alt_text, validate_blob_size, validate_dimensions, validate_filename,
10    validate_media_type,
11};
12use crate::change_log::op_type;
13use crate::choices::TaskSource;
14use crate::ids::{BASE32, MetadataFieldId, ProjectId, WorkspaceId};
15use crate::recurrence::{
16    RecurrenceDuePolicy, RecurrenceFrequency, RecurrenceOutcome, RecurrenceRule,
17    RecurrenceSchedule, RecurrenceSeriesId, RecurrenceSeriesState, TimeZoneId, WeekdaySet,
18    derive_occurrence_identity, is_slot, next_slot_after,
19};
20use crate::task_fields::TaskField;
21
22pub const SYNC_PROTOCOL_VERSION: u32 = 14;
23const MAX_CHANGE_PAYLOAD_BYTES: usize = 64 * 1024;
24pub fn sync_server_url_is_valid(server: &str) -> bool {
25    let Ok(url) = url::Url::parse(server) else {
26        return false;
27    };
28    matches!(url.scheme(), "http" | "https")
29        && url.host_str().is_some()
30        && url.username().is_empty()
31        && url.password().is_none()
32        && url.query().is_none()
33        && url.fragment().is_none()
34}
35pub const MAX_PUSH_BATCH: usize = 256;
36pub const MAX_PULL_BATCH: u32 = 512;
37pub const MAX_BLOB_TRANSFER_OBJECTS: usize = 16;
38pub const MAX_BLOB_TRANSFER_BYTES: u64 = 64 * 1024 * 1024;
39pub const DAEMON_SYNC_PAGE_BUDGET: usize = 8;
40pub const DAEMON_INCOMPLETE_RESCHEDULE_MS: u64 = 100;
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ChangeWire {
44    pub change_id: String,
45    pub client_id: String,
46    pub local_seq: i64,
47    pub entity_type: String,
48    pub entity_id: String,
49    pub field: Option<String>,
50    pub op_type: String,
51    pub payload: Value,
52    pub base_version: Option<String>,
53    pub created_at: String,
54    pub server_seq: Option<i64>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub(crate) struct AttachmentAddPayload {
59    pub(crate) workspace_id: String,
60    pub(crate) workspace_key: String,
61    pub(crate) attachment_id: String,
62    pub(crate) sha256: String,
63    pub(crate) byte_size: i64,
64    pub(crate) media_type: String,
65    pub(crate) filename: Option<String>,
66    pub(crate) alt_text: Option<String>,
67    pub(crate) width: Option<i64>,
68    pub(crate) height: Option<i64>,
69    pub(crate) created_at: String,
70}
71
72impl AttachmentAddPayload {
73    pub(crate) fn from_change(change: &ChangeWire) -> Result<Self> {
74        validate_attachment_change_envelope(change, op_type::ATTACHMENT_ADD)?;
75        required_workspace_payload(&change.payload)?;
76        let attachment_id = required_string_payload("attachment_id", &change.payload)?;
77        ensure_sync_id("attachment_id", &attachment_id)?;
78        let sha256 = required_string_payload("sha256", &change.payload)?;
79        validate_sha256_for_sync(&sha256)?;
80        let byte_size = required_i64_payload("byte_size", &change.payload)?;
81        validate_blob_size_for_sync(byte_size)?;
82        let media_type = required_string_payload("media_type", &change.payload)?;
83        map_attachment_validation(validate_media_type(&media_type))?;
84        let filename = optional_string_payload("filename", &change.payload)?;
85        map_attachment_validation(validate_filename(filename.as_deref()))?;
86        let alt_text = optional_string_payload("alt_text", &change.payload)?;
87        map_attachment_validation(validate_alt_text(alt_text.as_deref()))?;
88        let width = optional_i64_payload("width", &change.payload)?;
89        let height = optional_i64_payload("height", &change.payload)?;
90        map_attachment_validation(validate_dimensions(width, height))?;
91        required_timestamp_payload("created_at", &change.payload)?;
92        deserialize_attachment_payload(&change.payload)
93    }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub(crate) struct AttachmentDeletePayload {
98    pub(crate) workspace_id: String,
99    pub(crate) workspace_key: String,
100    pub(crate) attachment_id: String,
101    pub(crate) deleted_at: String,
102}
103
104impl AttachmentDeletePayload {
105    pub(crate) fn from_change(change: &ChangeWire) -> Result<Self> {
106        validate_attachment_change_envelope(change, op_type::ATTACHMENT_DELETE)?;
107        required_workspace_payload(&change.payload)?;
108        let attachment_id = required_string_payload("attachment_id", &change.payload)?;
109        ensure_sync_id("attachment_id", &attachment_id)?;
110        required_timestamp_payload("deleted_at", &change.payload)?;
111        deserialize_attachment_payload(&change.payload)
112    }
113}
114
115fn deserialize_attachment_payload<T>(payload: &Value) -> Result<T>
116where
117    T: serde::de::DeserializeOwned,
118{
119    serde_json::from_value(payload.clone())
120        .map_err(|error| anyhow::anyhow!("error invalid-sync-change payload {error}"))
121}
122
123fn validate_attachment_change_envelope(change: &ChangeWire, expected_op_type: &str) -> Result<()> {
124    if change.op_type != expected_op_type {
125        bail!("error invalid-sync-change op_type={}", change.op_type);
126    }
127    ensure_entity_type(change, "task")?;
128    ensure_sync_id("entity_id", &change.entity_id)?;
129    if change.field.as_deref() != Some("attachments") {
130        bail!("error invalid-sync-change field=attachments");
131    }
132    if !change.payload.is_object() {
133        bail!("error invalid-sync-change payload expected-object");
134    }
135    if serde_json::to_vec(&change.payload)?.len() > MAX_CHANGE_PAYLOAD_BYTES {
136        bail!("error invalid-sync-change payload-too-large limit={MAX_CHANGE_PAYLOAD_BYTES}");
137    }
138    Ok(())
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct PushAck {
143    pub change_id: String,
144    pub server_seq: i64,
145}
146
147#[derive(Debug)]
148pub struct ChangeRow {
149    pub change_id: String,
150    pub client_id: String,
151    pub local_seq: i64,
152    pub entity_type: String,
153    pub entity_id: String,
154    pub field: Option<String>,
155    pub op_type: String,
156    pub payload: String,
157    pub base_version: Option<String>,
158    pub created_at: String,
159    pub server_seq: Option<i64>,
160}
161
162impl ChangeRow {
163    pub fn into_wire(self) -> ChangeWire {
164        ChangeWire {
165            change_id: self.change_id,
166            client_id: self.client_id,
167            local_seq: self.local_seq,
168            entity_type: self.entity_type,
169            entity_id: self.entity_id,
170            field: self.field,
171            op_type: self.op_type,
172            payload: serde_json::from_str(&self.payload).unwrap_or(Value::Null),
173            base_version: self.base_version,
174            created_at: self.created_at,
175            server_seq: self.server_seq,
176        }
177    }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct SyncRequest {
182    #[serde(default)]
183    pub protocol_version: Option<u32>,
184    pub client_id: String,
185    pub after: i64,
186    #[serde(default)]
187    pub pull_limit: Option<u32>,
188    pub changes: Vec<ChangeWire>,
189}
190
191#[derive(Debug, Serialize, Deserialize)]
192pub struct SyncResponse {
193    pub protocol_version: u32,
194    pub cursor: i64,
195    pub has_more: bool,
196    #[serde(default)]
197    pub push_acks: Vec<PushAck>,
198    pub changes: Vec<ChangeWire>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct BlobUploadContract {
203    pub workspace_id: String,
204    pub sha256: String,
205    pub byte_size: i64,
206    pub media_type: String,
207    pub width: i64,
208    pub height: i64,
209}
210
211#[derive(Debug, Serialize, Deserialize)]
212pub struct MissingBlobsRequest {
213    pub blobs: Vec<BlobUploadContract>,
214}
215
216#[derive(Debug, Serialize, Deserialize)]
217pub struct MissingBlobsResponse {
218    pub missing: Vec<String>,
219}
220
221pub fn validate_blob_contracts(blobs: &[BlobUploadContract]) -> Result<()> {
222    if blobs.len() > MAX_PUSH_BATCH {
223        bail!(
224            "error blob-batch-too-large limit={} got={}",
225            MAX_PUSH_BATCH,
226            blobs.len()
227        );
228    }
229    let mut seen = HashSet::with_capacity(blobs.len());
230    let mut hashes = HashSet::with_capacity(blobs.len());
231    for blob in blobs {
232        ensure_sync_id("workspace_id", &blob.workspace_id)?;
233        validate_sha256_for_sync(&blob.sha256)?;
234        validate_blob_size_for_sync(blob.byte_size)?;
235        map_attachment_validation(validate_media_type(&blob.media_type))?;
236        map_attachment_validation(validate_dimensions(Some(blob.width), Some(blob.height)))?;
237        if !seen.insert((blob.workspace_id.as_str(), blob.sha256.as_str())) {
238            bail!("error duplicate-blob-contract");
239        }
240        hashes.insert(blob.sha256.as_str());
241    }
242    if hashes.len() > MAX_BLOB_TRANSFER_OBJECTS {
243        bail!(
244            "error blob-batch-too-large limit={} got={}",
245            MAX_BLOB_TRANSFER_OBJECTS,
246            hashes.len()
247        );
248    }
249    Ok(())
250}
251
252pub fn validate_blob_hashes(hashes: &[String]) -> Result<()> {
253    if hashes.len() > MAX_BLOB_TRANSFER_OBJECTS {
254        bail!(
255            "error blob-batch-too-large limit={} got={}",
256            MAX_BLOB_TRANSFER_OBJECTS,
257            hashes.len()
258        );
259    }
260    let mut seen = HashSet::with_capacity(hashes.len());
261    for hash in hashes {
262        validate_sha256_for_sync(hash)?;
263        if !seen.insert(hash.as_str()) {
264            bail!("error duplicate-blob-hash");
265        }
266    }
267    Ok(())
268}
269
270#[derive(Debug)]
271struct SyncProtocolError {
272    client: u32,
273    server: u32,
274}
275
276impl std::fmt::Display for SyncProtocolError {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        write!(
279            f,
280            "error sync-protocol-unsupported client={} server={}",
281            self.client, self.server
282        )
283    }
284}
285
286impl std::error::Error for SyncProtocolError {}
287
288fn sync_protocol_error(client: u32, server: u32) -> anyhow::Error {
289    anyhow::Error::new(SyncProtocolError { client, server })
290}
291
292pub fn validate_sync_protocol_version(client: u32, server: u32) -> Result<()> {
293    if client != server {
294        return Err(sync_protocol_error(client, server));
295    }
296    Ok(())
297}
298
299pub fn validate_sync_request_protocol_version(client: Option<u32>) -> Result<()> {
300    validate_sync_protocol_version(client.unwrap_or(0), SYNC_PROTOCOL_VERSION)
301}
302
303pub fn request_pull_limit(requested: Option<u32>) -> Result<u32> {
304    match requested {
305        None => Ok(MAX_PULL_BATCH),
306        Some(limit @ 1..=MAX_PULL_BATCH) => Ok(limit),
307        Some(limit) => {
308            bail!("error sync-pull-limit-out-of-range min=1 max={MAX_PULL_BATCH} got={limit}")
309        }
310    }
311}
312
313#[derive(Debug, Clone, Copy)]
314pub struct ValidatedSyncRequestEnvelope {
315    pub after: i64,
316    pub pull_limit: u32,
317    pub push_count: usize,
318}
319
320pub fn validate_sync_request_envelope(
321    request: &SyncRequest,
322) -> Result<ValidatedSyncRequestEnvelope> {
323    validate_sync_request_protocol_version(request.protocol_version)?;
324    validate_request_cursor(request.after)?;
325    validate_push_batch_size(request.changes.len())?;
326    Ok(ValidatedSyncRequestEnvelope {
327        after: request.after,
328        pull_limit: request_pull_limit(request.pull_limit)?,
329        push_count: request.changes.len(),
330    })
331}
332
333fn validate_request_cursor(after: i64) -> Result<()> {
334    if after < 0 {
335        bail!("error sync-after-out-of-range min=0 got={after}");
336    }
337    Ok(())
338}
339
340fn validate_push_batch_size(len: usize) -> Result<()> {
341    if len > MAX_PUSH_BATCH {
342        bail!("error sync-push-too-large limit={MAX_PUSH_BATCH} got={len}");
343    }
344    Ok(())
345}
346
347pub fn validate_sync_response_for_request(
348    after: i64,
349    pull_limit: u32,
350    request_change_ids: &[String],
351    response: &SyncResponse,
352) -> Result<()> {
353    validate_sync_protocol_version(SYNC_PROTOCOL_VERSION, response.protocol_version)?;
354    if response.changes.len() > pull_limit as usize {
355        bail!(
356            "error invalid-sync-response pull-too-large limit={} got={}",
357            pull_limit,
358            response.changes.len()
359        );
360    }
361    if response.cursor < after {
362        bail!(
363            "error invalid-sync-response cursor-regressed after={} cursor={}",
364            after,
365            response.cursor
366        );
367    }
368    validate_push_acks(request_change_ids, response)?;
369    validate_pull_page(after, pull_limit, response)?;
370    validate_push_pull_overlap(response)?;
371    Ok(())
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375enum ChangeDirection {
376    Pushed,
377    Pulled,
378}
379
380pub fn validate_pushed_change(change: &ChangeWire) -> Result<()> {
381    validate_change_shape(change, ChangeDirection::Pushed)
382}
383
384fn validate_pulled_change(change: &ChangeWire) -> Result<()> {
385    validate_change_shape(change, ChangeDirection::Pulled)
386}
387
388fn validate_change_shape(change: &ChangeWire, direction: ChangeDirection) -> Result<()> {
389    ensure_non_empty("change_id", &change.change_id)?;
390    ensure_non_empty("client_id", &change.client_id)?;
391    ensure_non_empty("entity_id", &change.entity_id)?;
392    ensure_non_empty("op_type", &change.op_type)?;
393    ensure_non_empty("entity_type", &change.entity_type)?;
394    if direction == ChangeDirection::Pushed {
395        ensure_sync_id("change_id", &change.change_id)?;
396    }
397    validate_change_server_seq(change, direction)?;
398    if !change.payload.is_object() {
399        bail!("error invalid-sync-change payload expected-object");
400    }
401    if serde_json::to_vec(&change.payload)?.len() > MAX_CHANGE_PAYLOAD_BYTES {
402        bail!("error invalid-sync-change payload-too-large limit={MAX_CHANGE_PAYLOAD_BYTES}");
403    }
404
405    match change.op_type.as_str() {
406        op_type::CREATE_WORKSPACE => {
407            ensure_entity_type(change, "workspace")?;
408            ensure_sync_id("entity_id", &change.entity_id)?;
409            required_string_payload("key", &change.payload)?;
410            required_string_payload("name", &change.payload)?;
411            required_string_payload("created_at", &change.payload)?;
412        }
413        op_type::SET_WORKSPACE_FIELD => {
414            ensure_entity_type(change, "workspace")?;
415            ensure_sync_id("entity_id", &change.entity_id)?;
416            let field = change
417                .field
418                .as_deref()
419                .filter(|field| !field.trim().is_empty())
420                .context("error invalid-sync-change field missing")?;
421            if !matches!(field, "name" | "key") {
422                bail!("error invalid-sync-change field={field}");
423            }
424            required_string_payload("value", &change.payload)?;
425        }
426        op_type::CREATE_PROJECT => {
427            ensure_entity_type(change, "project")?;
428            ensure_project_id("entity_id", &change.entity_id)?;
429            optional_workspace_payload(&change.payload)?;
430            required_string_payload("key", &change.payload)?;
431            required_string_payload("name", &change.payload)?;
432            required_string_payload("prefix", &change.payload)?;
433            required_string_payload("created_at", &change.payload)?;
434        }
435        op_type::SET_PROJECT_METADATA => {
436            ensure_entity_type(change, "project")?;
437            ensure_project_id("entity_id", &change.entity_id)?;
438            optional_workspace_payload(&change.payload)?;
439            required_string_payload("key", &change.payload)?;
440            required_string_payload("name", &change.payload)?;
441            required_string_payload("prefix", &change.payload)?;
442            required_string_payload("updated_at", &change.payload)?;
443        }
444        op_type::CREATE_LABEL => {
445            ensure_entity_type(change, "label")?;
446            optional_workspace_payload(&change.payload)?;
447            required_string_payload("name", &change.payload)?;
448            required_string_payload("created_at", &change.payload)?;
449        }
450        op_type::CREATE_METADATA_FIELD => {
451            ensure_entity_type(change, "metadata_field")?;
452            ensure_metadata_field_id("entity_id", &change.entity_id)?;
453            required_workspace_payload(&change.payload)?;
454            validate_metadata_key_payload("key", &change.payload)?;
455            required_timestamp_payload("created_at", &change.payload)?;
456        }
457        op_type::SET_METADATA_FIELD => {
458            ensure_entity_type(change, "metadata_field")?;
459            ensure_metadata_field_id("entity_id", &change.entity_id)?;
460            required_workspace_payload(&change.payload)?;
461            if change.field.as_deref() != Some("key") {
462                bail!("error invalid-sync-change field=key");
463            }
464            validate_metadata_key_payload("key", &change.payload)?;
465            optional_bool_payload("conflict_resolution", &change.payload)?;
466        }
467        op_type::SET_TASK_METADATA | op_type::REMOVE_TASK_METADATA => {
468            ensure_entity_type(change, "task")?;
469            ensure_sync_id("entity_id", &change.entity_id)?;
470            required_workspace_payload(&change.payload)?;
471            let field_id = required_string_payload("field_id", &change.payload)?;
472            ensure_metadata_field_id("field_id", &field_id)?;
473            let expected_field = format!("metadata:{field_id}");
474            if change.field.as_deref() != Some(expected_field.as_str()) {
475                bail!("error invalid-sync-change metadata-field-mismatch");
476            }
477            validate_metadata_key_payload("key", &change.payload)?;
478            optional_bool_payload("conflict_resolution", &change.payload)?;
479            if change.op_type == op_type::SET_TASK_METADATA {
480                validate_metadata_value_payload("value", &change.payload)?;
481            }
482        }
483        op_type::CREATE_TASK => {
484            ensure_entity_type(change, "task")?;
485            ensure_sync_id("entity_id", &change.entity_id)?;
486            optional_workspace_payload(&change.payload)?;
487            required_string_payload("title", &change.payload)?;
488            let project_id = required_string_payload("project_id", &change.payload)?;
489            ensure_project_id("project_id", &project_id)?;
490            optional_string_payload("description", &change.payload)?;
491            let recurrence_task = change
492                .payload
493                .get("series_id")
494                .and_then(Value::as_str)
495                .is_some();
496            if recurrence_task {
497                let series_id = required_string_payload("series_id", &change.payload)?;
498                series_id.parse::<RecurrenceSeriesId>().map_err(|_| {
499                    anyhow::anyhow!("error invalid-sync-change series_id invalid-id")
500                })?;
501                validate_recurrence_task(change)?;
502            } else {
503                required_string_payload("project_key", &change.payload)?;
504                required_string_payload("project_name", &change.payload)?;
505                required_string_payload("project_prefix", &change.payload)?;
506            }
507            if let Some(status) = optional_string_payload("status", &change.payload)? {
508                validate_sync_task_field_value(TaskField::Status, &status)?;
509            }
510            if let Some(priority) = optional_string_payload("priority", &change.payload)? {
511                validate_sync_task_field_value(TaskField::Priority, &priority)?;
512            }
513            if let Some(source) = optional_string_payload("source", &change.payload)? {
514                TaskSource::parse(&source)
515                    .context("error invalid-sync-change invalid-task-source")?;
516            }
517            if let Some(available_at) = optional_string_payload("available_at", &change.payload)? {
518                validate_sync_task_field_value(TaskField::AvailableAt, &available_at)?;
519            }
520            if let Some(due_on) = optional_string_payload("due_on", &change.payload)? {
521                validate_sync_task_field_value(TaskField::DueOn, &due_on)?;
522            }
523            if let Some(is_epic) = optional_string_payload("is_epic", &change.payload)? {
524                validate_sync_task_field_value(TaskField::IsEpic, &is_epic)?;
525            }
526            optional_string_array_payload("labels", &change.payload)?;
527            validate_metadata_array_payload(&change.payload)?;
528            optional_string_payload("created_at", &change.payload)?;
529        }
530        op_type::SET_FIELD | op_type::RESOLVE_FIELD => {
531            ensure_entity_type(change, "task")?;
532            ensure_sync_id("entity_id", &change.entity_id)?;
533            optional_workspace_payload(&change.payload)?;
534            let field = change
535                .field
536                .as_deref()
537                .filter(|field| !field.trim().is_empty())
538                .context("error invalid-sync-change field missing")?;
539            let task_field = TaskField::parse_for_sync(field)?;
540            let value = required_string_payload("value", &change.payload)?;
541            validate_sync_task_field_value(task_field, &value)?;
542            if task_field == TaskField::Project {
543                let project_id = required_string_payload("project_id", &change.payload)?;
544                ensure_project_id("project_id", &project_id)?;
545                if value != project_id {
546                    bail!("error invalid-sync-change project-value-mismatch");
547                }
548                required_string_payload("project_key", &change.payload)?;
549                required_string_payload("project_name", &change.payload)?;
550                required_string_payload("project_prefix", &change.payload)?;
551            }
552        }
553        op_type::LABEL_ADD | op_type::LABEL_REMOVE => {
554            ensure_entity_type(change, "task")?;
555            ensure_sync_id("entity_id", &change.entity_id)?;
556            optional_workspace_payload(&change.payload)?;
557            required_string_payload("label", &change.payload)?;
558        }
559        op_type::NOTE_ADD => {
560            ensure_entity_type(change, "task")?;
561            ensure_sync_id("entity_id", &change.entity_id)?;
562            optional_workspace_payload(&change.payload)?;
563            let note_id = required_string_payload("note_id", &change.payload)?;
564            ensure_sync_id("note_id", &note_id)?;
565            required_string_payload("body", &change.payload)?;
566            required_string_payload("created_at", &change.payload)?;
567        }
568        op_type::DEPENDENCY_ADD | op_type::DEPENDENCY_REMOVE => {
569            ensure_entity_type(change, "task")?;
570            ensure_sync_id("entity_id", &change.entity_id)?;
571            required_workspace_payload(&change.payload)?;
572            let depends_on_task_id =
573                required_string_payload("depends_on_task_id", &change.payload)?;
574            ensure_sync_id("depends_on_task_id", &depends_on_task_id)?;
575            if change.entity_id == depends_on_task_id {
576                bail!("error invalid-sync-change dependency-self");
577            }
578        }
579        op_type::EPIC_LINK_ADD | op_type::EPIC_LINK_REMOVE => {
580            ensure_entity_type(change, "task")?;
581            ensure_sync_id("entity_id", &change.entity_id)?;
582            required_workspace_payload(&change.payload)?;
583            let epic_task_id = required_string_payload("epic_task_id", &change.payload)?;
584            ensure_sync_id("epic_task_id", &epic_task_id)?;
585            if change.entity_id == epic_task_id {
586                bail!("error invalid-sync-change epic-self");
587            }
588            if change.op_type == op_type::EPIC_LINK_ADD {
589                required_timestamp_payload("created_at", &change.payload)?;
590            }
591        }
592        op_type::PROJECT_DELETE => {
593            ensure_entity_type(change, "project")?;
594            ensure_project_id("entity_id", &change.entity_id)?;
595            required_workspace_payload(&change.payload)?;
596            required_timestamp_payload("deleted_at", &change.payload)?;
597        }
598        op_type::SET_LABEL_NAME => {
599            ensure_entity_type(change, "label")?;
600            required_workspace_payload(&change.payload)?;
601            let name = required_string_payload("name", &change.payload)?;
602            if name != change.entity_id {
603                bail!("error invalid-sync-change label-value-mismatch");
604            }
605            required_string_payload("new_name", &change.payload)?;
606            required_timestamp_payload("renamed_at", &change.payload)?;
607        }
608        op_type::LABEL_DELETE => {
609            ensure_entity_type(change, "label")?;
610            required_workspace_payload(&change.payload)?;
611            let name = required_string_payload("name", &change.payload)?;
612            if name != change.entity_id {
613                bail!("error invalid-sync-change label-value-mismatch");
614            }
615            required_timestamp_payload("deleted_at", &change.payload)?;
616        }
617        op_type::NOTE_EDIT => {
618            ensure_entity_type(change, "task")?;
619            ensure_sync_id("entity_id", &change.entity_id)?;
620            if change.field.as_deref() != Some("notes") {
621                bail!("error invalid-sync-change field=notes");
622            }
623            required_workspace_payload(&change.payload)?;
624            let note_id = required_string_payload("note_id", &change.payload)?;
625            ensure_sync_id("note_id", &note_id)?;
626            required_string_payload("body", &change.payload)?;
627            required_timestamp_payload("edited_at", &change.payload)?;
628        }
629        op_type::LABEL_RESTORE => {
630            ensure_entity_type(change, "label")?;
631            required_workspace_payload(&change.payload)?;
632            let name = required_string_payload("name", &change.payload)?;
633            if name != change.entity_id {
634                bail!("error invalid-sync-change label-value-mismatch");
635            }
636            required_timestamp_payload("created_at", &change.payload)?;
637            required_timestamp_payload("restored_at", &change.payload)?;
638            for key in ["task_ids", "series_ids"] {
639                optional_string_array_payload(key, &change.payload)?;
640                if change.payload.get(key).is_none() {
641                    bail!("error invalid-sync-change payload.{key} missing");
642                }
643            }
644            for task_id in string_array_payload("task_ids", &change.payload)? {
645                ensure_sync_id("task_ids", &task_id)?;
646            }
647            for series_id in string_array_payload("series_ids", &change.payload)? {
648                ensure_sync_id("series_ids", &series_id)?;
649            }
650        }
651        op_type::NOTE_DELETE => {
652            ensure_entity_type(change, "task")?;
653            ensure_sync_id("entity_id", &change.entity_id)?;
654            if change.field.as_deref() != Some("notes") {
655                bail!("error invalid-sync-change field=notes");
656            }
657            required_workspace_payload(&change.payload)?;
658            let note_id = required_string_payload("note_id", &change.payload)?;
659            ensure_sync_id("note_id", &note_id)?;
660            required_timestamp_payload("deleted_at", &change.payload)?;
661        }
662        op_type::SET_RECURRENCE_METADATA | op_type::REMOVE_RECURRENCE_METADATA => {
663            ensure_entity_type(change, "recurrence_series")?;
664            change
665                .entity_id
666                .parse::<RecurrenceSeriesId>()
667                .map_err(|_| anyhow::anyhow!("error invalid-sync-change entity_id invalid-id"))?;
668            required_workspace_payload(&change.payload)?;
669            let field_id = required_string_payload("field_id", &change.payload)?;
670            ensure_metadata_field_id("field_id", &field_id)?;
671            let expected_field = format!("metadata:{field_id}");
672            if change.field.as_deref() != Some(expected_field.as_str()) {
673                bail!("error invalid-sync-change metadata-field-mismatch");
674            }
675            validate_metadata_key_payload("key", &change.payload)?;
676            optional_bool_payload("conflict_resolution", &change.payload)?;
677            if change.op_type == op_type::SET_RECURRENCE_METADATA {
678                validate_metadata_value_payload("value", &change.payload)?;
679            }
680        }
681        op_type::CREATE_RECURRENCE_SERIES => validate_recurrence_create(change)?,
682        op_type::UPDATE_RECURRENCE_TEMPLATE => validate_recurrence_template(change)?,
683        op_type::PROJECT_RECURRENCE_OCCURRENCE => validate_recurrence_projection(change)?,
684        op_type::RESOLVE_RECURRENCE_OCCURRENCE => validate_recurrence_outcome(change)?,
685        op_type::SET_RECURRENCE_STATE | op_type::STOP_RECURRENCE_SERIES => {
686            validate_recurrence_state(change)?
687        }
688        op_type::OPEN_RECURRENCE_PAUSE => validate_recurrence_pause(change, true)?,
689        op_type::CLOSE_RECURRENCE_PAUSE => validate_recurrence_pause(change, false)?,
690        op_type::ATTACHMENT_ADD => {
691            AttachmentAddPayload::from_change(change)?;
692        }
693        op_type::ATTACHMENT_DELETE => {
694            AttachmentDeletePayload::from_change(change)?;
695        }
696        _ => bail!("error invalid-sync-change op_type={}", change.op_type),
697    }
698    Ok(())
699}
700
701fn validate_recurrence_task(change: &ChangeWire) -> Result<()> {
702    let workspace_id: WorkspaceId = required_string_payload("workspace_id", &change.payload)?
703        .parse()
704        .map_err(|_| anyhow::anyhow!("error invalid-sync-change workspace_id invalid-id"))?;
705    let series_id: RecurrenceSeriesId = required_string_payload("series_id", &change.payload)?
706        .parse()
707        .map_err(|_| anyhow::anyhow!("error invalid-sync-change series_id invalid-id"))?;
708    let slot_on = recurrence_date_payload("slot_on", &change.payload)?;
709    let schedule = recurrence_schedule_payload(&change.payload)?;
710    if !is_slot(&schedule.rule, schedule.start_on, slot_on) {
711        bail!("error invalid-sync-change recurrence-slot-off-lattice");
712    }
713    let identity = derive_occurrence_identity(&workspace_id, &series_id, &schedule, slot_on)
714        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
715    let slot = crate::recurrence::slot_values(&schedule, slot_on)
716        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
717    for (key, expected) in [
718        ("task_id", identity.task_id.as_str()),
719        ("created_at", identity.created_at.as_str()),
720        ("updated_at", identity.updated_at.as_str()),
721        ("available_at", slot.available_at.as_str()),
722        ("due_on", slot.due_on.as_deref().unwrap_or("")),
723        ("task_change_id", identity.task_change_id.as_str()),
724        (
725            "occurrence_change_id",
726            identity.occurrence_change_id.as_str(),
727        ),
728        (
729            "task_field_version_seed",
730            identity.field_version_seeds.task.as_str(),
731        ),
732        (
733            "occurrence_field_version_seed",
734            identity.field_version_seeds.occurrence.as_str(),
735        ),
736    ] {
737        if required_string_payload(key, &change.payload)? != expected {
738            bail!("error invalid-sync-change recurrence-deterministic-mismatch field={key}");
739        }
740    }
741    if change.entity_id != identity.task_id.as_str()
742        || change.change_id != identity.task_change_id
743        || change.created_at != identity.created_at
744    {
745        bail!("error invalid-sync-change recurrence-deterministic-mismatch field=change_identity");
746    }
747    Ok(())
748}
749
750fn validate_recurrence_create(change: &ChangeWire) -> Result<()> {
751    ensure_entity_type(change, "recurrence_series")?;
752    let series_id: RecurrenceSeriesId = change
753        .entity_id
754        .parse()
755        .map_err(|_| anyhow::anyhow!("error invalid-sync-change entity_id invalid-id"))?;
756    required_workspace_payload(&change.payload)?;
757    if required_string_payload("series_id", &change.payload)? != series_id.as_str() {
758        bail!("error invalid-sync-change recurrence-series-id-mismatch");
759    }
760    required_string_payload("title", &change.payload)?;
761    required_string_payload("description", &change.payload)?;
762    let project_id = required_string_payload("project_id", &change.payload)?;
763    ensure_project_id("project_id", &project_id)?;
764    required_string_payload("project_key", &change.payload)?;
765    required_string_payload("project_name", &change.payload)?;
766    required_string_payload("project_prefix", &change.payload)?;
767    validate_sync_task_field_value(
768        TaskField::Priority,
769        &required_string_payload("priority", &change.payload)?,
770    )?;
771    let status = required_string_payload("initial_status", &change.payload)?;
772    validate_sync_task_field_value(TaskField::Status, &status)?;
773    if matches!(status.as_str(), "done" | "canceled") {
774        bail!("error invalid-sync-change recurrence-terminal-template");
775    }
776    recurrence_schedule_payload(&change.payload)?;
777    optional_string_array_payload("labels", &change.payload)?;
778    validate_metadata_array_payload(&change.payload)?;
779    if required_string_payload("state", &change.payload)? != "active"
780        || !required_string_payload("stopped_at", &change.payload)?.is_empty()
781    {
782        bail!("error invalid-sync-change recurrence-create-state");
783    }
784    required_timestamp_payload("created_at", &change.payload)?;
785    required_timestamp_payload("updated_at", &change.payload)?;
786    Ok(())
787}
788
789fn validate_recurrence_template(change: &ChangeWire) -> Result<()> {
790    validate_recurrence_entity(change)?;
791    let fields = change
792        .payload
793        .get("fields")
794        .and_then(Value::as_array)
795        .context("error invalid-sync-change payload.fields missing")?;
796    if fields.len() > 8 {
797        bail!("error invalid-sync-change recurrence-template-fields-too-large");
798    }
799    let base_versions = change
800        .payload
801        .get("base_versions")
802        .and_then(Value::as_object)
803        .context("error invalid-sync-change payload.base_versions missing")?;
804    let mut seen = HashSet::new();
805    for pair in fields {
806        let pair = pair
807            .as_array()
808            .filter(|pair| pair.len() == 2)
809            .context("error invalid-sync-change recurrence-template-field-pair")?;
810        let field = pair[0]
811            .as_str()
812            .context("error invalid-sync-change recurrence-template-field")?;
813        let value = pair[1]
814            .as_str()
815            .context("error invalid-sync-change recurrence-template-value")?;
816        if !seen.insert(field) {
817            bail!("error invalid-sync-change recurrence-template-duplicate-field");
818        }
819        if !matches!(
820            base_versions.get(field),
821            Some(Value::String(_)) | Some(Value::Null)
822        ) {
823            bail!("error invalid-sync-change recurrence-template-base-version field={field}");
824        }
825        match field {
826            "title" | "description" => {}
827            "project" => ensure_project_id("project_id", value)?,
828            "priority" => validate_sync_task_field_value(TaskField::Priority, value)?,
829            "initial_status" => {
830                validate_sync_task_field_value(TaskField::Status, value)?;
831                if matches!(value, "done" | "canceled") {
832                    bail!("error invalid-sync-change recurrence-terminal-template");
833                }
834            }
835            "available_local_time" => {
836                validate_local_time(value)?;
837            }
838            "due_policy" => {
839                RecurrenceDuePolicy::parse(value)
840                    .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
841            }
842            _ => bail!("error invalid-sync-change recurrence-template-field={field}"),
843        }
844    }
845    optional_string_array_payload("labels", &change.payload)?;
846    let labels_changed = change
847        .payload
848        .get("labels_changed")
849        .and_then(Value::as_bool)
850        .context("error invalid-sync-change payload.labels_changed missing")?;
851    if labels_changed
852        && !matches!(
853            base_versions.get("labels"),
854            Some(Value::String(_)) | Some(Value::Null)
855        )
856    {
857        bail!("error invalid-sync-change recurrence-template-base-version field=labels");
858    }
859    required_timestamp_payload("updated_at", &change.payload)?;
860    Ok(())
861}
862
863fn validate_recurrence_projection(change: &ChangeWire) -> Result<()> {
864    validate_recurrence_entity(change)?;
865    if change.field.as_deref() != Some("projection") {
866        bail!("error invalid-sync-change field=projection");
867    }
868    let workspace_id: WorkspaceId = required_string_payload("workspace_id", &change.payload)?
869        .parse()
870        .map_err(|_| anyhow::anyhow!("error invalid-sync-change workspace_id invalid-id"))?;
871    let series_id: RecurrenceSeriesId = change.entity_id.parse().unwrap();
872    let slot_on = recurrence_date_payload("slot_on", &change.payload)?;
873    let schedule = recurrence_schedule_payload(&change.payload)?;
874    if !is_slot(&schedule.rule, schedule.start_on, slot_on) {
875        bail!("error invalid-sync-change recurrence-slot-off-lattice");
876    }
877    let identity = derive_occurrence_identity(&workspace_id, &series_id, &schedule, slot_on)
878        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
879    for (key, expected) in [
880        ("task_id", identity.task_id.as_str()),
881        (
882            "projected_at",
883            identity.occurrence_link.projected_at.as_str(),
884        ),
885        ("task_change_id", identity.task_change_id.as_str()),
886        (
887            "occurrence_change_id",
888            identity.occurrence_change_id.as_str(),
889        ),
890        (
891            "task_field_version_seed",
892            identity.field_version_seeds.task.as_str(),
893        ),
894        (
895            "occurrence_field_version_seed",
896            identity.field_version_seeds.occurrence.as_str(),
897        ),
898    ] {
899        if required_string_payload(key, &change.payload)? != expected {
900            bail!("error invalid-sync-change recurrence-deterministic-mismatch field={key}");
901        }
902    }
903    if change.change_id != identity.occurrence_change_id
904        || change.created_at != identity.occurrence_link.projected_at
905    {
906        bail!("error invalid-sync-change recurrence-deterministic-mismatch field=change_id");
907    }
908    Ok(())
909}
910
911fn validate_recurrence_outcome(change: &ChangeWire) -> Result<()> {
912    validate_recurrence_entity(change)?;
913    validate_timestamp_value("created_at", &change.created_at)?;
914    if change.field.as_deref() != Some("outcome") {
915        bail!("error invalid-sync-change field=outcome");
916    }
917    let slot_on = recurrence_date_payload("slot_on", &change.payload)?;
918    let schedule = recurrence_schedule_payload(&change.payload)?;
919    if !is_slot(&schedule.rule, schedule.start_on, slot_on) {
920        bail!("error invalid-sync-change recurrence-slot-off-lattice");
921    }
922    let outcome = RecurrenceOutcome::parse(&required_string_payload("outcome", &change.payload)?)
923        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
924    required_timestamp_payload("resolved_at", &change.payload)?;
925    let conflict_resolution = change
926        .payload
927        .get("conflict_resolution")
928        .and_then(Value::as_bool)
929        .unwrap_or(false);
930    let task_id = required_string_payload("task_id", &change.payload)?;
931    ensure_sync_id("task_id", &task_id)?;
932    let workspace_id: WorkspaceId =
933        required_string_payload("workspace_id", &change.payload)?.parse()?;
934    let series_id: RecurrenceSeriesId = change.entity_id.parse().unwrap();
935    let identity = derive_occurrence_identity(&workspace_id, &series_id, &schedule, slot_on)?;
936    if task_id != identity.task_id.as_str() {
937        bail!("error invalid-sync-change recurrence-deterministic-mismatch field=task_id");
938    }
939    let expected_status = match outcome {
940        RecurrenceOutcome::Completed => "done",
941        RecurrenceOutcome::Skipped => "canceled",
942    };
943    if required_string_payload("task_status", &change.payload)? != expected_status {
944        bail!("error invalid-sync-change recurrence-outcome-status-mismatch");
945    }
946    let status_change_id = required_string_payload("task_status_change_id", &change.payload)?;
947    if !conflict_resolution || !status_change_id.is_empty() {
948        ensure_sync_id("task_status_change_id", &status_change_id)?;
949    }
950    let successor = required_string_payload("successor_task_id", &change.payload)?;
951    if !successor.is_empty() {
952        ensure_sync_id("successor_task_id", &successor)?;
953        let successor_slot = next_slot_after(&schedule.rule, schedule.start_on, slot_on)
954            .context("error invalid-sync-change recurrence-successor-out-of-range")?;
955        let successor_identity =
956            derive_occurrence_identity(&workspace_id, &series_id, &schedule, successor_slot)?;
957        if successor != successor_identity.task_id.as_str() {
958            bail!(
959                "error invalid-sync-change recurrence-deterministic-mismatch field=successor_task_id"
960            );
961        }
962    }
963    Ok(())
964}
965
966fn validate_recurrence_state(change: &ChangeWire) -> Result<()> {
967    validate_recurrence_entity(change)?;
968    if change.field.as_deref() != Some("state") {
969        bail!("error invalid-sync-change field=state");
970    }
971    let conflict_resolution = change
972        .payload
973        .get("conflict_resolution")
974        .and_then(Value::as_bool)
975        .unwrap_or(false);
976    if change.base_version.is_none() && !conflict_resolution {
977        bail!("error invalid-sync-change recurrence-lifecycle-base-version-missing");
978    }
979    let state = RecurrenceSeriesState::parse(&required_string_payload("state", &change.payload)?)
980        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
981    let stopped_at = required_string_payload("stopped_at", &change.payload)?;
982    if matches!(state, RecurrenceSeriesState::Stopped) {
983        required_timestamp_payload("stopped_at", &change.payload)?;
984    } else if !stopped_at.is_empty() {
985        bail!("error invalid-sync-change recurrence-stop-state-mismatch");
986    }
987    required_timestamp_payload("changed_at", &change.payload)?;
988    Ok(())
989}
990
991fn validate_recurrence_pause(change: &ChangeWire, open: bool) -> Result<()> {
992    validate_recurrence_entity(change)?;
993    if change.field.as_deref() != Some("pause") {
994        bail!("error invalid-sync-change field=pause");
995    }
996    if open {
997        let interval_id = required_string_payload("interval_id", &change.payload)?;
998        ensure_sync_id("interval_id", &interval_id)?;
999        required_timestamp_payload("paused_at", &change.payload)?;
1000        let slot = required_string_payload("suspended_slot_on", &change.payload)?;
1001        if !slot.is_empty() {
1002            slot.parse::<NaiveDate>()
1003                .context("error invalid-sync-change suspended_slot_on")?;
1004            let task_id = required_string_payload("suspended_task_id", &change.payload)?;
1005            ensure_sync_id("suspended_task_id", &task_id)?;
1006        }
1007    } else {
1008        required_timestamp_payload("resumed_at", &change.payload)?;
1009        if let Some(interval_id) = optional_string_payload("interval_id", &change.payload)? {
1010            ensure_sync_id("interval_id", &interval_id)?;
1011        }
1012    }
1013    Ok(())
1014}
1015
1016fn validate_recurrence_entity(change: &ChangeWire) -> Result<()> {
1017    ensure_entity_type(change, "recurrence_series")?;
1018    change
1019        .entity_id
1020        .parse::<RecurrenceSeriesId>()
1021        .map_err(|_| anyhow::anyhow!("error invalid-sync-change entity_id invalid-id"))?;
1022    required_workspace_payload(&change.payload)?;
1023    Ok(())
1024}
1025
1026fn recurrence_schedule_payload(payload: &Value) -> Result<RecurrenceSchedule> {
1027    let frequency = RecurrenceFrequency::parse(&required_string_payload("frequency", payload)?)
1028        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
1029    let interval = required_i64_payload("interval", payload)?;
1030    let interval = u32::try_from(interval)
1031        .context("error invalid-sync-change recurrence-interval-out-of-range")?;
1032    let weekdays_text = required_string_payload("weekdays", payload)?;
1033    let weekdays = weekdays_text
1034        .parse::<WeekdaySet>()
1035        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
1036    if weekdays.to_string() != weekdays_text {
1037        bail!("error invalid-sync-change recurrence-weekdays-noncanonical");
1038    }
1039    let rule = RecurrenceRule::new(frequency, interval, weekdays)
1040        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
1041    let timezone_text = required_string_payload("timezone", payload)?;
1042    let timezone = timezone_text
1043        .parse::<TimeZoneId>()
1044        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
1045    if timezone_text.parse::<chrono_tz::Tz>()?.to_string() != timezone_text {
1046        bail!("error invalid-sync-change recurrence-timezone-noncanonical");
1047    }
1048    let start_on = recurrence_date_payload("start_on", payload)?;
1049    let available = required_string_payload("available_local_time", payload)?;
1050    let available_local_time = if available.is_empty() {
1051        None
1052    } else {
1053        Some(validate_local_time(&available)?)
1054    };
1055    let due_policy = RecurrenceDuePolicy::parse(&required_string_payload("due_policy", payload)?)
1056        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
1057    Ok(RecurrenceSchedule::new(
1058        rule,
1059        timezone,
1060        start_on,
1061        available_local_time,
1062        due_policy,
1063    ))
1064}
1065
1066fn recurrence_date_payload(key: &str, payload: &Value) -> Result<NaiveDate> {
1067    required_string_payload(key, payload)?
1068        .parse()
1069        .with_context(|| format!("error invalid-sync-change payload.{key} invalid-date"))
1070}
1071
1072fn validate_local_time(value: &str) -> Result<NaiveTime> {
1073    NaiveTime::parse_from_str(value, "%H:%M:%S")
1074        .context("error invalid-sync-change recurrence-local-time")
1075}
1076
1077fn validate_sha256_for_sync(value: &str) -> Result<()> {
1078    if value.len() == 64
1079        && value
1080            .bytes()
1081            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1082    {
1083        Ok(())
1084    } else {
1085        bail!("error invalid-sync-change invalid-sha256");
1086    }
1087}
1088
1089fn validate_blob_size_for_sync(byte_size: i64) -> Result<()> {
1090    let Ok(bytes) = usize::try_from(byte_size) else {
1091        bail!("error invalid-sync-change error invalid-attachment-size bytes={byte_size}");
1092    };
1093    if bytes == 0 || bytes > MAX_BLOB_BYTES {
1094        bail!("error invalid-sync-change error invalid-attachment-size bytes={byte_size}");
1095    }
1096    map_attachment_validation(validate_blob_size(bytes))
1097}
1098
1099fn map_attachment_validation(result: Result<()>) -> Result<()> {
1100    result.map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))
1101}
1102
1103fn validate_change_server_seq(change: &ChangeWire, direction: ChangeDirection) -> Result<()> {
1104    match direction {
1105        ChangeDirection::Pushed if change.server_seq.is_some() => {
1106            bail!("error invalid-sync-change server_seq client-supplied");
1107        }
1108        ChangeDirection::Pulled => match change.server_seq {
1109            Some(server_seq) if server_seq > 0 => {}
1110            Some(server_seq) => {
1111                bail!("error invalid-sync-change server_seq={server_seq}");
1112            }
1113            None => bail!("error invalid-sync-change server_seq missing"),
1114        },
1115        ChangeDirection::Pushed => {}
1116    }
1117    Ok(())
1118}
1119
1120fn validate_push_acks(request_change_ids: &[String], response: &SyncResponse) -> Result<()> {
1121    if response.push_acks.len() != request_change_ids.len() {
1122        bail!(
1123            "error invalid-sync-response push-ack-count expected={} got={}",
1124            request_change_ids.len(),
1125            response.push_acks.len()
1126        );
1127    }
1128    let expected = request_change_ids
1129        .iter()
1130        .map(String::as_str)
1131        .collect::<HashSet<_>>();
1132    let mut seen = HashSet::with_capacity(response.push_acks.len());
1133    for ack in &response.push_acks {
1134        if !expected.contains(ack.change_id.as_str()) {
1135            bail!(
1136                "error invalid-sync-response unexpected-push-ack change_id={}",
1137                ack.change_id
1138            );
1139        }
1140        if ack.server_seq <= 0 {
1141            bail!(
1142                "error invalid-sync-response push-ack-server-seq change_id={} server_seq={}",
1143                ack.change_id,
1144                ack.server_seq
1145            );
1146        }
1147        if !seen.insert(ack.change_id.as_str()) {
1148            bail!(
1149                "error invalid-sync-response duplicate-push-ack change_id={}",
1150                ack.change_id
1151            );
1152        }
1153    }
1154    Ok(())
1155}
1156
1157fn validate_pull_page(after: i64, pull_limit: u32, response: &SyncResponse) -> Result<()> {
1158    let mut previous = after;
1159    let mut change_ids = HashSet::with_capacity(response.changes.len());
1160    for change in &response.changes {
1161        validate_pulled_change(change)?;
1162        if !change_ids.insert(&change.change_id) {
1163            bail!(
1164                "error invalid-sync-response duplicate-pull-change change_id={}",
1165                change.change_id
1166            );
1167        }
1168        let server_seq = change.server_seq.with_context(|| {
1169            format!(
1170                "error invalid-sync-response missing-server-seq change_id={}",
1171                change.change_id
1172            )
1173        })?;
1174        if server_seq <= previous {
1175            bail!(
1176                "error invalid-sync-response server-seq-order previous={} server_seq={}",
1177                previous,
1178                server_seq
1179            );
1180        }
1181        previous = server_seq;
1182    }
1183    let expected_cursor = response
1184        .changes
1185        .last()
1186        .and_then(|change| change.server_seq)
1187        .unwrap_or(after);
1188    if response.cursor != expected_cursor {
1189        bail!(
1190            "error invalid-sync-response cursor-mismatch expected={} got={}",
1191            expected_cursor,
1192            response.cursor
1193        );
1194    }
1195    if response.has_more && response.changes.len() < pull_limit as usize {
1196        bail!(
1197            "error invalid-sync-response has-more-short-page returned={} limit={}",
1198            response.changes.len(),
1199            pull_limit
1200        );
1201    }
1202    Ok(())
1203}
1204
1205fn validate_push_pull_overlap(response: &SyncResponse) -> Result<()> {
1206    let acked = response
1207        .push_acks
1208        .iter()
1209        .map(|ack| (ack.change_id.as_str(), ack.server_seq))
1210        .collect::<HashMap<_, _>>();
1211    for change in &response.changes {
1212        if let Some(acked_server_seq) = acked.get(change.change_id.as_str()) {
1213            let Some(pull_server_seq) = change.server_seq else {
1214                continue;
1215            };
1216            if *acked_server_seq != pull_server_seq {
1217                bail!(
1218                    "error invalid-sync-response push-pull-server-seq-mismatch change_id={} ack={} pull={}",
1219                    change.change_id,
1220                    acked_server_seq,
1221                    pull_server_seq
1222                );
1223            }
1224        }
1225    }
1226    Ok(())
1227}
1228
1229fn validate_sync_task_field_value(field: TaskField, value: &str) -> Result<()> {
1230    field
1231        .validate_value(value)
1232        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))
1233}
1234
1235fn ensure_entity_type(change: &ChangeWire, expected: &str) -> Result<()> {
1236    if change.entity_type == expected {
1237        Ok(())
1238    } else {
1239        bail!(
1240            "error invalid-sync-change op_type={} entity_type={} expected={}",
1241            change.op_type,
1242            change.entity_type,
1243            expected
1244        )
1245    }
1246}
1247
1248fn ensure_non_empty(name: &str, value: &str) -> Result<()> {
1249    if value.trim().is_empty() {
1250        bail!("error invalid-sync-change {name} empty");
1251    }
1252    Ok(())
1253}
1254
1255fn ensure_sync_id(name: &str, value: &str) -> Result<()> {
1256    if value.len() == 16 && value.bytes().all(|byte| BASE32.contains(&byte)) {
1257        Ok(())
1258    } else {
1259        bail!("error invalid-sync-change {name} invalid-id");
1260    }
1261}
1262
1263fn ensure_project_id(name: &str, value: &str) -> Result<()> {
1264    if value.parse::<ProjectId>().is_ok() {
1265        Ok(())
1266    } else {
1267        bail!("error invalid-sync-change {name} invalid-id");
1268    }
1269}
1270
1271fn required_string_payload(key: &str, payload: &Value) -> Result<String> {
1272    payload
1273        .get(key)
1274        .and_then(Value::as_str)
1275        .map(str::to_string)
1276        .with_context(|| format!("error invalid-sync-change payload.{key} missing"))
1277}
1278
1279fn required_i64_payload(key: &str, payload: &Value) -> Result<i64> {
1280    match payload.get(key) {
1281        Some(Value::Number(value)) => value
1282            .as_i64()
1283            .with_context(|| format!("error invalid-sync-change payload.{key} invalid")),
1284        Some(Value::Null) | None => bail!("error invalid-sync-change payload.{key} missing"),
1285        Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1286    }
1287}
1288
1289fn required_timestamp_payload(key: &str, payload: &Value) -> Result<String> {
1290    let value = required_string_payload(key, payload)?;
1291    validate_timestamp_value(&format!("payload.{key}"), &value)?;
1292    Ok(value)
1293}
1294
1295fn validate_timestamp_value(label: &str, value: &str) -> Result<()> {
1296    if value.ends_with('Z') && chrono::DateTime::parse_from_rfc3339(value).is_ok() {
1297        Ok(())
1298    } else {
1299        bail!("error invalid-sync-change {label} invalid-timestamp");
1300    }
1301}
1302
1303fn required_workspace_payload(payload: &Value) -> Result<()> {
1304    let id = required_string_payload("workspace_id", payload)?;
1305    if id.parse::<WorkspaceId>().is_err() {
1306        bail!("error invalid-sync-change workspace_id invalid-id");
1307    }
1308    required_string_payload("workspace_key", payload)?;
1309    Ok(())
1310}
1311
1312fn optional_workspace_payload(payload: &Value) -> Result<()> {
1313    if payload.get("workspace_id").is_none() && payload.get("workspace_key").is_none() {
1314        return Ok(());
1315    }
1316    required_workspace_payload(payload)
1317}
1318
1319fn optional_string_payload(key: &str, payload: &Value) -> Result<Option<String>> {
1320    match payload.get(key) {
1321        Some(Value::String(value)) => Ok(Some(value.clone())),
1322        Some(Value::Null) | None => Ok(None),
1323        Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1324    }
1325}
1326
1327fn optional_i64_payload(key: &str, payload: &Value) -> Result<Option<i64>> {
1328    match payload.get(key) {
1329        Some(Value::Number(value)) => value
1330            .as_i64()
1331            .map(Some)
1332            .with_context(|| format!("error invalid-sync-change payload.{key} invalid")),
1333        Some(Value::Null) | None => Ok(None),
1334        Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1335    }
1336}
1337
1338fn optional_bool_payload(key: &str, payload: &Value) -> Result<Option<bool>> {
1339    match payload.get(key) {
1340        Some(Value::Bool(value)) => Ok(Some(*value)),
1341        Some(Value::Null) | None => Ok(None),
1342        Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1343    }
1344}
1345
1346fn string_array_payload(key: &str, payload: &Value) -> Result<Vec<String>> {
1347    payload
1348        .get(key)
1349        .and_then(Value::as_array)
1350        .with_context(|| format!("error invalid-sync-change payload.{key} invalid"))?
1351        .iter()
1352        .map(|value| {
1353            value
1354                .as_str()
1355                .map(str::to_string)
1356                .with_context(|| format!("error invalid-sync-change payload.{key} invalid"))
1357        })
1358        .collect()
1359}
1360
1361fn optional_string_array_payload(key: &str, payload: &Value) -> Result<()> {
1362    match payload.get(key) {
1363        Some(Value::Array(values))
1364            if values
1365                .iter()
1366                .all(|value| value.as_str().is_some_and(|value| !value.trim().is_empty())) =>
1367        {
1368            Ok(())
1369        }
1370        Some(Value::Null) | None => Ok(()),
1371        Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1372    }
1373}
1374
1375fn ensure_metadata_field_id(name: &str, value: &str) -> Result<()> {
1376    value
1377        .parse::<MetadataFieldId>()
1378        .map(|_| ())
1379        .map_err(|_| anyhow::anyhow!("error invalid-sync-change {name} invalid-id"))
1380}
1381
1382fn validate_metadata_key_payload(key: &str, payload: &Value) -> Result<String> {
1383    let value = required_string_payload(key, payload)?;
1384    let normalized = crate::metadata::normalize_metadata_key(&value)
1385        .map_err(|_| anyhow::anyhow!("error invalid-sync-change payload.{key} invalid"))?;
1386    if normalized != value {
1387        bail!("error invalid-sync-change payload.{key} noncanonical");
1388    }
1389    Ok(value)
1390}
1391
1392fn validate_metadata_value_payload(key: &str, payload: &Value) -> Result<String> {
1393    let value = required_string_payload(key, payload)?;
1394    if value.len() > crate::metadata::MAX_METADATA_VALUE_BYTES {
1395        bail!("error invalid-sync-change payload.{key} too-large");
1396    }
1397    Ok(value)
1398}
1399
1400fn validate_metadata_array_payload(payload: &Value) -> Result<()> {
1401    let Some(values) = payload.get("metadata") else {
1402        return Ok(());
1403    };
1404    let values = values
1405        .as_array()
1406        .context("error invalid-sync-change payload.metadata invalid")?;
1407    if values.len() > crate::metadata::MAX_METADATA_VALUES {
1408        bail!("error invalid-sync-change payload.metadata too-many");
1409    }
1410    let mut ids = HashSet::new();
1411    let mut keys = HashSet::new();
1412    let mut total_bytes = 0usize;
1413    for value in values {
1414        let object = value
1415            .as_object()
1416            .context("error invalid-sync-change payload.metadata invalid")?;
1417        let field_id = required_string_payload("field_id", value)?;
1418        ensure_metadata_field_id("metadata.field_id", &field_id)?;
1419        let key = validate_metadata_key_payload("key", value)?;
1420        let metadata_value = validate_metadata_value_payload("value", value)?;
1421        if !ids.insert(field_id) || !keys.insert(key) {
1422            bail!("error invalid-sync-change payload.metadata duplicate");
1423        }
1424        if object.len() != 3 {
1425            bail!("error invalid-sync-change payload.metadata invalid");
1426        }
1427        total_bytes = total_bytes.saturating_add(metadata_value.len());
1428    }
1429    if total_bytes > crate::metadata::MAX_METADATA_TOTAL_BYTES {
1430        bail!("error invalid-sync-change payload.metadata too-large");
1431    }
1432    Ok(())
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437    use super::*;
1438    use crate::change_log::{ChangePayload, op_type};
1439    use crate::workspaces::Workspace;
1440
1441    fn test_workspace() -> Workspace {
1442        Workspace {
1443            id: "0000000000000000".parse().unwrap(),
1444            key: "default".to_string(),
1445            name: "default".to_string(),
1446        }
1447    }
1448
1449    fn make_change_wire(
1450        op_type: &str,
1451        entity_type: &str,
1452        entity_id: &str,
1453        payload: serde_json::Value,
1454    ) -> ChangeWire {
1455        ChangeWire {
1456            change_id: "AAAAAAAAAAAAAAA0".to_string(),
1457            client_id: "client".to_string(),
1458            local_seq: 1,
1459            entity_type: entity_type.to_string(),
1460            entity_id: entity_id.to_string(),
1461            field: None,
1462            op_type: op_type.to_string(),
1463            payload,
1464            base_version: None,
1465            created_at: "2026-06-01T00:00:00Z".to_string(),
1466            server_seq: None,
1467        }
1468    }
1469
1470    #[test]
1471    fn sync_timestamps_accept_fractional_utc_precision() {
1472        validate_timestamp_value("created_at", "2026-07-28T06:41:44Z").unwrap();
1473        validate_timestamp_value("created_at", "2026-07-28T06:41:44.000000Z").unwrap();
1474        validate_timestamp_value("created_at", "2026-07-28T06:41:44.123456789Z").unwrap();
1475    }
1476
1477    #[test]
1478    fn sync_timestamps_reject_offsets_and_invalid_calendar_values() {
1479        for value in ["2026-07-28T08:41:44+02:00", "2026-13-28T06:41:44Z", "today"] {
1480            assert!(validate_timestamp_value("created_at", value).is_err());
1481        }
1482    }
1483
1484    #[test]
1485    fn recurrence_projection_rejects_nondeterministic_change_timestamp() {
1486        let workspace = test_workspace();
1487        let series_id: RecurrenceSeriesId = "AAAAAAAAAAAAAAAA".parse().unwrap();
1488        let schedule = RecurrenceSchedule::new(
1489            RecurrenceRule::daily(),
1490            "UTC".parse().unwrap(),
1491            "2026-07-20".parse().unwrap(),
1492            None,
1493            RecurrenceDuePolicy::SameDay,
1494        );
1495        let slot_on: NaiveDate = "2026-07-20".parse().unwrap();
1496        let identity =
1497            derive_occurrence_identity(&workspace.id, &series_id, &schedule, slot_on).unwrap();
1498        let payload = ChangePayload::workspace(&workspace)
1499            .set("series_id", series_id.as_str())
1500            .set("slot_on", slot_on.to_string())
1501            .set("task_id", identity.task_id.as_str())
1502            .set("projected_at", &identity.occurrence_link.projected_at)
1503            .set("task_change_id", &identity.task_change_id)
1504            .set("occurrence_change_id", &identity.occurrence_change_id)
1505            .set(
1506                "task_field_version_seed",
1507                &identity.field_version_seeds.task,
1508            )
1509            .set(
1510                "occurrence_field_version_seed",
1511                &identity.field_version_seeds.occurrence,
1512            )
1513            .set("frequency", "daily")
1514            .set("interval", 1)
1515            .set("weekdays", "")
1516            .set("timezone", "UTC")
1517            .set("start_on", "2026-07-20")
1518            .set("available_local_time", "")
1519            .set("due_policy", "same_day")
1520            .into_value();
1521        let mut change = make_change_wire(
1522            op_type::PROJECT_RECURRENCE_OCCURRENCE,
1523            "recurrence_series",
1524            series_id.as_str(),
1525            payload,
1526        );
1527        change.change_id = identity.occurrence_change_id;
1528        change.field = Some("projection".to_string());
1529        change.created_at = identity.occurrence_link.projected_at;
1530        validate_pushed_change(&change).unwrap();
1531
1532        change.created_at = "2026-07-20T00:00:01Z".to_string();
1533        assert!(
1534            validate_pushed_change(&change)
1535                .unwrap_err()
1536                .to_string()
1537                .contains("recurrence-deterministic-mismatch")
1538        );
1539    }
1540
1541    #[test]
1542    fn request_pull_limit_has_default_and_bounds() {
1543        assert_eq!(request_pull_limit(None).unwrap(), MAX_PULL_BATCH);
1544        assert!(request_pull_limit(Some(MAX_PULL_BATCH)).is_ok());
1545        assert_eq!(
1546            request_pull_limit(Some(0)).unwrap_err().to_string(),
1547            "error sync-pull-limit-out-of-range min=1 max=512 got=0"
1548        );
1549        assert_eq!(
1550            request_pull_limit(Some(MAX_PULL_BATCH + 1))
1551                .unwrap_err()
1552                .to_string(),
1553            "error sync-pull-limit-out-of-range min=1 max=512 got=513"
1554        );
1555    }
1556
1557    #[test]
1558    fn request_envelope_rejects_negative_cursor_and_oversized_push_batch() {
1559        let request = SyncRequest {
1560            protocol_version: Some(SYNC_PROTOCOL_VERSION),
1561            client_id: "test-client".to_string(),
1562            after: -1,
1563            pull_limit: Some(MAX_PULL_BATCH),
1564            changes: Vec::new(),
1565        };
1566        assert_eq!(
1567            validate_sync_request_envelope(&request)
1568                .unwrap_err()
1569                .to_string(),
1570            "error sync-after-out-of-range min=0 got=-1"
1571        );
1572
1573        let request = SyncRequest {
1574            protocol_version: Some(SYNC_PROTOCOL_VERSION),
1575            client_id: "test-client".to_string(),
1576            after: 0,
1577            pull_limit: Some(MAX_PULL_BATCH),
1578            changes: vec![
1579                ChangeWire {
1580                    change_id: "AAAAAAAAAAAAAAA0".to_string(),
1581                    client_id: "client".to_string(),
1582                    local_seq: 1,
1583                    entity_type: "task".to_string(),
1584                    entity_id: "BBBBBBBBBBBBBBBB".to_string(),
1585                    field: None,
1586                    op_type: "create_task".to_string(),
1587                    payload: serde_json::json!({"title":"oops","project_id":"0000000000000000","project_key":"app","project_name":"app","project_prefix":"APP","workspace_id":"0000000000000000","workspace_key":"default","created_at":"2026-01-01T00:00:00Z"}),
1588                    base_version: None,
1589                    created_at: "2026-01-01T00:00:00Z".to_string(),
1590                    server_seq: None,
1591                };
1592                MAX_PUSH_BATCH + 1
1593            ],
1594        };
1595        assert_eq!(
1596            validate_sync_request_envelope(&request)
1597                .unwrap_err()
1598                .to_string(),
1599            "error sync-push-too-large limit=256 got=257"
1600        );
1601    }
1602
1603    #[test]
1604    fn response_validation_respects_request_pull_limit() {
1605        let response = SyncResponse {
1606            protocol_version: SYNC_PROTOCOL_VERSION,
1607            cursor: 1,
1608            has_more: false,
1609            push_acks: vec![],
1610            changes: vec![
1611                ChangeWire {
1612                    change_id: "AAAAAAAAAAAAAAA1".to_string(),
1613                    client_id: "client".to_string(),
1614                    local_seq: 1,
1615                    entity_type: "task".to_string(),
1616                    entity_id: "BBBBBBBBBBBBBBBB".to_string(),
1617                    field: None,
1618                    op_type: "create_task".to_string(),
1619                    payload: serde_json::json!({
1620                        "title":"one",
1621                        "project_id":"0000000000000000",
1622                        "project_key":"app",
1623                        "project_name":"app",
1624                        "project_prefix":"APP",
1625                    }),
1626                    base_version: None,
1627                    created_at: "2026-01-01T00:00:00Z".to_string(),
1628                    server_seq: Some(1),
1629                };
1630                MAX_PULL_BATCH as usize + 1
1631            ],
1632        };
1633        assert_eq!(
1634            validate_sync_response_for_request(0, MAX_PULL_BATCH, &[], &response)
1635                .unwrap_err()
1636                .to_string(),
1637            "error invalid-sync-response pull-too-large limit=512 got=513"
1638        );
1639    }
1640
1641    #[test]
1642    fn constructed_create_task_payload_passes_wire_validation() {
1643        let ws = test_workspace();
1644        let payload = ChangePayload::workspace(&ws)
1645            .set("title", "test task")
1646            .set("description", "a description")
1647            .set("project_id", "1111111111111111")
1648            .set("project_key", "app")
1649            .set("project_name", "App")
1650            .set("project_prefix", "APP")
1651            .set("status", "inbox")
1652            .set("priority", "none")
1653            .set("created_at", "2026-06-01T00:00:00Z")
1654            .into_value();
1655        let change = make_change_wire(op_type::CREATE_TASK, "task", "BBBBBBBBBBBBBBBB", payload);
1656        validate_pushed_change(&change)
1657            .expect("create_task payload built with ChangePayload should be wire-valid");
1658    }
1659
1660    #[test]
1661    fn constructed_create_project_payload_passes_wire_validation() {
1662        let ws = test_workspace();
1663        let payload = ChangePayload::workspace(&ws)
1664            .set("key", "app")
1665            .set("name", "App")
1666            .set("prefix", "APP")
1667            .set("created_at", "2026-06-01T00:00:00Z")
1668            .into_value();
1669        let change = make_change_wire(
1670            op_type::CREATE_PROJECT,
1671            "project",
1672            "1111111111111111",
1673            payload,
1674        );
1675        validate_pushed_change(&change)
1676            .expect("create_project payload built with ChangePayload should be wire-valid");
1677    }
1678
1679    #[test]
1680    fn project_changes_reject_invalid_project_ids() {
1681        let ws = test_workspace();
1682        let project_payload = ChangePayload::workspace(&ws)
1683            .set("key", "app")
1684            .set("name", "App")
1685            .set("prefix", "APP")
1686            .set("created_at", "2026-06-01T00:00:00Z")
1687            .into_value();
1688        let create_project = make_change_wire(
1689            op_type::CREATE_PROJECT,
1690            "project",
1691            "invalid",
1692            project_payload,
1693        );
1694        assert_eq!(
1695            validate_pushed_change(&create_project)
1696                .unwrap_err()
1697                .to_string(),
1698            "error invalid-sync-change entity_id invalid-id"
1699        );
1700
1701        let task_payload = ChangePayload::workspace(&ws)
1702            .set("title", "test task")
1703            .set("project_id", "invalid")
1704            .set("project_key", "app")
1705            .set("project_name", "App")
1706            .set("project_prefix", "APP")
1707            .set("created_at", "2026-06-01T00:00:00Z")
1708            .into_value();
1709        let create_task = make_change_wire(
1710            op_type::CREATE_TASK,
1711            "task",
1712            "BBBBBBBBBBBBBBBB",
1713            task_payload,
1714        );
1715        assert_eq!(
1716            validate_pushed_change(&create_task)
1717                .unwrap_err()
1718                .to_string(),
1719            "error invalid-sync-change project_id invalid-id"
1720        );
1721
1722        let field_payload = ChangePayload::workspace(&ws)
1723            .set("value", "invalid")
1724            .set("project_id", "invalid")
1725            .set("project_key", "app")
1726            .set("project_name", "App")
1727            .set("project_prefix", "APP")
1728            .into_value();
1729        let mut set_field = make_change_wire(
1730            op_type::SET_FIELD,
1731            "task",
1732            "BBBBBBBBBBBBBBBB",
1733            field_payload,
1734        );
1735        set_field.field = Some("project".to_string());
1736        assert_eq!(
1737            validate_pushed_change(&set_field).unwrap_err().to_string(),
1738            "error invalid-sync-change project_id invalid-id"
1739        );
1740    }
1741
1742    #[test]
1743    fn constructed_label_add_payload_passes_wire_validation() {
1744        let ws = test_workspace();
1745        let payload = ChangePayload::workspace(&ws)
1746            .set("label", "bug")
1747            .into_value();
1748        let mut change = make_change_wire(op_type::LABEL_ADD, "task", "BBBBBBBBBBBBBBBB", payload);
1749        change.field = Some("labels".to_string());
1750        validate_pushed_change(&change)
1751            .expect("label_add payload built with ChangePayload should be wire-valid");
1752    }
1753
1754    #[test]
1755    fn constructed_label_administration_payloads_pass_wire_validation() {
1756        let ws = test_workspace();
1757        let rename = make_change_wire(
1758            op_type::SET_LABEL_NAME,
1759            "label",
1760            "old",
1761            ChangePayload::workspace(&ws)
1762                .set("name", "old")
1763                .set("new_name", "new")
1764                .set("renamed_at", "2026-06-01T00:00:00Z")
1765                .into_value(),
1766        );
1767        validate_pushed_change(&rename).unwrap();
1768
1769        let restore = make_change_wire(
1770            op_type::LABEL_RESTORE,
1771            "label",
1772            "new",
1773            ChangePayload::workspace(&ws)
1774                .set("name", "new")
1775                .set("created_at", "2026-06-01T00:00:00Z")
1776                .set("task_ids", ["BBBBBBBBBBBBBBBB"])
1777                .set("series_ids", ["CCCCCCCCCCCCCCCC"])
1778                .set("restored_at", "2026-06-01T00:00:01Z")
1779                .into_value(),
1780        );
1781        validate_pushed_change(&restore).unwrap();
1782    }
1783
1784    #[test]
1785    fn constructed_dependency_add_payload_passes_wire_validation() {
1786        let ws = test_workspace();
1787        let payload = ChangePayload::workspace(&ws)
1788            .set("depends_on_task_id", "CCCCCCCCCCCCCCCC")
1789            .into_value();
1790        let mut change =
1791            make_change_wire(op_type::DEPENDENCY_ADD, "task", "BBBBBBBBBBBBBBBB", payload);
1792        change.field = Some("dependencies".to_string());
1793        validate_pushed_change(&change)
1794            .expect("dependency_add payload built with ChangePayload should be wire-valid");
1795    }
1796
1797    #[test]
1798    fn constructed_note_edit_payload_passes_wire_validation() {
1799        let ws = test_workspace();
1800        let payload = ChangePayload::workspace(&ws)
1801            .set("note_id", "DDDDDDDDDDDDDDDD")
1802            .set("body", "corrected note body")
1803            .set("edited_at", "2026-06-01T01:00:00Z")
1804            .into_value();
1805        let mut change = make_change_wire(op_type::NOTE_EDIT, "task", "BBBBBBBBBBBBBBBB", payload);
1806        change.field = Some("notes".to_string());
1807        validate_pushed_change(&change)
1808            .expect("note_edit payload built with ChangePayload should be wire-valid");
1809    }
1810
1811    #[test]
1812    fn constructed_note_add_payload_passes_wire_validation() {
1813        let ws = test_workspace();
1814        let payload = ChangePayload::workspace(&ws)
1815            .set("note_id", "DDDDDDDDDDDDDDDD")
1816            .set("body", "note body")
1817            .set("created_at", "2026-06-01T00:00:00Z")
1818            .into_value();
1819        let mut change = make_change_wire(op_type::NOTE_ADD, "task", "BBBBBBBBBBBBBBBB", payload);
1820        change.field = Some("notes".to_string());
1821        validate_pushed_change(&change)
1822            .expect("note_add payload built with ChangePayload should be wire-valid");
1823    }
1824
1825    fn attachment_add_payload() -> Value {
1826        ChangePayload::workspace(&test_workspace())
1827            .set("attachment_id", "7KQ9A1X4MV2P8D6R")
1828            .set(
1829                "sha256",
1830                "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
1831            )
1832            .set("byte_size", 12_i64)
1833            .set("media_type", "image/png")
1834            .set("filename", None::<&str>)
1835            .set("alt_text", None::<&str>)
1836            .set("width", 320_i64)
1837            .set("height", 240_i64)
1838            .set("created_at", "2026-06-01T00:00:00Z")
1839            .into_value()
1840    }
1841
1842    fn attachment_change(op_type: &str, payload: Value) -> ChangeWire {
1843        let mut change = make_change_wire(op_type, "task", "BBBBBBBBBBBBBBBB", payload);
1844        change.field = Some("attachments".to_string());
1845        change
1846    }
1847
1848    #[test]
1849    fn constructed_attachment_payloads_parse_without_json_or_protocol_changes() {
1850        let add_value = attachment_add_payload();
1851        let add = AttachmentAddPayload::from_change(&attachment_change(
1852            op_type::ATTACHMENT_ADD,
1853            add_value.clone(),
1854        ))
1855        .unwrap();
1856        assert_eq!(serde_json::to_value(add).unwrap(), add_value);
1857
1858        let delete_value = ChangePayload::workspace(&test_workspace())
1859            .set("attachment_id", "7KQ9A1X4MV2P8D6R")
1860            .set("deleted_at", "2026-06-01T00:00:00Z")
1861            .into_value();
1862        let delete = AttachmentDeletePayload::from_change(&attachment_change(
1863            op_type::ATTACHMENT_DELETE,
1864            delete_value.clone(),
1865        ))
1866        .unwrap();
1867        assert_eq!(serde_json::to_value(delete).unwrap(), delete_value);
1868        assert_eq!(SYNC_PROTOCOL_VERSION, 14);
1869    }
1870
1871    #[test]
1872    fn attachment_payloads_accept_extensions_and_missing_optional_text() {
1873        let mut payload = attachment_add_payload();
1874        let object = payload.as_object_mut().unwrap();
1875        object.remove("filename");
1876        object.remove("alt_text");
1877        object.insert("extension".to_string(), serde_json::json!({ "version": 1 }));
1878
1879        AttachmentAddPayload::from_change(&attachment_change(op_type::ATTACHMENT_ADD, payload))
1880            .unwrap();
1881    }
1882
1883    #[test]
1884    fn attachment_payload_authority_rejects_malformed_fields() {
1885        let cases = [
1886            ("workspace_id", Value::Null, "workspace_id"),
1887            (
1888                "attachment_id",
1889                Value::String("short".to_string()),
1890                "attachment_id",
1891            ),
1892            (
1893                "sha256",
1894                Value::String("short".to_string()),
1895                "invalid-sha256",
1896            ),
1897            ("byte_size", Value::String("12".to_string()), "byte_size"),
1898            ("byte_size", Value::from(-1), "invalid-attachment-size"),
1899            (
1900                "media_type",
1901                Value::String("image/svg+xml".to_string()),
1902                "unsupported-attachment-media-type",
1903            ),
1904            (
1905                "filename",
1906                Value::String("bad/name.png".to_string()),
1907                "invalid-attachment-filename",
1908            ),
1909            (
1910                "alt_text",
1911                Value::String("bad\nalt".to_string()),
1912                "invalid-attachment-alt-text",
1913            ),
1914            ("height", Value::Null, "invalid-attachment-dimensions"),
1915            ("width", Value::from(0), "invalid-attachment-dimensions"),
1916            (
1917                "created_at",
1918                Value::String("today".to_string()),
1919                "invalid-timestamp",
1920            ),
1921        ];
1922        for (key, value, expected) in cases {
1923            let mut payload = attachment_add_payload();
1924            payload[key] = value;
1925            let error = AttachmentAddPayload::from_change(&attachment_change(
1926                op_type::ATTACHMENT_ADD,
1927                payload,
1928            ))
1929            .unwrap_err();
1930            assert!(
1931                error.to_string().contains(expected),
1932                "{key} error did not contain {expected}: {error}"
1933            );
1934        }
1935
1936        let mut wrong_field = attachment_change(op_type::ATTACHMENT_ADD, attachment_add_payload());
1937        wrong_field.field = Some("description".to_string());
1938        assert!(
1939            AttachmentAddPayload::from_change(&wrong_field)
1940                .unwrap_err()
1941                .to_string()
1942                .contains("field=attachments")
1943        );
1944
1945        let mut invalid_entity =
1946            attachment_change(op_type::ATTACHMENT_ADD, attachment_add_payload());
1947        invalid_entity.entity_id = "invalid".to_string();
1948        assert!(
1949            AttachmentAddPayload::from_change(&invalid_entity)
1950                .unwrap_err()
1951                .to_string()
1952                .contains("entity_id")
1953        );
1954    }
1955}