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