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