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 = 15;
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::RELATED_ADD | op_type::RELATED_REMOVE => {
580            ensure_entity_type(change, "task")?;
581            ensure_sync_id("entity_id", &change.entity_id)?;
582            required_workspace_payload(&change.payload)?;
583            if change.field.as_deref() != Some("related") {
584                bail!("error invalid-sync-change field=related");
585            }
586            let related_task_id = required_string_payload("related_task_id", &change.payload)?;
587            ensure_sync_id("related_task_id", &related_task_id)?;
588            if change.entity_id == related_task_id {
589                bail!("error invalid-sync-change related-self");
590            }
591        }
592        op_type::EPIC_LINK_ADD | op_type::EPIC_LINK_REMOVE => {
593            ensure_entity_type(change, "task")?;
594            ensure_sync_id("entity_id", &change.entity_id)?;
595            required_workspace_payload(&change.payload)?;
596            let epic_task_id = required_string_payload("epic_task_id", &change.payload)?;
597            ensure_sync_id("epic_task_id", &epic_task_id)?;
598            if change.entity_id == epic_task_id {
599                bail!("error invalid-sync-change epic-self");
600            }
601            if change.op_type == op_type::EPIC_LINK_ADD {
602                required_timestamp_payload("created_at", &change.payload)?;
603            }
604        }
605        op_type::PROJECT_DELETE => {
606            ensure_entity_type(change, "project")?;
607            ensure_project_id("entity_id", &change.entity_id)?;
608            required_workspace_payload(&change.payload)?;
609            required_timestamp_payload("deleted_at", &change.payload)?;
610        }
611        op_type::SET_LABEL_NAME => {
612            ensure_entity_type(change, "label")?;
613            required_workspace_payload(&change.payload)?;
614            let name = required_string_payload("name", &change.payload)?;
615            if name != change.entity_id {
616                bail!("error invalid-sync-change label-value-mismatch");
617            }
618            required_string_payload("new_name", &change.payload)?;
619            required_timestamp_payload("renamed_at", &change.payload)?;
620        }
621        op_type::LABEL_DELETE => {
622            ensure_entity_type(change, "label")?;
623            required_workspace_payload(&change.payload)?;
624            let name = required_string_payload("name", &change.payload)?;
625            if name != change.entity_id {
626                bail!("error invalid-sync-change label-value-mismatch");
627            }
628            required_timestamp_payload("deleted_at", &change.payload)?;
629        }
630        op_type::NOTE_EDIT => {
631            ensure_entity_type(change, "task")?;
632            ensure_sync_id("entity_id", &change.entity_id)?;
633            if change.field.as_deref() != Some("notes") {
634                bail!("error invalid-sync-change field=notes");
635            }
636            required_workspace_payload(&change.payload)?;
637            let note_id = required_string_payload("note_id", &change.payload)?;
638            ensure_sync_id("note_id", &note_id)?;
639            required_string_payload("body", &change.payload)?;
640            required_timestamp_payload("edited_at", &change.payload)?;
641        }
642        op_type::LABEL_RESTORE => {
643            ensure_entity_type(change, "label")?;
644            required_workspace_payload(&change.payload)?;
645            let name = required_string_payload("name", &change.payload)?;
646            if name != change.entity_id {
647                bail!("error invalid-sync-change label-value-mismatch");
648            }
649            required_timestamp_payload("created_at", &change.payload)?;
650            required_timestamp_payload("restored_at", &change.payload)?;
651            for key in ["task_ids", "series_ids"] {
652                optional_string_array_payload(key, &change.payload)?;
653                if change.payload.get(key).is_none() {
654                    bail!("error invalid-sync-change payload.{key} missing");
655                }
656            }
657            for task_id in string_array_payload("task_ids", &change.payload)? {
658                ensure_sync_id("task_ids", &task_id)?;
659            }
660            for series_id in string_array_payload("series_ids", &change.payload)? {
661                ensure_sync_id("series_ids", &series_id)?;
662            }
663        }
664        op_type::NOTE_DELETE => {
665            ensure_entity_type(change, "task")?;
666            ensure_sync_id("entity_id", &change.entity_id)?;
667            if change.field.as_deref() != Some("notes") {
668                bail!("error invalid-sync-change field=notes");
669            }
670            required_workspace_payload(&change.payload)?;
671            let note_id = required_string_payload("note_id", &change.payload)?;
672            ensure_sync_id("note_id", &note_id)?;
673            required_timestamp_payload("deleted_at", &change.payload)?;
674        }
675        op_type::SET_RECURRENCE_METADATA | op_type::REMOVE_RECURRENCE_METADATA => {
676            ensure_entity_type(change, "recurrence_series")?;
677            change
678                .entity_id
679                .parse::<RecurrenceSeriesId>()
680                .map_err(|_| anyhow::anyhow!("error invalid-sync-change entity_id invalid-id"))?;
681            required_workspace_payload(&change.payload)?;
682            let field_id = required_string_payload("field_id", &change.payload)?;
683            ensure_metadata_field_id("field_id", &field_id)?;
684            let expected_field = format!("metadata:{field_id}");
685            if change.field.as_deref() != Some(expected_field.as_str()) {
686                bail!("error invalid-sync-change metadata-field-mismatch");
687            }
688            validate_metadata_key_payload("key", &change.payload)?;
689            optional_bool_payload("conflict_resolution", &change.payload)?;
690            if change.op_type == op_type::SET_RECURRENCE_METADATA {
691                validate_metadata_value_payload("value", &change.payload)?;
692            }
693        }
694        op_type::CREATE_RECURRENCE_SERIES => validate_recurrence_create(change)?,
695        op_type::UPDATE_RECURRENCE_TEMPLATE => validate_recurrence_template(change)?,
696        op_type::PROJECT_RECURRENCE_OCCURRENCE => validate_recurrence_projection(change)?,
697        op_type::RESOLVE_RECURRENCE_OCCURRENCE => validate_recurrence_outcome(change)?,
698        op_type::SET_RECURRENCE_STATE | op_type::STOP_RECURRENCE_SERIES => {
699            validate_recurrence_state(change)?
700        }
701        op_type::OPEN_RECURRENCE_PAUSE => validate_recurrence_pause(change, true)?,
702        op_type::CLOSE_RECURRENCE_PAUSE => validate_recurrence_pause(change, false)?,
703        op_type::ATTACHMENT_ADD => {
704            AttachmentAddPayload::from_change(change)?;
705        }
706        op_type::ATTACHMENT_DELETE => {
707            AttachmentDeletePayload::from_change(change)?;
708        }
709        _ => bail!("error invalid-sync-change op_type={}", change.op_type),
710    }
711    Ok(())
712}
713
714fn validate_recurrence_task(change: &ChangeWire) -> Result<()> {
715    let workspace_id: WorkspaceId = required_string_payload("workspace_id", &change.payload)?
716        .parse()
717        .map_err(|_| anyhow::anyhow!("error invalid-sync-change workspace_id invalid-id"))?;
718    let series_id: RecurrenceSeriesId = required_string_payload("series_id", &change.payload)?
719        .parse()
720        .map_err(|_| anyhow::anyhow!("error invalid-sync-change series_id invalid-id"))?;
721    let slot_on = recurrence_date_payload("slot_on", &change.payload)?;
722    let schedule = recurrence_schedule_payload(&change.payload)?;
723    if !is_slot(&schedule.rule, schedule.start_on, slot_on) {
724        bail!("error invalid-sync-change recurrence-slot-off-lattice");
725    }
726    let identity = derive_occurrence_identity(&workspace_id, &series_id, &schedule, slot_on)
727        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
728    let slot = crate::recurrence::slot_values(&schedule, slot_on)
729        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
730    for (key, expected) in [
731        ("task_id", identity.task_id.as_str()),
732        ("created_at", identity.created_at.as_str()),
733        ("updated_at", identity.updated_at.as_str()),
734        ("available_at", slot.available_at.as_str()),
735        ("due_on", slot.due_on.as_deref().unwrap_or("")),
736        ("task_change_id", identity.task_change_id.as_str()),
737        (
738            "occurrence_change_id",
739            identity.occurrence_change_id.as_str(),
740        ),
741        (
742            "task_field_version_seed",
743            identity.field_version_seeds.task.as_str(),
744        ),
745        (
746            "occurrence_field_version_seed",
747            identity.field_version_seeds.occurrence.as_str(),
748        ),
749    ] {
750        if required_string_payload(key, &change.payload)? != expected {
751            bail!("error invalid-sync-change recurrence-deterministic-mismatch field={key}");
752        }
753    }
754    if change.entity_id != identity.task_id.as_str()
755        || change.change_id != identity.task_change_id
756        || change.created_at != identity.created_at
757    {
758        bail!("error invalid-sync-change recurrence-deterministic-mismatch field=change_identity");
759    }
760    Ok(())
761}
762
763fn validate_recurrence_create(change: &ChangeWire) -> Result<()> {
764    ensure_entity_type(change, "recurrence_series")?;
765    let series_id: RecurrenceSeriesId = change
766        .entity_id
767        .parse()
768        .map_err(|_| anyhow::anyhow!("error invalid-sync-change entity_id invalid-id"))?;
769    required_workspace_payload(&change.payload)?;
770    if required_string_payload("series_id", &change.payload)? != series_id.as_str() {
771        bail!("error invalid-sync-change recurrence-series-id-mismatch");
772    }
773    required_string_payload("title", &change.payload)?;
774    required_string_payload("description", &change.payload)?;
775    let project_id = required_string_payload("project_id", &change.payload)?;
776    ensure_project_id("project_id", &project_id)?;
777    required_string_payload("project_key", &change.payload)?;
778    required_string_payload("project_name", &change.payload)?;
779    required_string_payload("project_prefix", &change.payload)?;
780    validate_sync_task_field_value(
781        TaskField::Priority,
782        &required_string_payload("priority", &change.payload)?,
783    )?;
784    let status = required_string_payload("initial_status", &change.payload)?;
785    validate_sync_task_field_value(TaskField::Status, &status)?;
786    if matches!(status.as_str(), "done" | "canceled") {
787        bail!("error invalid-sync-change recurrence-terminal-template");
788    }
789    recurrence_schedule_payload(&change.payload)?;
790    optional_string_array_payload("labels", &change.payload)?;
791    validate_metadata_array_payload(&change.payload)?;
792    if required_string_payload("state", &change.payload)? != "active"
793        || !required_string_payload("stopped_at", &change.payload)?.is_empty()
794    {
795        bail!("error invalid-sync-change recurrence-create-state");
796    }
797    required_timestamp_payload("created_at", &change.payload)?;
798    required_timestamp_payload("updated_at", &change.payload)?;
799    Ok(())
800}
801
802fn validate_recurrence_template(change: &ChangeWire) -> Result<()> {
803    validate_recurrence_entity(change)?;
804    let fields = change
805        .payload
806        .get("fields")
807        .and_then(Value::as_array)
808        .context("error invalid-sync-change payload.fields missing")?;
809    if fields.len() > 8 {
810        bail!("error invalid-sync-change recurrence-template-fields-too-large");
811    }
812    let base_versions = change
813        .payload
814        .get("base_versions")
815        .and_then(Value::as_object)
816        .context("error invalid-sync-change payload.base_versions missing")?;
817    let mut seen = HashSet::new();
818    for pair in fields {
819        let pair = pair
820            .as_array()
821            .filter(|pair| pair.len() == 2)
822            .context("error invalid-sync-change recurrence-template-field-pair")?;
823        let field = pair[0]
824            .as_str()
825            .context("error invalid-sync-change recurrence-template-field")?;
826        let value = pair[1]
827            .as_str()
828            .context("error invalid-sync-change recurrence-template-value")?;
829        if !seen.insert(field) {
830            bail!("error invalid-sync-change recurrence-template-duplicate-field");
831        }
832        if !matches!(
833            base_versions.get(field),
834            Some(Value::String(_)) | Some(Value::Null)
835        ) {
836            bail!("error invalid-sync-change recurrence-template-base-version field={field}");
837        }
838        match field {
839            "title" | "description" => {}
840            "project" => ensure_project_id("project_id", value)?,
841            "priority" => validate_sync_task_field_value(TaskField::Priority, value)?,
842            "initial_status" => {
843                validate_sync_task_field_value(TaskField::Status, value)?;
844                if matches!(value, "done" | "canceled") {
845                    bail!("error invalid-sync-change recurrence-terminal-template");
846                }
847            }
848            "available_local_time" => {
849                validate_local_time(value)?;
850            }
851            "due_policy" => {
852                RecurrenceDuePolicy::parse(value)
853                    .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
854            }
855            _ => bail!("error invalid-sync-change recurrence-template-field={field}"),
856        }
857    }
858    optional_string_array_payload("labels", &change.payload)?;
859    let labels_changed = change
860        .payload
861        .get("labels_changed")
862        .and_then(Value::as_bool)
863        .context("error invalid-sync-change payload.labels_changed missing")?;
864    if labels_changed
865        && !matches!(
866            base_versions.get("labels"),
867            Some(Value::String(_)) | Some(Value::Null)
868        )
869    {
870        bail!("error invalid-sync-change recurrence-template-base-version field=labels");
871    }
872    required_timestamp_payload("updated_at", &change.payload)?;
873    Ok(())
874}
875
876fn validate_recurrence_projection(change: &ChangeWire) -> Result<()> {
877    validate_recurrence_entity(change)?;
878    if change.field.as_deref() != Some("projection") {
879        bail!("error invalid-sync-change field=projection");
880    }
881    let workspace_id: WorkspaceId = required_string_payload("workspace_id", &change.payload)?
882        .parse()
883        .map_err(|_| anyhow::anyhow!("error invalid-sync-change workspace_id invalid-id"))?;
884    let series_id: RecurrenceSeriesId = change.entity_id.parse().unwrap();
885    let slot_on = recurrence_date_payload("slot_on", &change.payload)?;
886    let schedule = recurrence_schedule_payload(&change.payload)?;
887    if !is_slot(&schedule.rule, schedule.start_on, slot_on) {
888        bail!("error invalid-sync-change recurrence-slot-off-lattice");
889    }
890    let identity = derive_occurrence_identity(&workspace_id, &series_id, &schedule, slot_on)
891        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
892    for (key, expected) in [
893        ("task_id", identity.task_id.as_str()),
894        (
895            "projected_at",
896            identity.occurrence_link.projected_at.as_str(),
897        ),
898        ("task_change_id", identity.task_change_id.as_str()),
899        (
900            "occurrence_change_id",
901            identity.occurrence_change_id.as_str(),
902        ),
903        (
904            "task_field_version_seed",
905            identity.field_version_seeds.task.as_str(),
906        ),
907        (
908            "occurrence_field_version_seed",
909            identity.field_version_seeds.occurrence.as_str(),
910        ),
911    ] {
912        if required_string_payload(key, &change.payload)? != expected {
913            bail!("error invalid-sync-change recurrence-deterministic-mismatch field={key}");
914        }
915    }
916    if change.change_id != identity.occurrence_change_id
917        || change.created_at != identity.occurrence_link.projected_at
918    {
919        bail!("error invalid-sync-change recurrence-deterministic-mismatch field=change_id");
920    }
921    Ok(())
922}
923
924fn validate_recurrence_outcome(change: &ChangeWire) -> Result<()> {
925    validate_recurrence_entity(change)?;
926    validate_timestamp_value("created_at", &change.created_at)?;
927    if change.field.as_deref() != Some("outcome") {
928        bail!("error invalid-sync-change field=outcome");
929    }
930    let slot_on = recurrence_date_payload("slot_on", &change.payload)?;
931    let schedule = recurrence_schedule_payload(&change.payload)?;
932    if !is_slot(&schedule.rule, schedule.start_on, slot_on) {
933        bail!("error invalid-sync-change recurrence-slot-off-lattice");
934    }
935    let outcome = RecurrenceOutcome::parse(&required_string_payload("outcome", &change.payload)?)
936        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
937    required_timestamp_payload("resolved_at", &change.payload)?;
938    let conflict_resolution = change
939        .payload
940        .get("conflict_resolution")
941        .and_then(Value::as_bool)
942        .unwrap_or(false);
943    let task_id = required_string_payload("task_id", &change.payload)?;
944    ensure_sync_id("task_id", &task_id)?;
945    let workspace_id: WorkspaceId =
946        required_string_payload("workspace_id", &change.payload)?.parse()?;
947    let series_id: RecurrenceSeriesId = change.entity_id.parse().unwrap();
948    let identity = derive_occurrence_identity(&workspace_id, &series_id, &schedule, slot_on)?;
949    if task_id != identity.task_id.as_str() {
950        bail!("error invalid-sync-change recurrence-deterministic-mismatch field=task_id");
951    }
952    let expected_status = match outcome {
953        RecurrenceOutcome::Completed => "done",
954        RecurrenceOutcome::Skipped => "canceled",
955    };
956    if required_string_payload("task_status", &change.payload)? != expected_status {
957        bail!("error invalid-sync-change recurrence-outcome-status-mismatch");
958    }
959    let status_change_id = required_string_payload("task_status_change_id", &change.payload)?;
960    if !conflict_resolution || !status_change_id.is_empty() {
961        ensure_sync_id("task_status_change_id", &status_change_id)?;
962    }
963    let successor = required_string_payload("successor_task_id", &change.payload)?;
964    if !successor.is_empty() {
965        ensure_sync_id("successor_task_id", &successor)?;
966        let successor_slot = next_slot_after(&schedule.rule, schedule.start_on, slot_on)
967            .context("error invalid-sync-change recurrence-successor-out-of-range")?;
968        let successor_identity =
969            derive_occurrence_identity(&workspace_id, &series_id, &schedule, successor_slot)?;
970        if successor != successor_identity.task_id.as_str() {
971            bail!(
972                "error invalid-sync-change recurrence-deterministic-mismatch field=successor_task_id"
973            );
974        }
975    }
976    Ok(())
977}
978
979fn validate_recurrence_state(change: &ChangeWire) -> Result<()> {
980    validate_recurrence_entity(change)?;
981    if change.field.as_deref() != Some("state") {
982        bail!("error invalid-sync-change field=state");
983    }
984    let conflict_resolution = change
985        .payload
986        .get("conflict_resolution")
987        .and_then(Value::as_bool)
988        .unwrap_or(false);
989    if change.base_version.is_none() && !conflict_resolution {
990        bail!("error invalid-sync-change recurrence-lifecycle-base-version-missing");
991    }
992    let state = RecurrenceSeriesState::parse(&required_string_payload("state", &change.payload)?)
993        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
994    let stopped_at = required_string_payload("stopped_at", &change.payload)?;
995    if matches!(state, RecurrenceSeriesState::Stopped) {
996        required_timestamp_payload("stopped_at", &change.payload)?;
997    } else if !stopped_at.is_empty() {
998        bail!("error invalid-sync-change recurrence-stop-state-mismatch");
999    }
1000    required_timestamp_payload("changed_at", &change.payload)?;
1001    Ok(())
1002}
1003
1004fn validate_recurrence_pause(change: &ChangeWire, open: bool) -> Result<()> {
1005    validate_recurrence_entity(change)?;
1006    if change.field.as_deref() != Some("pause") {
1007        bail!("error invalid-sync-change field=pause");
1008    }
1009    if open {
1010        let interval_id = required_string_payload("interval_id", &change.payload)?;
1011        ensure_sync_id("interval_id", &interval_id)?;
1012        required_timestamp_payload("paused_at", &change.payload)?;
1013        let slot = required_string_payload("suspended_slot_on", &change.payload)?;
1014        if !slot.is_empty() {
1015            slot.parse::<NaiveDate>()
1016                .context("error invalid-sync-change suspended_slot_on")?;
1017            let task_id = required_string_payload("suspended_task_id", &change.payload)?;
1018            ensure_sync_id("suspended_task_id", &task_id)?;
1019        }
1020    } else {
1021        required_timestamp_payload("resumed_at", &change.payload)?;
1022        if let Some(interval_id) = optional_string_payload("interval_id", &change.payload)? {
1023            ensure_sync_id("interval_id", &interval_id)?;
1024        }
1025    }
1026    Ok(())
1027}
1028
1029fn validate_recurrence_entity(change: &ChangeWire) -> Result<()> {
1030    ensure_entity_type(change, "recurrence_series")?;
1031    change
1032        .entity_id
1033        .parse::<RecurrenceSeriesId>()
1034        .map_err(|_| anyhow::anyhow!("error invalid-sync-change entity_id invalid-id"))?;
1035    required_workspace_payload(&change.payload)?;
1036    Ok(())
1037}
1038
1039fn recurrence_schedule_payload(payload: &Value) -> Result<RecurrenceSchedule> {
1040    let frequency = RecurrenceFrequency::parse(&required_string_payload("frequency", payload)?)
1041        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
1042    let interval = required_i64_payload("interval", payload)?;
1043    let interval = u32::try_from(interval)
1044        .context("error invalid-sync-change recurrence-interval-out-of-range")?;
1045    let weekdays_text = required_string_payload("weekdays", payload)?;
1046    let weekdays = weekdays_text
1047        .parse::<WeekdaySet>()
1048        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
1049    if weekdays.to_string() != weekdays_text {
1050        bail!("error invalid-sync-change recurrence-weekdays-noncanonical");
1051    }
1052    let rule = RecurrenceRule::new(frequency, interval, weekdays)
1053        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
1054    let timezone_text = required_string_payload("timezone", payload)?;
1055    let timezone = timezone_text
1056        .parse::<TimeZoneId>()
1057        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
1058    if timezone_text.parse::<chrono_tz::Tz>()?.to_string() != timezone_text {
1059        bail!("error invalid-sync-change recurrence-timezone-noncanonical");
1060    }
1061    let start_on = recurrence_date_payload("start_on", payload)?;
1062    let available = required_string_payload("available_local_time", payload)?;
1063    let available_local_time = if available.is_empty() {
1064        None
1065    } else {
1066        Some(validate_local_time(&available)?)
1067    };
1068    let due_policy = RecurrenceDuePolicy::parse(&required_string_payload("due_policy", payload)?)
1069        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
1070    Ok(RecurrenceSchedule::new(
1071        rule,
1072        timezone,
1073        start_on,
1074        available_local_time,
1075        due_policy,
1076    ))
1077}
1078
1079fn recurrence_date_payload(key: &str, payload: &Value) -> Result<NaiveDate> {
1080    required_string_payload(key, payload)?
1081        .parse()
1082        .with_context(|| format!("error invalid-sync-change payload.{key} invalid-date"))
1083}
1084
1085fn validate_local_time(value: &str) -> Result<NaiveTime> {
1086    NaiveTime::parse_from_str(value, "%H:%M:%S")
1087        .context("error invalid-sync-change recurrence-local-time")
1088}
1089
1090fn validate_sha256_for_sync(value: &str) -> Result<()> {
1091    if value.len() == 64
1092        && value
1093            .bytes()
1094            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1095    {
1096        Ok(())
1097    } else {
1098        bail!("error invalid-sync-change invalid-sha256");
1099    }
1100}
1101
1102fn validate_blob_size_for_sync(byte_size: i64) -> Result<()> {
1103    let Ok(bytes) = usize::try_from(byte_size) else {
1104        bail!("error invalid-sync-change error invalid-attachment-size bytes={byte_size}");
1105    };
1106    if bytes == 0 || bytes > MAX_BLOB_BYTES {
1107        bail!("error invalid-sync-change error invalid-attachment-size bytes={byte_size}");
1108    }
1109    map_attachment_validation(validate_blob_size(bytes))
1110}
1111
1112fn map_attachment_validation(result: Result<()>) -> Result<()> {
1113    result.map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))
1114}
1115
1116fn validate_change_server_seq(change: &ChangeWire, direction: ChangeDirection) -> Result<()> {
1117    match direction {
1118        ChangeDirection::Pushed if change.server_seq.is_some() => {
1119            bail!("error invalid-sync-change server_seq client-supplied");
1120        }
1121        ChangeDirection::Pulled => match change.server_seq {
1122            Some(server_seq) if server_seq > 0 => {}
1123            Some(server_seq) => {
1124                bail!("error invalid-sync-change server_seq={server_seq}");
1125            }
1126            None => bail!("error invalid-sync-change server_seq missing"),
1127        },
1128        ChangeDirection::Pushed => {}
1129    }
1130    Ok(())
1131}
1132
1133fn validate_push_acks(request_change_ids: &[String], response: &SyncResponse) -> Result<()> {
1134    if response.push_acks.len() != request_change_ids.len() {
1135        bail!(
1136            "error invalid-sync-response push-ack-count expected={} got={}",
1137            request_change_ids.len(),
1138            response.push_acks.len()
1139        );
1140    }
1141    let expected = request_change_ids
1142        .iter()
1143        .map(String::as_str)
1144        .collect::<HashSet<_>>();
1145    let mut seen = HashSet::with_capacity(response.push_acks.len());
1146    for ack in &response.push_acks {
1147        if !expected.contains(ack.change_id.as_str()) {
1148            bail!(
1149                "error invalid-sync-response unexpected-push-ack change_id={}",
1150                ack.change_id
1151            );
1152        }
1153        if ack.server_seq <= 0 {
1154            bail!(
1155                "error invalid-sync-response push-ack-server-seq change_id={} server_seq={}",
1156                ack.change_id,
1157                ack.server_seq
1158            );
1159        }
1160        if !seen.insert(ack.change_id.as_str()) {
1161            bail!(
1162                "error invalid-sync-response duplicate-push-ack change_id={}",
1163                ack.change_id
1164            );
1165        }
1166    }
1167    Ok(())
1168}
1169
1170fn validate_pull_page(after: i64, pull_limit: u32, response: &SyncResponse) -> Result<()> {
1171    let mut previous = after;
1172    let mut change_ids = HashSet::with_capacity(response.changes.len());
1173    for change in &response.changes {
1174        validate_pulled_change(change)?;
1175        if !change_ids.insert(&change.change_id) {
1176            bail!(
1177                "error invalid-sync-response duplicate-pull-change change_id={}",
1178                change.change_id
1179            );
1180        }
1181        let server_seq = change.server_seq.with_context(|| {
1182            format!(
1183                "error invalid-sync-response missing-server-seq change_id={}",
1184                change.change_id
1185            )
1186        })?;
1187        if server_seq <= previous {
1188            bail!(
1189                "error invalid-sync-response server-seq-order previous={} server_seq={}",
1190                previous,
1191                server_seq
1192            );
1193        }
1194        previous = server_seq;
1195    }
1196    let expected_cursor = response
1197        .changes
1198        .last()
1199        .and_then(|change| change.server_seq)
1200        .unwrap_or(after);
1201    if response.cursor != expected_cursor {
1202        bail!(
1203            "error invalid-sync-response cursor-mismatch expected={} got={}",
1204            expected_cursor,
1205            response.cursor
1206        );
1207    }
1208    if response.has_more && response.changes.len() < pull_limit as usize {
1209        bail!(
1210            "error invalid-sync-response has-more-short-page returned={} limit={}",
1211            response.changes.len(),
1212            pull_limit
1213        );
1214    }
1215    Ok(())
1216}
1217
1218fn validate_push_pull_overlap(response: &SyncResponse) -> Result<()> {
1219    let acked = response
1220        .push_acks
1221        .iter()
1222        .map(|ack| (ack.change_id.as_str(), ack.server_seq))
1223        .collect::<HashMap<_, _>>();
1224    for change in &response.changes {
1225        if let Some(acked_server_seq) = acked.get(change.change_id.as_str()) {
1226            let Some(pull_server_seq) = change.server_seq else {
1227                continue;
1228            };
1229            if *acked_server_seq != pull_server_seq {
1230                bail!(
1231                    "error invalid-sync-response push-pull-server-seq-mismatch change_id={} ack={} pull={}",
1232                    change.change_id,
1233                    acked_server_seq,
1234                    pull_server_seq
1235                );
1236            }
1237        }
1238    }
1239    Ok(())
1240}
1241
1242fn validate_sync_task_field_value(field: TaskField, value: &str) -> Result<()> {
1243    field
1244        .validate_value(value)
1245        .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))
1246}
1247
1248fn ensure_entity_type(change: &ChangeWire, expected: &str) -> Result<()> {
1249    if change.entity_type == expected {
1250        Ok(())
1251    } else {
1252        bail!(
1253            "error invalid-sync-change op_type={} entity_type={} expected={}",
1254            change.op_type,
1255            change.entity_type,
1256            expected
1257        )
1258    }
1259}
1260
1261fn ensure_non_empty(name: &str, value: &str) -> Result<()> {
1262    if value.trim().is_empty() {
1263        bail!("error invalid-sync-change {name} empty");
1264    }
1265    Ok(())
1266}
1267
1268fn ensure_sync_id(name: &str, value: &str) -> Result<()> {
1269    if value.len() == 16 && value.bytes().all(|byte| BASE32.contains(&byte)) {
1270        Ok(())
1271    } else {
1272        bail!("error invalid-sync-change {name} invalid-id");
1273    }
1274}
1275
1276fn ensure_project_id(name: &str, value: &str) -> Result<()> {
1277    if value.parse::<ProjectId>().is_ok() {
1278        Ok(())
1279    } else {
1280        bail!("error invalid-sync-change {name} invalid-id");
1281    }
1282}
1283
1284fn required_string_payload(key: &str, payload: &Value) -> Result<String> {
1285    payload
1286        .get(key)
1287        .and_then(Value::as_str)
1288        .map(str::to_string)
1289        .with_context(|| format!("error invalid-sync-change payload.{key} missing"))
1290}
1291
1292fn required_i64_payload(key: &str, payload: &Value) -> Result<i64> {
1293    match payload.get(key) {
1294        Some(Value::Number(value)) => value
1295            .as_i64()
1296            .with_context(|| format!("error invalid-sync-change payload.{key} invalid")),
1297        Some(Value::Null) | None => bail!("error invalid-sync-change payload.{key} missing"),
1298        Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1299    }
1300}
1301
1302fn required_timestamp_payload(key: &str, payload: &Value) -> Result<String> {
1303    let value = required_string_payload(key, payload)?;
1304    validate_timestamp_value(&format!("payload.{key}"), &value)?;
1305    Ok(value)
1306}
1307
1308fn validate_timestamp_value(label: &str, value: &str) -> Result<()> {
1309    if value.ends_with('Z') && chrono::DateTime::parse_from_rfc3339(value).is_ok() {
1310        Ok(())
1311    } else {
1312        bail!("error invalid-sync-change {label} invalid-timestamp");
1313    }
1314}
1315
1316fn required_workspace_payload(payload: &Value) -> Result<()> {
1317    let id = required_string_payload("workspace_id", payload)?;
1318    if id.parse::<WorkspaceId>().is_err() {
1319        bail!("error invalid-sync-change workspace_id invalid-id");
1320    }
1321    required_string_payload("workspace_key", payload)?;
1322    Ok(())
1323}
1324
1325fn optional_workspace_payload(payload: &Value) -> Result<()> {
1326    if payload.get("workspace_id").is_none() && payload.get("workspace_key").is_none() {
1327        return Ok(());
1328    }
1329    required_workspace_payload(payload)
1330}
1331
1332fn optional_string_payload(key: &str, payload: &Value) -> Result<Option<String>> {
1333    match payload.get(key) {
1334        Some(Value::String(value)) => Ok(Some(value.clone())),
1335        Some(Value::Null) | None => Ok(None),
1336        Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1337    }
1338}
1339
1340fn optional_i64_payload(key: &str, payload: &Value) -> Result<Option<i64>> {
1341    match payload.get(key) {
1342        Some(Value::Number(value)) => value
1343            .as_i64()
1344            .map(Some)
1345            .with_context(|| format!("error invalid-sync-change payload.{key} invalid")),
1346        Some(Value::Null) | None => Ok(None),
1347        Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1348    }
1349}
1350
1351fn optional_bool_payload(key: &str, payload: &Value) -> Result<Option<bool>> {
1352    match payload.get(key) {
1353        Some(Value::Bool(value)) => Ok(Some(*value)),
1354        Some(Value::Null) | None => Ok(None),
1355        Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1356    }
1357}
1358
1359fn string_array_payload(key: &str, payload: &Value) -> Result<Vec<String>> {
1360    payload
1361        .get(key)
1362        .and_then(Value::as_array)
1363        .with_context(|| format!("error invalid-sync-change payload.{key} invalid"))?
1364        .iter()
1365        .map(|value| {
1366            value
1367                .as_str()
1368                .map(str::to_string)
1369                .with_context(|| format!("error invalid-sync-change payload.{key} invalid"))
1370        })
1371        .collect()
1372}
1373
1374fn optional_string_array_payload(key: &str, payload: &Value) -> Result<()> {
1375    match payload.get(key) {
1376        Some(Value::Array(values))
1377            if values
1378                .iter()
1379                .all(|value| value.as_str().is_some_and(|value| !value.trim().is_empty())) =>
1380        {
1381            Ok(())
1382        }
1383        Some(Value::Null) | None => Ok(()),
1384        Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1385    }
1386}
1387
1388fn ensure_metadata_field_id(name: &str, value: &str) -> Result<()> {
1389    value
1390        .parse::<MetadataFieldId>()
1391        .map(|_| ())
1392        .map_err(|_| anyhow::anyhow!("error invalid-sync-change {name} invalid-id"))
1393}
1394
1395fn validate_metadata_key_payload(key: &str, payload: &Value) -> Result<String> {
1396    let value = required_string_payload(key, payload)?;
1397    let normalized = crate::metadata::normalize_metadata_key(&value)
1398        .map_err(|_| anyhow::anyhow!("error invalid-sync-change payload.{key} invalid"))?;
1399    if normalized != value {
1400        bail!("error invalid-sync-change payload.{key} noncanonical");
1401    }
1402    Ok(value)
1403}
1404
1405fn validate_metadata_value_payload(key: &str, payload: &Value) -> Result<String> {
1406    let value = required_string_payload(key, payload)?;
1407    if value.len() > crate::metadata::MAX_METADATA_VALUE_BYTES {
1408        bail!("error invalid-sync-change payload.{key} too-large");
1409    }
1410    Ok(value)
1411}
1412
1413fn validate_metadata_array_payload(payload: &Value) -> Result<()> {
1414    let Some(values) = payload.get("metadata") else {
1415        return Ok(());
1416    };
1417    let values = values
1418        .as_array()
1419        .context("error invalid-sync-change payload.metadata invalid")?;
1420    if values.len() > crate::metadata::MAX_METADATA_VALUES {
1421        bail!("error invalid-sync-change payload.metadata too-many");
1422    }
1423    let mut ids = HashSet::new();
1424    let mut keys = HashSet::new();
1425    let mut total_bytes = 0usize;
1426    for value in values {
1427        let object = value
1428            .as_object()
1429            .context("error invalid-sync-change payload.metadata invalid")?;
1430        let field_id = required_string_payload("field_id", value)?;
1431        ensure_metadata_field_id("metadata.field_id", &field_id)?;
1432        let key = validate_metadata_key_payload("key", value)?;
1433        let metadata_value = validate_metadata_value_payload("value", value)?;
1434        if !ids.insert(field_id) || !keys.insert(key) {
1435            bail!("error invalid-sync-change payload.metadata duplicate");
1436        }
1437        if object.len() != 3 {
1438            bail!("error invalid-sync-change payload.metadata invalid");
1439        }
1440        total_bytes = total_bytes.saturating_add(metadata_value.len());
1441    }
1442    if total_bytes > crate::metadata::MAX_METADATA_TOTAL_BYTES {
1443        bail!("error invalid-sync-change payload.metadata too-large");
1444    }
1445    Ok(())
1446}
1447
1448#[cfg(test)]
1449mod tests {
1450    use super::*;
1451    use crate::change_log::{ChangePayload, op_type};
1452    use crate::workspaces::Workspace;
1453
1454    fn test_workspace() -> Workspace {
1455        Workspace {
1456            id: "0000000000000000".parse().unwrap(),
1457            key: "default".to_string(),
1458            name: "default".to_string(),
1459        }
1460    }
1461
1462    fn make_change_wire(
1463        op_type: &str,
1464        entity_type: &str,
1465        entity_id: &str,
1466        payload: serde_json::Value,
1467    ) -> ChangeWire {
1468        ChangeWire {
1469            change_id: "AAAAAAAAAAAAAAA0".to_string(),
1470            client_id: "client".to_string(),
1471            local_seq: 1,
1472            entity_type: entity_type.to_string(),
1473            entity_id: entity_id.to_string(),
1474            field: None,
1475            op_type: op_type.to_string(),
1476            payload,
1477            base_version: None,
1478            created_at: "2026-06-01T00:00:00Z".to_string(),
1479            server_seq: None,
1480        }
1481    }
1482
1483    #[test]
1484    fn sync_timestamps_accept_fractional_utc_precision() {
1485        validate_timestamp_value("created_at", "2026-07-28T06:41:44Z").unwrap();
1486        validate_timestamp_value("created_at", "2026-07-28T06:41:44.000000Z").unwrap();
1487        validate_timestamp_value("created_at", "2026-07-28T06:41:44.123456789Z").unwrap();
1488    }
1489
1490    #[test]
1491    fn sync_timestamps_reject_offsets_and_invalid_calendar_values() {
1492        for value in ["2026-07-28T08:41:44+02:00", "2026-13-28T06:41:44Z", "today"] {
1493            assert!(validate_timestamp_value("created_at", value).is_err());
1494        }
1495    }
1496
1497    #[test]
1498    fn recurrence_projection_rejects_nondeterministic_change_timestamp() {
1499        let workspace = test_workspace();
1500        let series_id: RecurrenceSeriesId = "AAAAAAAAAAAAAAAA".parse().unwrap();
1501        let schedule = RecurrenceSchedule::new(
1502            RecurrenceRule::daily(),
1503            "UTC".parse().unwrap(),
1504            "2026-07-20".parse().unwrap(),
1505            None,
1506            RecurrenceDuePolicy::SameDay,
1507        );
1508        let slot_on: NaiveDate = "2026-07-20".parse().unwrap();
1509        let identity =
1510            derive_occurrence_identity(&workspace.id, &series_id, &schedule, slot_on).unwrap();
1511        let payload = ChangePayload::workspace(&workspace)
1512            .set("series_id", series_id.as_str())
1513            .set("slot_on", slot_on.to_string())
1514            .set("task_id", identity.task_id.as_str())
1515            .set("projected_at", &identity.occurrence_link.projected_at)
1516            .set("task_change_id", &identity.task_change_id)
1517            .set("occurrence_change_id", &identity.occurrence_change_id)
1518            .set(
1519                "task_field_version_seed",
1520                &identity.field_version_seeds.task,
1521            )
1522            .set(
1523                "occurrence_field_version_seed",
1524                &identity.field_version_seeds.occurrence,
1525            )
1526            .set("frequency", "daily")
1527            .set("interval", 1)
1528            .set("weekdays", "")
1529            .set("timezone", "UTC")
1530            .set("start_on", "2026-07-20")
1531            .set("available_local_time", "")
1532            .set("due_policy", "same_day")
1533            .into_value();
1534        let mut change = make_change_wire(
1535            op_type::PROJECT_RECURRENCE_OCCURRENCE,
1536            "recurrence_series",
1537            series_id.as_str(),
1538            payload,
1539        );
1540        change.change_id = identity.occurrence_change_id;
1541        change.field = Some("projection".to_string());
1542        change.created_at = identity.occurrence_link.projected_at;
1543        validate_pushed_change(&change).unwrap();
1544
1545        change.created_at = "2026-07-20T00:00:01Z".to_string();
1546        assert!(
1547            validate_pushed_change(&change)
1548                .unwrap_err()
1549                .to_string()
1550                .contains("recurrence-deterministic-mismatch")
1551        );
1552    }
1553
1554    #[test]
1555    fn request_pull_limit_has_default_and_bounds() {
1556        assert_eq!(request_pull_limit(None).unwrap(), MAX_PULL_BATCH);
1557        assert!(request_pull_limit(Some(MAX_PULL_BATCH)).is_ok());
1558        assert_eq!(
1559            request_pull_limit(Some(0)).unwrap_err().to_string(),
1560            "error sync-pull-limit-out-of-range min=1 max=512 got=0"
1561        );
1562        assert_eq!(
1563            request_pull_limit(Some(MAX_PULL_BATCH + 1))
1564                .unwrap_err()
1565                .to_string(),
1566            "error sync-pull-limit-out-of-range min=1 max=512 got=513"
1567        );
1568    }
1569
1570    #[test]
1571    fn request_envelope_rejects_negative_cursor_and_oversized_push_batch() {
1572        let request = SyncRequest {
1573            protocol_version: Some(SYNC_PROTOCOL_VERSION),
1574            client_id: "test-client".to_string(),
1575            after: -1,
1576            pull_limit: Some(MAX_PULL_BATCH),
1577            changes: Vec::new(),
1578        };
1579        assert_eq!(
1580            validate_sync_request_envelope(&request)
1581                .unwrap_err()
1582                .to_string(),
1583            "error sync-after-out-of-range min=0 got=-1"
1584        );
1585
1586        let request = SyncRequest {
1587            protocol_version: Some(SYNC_PROTOCOL_VERSION),
1588            client_id: "test-client".to_string(),
1589            after: 0,
1590            pull_limit: Some(MAX_PULL_BATCH),
1591            changes: vec![
1592                ChangeWire {
1593                    change_id: "AAAAAAAAAAAAAAA0".to_string(),
1594                    client_id: "client".to_string(),
1595                    local_seq: 1,
1596                    entity_type: "task".to_string(),
1597                    entity_id: "BBBBBBBBBBBBBBBB".to_string(),
1598                    field: None,
1599                    op_type: "create_task".to_string(),
1600                    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"}),
1601                    base_version: None,
1602                    created_at: "2026-01-01T00:00:00Z".to_string(),
1603                    server_seq: None,
1604                };
1605                MAX_PUSH_BATCH + 1
1606            ],
1607        };
1608        assert_eq!(
1609            validate_sync_request_envelope(&request)
1610                .unwrap_err()
1611                .to_string(),
1612            "error sync-push-too-large limit=256 got=257"
1613        );
1614    }
1615
1616    #[test]
1617    fn response_validation_respects_request_pull_limit() {
1618        let response = SyncResponse {
1619            protocol_version: SYNC_PROTOCOL_VERSION,
1620            cursor: 1,
1621            has_more: false,
1622            push_acks: vec![],
1623            changes: vec![
1624                ChangeWire {
1625                    change_id: "AAAAAAAAAAAAAAA1".to_string(),
1626                    client_id: "client".to_string(),
1627                    local_seq: 1,
1628                    entity_type: "task".to_string(),
1629                    entity_id: "BBBBBBBBBBBBBBBB".to_string(),
1630                    field: None,
1631                    op_type: "create_task".to_string(),
1632                    payload: serde_json::json!({
1633                        "title":"one",
1634                        "project_id":"0000000000000000",
1635                        "project_key":"app",
1636                        "project_name":"app",
1637                        "project_prefix":"APP",
1638                    }),
1639                    base_version: None,
1640                    created_at: "2026-01-01T00:00:00Z".to_string(),
1641                    server_seq: Some(1),
1642                };
1643                MAX_PULL_BATCH as usize + 1
1644            ],
1645        };
1646        assert_eq!(
1647            validate_sync_response_for_request(0, MAX_PULL_BATCH, &[], &response)
1648                .unwrap_err()
1649                .to_string(),
1650            "error invalid-sync-response pull-too-large limit=512 got=513"
1651        );
1652    }
1653
1654    #[test]
1655    fn constructed_create_task_payload_passes_wire_validation() {
1656        let ws = test_workspace();
1657        let payload = ChangePayload::workspace(&ws)
1658            .set("title", "test task")
1659            .set("description", "a description")
1660            .set("project_id", "1111111111111111")
1661            .set("project_key", "app")
1662            .set("project_name", "App")
1663            .set("project_prefix", "APP")
1664            .set("status", "inbox")
1665            .set("priority", "none")
1666            .set("created_at", "2026-06-01T00:00:00Z")
1667            .into_value();
1668        let change = make_change_wire(op_type::CREATE_TASK, "task", "BBBBBBBBBBBBBBBB", payload);
1669        validate_pushed_change(&change)
1670            .expect("create_task payload built with ChangePayload should be wire-valid");
1671    }
1672
1673    #[test]
1674    fn constructed_create_project_payload_passes_wire_validation() {
1675        let ws = test_workspace();
1676        let payload = ChangePayload::workspace(&ws)
1677            .set("key", "app")
1678            .set("name", "App")
1679            .set("prefix", "APP")
1680            .set("created_at", "2026-06-01T00:00:00Z")
1681            .into_value();
1682        let change = make_change_wire(
1683            op_type::CREATE_PROJECT,
1684            "project",
1685            "1111111111111111",
1686            payload,
1687        );
1688        validate_pushed_change(&change)
1689            .expect("create_project payload built with ChangePayload should be wire-valid");
1690    }
1691
1692    #[test]
1693    fn project_changes_reject_invalid_project_ids() {
1694        let ws = test_workspace();
1695        let project_payload = ChangePayload::workspace(&ws)
1696            .set("key", "app")
1697            .set("name", "App")
1698            .set("prefix", "APP")
1699            .set("created_at", "2026-06-01T00:00:00Z")
1700            .into_value();
1701        let create_project = make_change_wire(
1702            op_type::CREATE_PROJECT,
1703            "project",
1704            "invalid",
1705            project_payload,
1706        );
1707        assert_eq!(
1708            validate_pushed_change(&create_project)
1709                .unwrap_err()
1710                .to_string(),
1711            "error invalid-sync-change entity_id invalid-id"
1712        );
1713
1714        let task_payload = ChangePayload::workspace(&ws)
1715            .set("title", "test task")
1716            .set("project_id", "invalid")
1717            .set("project_key", "app")
1718            .set("project_name", "App")
1719            .set("project_prefix", "APP")
1720            .set("created_at", "2026-06-01T00:00:00Z")
1721            .into_value();
1722        let create_task = make_change_wire(
1723            op_type::CREATE_TASK,
1724            "task",
1725            "BBBBBBBBBBBBBBBB",
1726            task_payload,
1727        );
1728        assert_eq!(
1729            validate_pushed_change(&create_task)
1730                .unwrap_err()
1731                .to_string(),
1732            "error invalid-sync-change project_id invalid-id"
1733        );
1734
1735        let field_payload = ChangePayload::workspace(&ws)
1736            .set("value", "invalid")
1737            .set("project_id", "invalid")
1738            .set("project_key", "app")
1739            .set("project_name", "App")
1740            .set("project_prefix", "APP")
1741            .into_value();
1742        let mut set_field = make_change_wire(
1743            op_type::SET_FIELD,
1744            "task",
1745            "BBBBBBBBBBBBBBBB",
1746            field_payload,
1747        );
1748        set_field.field = Some("project".to_string());
1749        assert_eq!(
1750            validate_pushed_change(&set_field).unwrap_err().to_string(),
1751            "error invalid-sync-change project_id invalid-id"
1752        );
1753    }
1754
1755    #[test]
1756    fn constructed_label_add_payload_passes_wire_validation() {
1757        let ws = test_workspace();
1758        let payload = ChangePayload::workspace(&ws)
1759            .set("label", "bug")
1760            .into_value();
1761        let mut change = make_change_wire(op_type::LABEL_ADD, "task", "BBBBBBBBBBBBBBBB", payload);
1762        change.field = Some("labels".to_string());
1763        validate_pushed_change(&change)
1764            .expect("label_add payload built with ChangePayload should be wire-valid");
1765    }
1766
1767    #[test]
1768    fn constructed_label_administration_payloads_pass_wire_validation() {
1769        let ws = test_workspace();
1770        let rename = make_change_wire(
1771            op_type::SET_LABEL_NAME,
1772            "label",
1773            "old",
1774            ChangePayload::workspace(&ws)
1775                .set("name", "old")
1776                .set("new_name", "new")
1777                .set("renamed_at", "2026-06-01T00:00:00Z")
1778                .into_value(),
1779        );
1780        validate_pushed_change(&rename).unwrap();
1781
1782        let restore = make_change_wire(
1783            op_type::LABEL_RESTORE,
1784            "label",
1785            "new",
1786            ChangePayload::workspace(&ws)
1787                .set("name", "new")
1788                .set("created_at", "2026-06-01T00:00:00Z")
1789                .set("task_ids", ["BBBBBBBBBBBBBBBB"])
1790                .set("series_ids", ["CCCCCCCCCCCCCCCC"])
1791                .set("restored_at", "2026-06-01T00:00:01Z")
1792                .into_value(),
1793        );
1794        validate_pushed_change(&restore).unwrap();
1795    }
1796
1797    #[test]
1798    fn constructed_dependency_add_payload_passes_wire_validation() {
1799        let ws = test_workspace();
1800        let payload = ChangePayload::workspace(&ws)
1801            .set("depends_on_task_id", "CCCCCCCCCCCCCCCC")
1802            .into_value();
1803        let mut change =
1804            make_change_wire(op_type::DEPENDENCY_ADD, "task", "BBBBBBBBBBBBBBBB", payload);
1805        change.field = Some("dependencies".to_string());
1806        validate_pushed_change(&change)
1807            .expect("dependency_add payload built with ChangePayload should be wire-valid");
1808    }
1809
1810    #[test]
1811    fn related_payload_validation_requires_distinct_task_endpoints() {
1812        let ws = test_workspace();
1813        let payload = ChangePayload::workspace(&ws)
1814            .set("related_task_id", "CCCCCCCCCCCCCCCC")
1815            .into_value();
1816        let mut change =
1817            make_change_wire(op_type::RELATED_ADD, "task", "BBBBBBBBBBBBBBBB", payload);
1818        change.field = Some("related".to_string());
1819        validate_pushed_change(&change)
1820            .expect("related_add payload built with ChangePayload should be wire-valid");
1821
1822        change.payload["related_task_id"] = serde_json::json!("BBBBBBBBBBBBBBBB");
1823        assert_eq!(
1824            validate_pushed_change(&change).unwrap_err().to_string(),
1825            "error invalid-sync-change related-self"
1826        );
1827    }
1828
1829    #[test]
1830    fn constructed_note_edit_payload_passes_wire_validation() {
1831        let ws = test_workspace();
1832        let payload = ChangePayload::workspace(&ws)
1833            .set("note_id", "DDDDDDDDDDDDDDDD")
1834            .set("body", "corrected note body")
1835            .set("edited_at", "2026-06-01T01:00:00Z")
1836            .into_value();
1837        let mut change = make_change_wire(op_type::NOTE_EDIT, "task", "BBBBBBBBBBBBBBBB", payload);
1838        change.field = Some("notes".to_string());
1839        validate_pushed_change(&change)
1840            .expect("note_edit payload built with ChangePayload should be wire-valid");
1841    }
1842
1843    #[test]
1844    fn constructed_note_add_payload_passes_wire_validation() {
1845        let ws = test_workspace();
1846        let payload = ChangePayload::workspace(&ws)
1847            .set("note_id", "DDDDDDDDDDDDDDDD")
1848            .set("body", "note body")
1849            .set("created_at", "2026-06-01T00:00:00Z")
1850            .into_value();
1851        let mut change = make_change_wire(op_type::NOTE_ADD, "task", "BBBBBBBBBBBBBBBB", payload);
1852        change.field = Some("notes".to_string());
1853        validate_pushed_change(&change)
1854            .expect("note_add payload built with ChangePayload should be wire-valid");
1855    }
1856
1857    fn attachment_add_payload() -> Value {
1858        ChangePayload::workspace(&test_workspace())
1859            .set("attachment_id", "7KQ9A1X4MV2P8D6R")
1860            .set(
1861                "sha256",
1862                "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
1863            )
1864            .set("byte_size", 12_i64)
1865            .set("media_type", "image/png")
1866            .set("filename", None::<&str>)
1867            .set("alt_text", None::<&str>)
1868            .set("width", 320_i64)
1869            .set("height", 240_i64)
1870            .set("created_at", "2026-06-01T00:00:00Z")
1871            .into_value()
1872    }
1873
1874    fn attachment_change(op_type: &str, payload: Value) -> ChangeWire {
1875        let mut change = make_change_wire(op_type, "task", "BBBBBBBBBBBBBBBB", payload);
1876        change.field = Some("attachments".to_string());
1877        change
1878    }
1879
1880    #[test]
1881    fn constructed_attachment_payloads_parse_without_json_or_protocol_changes() {
1882        let add_value = attachment_add_payload();
1883        let add = AttachmentAddPayload::from_change(&attachment_change(
1884            op_type::ATTACHMENT_ADD,
1885            add_value.clone(),
1886        ))
1887        .unwrap();
1888        assert_eq!(serde_json::to_value(add).unwrap(), add_value);
1889
1890        let delete_value = ChangePayload::workspace(&test_workspace())
1891            .set("attachment_id", "7KQ9A1X4MV2P8D6R")
1892            .set("deleted_at", "2026-06-01T00:00:00Z")
1893            .into_value();
1894        let delete = AttachmentDeletePayload::from_change(&attachment_change(
1895            op_type::ATTACHMENT_DELETE,
1896            delete_value.clone(),
1897        ))
1898        .unwrap();
1899        assert_eq!(serde_json::to_value(delete).unwrap(), delete_value);
1900        assert_eq!(SYNC_PROTOCOL_VERSION, 15);
1901    }
1902
1903    #[test]
1904    fn attachment_payloads_accept_extensions_and_missing_optional_text() {
1905        let mut payload = attachment_add_payload();
1906        let object = payload.as_object_mut().unwrap();
1907        object.remove("filename");
1908        object.remove("alt_text");
1909        object.insert("extension".to_string(), serde_json::json!({ "version": 1 }));
1910
1911        AttachmentAddPayload::from_change(&attachment_change(op_type::ATTACHMENT_ADD, payload))
1912            .unwrap();
1913    }
1914
1915    #[test]
1916    fn attachment_payload_authority_rejects_malformed_fields() {
1917        let cases = [
1918            ("workspace_id", Value::Null, "workspace_id"),
1919            (
1920                "attachment_id",
1921                Value::String("short".to_string()),
1922                "attachment_id",
1923            ),
1924            (
1925                "sha256",
1926                Value::String("short".to_string()),
1927                "invalid-sha256",
1928            ),
1929            ("byte_size", Value::String("12".to_string()), "byte_size"),
1930            ("byte_size", Value::from(-1), "invalid-attachment-size"),
1931            (
1932                "media_type",
1933                Value::String("image/svg+xml".to_string()),
1934                "unsupported-attachment-media-type",
1935            ),
1936            (
1937                "filename",
1938                Value::String("bad/name.png".to_string()),
1939                "invalid-attachment-filename",
1940            ),
1941            (
1942                "alt_text",
1943                Value::String("bad\nalt".to_string()),
1944                "invalid-attachment-alt-text",
1945            ),
1946            ("height", Value::Null, "invalid-attachment-dimensions"),
1947            ("width", Value::from(0), "invalid-attachment-dimensions"),
1948            (
1949                "created_at",
1950                Value::String("today".to_string()),
1951                "invalid-timestamp",
1952            ),
1953        ];
1954        for (key, value, expected) in cases {
1955            let mut payload = attachment_add_payload();
1956            payload[key] = value;
1957            let error = AttachmentAddPayload::from_change(&attachment_change(
1958                op_type::ATTACHMENT_ADD,
1959                payload,
1960            ))
1961            .unwrap_err();
1962            assert!(
1963                error.to_string().contains(expected),
1964                "{key} error did not contain {expected}: {error}"
1965            );
1966        }
1967
1968        let mut wrong_field = attachment_change(op_type::ATTACHMENT_ADD, attachment_add_payload());
1969        wrong_field.field = Some("description".to_string());
1970        assert!(
1971            AttachmentAddPayload::from_change(&wrong_field)
1972                .unwrap_err()
1973                .to_string()
1974                .contains("field=attachments")
1975        );
1976
1977        let mut invalid_entity =
1978            attachment_change(op_type::ATTACHMENT_ADD, attachment_add_payload());
1979        invalid_entity.entity_id = "invalid".to_string();
1980        assert!(
1981            AttachmentAddPayload::from_change(&invalid_entity)
1982                .unwrap_err()
1983                .to_string()
1984                .contains("entity_id")
1985        );
1986    }
1987}